Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
>
Expand Down
19 changes: 13 additions & 6 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
114 changes: 114 additions & 0 deletions internal/openstack/env.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
Loading
Loading