From 367a67bdb2c2ad197020285ec2b65b47c2c804a2 Mon Sep 17 00:00:00 2001 From: dmbuil Date: Wed, 25 Mar 2026 23:35:06 +0100 Subject: [PATCH 1/2] feat: add OpenStack plugin --- plugins/openstack/credentials.go | 202 +++++++++++++ plugins/openstack/credentials_test.go | 255 +++++++++++++++++ plugins/openstack/importers.go | 268 ++++++++++++++++++ plugins/openstack/openstack.go | 42 +++ plugins/openstack/plugin.go | 23 ++ .../test-fixtures/clouds-appcred.yaml | 11 + .../test-fixtures/clouds-no-password.yaml | 13 + plugins/openstack/test-fixtures/clouds.yaml | 14 + plugins/openstack/test-fixtures/openrc.sh | 15 + plugins/openstack/test-fixtures/secure.yaml | 4 + 10 files changed, 847 insertions(+) create mode 100644 plugins/openstack/credentials.go create mode 100644 plugins/openstack/credentials_test.go create mode 100644 plugins/openstack/importers.go create mode 100644 plugins/openstack/openstack.go create mode 100644 plugins/openstack/plugin.go create mode 100644 plugins/openstack/test-fixtures/clouds-appcred.yaml create mode 100644 plugins/openstack/test-fixtures/clouds-no-password.yaml create mode 100644 plugins/openstack/test-fixtures/clouds.yaml create mode 100644 plugins/openstack/test-fixtures/openrc.sh create mode 100644 plugins/openstack/test-fixtures/secure.yaml diff --git a/plugins/openstack/credentials.go b/plugins/openstack/credentials.go new file mode 100644 index 00000000..b0660a82 --- /dev/null +++ b/plugins/openstack/credentials.go @@ -0,0 +1,202 @@ +package openstack + +import ( + "context" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/provision" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +const ( + fieldUserDomainName = sdk.FieldName("User Domain Name") + fieldProjectDomainName = sdk.FieldName("Project Domain Name") + fieldProjectDomainID = sdk.FieldName("Project Domain ID") + fieldInterface = sdk.FieldName("Interface") + fieldIdentityAPIVersion = sdk.FieldName("Identity API Version") + fieldCloud = sdk.FieldName("Cloud") + fieldAuthType = sdk.FieldName("Auth Type") + fieldApplicationCredentialID = sdk.FieldName("Application Credential ID") + fieldApplicationCredentialSecret = sdk.FieldName("Application Credential Secret") +) + +func Credentials() schema.CredentialType { + return schema.CredentialType{ + Name: credname.AccessKey, + DocsURL: sdk.URL("https://docs.openstack.org/python-openstackclient/latest/configuration/index.html"), + ManagementURL: sdk.URL("https://docs.openstack.org/keystone/latest/"), + Fields: []schema.CredentialField{ + { + Name: fieldname.URL, + MarkdownDescription: "The Keystone identity service endpoint URL (OS_AUTH_URL).", + }, + // Password-based auth fields + { + Name: fieldname.Username, + MarkdownDescription: "The username used to authenticate to OpenStack (OS_USERNAME). Required for password-based auth.", + Optional: true, + }, + { + Name: fieldname.Password, + MarkdownDescription: "The password used to authenticate to OpenStack (OS_PASSWORD). Required for password-based auth.", + Secret: true, + Optional: true, + }, + // Application credential auth fields + { + Name: fieldApplicationCredentialID, + MarkdownDescription: "The application credential ID (OS_APPLICATION_CREDENTIAL_ID). Required for v3applicationcredential auth.", + Optional: true, + }, + { + Name: fieldApplicationCredentialSecret, + MarkdownDescription: "The application credential secret (OS_APPLICATION_CREDENTIAL_SECRET). Required for v3applicationcredential auth.", + Secret: true, + Optional: true, + }, + // Scoping fields + { + Name: fieldname.Project, + AlternativeNames: []string{"Project Name"}, + MarkdownDescription: "The project name to scope the OpenStack session to (OS_PROJECT_NAME).", + Optional: true, + }, + { + Name: fieldname.ProjectID, + MarkdownDescription: "The project ID to scope the OpenStack session to (OS_PROJECT_ID).", + Optional: true, + }, + { + Name: fieldname.Region, + AlternativeNames: []string{"Region Name"}, + MarkdownDescription: "The region of the OpenStack endpoint to use (OS_REGION_NAME).", + Optional: true, + }, + { + Name: fieldUserDomainName, + MarkdownDescription: "The domain name of the user (OS_USER_DOMAIN_NAME).", + Optional: true, + }, + { + Name: fieldProjectDomainName, + MarkdownDescription: "The domain name of the project (OS_PROJECT_DOMAIN_NAME).", + Optional: true, + }, + { + Name: fieldProjectDomainID, + MarkdownDescription: "The domain ID of the project (OS_PROJECT_DOMAIN_ID).", + Optional: true, + }, + // Connection fields + { + Name: fieldInterface, + MarkdownDescription: "The endpoint interface to use (OS_INTERFACE). Defaults to \"public\".", + Optional: true, + }, + { + Name: fieldIdentityAPIVersion, + MarkdownDescription: "The Keystone API version to use (OS_IDENTITY_API_VERSION). Defaults to \"3\".", + Optional: true, + }, + { + Name: fieldAuthType, + MarkdownDescription: "The authentication type (OS_AUTH_TYPE). Auto-detected: \"password\" or \"v3applicationcredential\".", + Optional: true, + }, + { + Name: fieldCloud, + MarkdownDescription: "The cloud name from clouds.yaml to use (OS_CLOUD). Useful for distinguishing multiple environments.", + Optional: true, + }, + }, + DefaultProvisioner: envVarsWithDefaults(defaultEnvVarMapping, map[string]string{ + "OS_INTERFACE": "public", + "OS_IDENTITY_API_VERSION": "3", + }), + Importer: importer.TryAll( + importer.TryEnvVarPair(defaultEnvVarMapping), + TryOpenStackCloudRC("~/openrc.sh"), + TryOpenStackCloudRC("~/.config/openstack/openrc.sh"), + TryOpenStackCloudsYAMLFromEnvVar(), + TryOpenStackCloudsAndSecureYAML( + "~/.config/openstack/clouds.yaml", + "~/.config/openstack/secure.yaml", + ), + TryOpenStackCloudsAndSecureYAML( + "/etc/openstack/clouds.yaml", + "/etc/openstack/secure.yaml", + ), + ), + } +} + +var defaultEnvVarMapping = map[string]sdk.FieldName{ + "OS_AUTH_URL": fieldname.URL, + "OS_USERNAME": fieldname.Username, + "OS_PASSWORD": fieldname.Password, + "OS_APPLICATION_CREDENTIAL_ID": fieldApplicationCredentialID, + "OS_APPLICATION_CREDENTIAL_SECRET": fieldApplicationCredentialSecret, + "OS_PROJECT_NAME": fieldname.Project, + "OS_PROJECT_ID": fieldname.ProjectID, + "OS_REGION_NAME": fieldname.Region, + "OS_USER_DOMAIN_NAME": fieldUserDomainName, + "OS_PROJECT_DOMAIN_NAME": fieldProjectDomainName, + "OS_PROJECT_DOMAIN_ID": fieldProjectDomainID, + "OS_INTERFACE": fieldInterface, + "OS_IDENTITY_API_VERSION": fieldIdentityAPIVersion, + "OS_AUTH_TYPE": fieldAuthType, + "OS_CLOUD": fieldCloud, +} + +// envVarsWithDefaults wraps provision.EnvVars and injects default env var values +// for any fields not present in the 1Password item. +func envVarsWithDefaults(schema map[string]sdk.FieldName, defaults map[string]string) sdk.Provisioner { + return provisionerWithDefaults{ + base: provision.EnvVars(schema), + defaults: defaults, + } +} + +type provisionerWithDefaults struct { + base sdk.Provisioner + defaults map[string]string +} + +func (p provisionerWithDefaults) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + p.base.Provision(ctx, in, out) + + // Apply static defaults for fields not set in the item. + for envVar, defaultVal := range p.defaults { + if _, set := out.Environment[envVar]; !set { + out.AddEnvVar(envVar, defaultVal) + } + } + + // Auto-detect OS_AUTH_TYPE if not explicitly set in the item. + if _, set := out.Environment["OS_AUTH_TYPE"]; !set { + if _, hasAppCred := out.Environment["OS_APPLICATION_CREDENTIAL_ID"]; hasAppCred { + out.AddEnvVar("OS_AUTH_TYPE", "v3applicationcredential") + } else { + out.AddEnvVar("OS_AUTH_TYPE", "password") + } + } + + // When using application credentials, remove password-based fields to avoid + // confusing the OpenStack CLI with conflicting auth parameters. + if out.Environment["OS_AUTH_TYPE"] == "v3applicationcredential" { + delete(out.Environment, "OS_USERNAME") + delete(out.Environment, "OS_PASSWORD") + } +} + +func (p provisionerWithDefaults) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { + p.base.Deprovision(ctx, in, out) +} + +func (p provisionerWithDefaults) Description() string { + return p.base.Description() +} + diff --git a/plugins/openstack/credentials_test.go b/plugins/openstack/credentials_test.go new file mode 100644 index 00000000..274f1f15 --- /dev/null +++ b/plugins/openstack/credentials_test.go @@ -0,0 +1,255 @@ +package openstack + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestCredentialsProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, Credentials().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "password auth - all fields": { + ItemFields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldname.Username: "myuser", + fieldname.Password: "s3cr3tpassword", + fieldname.Project: "myproject", + fieldname.ProjectID: "abc123def456abc123def456abc123de", + fieldname.Region: "RegionOne", + fieldUserDomainName: "Default", + fieldProjectDomainName: "Default", + fieldProjectDomainID: "default", + fieldInterface: "internal", + fieldIdentityAPIVersion: "3", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com:5000/v3", + "OS_USERNAME": "myuser", + "OS_PASSWORD": "s3cr3tpassword", + "OS_PROJECT_NAME": "myproject", + "OS_PROJECT_ID": "abc123def456abc123def456abc123de", + "OS_REGION_NAME": "RegionOne", + "OS_USER_DOMAIN_NAME": "Default", + "OS_PROJECT_DOMAIN_NAME": "Default", + "OS_PROJECT_DOMAIN_ID": "default", + "OS_INTERFACE": "internal", + "OS_IDENTITY_API_VERSION": "3", + "OS_AUTH_TYPE": "password", + }, + }, + }, + "password auth - defaults applied": { + ItemFields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldname.Username: "myuser", + fieldname.Password: "s3cr3tpassword", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com:5000/v3", + "OS_USERNAME": "myuser", + "OS_PASSWORD": "s3cr3tpassword", + "OS_INTERFACE": "public", + "OS_IDENTITY_API_VERSION": "3", + "OS_AUTH_TYPE": "password", + }, + }, + }, + "application credential auth": { + ItemFields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldApplicationCredentialID: "xxxxxxxxxxxxxxx", + fieldApplicationCredentialSecret: "yyyyy342lhkwdh", + fieldname.Region: "RegionOne", + fieldProjectDomainName: "Default", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com:5000/v3", + "OS_APPLICATION_CREDENTIAL_ID": "xxxxxxxxxxxxxxx", + "OS_APPLICATION_CREDENTIAL_SECRET": "yyyyy342lhkwdh", + "OS_REGION_NAME": "RegionOne", + "OS_PROJECT_DOMAIN_NAME": "Default", + "OS_INTERFACE": "public", + "OS_IDENTITY_API_VERSION": "3", + "OS_AUTH_TYPE": "v3applicationcredential", + }, + }, + }, + }) +} + +func TestCredentialsImporter(t *testing.T) { + plugintest.TestImporter(t, Credentials().Importer, map[string]plugintest.ImportCase{ + "environment variables - password auth": { + Environment: map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com:5000/v3", + "OS_USERNAME": "myuser", + "OS_PASSWORD": "s3cr3tpassword", + "OS_PROJECT_NAME": "myproject", + "OS_PROJECT_ID": "abc123def456abc123def456abc123de", + "OS_REGION_NAME": "RegionOne", + "OS_USER_DOMAIN_NAME": "Default", + "OS_PROJECT_DOMAIN_NAME": "Default", + "OS_PROJECT_DOMAIN_ID": "default", + "OS_INTERFACE": "public", + "OS_IDENTITY_API_VERSION": "3", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldname.Username: "myuser", + fieldname.Password: "s3cr3tpassword", + fieldname.Project: "myproject", + fieldname.ProjectID: "abc123def456abc123def456abc123de", + fieldname.Region: "RegionOne", + fieldUserDomainName: "Default", + fieldProjectDomainName: "Default", + fieldProjectDomainID: "default", + fieldInterface: "public", + fieldIdentityAPIVersion: "3", + }, + }, + }, + }, + "environment variables - application credential auth": { + Environment: map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com:5000/v3", + "OS_APPLICATION_CREDENTIAL_ID": "xxxxxxxxxxxxxxx", + "OS_APPLICATION_CREDENTIAL_SECRET": "yyyyy342lhkwdh", + "OS_REGION_NAME": "RegionOne", + "OS_AUTH_TYPE": "v3applicationcredential", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldApplicationCredentialID: "xxxxxxxxxxxxxxx", + fieldApplicationCredentialSecret: "yyyyy342lhkwdh", + fieldname.Region: "RegionOne", + fieldAuthType: "v3applicationcredential", + }, + }, + }, + }, + "cloudrc file": { + Files: map[string]string{ + "~/openrc.sh": plugintest.LoadFixture(t, "openrc.sh"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldname.Username: "myuser", + fieldname.Password: "s3cr3tpassword", + fieldname.Project: "myproject", + fieldname.ProjectID: "abc123def456abc123def456abc123de", + fieldname.Region: "RegionOne", + fieldUserDomainName: "Default", + fieldProjectDomainName: "Default", + fieldProjectDomainID: "default", + fieldInterface: "public", + fieldIdentityAPIVersion: "3", + }, + }, + }, + }, + "clouds.yaml file - password auth": { + Files: map[string]string{ + "~/.config/openstack/clouds.yaml": plugintest.LoadFixture(t, "clouds.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldname.Username: "myuser", + fieldname.Password: "s3cr3tpassword", + fieldname.Project: "myproject", + fieldname.ProjectID: "abc123def456abc123def456abc123de", + fieldname.Region: "RegionOne", + fieldUserDomainName: "Default", + fieldProjectDomainName: "Default", + fieldProjectDomainID: "default", + fieldInterface: "public", + fieldIdentityAPIVersion: "3", + }, + NameHint: "mycloud", + }, + }, + }, + "clouds.yaml file - application credential auth": { + Files: map[string]string{ + "~/.config/openstack/clouds.yaml": plugintest.LoadFixture(t, "clouds-appcred.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldApplicationCredentialID: "xxxxxxxxxxxxxxx", + fieldApplicationCredentialSecret: "yyyyy342lhkwdh", + fieldname.Region: "RegionOne", + fieldProjectDomainName: "Default", + fieldInterface: "public", + fieldIdentityAPIVersion: "3", + fieldAuthType: "v3applicationcredential", + }, + NameHint: "mycloud", + }, + }, + }, + "OS_CLIENT_CONFIG_FILE env var": { + Environment: map[string]string{ + "OS_CLIENT_CONFIG_FILE": "~/custom-clouds.yaml", + }, + Files: map[string]string{ + "~/custom-clouds.yaml": plugintest.LoadFixture(t, "clouds.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldname.Username: "myuser", + fieldname.Password: "s3cr3tpassword", + fieldname.Project: "myproject", + fieldname.ProjectID: "abc123def456abc123def456abc123de", + fieldname.Region: "RegionOne", + fieldUserDomainName: "Default", + fieldProjectDomainName: "Default", + fieldProjectDomainID: "default", + fieldInterface: "public", + fieldIdentityAPIVersion: "3", + }, + NameHint: "mycloud", + }, + }, + }, + "clouds.yaml + secure.yaml merged": { + Files: map[string]string{ + "~/.config/openstack/clouds.yaml": plugintest.LoadFixture(t, "clouds-no-password.yaml"), + "~/.config/openstack/secure.yaml": plugintest.LoadFixture(t, "secure.yaml"), + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.URL: "https://keystone.example.com:5000/v3", + fieldname.Username: "myuser", + fieldname.Password: "s3cr3tpassword", + fieldname.Project: "myproject", + fieldname.ProjectID: "abc123def456abc123def456abc123de", + fieldname.Region: "RegionOne", + fieldUserDomainName: "Default", + fieldProjectDomainName: "Default", + fieldProjectDomainID: "default", + fieldInterface: "public", + fieldIdentityAPIVersion: "3", + }, + NameHint: "mycloud", + }, + }, + }, + }) +} diff --git a/plugins/openstack/importers.go b/plugins/openstack/importers.go new file mode 100644 index 00000000..6360bdc3 --- /dev/null +++ b/plugins/openstack/importers.go @@ -0,0 +1,268 @@ +package openstack + +import ( + "bufio" + "context" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +// TryOpenStackCloudRC imports credentials from an OpenStack RC file (cloudrc / openrc.sh). +// These files are typically downloaded from the OpenStack Horizon dashboard and contain +// "export OS_*=value" shell statements that configure authentication environment variables. +func TryOpenStackCloudRC(path string) sdk.Importer { + return importer.TryFile(path, func(ctx context.Context, contents importer.FileContents, in sdk.ImportInput, out *sdk.ImportAttempt) { + fields := make(map[sdk.FieldName]string) + + scanner := bufio.NewScanner(strings.NewReader(contents.ToString())) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !strings.HasPrefix(line, "export ") { + continue + } + line = strings.TrimPrefix(line, "export ") + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + // Strip surrounding quotes if present + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + if fieldName, ok := defaultEnvVarMapping[key]; ok && value != "" { + fields[fieldName] = value + } + } + + if len(fields) > 0 { + out.AddCandidate(sdk.ImportCandidate{Fields: fields}) + } + }) +} + +// TryOpenStackCloudsYAMLFromEnvVar imports credentials from the clouds.yaml file pointed to +// by the OS_CLIENT_CONFIG_FILE environment variable, which the OpenStack CLI checks first +// before falling back to the standard locations. A secure.yaml in the same directory is +// also merged in if present. +func TryOpenStackCloudsYAMLFromEnvVar() sdk.Importer { + return func(ctx context.Context, in sdk.ImportInput, out *sdk.ImportOutput) { + cloudsPath := os.Getenv("OS_CLIENT_CONFIG_FILE") + if cloudsPath == "" { + return + } + // Derive secure.yaml as a sibling of the specified clouds file (unexpanded, + // so TryOpenStackCloudsAndSecureYAML can expand it correctly). + securePath := filepath.Join(filepath.Dir(cloudsPath), "secure.yaml") + TryOpenStackCloudsAndSecureYAML(cloudsPath, securePath)(ctx, in, out) + } +} + +// TryOpenStackCloudsAndSecureYAML imports credentials by merging an OpenStack clouds.yaml +// file with the companion secure.yaml file at the same path. secure.yaml provides the base +// values (typically sensitive fields such as passwords) and clouds.yaml takes priority, +// following the OpenStack CLI convention for separating sensitive data from configuration. +func TryOpenStackCloudsAndSecureYAML(cloudsPath, securePath string) sdk.Importer { + return importer.TryFile(cloudsPath, func(ctx context.Context, contents importer.FileContents, in sdk.ImportInput, out *sdk.ImportAttempt) { + var cloudsConfig cloudsYAML + if err := contents.ToYAML(&cloudsConfig); err != nil { + out.AddError(err) + return + } + + // Read secure.yaml (optional — silently skip if absent or no path given). + var secureConfig cloudsYAML + if securePath != "" { + secureFilePath := expandPath(securePath, in) + if secureBytes, err := os.ReadFile(secureFilePath); err == nil { + _ = importer.FileContents(secureBytes).ToYAML(&secureConfig) + } + } + + // Build merged cloud map. clouds.yaml is the authoritative source of which clouds + // exist — orphaned entries in secure.yaml (no matching clouds.yaml entry) are ignored. + allClouds := make(map[string]cloudEntry) + for name, entry := range cloudsConfig.Clouds { + if secureEntry, exists := secureConfig.Clouds[name]; exists { + allClouds[name] = mergeCloudEntry(secureEntry, entry) + } else { + allClouds[name] = entry + } + } + + cloudNames := make([]string, 0, len(allClouds)) + for name := range allClouds { + cloudNames = append(cloudNames, name) + } + sort.Strings(cloudNames) + + for _, cloudName := range cloudNames { + cloud := allClouds[cloudName] + fields := make(map[sdk.FieldName]string) + + if cloud.Auth.AuthURL != "" { + fields[fieldname.URL] = cloud.Auth.AuthURL + } + if cloud.Auth.Username != "" { + fields[fieldname.Username] = cloud.Auth.Username + } + if cloud.Auth.Password != "" { + fields[fieldname.Password] = cloud.Auth.Password + } + if cloud.Auth.ApplicationCredentialID != "" { + fields[fieldApplicationCredentialID] = cloud.Auth.ApplicationCredentialID + } + if cloud.Auth.ApplicationCredentialSecret != "" { + fields[fieldApplicationCredentialSecret] = cloud.Auth.ApplicationCredentialSecret + } + if cloud.Auth.ProjectName != "" { + fields[fieldname.Project] = cloud.Auth.ProjectName + } + if cloud.Auth.ProjectID != "" { + fields[fieldname.ProjectID] = cloud.Auth.ProjectID + } + if cloud.RegionName != "" { + fields[fieldname.Region] = cloud.RegionName + } + if cloud.Auth.UserDomainName != "" { + fields[fieldUserDomainName] = cloud.Auth.UserDomainName + } + // project_domain_name can appear inside auth or at the entry level + if cloud.Auth.ProjectDomainName != "" { + fields[fieldProjectDomainName] = cloud.Auth.ProjectDomainName + } else if cloud.ProjectDomainName != "" { + fields[fieldProjectDomainName] = cloud.ProjectDomainName + } + if cloud.Auth.ProjectDomainID != "" { + fields[fieldProjectDomainID] = cloud.Auth.ProjectDomainID + } + if cloud.Interface != "" { + fields[fieldInterface] = cloud.Interface + } + if cloud.IdentityAPIVersion != "" { + fields[fieldIdentityAPIVersion] = cloud.IdentityAPIVersion + } + if cloud.AuthType != "" { + fields[fieldAuthType] = cloud.AuthType + } + + if len(fields) > 0 { + out.AddCandidate(sdk.ImportCandidate{ + Fields: fields, + NameHint: importer.SanitizeNameHint(cloudName), + }) + } + } + }) +} + +// TryOpenStackCloudsYAML imports credentials from an OpenStack clouds.yaml file only, +// without loading a companion secure.yaml. Use TryOpenStackCloudsAndSecureYAML when +// both files may be present. +func TryOpenStackCloudsYAML(path string) sdk.Importer { + return TryOpenStackCloudsAndSecureYAML(path, "") +} + +// expandPath resolves a path that may start with "~" to an absolute path using +// the home directory from the import context. +func expandPath(path string, in sdk.ImportInput) string { + if strings.HasPrefix(path, "~") { + return in.FromHomeDir(path[1:]) + } + return in.FromRootDir(path) +} + +// mergeCloudEntry merges two cloudEntry structs. The base provides default values; +// any non-empty field in override takes priority. +func mergeCloudEntry(base, override cloudEntry) cloudEntry { + result := base + result.Auth = mergeCloudAuth(base.Auth, override.Auth) + if override.RegionName != "" { + result.RegionName = override.RegionName + } + if override.Interface != "" { + result.Interface = override.Interface + } + if override.IdentityAPIVersion != "" { + result.IdentityAPIVersion = override.IdentityAPIVersion + } + if override.AuthType != "" { + result.AuthType = override.AuthType + } + if override.ProjectDomainName != "" { + result.ProjectDomainName = override.ProjectDomainName + } + return result +} + +// mergeCloudAuth merges two cloudAuth structs. Any non-empty field in override takes priority. +func mergeCloudAuth(base, override cloudAuth) cloudAuth { + result := base + if override.AuthURL != "" { + result.AuthURL = override.AuthURL + } + if override.Username != "" { + result.Username = override.Username + } + if override.Password != "" { + result.Password = override.Password + } + if override.ApplicationCredentialID != "" { + result.ApplicationCredentialID = override.ApplicationCredentialID + } + if override.ApplicationCredentialSecret != "" { + result.ApplicationCredentialSecret = override.ApplicationCredentialSecret + } + if override.ProjectName != "" { + result.ProjectName = override.ProjectName + } + if override.ProjectID != "" { + result.ProjectID = override.ProjectID + } + if override.UserDomainName != "" { + result.UserDomainName = override.UserDomainName + } + if override.ProjectDomainName != "" { + result.ProjectDomainName = override.ProjectDomainName + } + if override.ProjectDomainID != "" { + result.ProjectDomainID = override.ProjectDomainID + } + return result +} + +type cloudsYAML struct { + Clouds map[string]cloudEntry `yaml:"clouds"` +} + +type cloudEntry struct { + Auth cloudAuth `yaml:"auth"` + RegionName string `yaml:"region_name"` + Interface string `yaml:"interface"` + IdentityAPIVersion string `yaml:"identity_api_version"` + AuthType string `yaml:"auth_type"` + ProjectDomainName string `yaml:"project_domain_name"` // can also appear at entry level +} + +type cloudAuth struct { + AuthURL string `yaml:"auth_url"` + Username string `yaml:"username"` + Password string `yaml:"password"` + ApplicationCredentialID string `yaml:"application_credential_id"` + ApplicationCredentialSecret string `yaml:"application_credential_secret"` + ProjectName string `yaml:"project_name"` + ProjectID string `yaml:"project_id"` + UserDomainName string `yaml:"user_domain_name"` + ProjectDomainName string `yaml:"project_domain_name"` + ProjectDomainID string `yaml:"project_domain_id"` +} diff --git a/plugins/openstack/openstack.go b/plugins/openstack/openstack.go new file mode 100644 index 00000000..ec836118 --- /dev/null +++ b/plugins/openstack/openstack.go @@ -0,0 +1,42 @@ +package openstack + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/needsauth" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" +) + +func OpenStackCLI() schema.Executable { + return schema.Executable{ + Name: "OpenStack CLI", + Runs: []string{"openstack"}, + DocsURL: sdk.URL("https://docs.openstack.org/python-openstackclient/latest/"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.NotWithoutArgs(), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.AccessKey, + }, + }, + } +} + +func OSC() schema.Executable { + return schema.Executable{ + Name: "OSC", + Runs: []string{"osc"}, + DocsURL: sdk.URL("https://gtema.github.io/openstack/cli.html"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.NotWithoutArgs(), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.AccessKey, + }, + }, + } +} diff --git a/plugins/openstack/plugin.go b/plugins/openstack/plugin.go new file mode 100644 index 00000000..a513fbef --- /dev/null +++ b/plugins/openstack/plugin.go @@ -0,0 +1,23 @@ +package openstack + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "openstack", + Platform: schema.PlatformInfo{ + Name: "OpenStack", + Homepage: sdk.URL("https://www.openstack.org"), + }, + Credentials: []schema.CredentialType{ + Credentials(), + }, + Executables: []schema.Executable{ + OpenStackCLI(), + OSC(), + }, + } +} diff --git a/plugins/openstack/test-fixtures/clouds-appcred.yaml b/plugins/openstack/test-fixtures/clouds-appcred.yaml new file mode 100644 index 00000000..3e5a232f --- /dev/null +++ b/plugins/openstack/test-fixtures/clouds-appcred.yaml @@ -0,0 +1,11 @@ +clouds: + mycloud: + auth: + auth_url: https://keystone.example.com:5000/v3 + application_credential_id: "xxxxxxxxxxxxxxx" + application_credential_secret: "yyyyy342lhkwdh" + region_name: "RegionOne" + interface: "public" + project_domain_name: "Default" + identity_api_version: "3" + auth_type: "v3applicationcredential" diff --git a/plugins/openstack/test-fixtures/clouds-no-password.yaml b/plugins/openstack/test-fixtures/clouds-no-password.yaml new file mode 100644 index 00000000..1dabf3aa --- /dev/null +++ b/plugins/openstack/test-fixtures/clouds-no-password.yaml @@ -0,0 +1,13 @@ +clouds: + mycloud: + auth: + auth_url: https://keystone.example.com:5000/v3 + username: myuser + project_name: myproject + project_id: abc123def456abc123def456abc123de + user_domain_name: Default + project_domain_name: Default + project_domain_id: default + region_name: RegionOne + interface: public + identity_api_version: "3" diff --git a/plugins/openstack/test-fixtures/clouds.yaml b/plugins/openstack/test-fixtures/clouds.yaml new file mode 100644 index 00000000..85b69afe --- /dev/null +++ b/plugins/openstack/test-fixtures/clouds.yaml @@ -0,0 +1,14 @@ +clouds: + mycloud: + auth: + auth_url: https://keystone.example.com:5000/v3 + username: myuser + password: s3cr3tpassword + project_id: abc123def456abc123def456abc123de + project_name: myproject + user_domain_name: Default + project_domain_name: Default + project_domain_id: default + region_name: RegionOne + interface: public + identity_api_version: "3" diff --git a/plugins/openstack/test-fixtures/openrc.sh b/plugins/openstack/test-fixtures/openrc.sh new file mode 100644 index 00000000..966869a7 --- /dev/null +++ b/plugins/openstack/test-fixtures/openrc.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# OpenStack RC File - credentials for OpenStack Identity API v3 +# Source this file to set up your OpenStack environment variables. + +export OS_AUTH_URL=https://keystone.example.com:5000/v3 +export OS_PROJECT_ID=abc123def456abc123def456abc123de +export OS_PROJECT_NAME=myproject +export OS_USER_DOMAIN_NAME=Default +export OS_PROJECT_DOMAIN_NAME=Default +export OS_PROJECT_DOMAIN_ID=default +export OS_REGION_NAME=RegionOne +export OS_USERNAME=myuser +export OS_PASSWORD=s3cr3tpassword +export OS_INTERFACE=public +export OS_IDENTITY_API_VERSION=3 diff --git a/plugins/openstack/test-fixtures/secure.yaml b/plugins/openstack/test-fixtures/secure.yaml new file mode 100644 index 00000000..6e77f7bf --- /dev/null +++ b/plugins/openstack/test-fixtures/secure.yaml @@ -0,0 +1,4 @@ +clouds: + mycloud: + auth: + password: s3cr3tpassword From ab9b7cbe38155a2ede3c870b026babbe6f91fe33 Mon Sep 17 00:00:00 2001 From: dmbuil Date: Thu, 26 Mar 2026 00:31:47 +0100 Subject: [PATCH 2/2] Add local dirs as well --- plugins/openstack/credentials.go | 2 ++ plugins/openstack/importers.go | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/plugins/openstack/credentials.go b/plugins/openstack/credentials.go index b0660a82..8081b1bd 100644 --- a/plugins/openstack/credentials.go +++ b/plugins/openstack/credentials.go @@ -118,6 +118,8 @@ func Credentials() schema.CredentialType { }), Importer: importer.TryAll( importer.TryEnvVarPair(defaultEnvVarMapping), + TryOpenStackCloudRCFromCWD(), + TryOpenStackCloudsYAMLFromCWD(), TryOpenStackCloudRC("~/openrc.sh"), TryOpenStackCloudRC("~/.config/openstack/openrc.sh"), TryOpenStackCloudsYAMLFromEnvVar(), diff --git a/plugins/openstack/importers.go b/plugins/openstack/importers.go index 6360bdc3..eb237366 100644 --- a/plugins/openstack/importers.go +++ b/plugins/openstack/importers.go @@ -68,6 +68,33 @@ func TryOpenStackCloudsYAMLFromEnvVar() sdk.Importer { } } +// TryOpenStackCloudRCFromCWD imports credentials from an openrc.sh file in the current +// working directory, useful when credentials are stored alongside a project. +func TryOpenStackCloudRCFromCWD() sdk.Importer { + return func(ctx context.Context, in sdk.ImportInput, out *sdk.ImportOutput) { + cwd, err := os.Getwd() + if err != nil { + return + } + TryOpenStackCloudRC(filepath.Join(cwd, "openrc.sh"))(ctx, in, out) + } +} + +// TryOpenStackCloudsYAMLFromCWD imports credentials from a clouds.yaml file in the current +// working directory, merging with a companion secure.yaml if present. +func TryOpenStackCloudsYAMLFromCWD() sdk.Importer { + return func(ctx context.Context, in sdk.ImportInput, out *sdk.ImportOutput) { + cwd, err := os.Getwd() + if err != nil { + return + } + TryOpenStackCloudsAndSecureYAML( + filepath.Join(cwd, "clouds.yaml"), + filepath.Join(cwd, "secure.yaml"), + )(ctx, in, out) + } +} + // TryOpenStackCloudsAndSecureYAML imports credentials by merging an OpenStack clouds.yaml // file with the companion secure.yaml file at the same path. secure.yaml provides the base // values (typically sensitive fields such as passwords) and clouds.yaml takes priority,