From a482648688897bc59eaf84f421276c96a9d1a8d4 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Fri, 2 Oct 2026 10:43:37 +1300 Subject: [PATCH] support authentication from OS_* environment variables --- README.md | 24 +- cmd/main.go | 19 +- internal/openstack/env.go | 114 ++++++++++ internal/openstack/env_test.go | 227 +++++++++++++++++++ internal/resources/contexts/commands.go | 19 ++ internal/resources/contexts/contexts.go | 22 +- internal/resources/contexts/contexts_test.go | 92 ++++++++ 7 files changed, 507 insertions(+), 10 deletions(-) create mode 100644 internal/openstack/env.go create mode 100644 internal/openstack/env_test.go create mode 100644 internal/resources/contexts/contexts_test.go diff --git a/README.md b/README.md index e3a78ae..69b2ff0 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,27 @@ configuration files from the following places: > [!Note] > All the discovered files will be automatically loaded as context in **o7k** +### Environment variables + +**o7k** can also authenticate from the standard OpenStack `OS_*` environment variables, e.g. after sourcing an +`openrc` file or by injecting them from a secret manager: + +```bash +source openrc.sh && o7k +``` + +When `OS_AUTH_URL` is set, an additional context named `envvars` is listed after the `clouds.yaml` contexts +(the name follows the openstacksdk convention), and a `clouds.yaml` file is no longer required. + +The following variables are honoured: `OS_AUTH_URL`, `OS_USERNAME` or `OS_USER_ID`, `OS_PASSWORD`, +`OS_PROJECT_ID` or `OS_PROJECT_NAME`, `OS_USER_DOMAIN_ID` or `OS_USER_DOMAIN_NAME`, `OS_PROJECT_DOMAIN_ID` or +`OS_PROJECT_DOMAIN_NAME`, `OS_DOMAIN_ID` or `OS_DOMAIN_NAME`, `OS_APPLICATION_CREDENTIAL_ID`, +`OS_APPLICATION_CREDENTIAL_NAME`, `OS_APPLICATION_CREDENTIAL_SECRET`, `OS_TOKEN` and `OS_REGION_NAME`. + +> [!Note] +> As with `clouds.yaml`, the user and the project are expected to live in the same domain; set `OS_PROJECT_ID` +> to scope to a project in a different domain. `OS_CACERT`, `OS_CERT` and `OS_KEY` are not applied to this context yet. + ### Global Controls | Key | Action | @@ -957,7 +978,8 @@ func connectClient(_ context.Context, current pluginsdk.Context) (*golangsdk.Pro uses Open Telekom Cloud Golang SDK. A provider plugin using another SDK should implement `connectClient` using that SDK's `clouds.yaml` and authentication support. > [!Note] -> Always use both `Cloud` and `CloudsPath`. `CloudsPath` identifies the exact `clouds.yaml` file from which **o7k** loaded the active cloud. +> Always use both `Cloud` and `CloudsPath`. `CloudsPath` identifies the exact `clouds.yaml` file from which **o7k** loaded the active cloud. +> When the active context is `envvars` (credentials taken from the `OS_*` environment variables), `CloudsPath` is empty; plugin processes inherit the environment of **o7k** and should authenticate from it. > > `pluginsdk.ClientProvider` caches the authenticated provider client for the current context generation. When the user activates another **o7k** context, the generation changes and the provider client is recreated automatically. > diff --git a/cmd/main.go b/cmd/main.go index 523e1db..4762eb5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -117,14 +117,21 @@ func main() { cloudsPaths, err := openstack.DiscoverCloudsFiles("") if err != nil { - fmt.Fprintf(stderr, "error discovering clouds.yaml: %v\n", err) - os.Exit(1) + if _, ok := openstack.EnvCloud(); !ok { + fmt.Fprintf(stderr, "error discovering clouds.yaml: %v\n", err) + os.Exit(1) + } + + logger.Info("clouds.yaml not found, using OS_* environment variables", "error", err) + cloudsPaths = nil } - _, err = openstack.LoadClouds(cloudsPaths) - if err != nil { - fmt.Fprintf(stderr, "error loading clouds.yaml: %v\n", err) - os.Exit(1) + if len(cloudsPaths) > 0 { + _, err = openstack.LoadClouds(cloudsPaths) + if err != nil { + fmt.Fprintf(stderr, "error loading clouds.yaml: %v\n", err) + os.Exit(1) + } } if err := registry.Register(contexts.New(cloudsPaths)); err != nil { diff --git a/internal/openstack/env.go b/internal/openstack/env.go new file mode 100644 index 0000000..0949b30 --- /dev/null +++ b/internal/openstack/env.go @@ -0,0 +1,114 @@ +package openstack + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/config" +) + +const ( + // EnvCloudName is the name of the context built from the OS_* environment + // variables. It matches the name openstacksdk uses for the same thing. + EnvCloudName = "envvars" + + // EnvCloudSource is shown in place of a clouds.yaml path for the context + // built from the OS_* environment variables. + EnvCloudSource = "OS_* environment" +) + +// EnvCloud reports whether OS_AUTH_URL is set and, if so, returns the display +// data of the context built from the OS_* environment variables. +func EnvCloud() (Cloud, bool) { + authURL := os.Getenv("OS_AUTH_URL") + if authURL == "" { + return Cloud{}, false + } + + domainID, domainName := envDomain() + + return Cloud{ + Name: EnvCloudName, + Region: os.Getenv("OS_REGION_NAME"), + Project: firstNonEmpty(os.Getenv("OS_PROJECT_NAME"), os.Getenv("OS_PROJECT_ID")), + Domain: firstNonEmpty(domainName, domainID), + Identity: authURL, + }, true +} + +// ConnectFromEnv authenticates using the OS_* environment variables. +func (c *Context) ConnectFromEnv(ctx context.Context) error { + provider, err := config.NewProviderClient(ctx, envAuthOptions()) + if err != nil { + return fmt.Errorf("authenticating cloud %q: %w", c.Cloud, err) + } + + c.Provider = provider + c.CloudsPath = "" + + slog.Info("connected to cloud", "cloud", c.Cloud, "identityEndpoint", provider.IdentityEndpoint) + + return nil +} + +// envAuthOptions maps the OS_* environment variables onto gophercloud auth +// options, with the same precedence clouds.Parse applies to clouds.yaml. +func envAuthOptions() gophercloud.AuthOptions { + domainID, domainName := envDomain() + token := os.Getenv("OS_TOKEN") + + return gophercloud.AuthOptions{ + IdentityEndpoint: os.Getenv("OS_AUTH_URL"), + Username: os.Getenv("OS_USERNAME"), + UserID: firstNonEmpty(os.Getenv("OS_USER_ID"), os.Getenv("OS_USERID")), + Password: os.Getenv("OS_PASSWORD"), + Passcode: os.Getenv("OS_PASSCODE"), + DomainID: domainID, + DomainName: domainName, + TenantID: firstNonEmpty(os.Getenv("OS_PROJECT_ID"), os.Getenv("OS_TENANT_ID")), + TenantName: firstNonEmpty(os.Getenv("OS_PROJECT_NAME"), os.Getenv("OS_TENANT_NAME")), + TokenID: token, + ApplicationCredentialID: os.Getenv("OS_APPLICATION_CREDENTIAL_ID"), + ApplicationCredentialName: os.Getenv("OS_APPLICATION_CREDENTIAL_NAME"), + ApplicationCredentialSecret: os.Getenv("OS_APPLICATION_CREDENTIAL_SECRET"), + // gophercloud rejects AllowReauth when a token is passed through as is. + AllowReauth: token == "", + } +} + +// envDomain picks the domain used for authentication: the user domain first, +// then the project domain, then the plain domain. Within a level the ID wins +// over the name, and only one of the two is ever returned, because gophercloud +// refuses auth options that carry both a domain ID and a domain name. +func envDomain() (id, name string) { + levels := [][2]string{ + {"OS_USER_DOMAIN_ID", "OS_USER_DOMAIN_NAME"}, + {"OS_PROJECT_DOMAIN_ID", "OS_PROJECT_DOMAIN_NAME"}, + {"OS_DOMAIN_ID", "OS_DOMAIN_NAME"}, + } + + for _, level := range levels { + if value := os.Getenv(level[0]); value != "" { + return value, "" + } + + if value := os.Getenv(level[1]); value != "" { + return "", value + } + } + + return "", "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + + return "" +} diff --git a/internal/openstack/env_test.go b/internal/openstack/env_test.go new file mode 100644 index 0000000..c05e9d5 --- /dev/null +++ b/internal/openstack/env_test.go @@ -0,0 +1,227 @@ +package openstack + +import ( + "context" + "strings" + "testing" +) + +// envVariables lists every variable read by env.go so that tests can clear +// them all and not depend on the developer's shell. +var envVariables = []string{ + "OS_AUTH_URL", + "OS_USERNAME", + "OS_USER_ID", + "OS_USERID", + "OS_PASSWORD", + "OS_PASSCODE", + "OS_USER_DOMAIN_ID", + "OS_USER_DOMAIN_NAME", + "OS_PROJECT_DOMAIN_ID", + "OS_PROJECT_DOMAIN_NAME", + "OS_DOMAIN_ID", + "OS_DOMAIN_NAME", + "OS_PROJECT_ID", + "OS_PROJECT_NAME", + "OS_TENANT_ID", + "OS_TENANT_NAME", + "OS_TOKEN", + "OS_APPLICATION_CREDENTIAL_ID", + "OS_APPLICATION_CREDENTIAL_NAME", + "OS_APPLICATION_CREDENTIAL_SECRET", + "OS_REGION_NAME", +} + +func setEnv(t *testing.T, vars map[string]string) { + t.Helper() + + for _, name := range envVariables { + t.Setenv(name, "") + } + + for name, value := range vars { + t.Setenv(name, value) + } +} + +func TestEnvCloudNotConfigured(t *testing.T) { + setEnv(t, nil) + + if _, ok := EnvCloud(); ok { + t.Fatal("EnvCloud() reported a cloud without OS_AUTH_URL") + } +} + +func TestEnvCloudOpenRC(t *testing.T) { + setEnv(t, map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com/v3", + "OS_USERNAME": "alice", + "OS_PASSWORD": "secret", + "OS_PROJECT_NAME": "research", + "OS_USER_DOMAIN_NAME": "Default", + "OS_PROJECT_DOMAIN_NAME": "Default", + "OS_REGION_NAME": "region-one", + }) + + cloud, ok := EnvCloud() + if !ok { + t.Fatal("EnvCloud() reported no cloud with OS_AUTH_URL set") + } + + want := Cloud{ + Name: EnvCloudName, + Region: "region-one", + Project: "research", + Domain: "Default", + Identity: "https://keystone.example.com/v3", + } + + if cloud != want { + t.Fatalf("EnvCloud() = %+v, want %+v", cloud, want) + } + + opts := envAuthOptions() + + if opts.IdentityEndpoint != "https://keystone.example.com/v3" { + t.Fatalf("IdentityEndpoint = %q", opts.IdentityEndpoint) + } + + if opts.Username != "alice" || opts.Password != "secret" { + t.Fatalf("Username = %q, Password = %q", opts.Username, opts.Password) + } + + if opts.DomainName != "Default" || opts.DomainID != "" { + t.Fatalf("DomainName = %q, DomainID = %q, want Default and empty", opts.DomainName, opts.DomainID) + } + + if opts.TenantName != "research" || opts.TenantID != "" { + t.Fatalf("TenantName = %q, TenantID = %q, want research and empty", opts.TenantName, opts.TenantID) + } + + if !opts.AllowReauth { + t.Fatal("AllowReauth = false, want true") + } +} + +func TestEnvAuthOptionsHorizonRC(t *testing.T) { + // Horizon's generated openrc sets a user domain name and a project domain + // ID; gophercloud refuses auth options that carry both an ID and a name. + setEnv(t, map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com/v3", + "OS_USERNAME": "alice", + "OS_PASSWORD": "secret", + "OS_PROJECT_ID": "0123456789abcdef", + "OS_PROJECT_NAME": "research", + "OS_USER_DOMAIN_NAME": "Default", + "OS_PROJECT_DOMAIN_ID": "default", + "OS_INTERFACE": "public", + "OS_IDENTITY_API_VERSION": "3", + }) + + opts := envAuthOptions() + + if opts.DomainName != "Default" || opts.DomainID != "" { + t.Fatalf("DomainName = %q, DomainID = %q, want Default and empty", opts.DomainName, opts.DomainID) + } + + if opts.TenantID != "0123456789abcdef" || opts.TenantName != "research" { + t.Fatalf("TenantID = %q, TenantName = %q", opts.TenantID, opts.TenantName) + } +} + +func TestEnvAuthOptionsDomainIDWins(t *testing.T) { + setEnv(t, map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com/v3", + "OS_USER_DOMAIN_ID": "d1", + "OS_USER_DOMAIN_NAME": "Default", + "OS_DOMAIN_NAME": "ignored", + }) + + opts := envAuthOptions() + + if opts.DomainID != "d1" || opts.DomainName != "" { + t.Fatalf("DomainID = %q, DomainName = %q, want d1 and empty", opts.DomainID, opts.DomainName) + } +} + +func TestEnvAuthOptionsApplicationCredential(t *testing.T) { + setEnv(t, map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com/v3", + "OS_APPLICATION_CREDENTIAL_ID": "app-id", + "OS_APPLICATION_CREDENTIAL_SECRET": "app-secret", + }) + + opts := envAuthOptions() + + if opts.ApplicationCredentialID != "app-id" || opts.ApplicationCredentialSecret != "app-secret" { + t.Fatalf("ApplicationCredentialID = %q, ApplicationCredentialSecret = %q", opts.ApplicationCredentialID, opts.ApplicationCredentialSecret) + } + + if opts.Username != "" || opts.Password != "" || opts.DomainName != "" || opts.DomainID != "" { + t.Fatalf("unexpected user fields set: %+v", opts) + } + + if !opts.AllowReauth { + t.Fatal("AllowReauth = false, want true") + } +} + +func TestEnvAuthOptionsUserID(t *testing.T) { + setEnv(t, map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com/v3", + "OS_USER_ID": "new", + "OS_USERID": "old", + }) + + if got := envAuthOptions().UserID; got != "new" { + t.Fatalf("UserID = %q, want new", got) + } + + t.Setenv("OS_USER_ID", "") + + if got := envAuthOptions().UserID; got != "old" { + t.Fatalf("UserID = %q, want old", got) + } +} + +func TestEnvAuthOptionsToken(t *testing.T) { + setEnv(t, map[string]string{ + "OS_AUTH_URL": "https://keystone.example.com/v3", + "OS_TOKEN": "token", + }) + + opts := envAuthOptions() + + if opts.TokenID != "token" { + t.Fatalf("TokenID = %q, want token", opts.TokenID) + } + + if opts.AllowReauth { + t.Fatal("AllowReauth = true, want false with a token") + } +} + +func TestConnectFromEnvError(t *testing.T) { + setEnv(t, map[string]string{ + "OS_AUTH_URL": "http://127.0.0.1:1/v3", + "OS_USERNAME": "alice", + "OS_PASSWORD": "secret", + "OS_PROJECT_NAME": "research", + "OS_USER_DOMAIN_NAME": "Default", + }) + + c := Context{Cloud: EnvCloudName} + + err := c.ConnectFromEnv(context.Background()) + if err == nil { + t.Fatal("ConnectFromEnv() succeeded against a closed port") + } + + if !strings.Contains(err.Error(), `authenticating cloud "envvars"`) { + t.Fatalf("ConnectFromEnv() error = %q", err) + } + + if c.Provider != nil { + t.Fatal("Provider set after a failed connection") + } +} diff --git a/internal/resources/contexts/commands.go b/internal/resources/contexts/commands.go index 2c6a7fb..0777a54 100644 --- a/internal/resources/contexts/commands.go +++ b/internal/resources/contexts/commands.go @@ -19,6 +19,25 @@ func (r *Resource) activate(row resource.Row) tea.Cmd { cloudName := row.ID return func() tea.Msg { + if cloudName == openstack.EnvCloudName { + cloud, _ := openstack.EnvCloud() + + next := openstack.Context{ + Cloud: cloudName, + Identity: cloud.Identity, + Region: cloud.Region, + Domain: cloud.Domain, + Project: cloud.Project, + } + + err := next.ConnectFromEnv(context.Background()) + + return ActivatedMsg{ + Context: &next, + Err: err, + } + } + clouds, err := openstack.LoadClouds(cloudsPaths) if err != nil { return ActivatedMsg{Err: err} diff --git a/internal/resources/contexts/contexts.go b/internal/resources/contexts/contexts.go index 953dcdd..452ee0c 100644 --- a/internal/resources/contexts/contexts.go +++ b/internal/resources/contexts/contexts.go @@ -2,6 +2,7 @@ package contexts import ( "context" + "log/slog" "github.com/akyriako/o7k/internal/openstack" "github.com/akyriako/o7k/internal/resource" @@ -45,9 +46,24 @@ func (r *Resource) Commands() []resource.Command { } func (r *Resource) List(_ context.Context) ([]resource.Row, error) { - clouds, err := openstack.LoadClouds(r.cloudsPaths) - if err != nil { - return nil, err + clouds := &openstack.Clouds{} + + if len(r.cloudsPaths) > 0 { + loaded, err := openstack.LoadClouds(r.cloudsPaths) + if err != nil { + return nil, err + } + + clouds = loaded + } + + if cloud, ok := openstack.EnvCloud(); ok { + if _, exists := clouds.Get(cloud.Name); exists { + slog.Warn("ignoring OS_* environment variables, clouds.yaml defines a cloud with the same name", "cloud", cloud.Name) + } else { + cloud.Path = openstack.EnvCloudSource + clouds.Items = append(clouds.Items, cloud) + } } rows := make([]resource.Row, 0, len(clouds.Items)) diff --git a/internal/resources/contexts/contexts_test.go b/internal/resources/contexts/contexts_test.go new file mode 100644 index 0000000..02212ab --- /dev/null +++ b/internal/resources/contexts/contexts_test.go @@ -0,0 +1,92 @@ +package contexts + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/akyriako/o7k/internal/openstack" +) + +func writeCloudsFile(t *testing.T, name string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "clouds.yaml") + + content := "clouds:\n " + name + ":\n region_name: region-one\n auth:\n auth_url: https://prod.example.com\n project_name: prod-project\n domain_name: prod-domain\n" + + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + return path +} + +func TestListWithoutEnvironment(t *testing.T) { + t.Setenv("OS_AUTH_URL", "") + + path := writeCloudsFile(t, "prod") + + rows, err := New([]string{path}).List(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(rows) != 1 || rows[0].ID != "prod" { + t.Fatalf("List() = %+v, want only prod", rows) + } + + if rows[0].Fields["cloudsyaml"] != path { + t.Fatalf("cloudsyaml = %q, want %q", rows[0].Fields["cloudsyaml"], path) + } +} + +func TestListAppendsEnvironmentLast(t *testing.T) { + t.Setenv("OS_AUTH_URL", "https://keystone.example.com/v3") + + rows, err := New([]string{writeCloudsFile(t, "prod")}).List(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(rows) != 2 || rows[0].ID != "prod" || rows[1].ID != openstack.EnvCloudName { + t.Fatalf("List() = %+v, want prod then %s", rows, openstack.EnvCloudName) + } + + if rows[1].Fields["cloudsyaml"] != openstack.EnvCloudSource { + t.Fatalf("cloudsyaml = %q, want %q", rows[1].Fields["cloudsyaml"], openstack.EnvCloudSource) + } +} + +func TestListSkipsEnvironmentOnNameCollision(t *testing.T) { + t.Setenv("OS_AUTH_URL", "https://keystone.example.com/v3") + + path := writeCloudsFile(t, openstack.EnvCloudName) + + rows, err := New([]string{path}).List(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(rows) != 1 || rows[0].ID != openstack.EnvCloudName { + t.Fatalf("List() = %+v, want a single %s row", rows, openstack.EnvCloudName) + } + + if rows[0].Fields["cloudsyaml"] != path { + t.Fatalf("cloudsyaml = %q, want the clouds.yaml entry %q", rows[0].Fields["cloudsyaml"], path) + } +} + +func TestListEnvironmentOnly(t *testing.T) { + t.Setenv("OS_AUTH_URL", "https://keystone.example.com/v3") + + rows, err := New(nil).List(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(rows) != 1 || rows[0].ID != openstack.EnvCloudName { + t.Fatalf("List() = %+v, want a single %s row", rows, openstack.EnvCloudName) + } +}