Skip to content

Commit f0c3c53

Browse files
authored
Merge pull request #12 from platform9/feat/provider-clouds-yaml
feat(provider): clouds.yaml (cloud / OS_CLOUD) support
2 parents 6f68fee + bd49286 commit f0c3c53

6 files changed

Lines changed: 364 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ All notable changes to this project are documented here. The format is based on
5454
descriptions. Generated docs are produced on demand / at release and are not committed.
5555
- CI: `golangci-lint` (v2) and a docs-generation smoke test added to `test.yml`.
5656

57+
- Provider `cloud` (clouds.yaml) support: when `cloud` (or `OS_CLOUD`) is set, auth
58+
defaults are sourced from a `clouds.yaml` entry (searched at `$OS_CLIENT_CONFIG_FILE`,
59+
`./clouds.yaml`, `~/.config/openstack/clouds.yaml`, `/etc/openstack/clouds.yaml`).
60+
Precedence is explicit config > `OS_*` env > `clouds.yaml`.
61+
5762
### Known gaps
58-
- `cloud` (clouds.yaml) is declared but not yet implemented; it errors if set.
5963
- `max_retries` / retry transport and per-resource `region` override are stubs pending
6064
Phase 1.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/hashicorp/terraform-plugin-framework v1.19.0
99
github.com/hashicorp/terraform-plugin-go v0.31.0
1010
github.com/hashicorp/terraform-plugin-testing v1.16.0
11+
gopkg.in/yaml.v3 v3.0.1
1112
)
1213

1314
require (
@@ -82,5 +83,4 @@ require (
8283
google.golang.org/grpc v1.82.0 // indirect
8384
google.golang.org/protobuf v1.36.11 // indirect
8485
gopkg.in/yaml.v2 v2.4.0 // indirect
85-
gopkg.in/yaml.v3 v3.0.1 // indirect
8686
)

internal/clients/clouds.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) Platform9 Systems, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package clients
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
// CloudConfig holds the auth-relevant fields resolved from a single clouds.yaml
15+
// entry. These values are the lowest-precedence defaults: explicit provider
16+
// configuration and OS_* environment variables override them.
17+
type CloudConfig struct {
18+
AuthURL string
19+
Region string
20+
Username string
21+
UserID string
22+
Password string
23+
TenantName string
24+
TenantID string
25+
UserDomainID string
26+
UserDomainName string
27+
ProjectDomainID string
28+
ProjectDomainName string
29+
Token string
30+
AppCredID string
31+
AppCredName string
32+
AppCredSecret string
33+
CACertFile string
34+
35+
// Insecure mirrors clouds.yaml `verify` (verify: false => insecure: true).
36+
// HasInsecure records whether `verify` was present at all, so callers can
37+
// distinguish "not set" from "explicitly true".
38+
Insecure bool
39+
HasInsecure bool
40+
}
41+
42+
// cloudsFile is the subset of clouds.yaml this provider understands.
43+
type cloudsFile struct {
44+
Clouds map[string]cloudEntry `yaml:"clouds"`
45+
}
46+
47+
type cloudEntry struct {
48+
Auth cloudAuth `yaml:"auth"`
49+
RegionName string `yaml:"region_name"`
50+
Verify *bool `yaml:"verify"`
51+
CACert string `yaml:"cacert"`
52+
}
53+
54+
type cloudAuth struct {
55+
AuthURL string `yaml:"auth_url"`
56+
Username string `yaml:"username"`
57+
UserID string `yaml:"user_id"`
58+
Password string `yaml:"password"`
59+
ProjectName string `yaml:"project_name"`
60+
ProjectID string `yaml:"project_id"`
61+
UserDomainName string `yaml:"user_domain_name"`
62+
UserDomainID string `yaml:"user_domain_id"`
63+
ProjectDomainName string `yaml:"project_domain_name"`
64+
ProjectDomainID string `yaml:"project_domain_id"`
65+
DomainName string `yaml:"domain_name"`
66+
DomainID string `yaml:"domain_id"`
67+
Token string `yaml:"token"`
68+
ApplicationCredentialID string `yaml:"application_credential_id"`
69+
ApplicationCredentialName string `yaml:"application_credential_name"`
70+
ApplicationCredentialSecret string `yaml:"application_credential_secret"`
71+
}
72+
73+
// LoadCloud finds a clouds.yaml file and returns the named cloud's resolved
74+
// configuration. It searches, in order: $OS_CLIENT_CONFIG_FILE, ./clouds.yaml,
75+
// ~/.config/openstack/clouds.yaml, and /etc/openstack/clouds.yaml.
76+
//
77+
// Only the subset of clouds.yaml relevant to authentication is read. Secrets
78+
// split into a separate secure.yaml, cloud "profiles", and clouds-public.yaml
79+
// are not yet resolved.
80+
func LoadCloud(name string) (*CloudConfig, error) {
81+
path, data, err := findCloudsYAML()
82+
if err != nil {
83+
return nil, err
84+
}
85+
86+
var cf cloudsFile
87+
if err := yaml.Unmarshal(data, &cf); err != nil {
88+
return nil, fmt.Errorf("pcd: parsing %s: %w", path, err)
89+
}
90+
91+
entry, ok := cf.Clouds[name]
92+
if !ok {
93+
return nil, fmt.Errorf("pcd: cloud %q not found in %s", name, path)
94+
}
95+
96+
// A single `domain_name`/`domain_id` under auth applies to both the user and
97+
// project domain unless a more specific key overrides it.
98+
cc := &CloudConfig{
99+
AuthURL: entry.Auth.AuthURL,
100+
Region: entry.RegionName,
101+
Username: entry.Auth.Username,
102+
UserID: entry.Auth.UserID,
103+
Password: entry.Auth.Password,
104+
TenantName: entry.Auth.ProjectName,
105+
TenantID: entry.Auth.ProjectID,
106+
UserDomainName: firstNonEmpty(entry.Auth.UserDomainName, entry.Auth.DomainName),
107+
UserDomainID: firstNonEmpty(entry.Auth.UserDomainID, entry.Auth.DomainID),
108+
ProjectDomainName: firstNonEmpty(entry.Auth.ProjectDomainName, entry.Auth.DomainName),
109+
ProjectDomainID: firstNonEmpty(entry.Auth.ProjectDomainID, entry.Auth.DomainID),
110+
Token: entry.Auth.Token,
111+
AppCredID: entry.Auth.ApplicationCredentialID,
112+
AppCredName: entry.Auth.ApplicationCredentialName,
113+
AppCredSecret: entry.Auth.ApplicationCredentialSecret,
114+
CACertFile: entry.CACert,
115+
}
116+
if entry.Verify != nil {
117+
cc.HasInsecure = true
118+
cc.Insecure = !*entry.Verify
119+
}
120+
return cc, nil
121+
}
122+
123+
func findCloudsYAML() (string, []byte, error) {
124+
var candidates []string
125+
if p := os.Getenv("OS_CLIENT_CONFIG_FILE"); p != "" {
126+
candidates = append(candidates, p)
127+
}
128+
candidates = append(candidates, "clouds.yaml")
129+
if home, err := os.UserHomeDir(); err == nil {
130+
candidates = append(candidates, filepath.Join(home, ".config", "openstack", "clouds.yaml"))
131+
}
132+
candidates = append(candidates, filepath.Join("/etc", "openstack", "clouds.yaml"))
133+
134+
for _, p := range candidates {
135+
if data, err := os.ReadFile(p); err == nil {
136+
return p, data, nil
137+
}
138+
}
139+
return "", nil, fmt.Errorf(
140+
"pcd: no clouds.yaml found (searched OS_CLIENT_CONFIG_FILE, ./clouds.yaml, ~/.config/openstack/clouds.yaml, /etc/openstack/clouds.yaml)")
141+
}

internal/clients/clouds_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) Platform9 Systems, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package clients
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
)
11+
12+
const sampleCloudsYAML = `
13+
clouds:
14+
pcd:
15+
region_name: Infra
16+
verify: false
17+
cacert: /etc/pki/ca.pem
18+
auth:
19+
auth_url: https://pcd.example.com/keystone/v3
20+
username: admin
21+
password: s3cret
22+
project_name: service
23+
user_domain_name: Default
24+
domain_name: Default
25+
application_credential_id: ""
26+
secure-cloud:
27+
auth:
28+
auth_url: https://other.example.com/v3
29+
username: bob
30+
password: pw
31+
project_id: abc123
32+
user_domain_id: d1
33+
project_domain_id: d2
34+
`
35+
36+
// writeCloudsYAML writes the sample file and points OS_CLIENT_CONFIG_FILE at it.
37+
func writeCloudsYAML(t *testing.T, body string) {
38+
t.Helper()
39+
dir := t.TempDir()
40+
p := filepath.Join(dir, "clouds.yaml")
41+
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
42+
t.Fatalf("writing fixture: %v", err)
43+
}
44+
t.Setenv("OS_CLIENT_CONFIG_FILE", p)
45+
}
46+
47+
func TestLoadCloud_basic(t *testing.T) {
48+
writeCloudsYAML(t, sampleCloudsYAML)
49+
50+
cc, err := LoadCloud("pcd")
51+
if err != nil {
52+
t.Fatalf("LoadCloud: %v", err)
53+
}
54+
55+
checks := map[string]struct{ got, want string }{
56+
"auth_url": {cc.AuthURL, "https://pcd.example.com/keystone/v3"},
57+
"region": {cc.Region, "Infra"},
58+
"username": {cc.Username, "admin"},
59+
"password": {cc.Password, "s3cret"},
60+
"project_name": {cc.TenantName, "service"},
61+
"user_domain_name": {cc.UserDomainName, "Default"},
62+
"project_domain_name": {cc.ProjectDomainName, "Default"}, // falls back to domain_name
63+
"cacert": {cc.CACertFile, "/etc/pki/ca.pem"},
64+
}
65+
for name, c := range checks {
66+
if c.got != c.want {
67+
t.Errorf("%s = %q, want %q", name, c.got, c.want)
68+
}
69+
}
70+
if !cc.HasInsecure || !cc.Insecure {
71+
t.Errorf("verify:false should set Insecure=true (HasInsecure=%v Insecure=%v)", cc.HasInsecure, cc.Insecure)
72+
}
73+
}
74+
75+
func TestLoadCloud_projectIDAndDistinctDomains(t *testing.T) {
76+
writeCloudsYAML(t, sampleCloudsYAML)
77+
78+
cc, err := LoadCloud("secure-cloud")
79+
if err != nil {
80+
t.Fatalf("LoadCloud: %v", err)
81+
}
82+
if cc.TenantID != "abc123" {
83+
t.Errorf("project_id = %q, want abc123", cc.TenantID)
84+
}
85+
if cc.UserDomainID != "d1" || cc.ProjectDomainID != "d2" {
86+
t.Errorf("distinct domains not honored: user=%q project=%q", cc.UserDomainID, cc.ProjectDomainID)
87+
}
88+
// verify absent => HasInsecure false, Insecure false (do not clobber other tiers).
89+
if cc.HasInsecure {
90+
t.Errorf("verify absent should leave HasInsecure=false, got true")
91+
}
92+
}
93+
94+
func TestLoadCloud_missingCloud(t *testing.T) {
95+
writeCloudsYAML(t, sampleCloudsYAML)
96+
if _, err := LoadCloud("does-not-exist"); err == nil {
97+
t.Fatal("expected error for missing cloud name")
98+
}
99+
}
100+
101+
func TestLoadCloud_malformed(t *testing.T) {
102+
writeCloudsYAML(t, "clouds: [this is not a map")
103+
if _, err := LoadCloud("pcd"); err == nil {
104+
t.Fatal("expected parse error for malformed YAML")
105+
}
106+
}
107+
108+
func TestLoadCloud_noFile(t *testing.T) {
109+
// Point at a non-existent file and rely on the other search paths being absent
110+
// in the test environment is fragile; instead point the env var at a missing
111+
// path and ensure that specific path fails to load. Because ./clouds.yaml or
112+
// system paths could theoretically exist, only assert when none resolve.
113+
t.Setenv("OS_CLIENT_CONFIG_FILE", filepath.Join(t.TempDir(), "absent.yaml"))
114+
if _, err := LoadCloud("pcd"); err == nil {
115+
if _, statErr := os.Stat("clouds.yaml"); statErr != nil {
116+
t.Fatal("expected error when no clouds.yaml is resolvable")
117+
}
118+
}
119+
}

internal/provider/config.go

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ func (p *pcdProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *
133133
"cloud": schema.StringAttribute{
134134
Optional: true,
135135
MarkdownDescription: "Name of a `clouds.yaml` entry to source configuration from. Falls back to `OS_CLOUD`. " +
136-
"**Not yet implemented in this pre-release** — use explicit `auth_url`/credentials for now.",
136+
"The file is searched at `$OS_CLIENT_CONFIG_FILE`, `./clouds.yaml`, " +
137+
"`~/.config/openstack/clouds.yaml`, then `/etc/openstack/clouds.yaml`. Explicit provider " +
138+
"arguments and `OS_*` environment variables override values from the file.",
137139
},
138140
"endpoint_overrides": schema.MapAttribute{
139141
Optional: true,
@@ -159,36 +161,41 @@ func (p *pcdProvider) Configure(ctx context.Context, req provider.ConfigureReque
159161
return
160162
}
161163

162-
// clouds.yaml support is declared in the schema for config parity but not yet
163-
// wired. Fail loudly rather than silently ignore a user who sets it.
164-
if strval(m.Cloud, "OS_CLOUD") != "" {
165-
resp.Diagnostics.AddAttributeError(
166-
path.Root("cloud"),
167-
"clouds.yaml (cloud) not yet supported",
168-
"This pre-release does not yet source configuration from clouds.yaml. "+
169-
"Configure auth_url and credentials explicitly (or via OS_* env vars).",
170-
)
171-
return
164+
// When `cloud` (or OS_CLOUD) is set, source auth defaults from that clouds.yaml
165+
// entry. Precedence is explicit config > OS_* env > clouds.yaml, so the loaded
166+
// values are the lowest tier of fallbacks.
167+
cv := clients.CloudConfig{}
168+
insecureDefault := false
169+
if cloudName := strval(m.Cloud, "OS_CLOUD"); cloudName != "" {
170+
cloud, err := clients.LoadCloud(cloudName)
171+
if err != nil {
172+
resp.Diagnostics.AddAttributeError(path.Root("cloud"), "Failed to load clouds.yaml", err.Error())
173+
return
174+
}
175+
cv = *cloud
176+
if cv.HasInsecure {
177+
insecureDefault = cv.Insecure
178+
}
172179
}
173180

174181
cfg := &clients.Config{
175-
AuthURL: strval(m.AuthURL, "OS_AUTH_URL"),
176-
Region: strval(m.Region, "OS_REGION_NAME"),
177-
Username: strval(m.UserName, "OS_USERNAME"),
178-
UserID: strval(m.UserID, "OS_USER_ID"),
179-
Password: strval(m.Password, "OS_PASSWORD"),
180-
TenantName: strval(m.TenantName, "OS_PROJECT_NAME", "OS_TENANT_NAME"),
181-
TenantID: strval(m.TenantID, "OS_PROJECT_ID", "OS_TENANT_ID"),
182-
UserDomainID: strval(m.UserDomainID, "OS_USER_DOMAIN_ID"),
183-
UserDomainName: strval(m.UserDomainName, "OS_USER_DOMAIN_NAME"),
184-
ProjectDomainID: strval(m.ProjectDomainID, "OS_PROJECT_DOMAIN_ID"),
185-
ProjectDomainName: strval(m.ProjectDomainName, "OS_PROJECT_DOMAIN_NAME"),
186-
Token: strval(m.Token, "OS_TOKEN", "OS_AUTH_TOKEN"),
187-
AppCredID: strval(m.AppCredID, "OS_APPLICATION_CREDENTIAL_ID"),
188-
AppCredName: strval(m.AppCredName, "OS_APPLICATION_CREDENTIAL_NAME"),
189-
AppCredSecret: strval(m.AppCredSecret, "OS_APPLICATION_CREDENTIAL_SECRET"),
190-
Insecure: boolval(m.Insecure, false, "OS_INSECURE"),
191-
CACertFile: strval(m.CACertFile, "OS_CACERT"),
182+
AuthURL: pick(m.AuthURL, cv.AuthURL, "OS_AUTH_URL"),
183+
Region: pick(m.Region, cv.Region, "OS_REGION_NAME"),
184+
Username: pick(m.UserName, cv.Username, "OS_USERNAME"),
185+
UserID: pick(m.UserID, cv.UserID, "OS_USER_ID"),
186+
Password: pick(m.Password, cv.Password, "OS_PASSWORD"),
187+
TenantName: pick(m.TenantName, cv.TenantName, "OS_PROJECT_NAME", "OS_TENANT_NAME"),
188+
TenantID: pick(m.TenantID, cv.TenantID, "OS_PROJECT_ID", "OS_TENANT_ID"),
189+
UserDomainID: pick(m.UserDomainID, cv.UserDomainID, "OS_USER_DOMAIN_ID"),
190+
UserDomainName: pick(m.UserDomainName, cv.UserDomainName, "OS_USER_DOMAIN_NAME"),
191+
ProjectDomainID: pick(m.ProjectDomainID, cv.ProjectDomainID, "OS_PROJECT_DOMAIN_ID"),
192+
ProjectDomainName: pick(m.ProjectDomainName, cv.ProjectDomainName, "OS_PROJECT_DOMAIN_NAME"),
193+
Token: pick(m.Token, cv.Token, "OS_TOKEN", "OS_AUTH_TOKEN"),
194+
AppCredID: pick(m.AppCredID, cv.AppCredID, "OS_APPLICATION_CREDENTIAL_ID"),
195+
AppCredName: pick(m.AppCredName, cv.AppCredName, "OS_APPLICATION_CREDENTIAL_NAME"),
196+
AppCredSecret: pick(m.AppCredSecret, cv.AppCredSecret, "OS_APPLICATION_CREDENTIAL_SECRET"),
197+
Insecure: boolval(m.Insecure, insecureDefault, "OS_INSECURE"),
198+
CACertFile: pick(m.CACertFile, cv.CACertFile, "OS_CACERT"),
192199
ClientCertFile: strval(m.Cert, "OS_CERT"),
193200
ClientKeyFile: strval(m.Key, "OS_KEY"),
194201
AllowReauth: boolval(m.AllowReauth, true),
@@ -223,9 +230,21 @@ func (p *pcdProvider) Configure(ctx context.Context, req provider.ConfigureReque
223230
resp.ResourceData = cfg
224231
}
225232

226-
// strval returns the configured value if set, otherwise the first non-empty env var.
233+
// pick resolves a value with precedence: explicit config value, then the first
234+
// non-empty environment variable, then the clouds.yaml fallback.
235+
func pick(v types.String, fallback string, envVars ...string) string {
236+
if s := strval(v, envVars...); s != "" {
237+
return s
238+
}
239+
return fallback
240+
}
241+
242+
// strval returns the configured value if set to a non-empty string, otherwise
243+
// the first non-empty env var. An explicitly-empty config value (e.g. a variable
244+
// defaulting to "") is treated as unset so the env fallback still applies —
245+
// matching terraform-provider-openstack's env-default behavior.
227246
func strval(v types.String, envVars ...string) string {
228-
if !v.IsNull() && !v.IsUnknown() {
247+
if !v.IsNull() && !v.IsUnknown() && v.ValueString() != "" {
229248
return v.ValueString()
230249
}
231250
for _, e := range envVars {

0 commit comments

Comments
 (0)