From 564a4d876af210333b0b5629d5d5c7ba6f83a2cc Mon Sep 17 00:00:00 2001 From: Sharpz7 Date: Mon, 27 Jul 2026 16:33:00 +0000 Subject: [PATCH 1/3] Add compute default microversion config --- openstack/clientconfig/results.go | 3 +++ openstack/clientconfig/testing/clouds.yaml | 3 +++ openstack/clientconfig/testing/fixtures.go | 19 ++++++++++++++----- .../clientconfig/testing/results_test.go | 1 + 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/openstack/clientconfig/results.go b/openstack/clientconfig/results.go index 0f21fbf9..537bcdbe 100644 --- a/openstack/clientconfig/results.go +++ b/openstack/clientconfig/results.go @@ -35,6 +35,9 @@ type Cloud struct { IdentityAPIVersion string `yaml:"identity_api_version,omitempty" json:"identity_api_version,omitempty"` VolumeAPIVersion string `yaml:"volume_api_version,omitempty" json:"volume_api_version,omitempty"` + // Service default microversion overrides. + ComputeDefaultMicroversion string `yaml:"compute_default_microversion,omitempty" json:"compute_default_microversion,omitempty"` + // Verify whether or not SSL API requests should be verified. Verify *bool `yaml:"verify,omitempty" json:"verify,omitempty"` diff --git a/openstack/clientconfig/testing/clouds.yaml b/openstack/clientconfig/testing/clouds.yaml index fe2d26d2..5fb47891 100644 --- a/openstack/clientconfig/testing/clouds.yaml +++ b/openstack/clientconfig/testing/clouds.yaml @@ -7,6 +7,7 @@ clouds: project_name: "Some Project" domain_name: "default" region_name: "HNL" + compute_default_microversion: "2.87" florida: interface: "admin" auth: @@ -139,11 +140,13 @@ clouds: username: "jdoe" password: "password" project_name: "Some Project" + compute_default_microversion: "2.87" regions: - name: PHL1 values: auth: auth_url: "https://phl1.example.com:5000/v3" + compute_default_microversion: "2.79" - PHL2 virginia: auth_type: "v3applicationcredential" diff --git a/openstack/clientconfig/testing/fixtures.go b/openstack/clientconfig/testing/fixtures.go index 053fa379..224edce8 100644 --- a/openstack/clientconfig/testing/fixtures.go +++ b/openstack/clientconfig/testing/fixtures.go @@ -51,10 +51,14 @@ var PhiladelphiaComplexPhl1CloudYAML = clientconfig.Cloud{ Password: "password", ProjectName: "Some Project", }, + ComputeDefaultMicroversion: "2.79", Regions: []clientconfig.Region{ { - Name: "PHL1", - Values: clientconfig.Cloud{AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}}, + Name: "PHL1", + Values: clientconfig.Cloud{ + AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}, + ComputeDefaultMicroversion: "2.79", + }, }, { Name: "PHL2", @@ -71,10 +75,14 @@ var PhiladelphiaComplexPhl2CloudYAML = clientconfig.Cloud{ Password: "password", ProjectName: "Some Project", }, + ComputeDefaultMicroversion: "2.87", Regions: []clientconfig.Region{ { - Name: "PHL1", - Values: clientconfig.Cloud{AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}}, + Name: "PHL1", + Values: clientconfig.Cloud{ + AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}, + ComputeDefaultMicroversion: "2.79", + }, }, { Name: "PHL2", @@ -170,7 +178,8 @@ var ChicagoCloudUseProfileYAML = clientconfig.Cloud{ } var HawaiiCloudYAML = clientconfig.Cloud{ - RegionName: "HNL", + RegionName: "HNL", + ComputeDefaultMicroversion: "2.87", AuthInfo: &clientconfig.AuthInfo{ AuthURL: "https://hi.example.com:5000/v3", Username: "jdoe", diff --git a/openstack/clientconfig/testing/results_test.go b/openstack/clientconfig/testing/results_test.go index 0149190d..90f9c3f9 100644 --- a/openstack/clientconfig/testing/results_test.go +++ b/openstack/clientconfig/testing/results_test.go @@ -29,6 +29,7 @@ var HawaiiExpected = `clouds: project_name: Some Project domain_name: default region_name: HNL + compute_default_microversion: "2.87" verify: true ` From 7a974fc24a6c073888243194cbe5d0b8db01eec6 Mon Sep 17 00:00:00 2001 From: Sharpz7 Date: Wed, 30 Sep 2026 01:50:36 +0000 Subject: [PATCH 2/3] Apply clouds.yaml service default microversions in clientconfig --- openstack/clientconfig/doc.go | 16 ++ openstack/clientconfig/microversions.go | 111 ++++++++++ openstack/clientconfig/microversions_test.go | 196 ++++++++++++++++++ openstack/clientconfig/requests.go | 15 ++ openstack/clientconfig/results.go | 6 +- openstack/clientconfig/testing/fixtures.go | 16 +- .../clientconfig/testing/results_test.go | 2 +- 7 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 openstack/clientconfig/microversions.go create mode 100644 openstack/clientconfig/microversions_test.go diff --git a/openstack/clientconfig/doc.go b/openstack/clientconfig/doc.go index 3f85b673..245050c0 100644 --- a/openstack/clientconfig/doc.go +++ b/openstack/clientconfig/doc.go @@ -42,5 +42,21 @@ Example to Create a Service Client from clouds.yaml if err != nil { panic(err) } + +# Service Default Microversions + +NewServiceClient applies {service_type}_default_microversion settings from +clouds.yaml, for example compute_default_microversion: "2.87". Hyphens in service +types become underscores in configuration keys. Canonical types and aliases are +accepted; block_storage_default_microversion, block_store_default_microversion, +volume_default_microversion, volumev2_default_microversion and +volumev3_default_microversion all configure block storage. Canonical names take +precedence, followed by aliases in gophercloud.ServiceTypeAliases order. + +Defaults survive profile, secure.yaml and regional configuration merging. +ClientOpts.Microversion overrides the configured service default. An unset or +empty default leaves the microversion unset. Callers can also change +ServiceClient.Microversion after creation. Defaults are sent to the API without +automatic microversion negotiation. */ package clientconfig diff --git a/openstack/clientconfig/microversions.go b/openstack/clientconfig/microversions.go new file mode 100644 index 00000000..b620fdc9 --- /dev/null +++ b/openstack/clientconfig/microversions.go @@ -0,0 +1,111 @@ +package clientconfig + +import ( + "encoding/json" + "strings" + + "github.com/gophercloud/gophercloud/v2" + "gopkg.in/yaml.v3" +) + +const defaultMicroversionSuffix = "_default_microversion" + +// UnmarshalYAML preserves service defaults without interpreting unrelated keys. +func (c *Cloud) UnmarshalYAML(unmarshal func(any) error) error { + type plain Cloud + var value plain + if err := unmarshal(&value); err != nil { + return err + } + var fields map[string]yaml.Node + if err := unmarshal(&fields); err != nil { + return err + } + for key, node := range fields { + if service, ok := strings.CutSuffix(key, defaultMicroversionSuffix); ok && service != "" { + var version string + if err := node.Decode(&version); err != nil { + return err + } + if value.DefaultMicroversions == nil { + value.DefaultMicroversions = make(map[string]string) + } + value.DefaultMicroversions[strings.ReplaceAll(service, "_", "-")] = version + } + } + *c = Cloud(value) + return nil +} + +// MarshalYAML keeps service defaults in the clouds.yaml format. +func (c Cloud) MarshalYAML() (any, error) { + type plain Cloud + fields := make(map[string]string, len(c.DefaultMicroversions)) + for service, version := range c.DefaultMicroversions { + fields[strings.ReplaceAll(service, "-", "_")+defaultMicroversionSuffix] = version + } + return struct { + Cloud plain `yaml:",inline"` + Defaults map[string]string `yaml:",inline"` + }{plain(c), fields}, nil +} + +// MarshalJSON preserves flat service keys for configuration merging. +func (c Cloud) MarshalJSON() ([]byte, error) { + type plain Cloud + data, err := json.Marshal(plain(c)) + if err != nil { + return nil, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return nil, err + } + for service, version := range c.DefaultMicroversions { + data, err := json.Marshal(version) + if err != nil { + return nil, err + } + fields[strings.ReplaceAll(service, "-", "_")+defaultMicroversionSuffix] = data + } + return json.Marshal(fields) +} + +// UnmarshalJSON restores service defaults after configuration merging. +func (c *Cloud) UnmarshalJSON(data []byte) error { + type plain Cloud + var value plain + if err := json.Unmarshal(data, &value); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + for key, data := range fields { + if service, ok := strings.CutSuffix(key, defaultMicroversionSuffix); ok && service != "" { + var version string + if err := json.Unmarshal(data, &version); err != nil { + return err + } + if value.DefaultMicroversions == nil { + value.DefaultMicroversions = make(map[string]string) + } + value.DefaultMicroversions[strings.ReplaceAll(service, "_", "-")] = version + } + } + *c = Cloud(value) + return nil +} + +// DefaultMicroversion resolves the official service name before its aliases. +func (c Cloud) DefaultMicroversion(serviceType string) string { + types := gophercloud.EndpointOpts{Type: serviceType} + types.ApplyDefaults(serviceType) + for _, service := range types.Types() { + if version, ok := c.DefaultMicroversions[service]; ok { + return version + } + } + return "" +} diff --git a/openstack/clientconfig/microversions_test.go b/openstack/clientconfig/microversions_test.go new file mode 100644 index 00000000..42857d86 --- /dev/null +++ b/openstack/clientconfig/microversions_test.go @@ -0,0 +1,196 @@ +package clientconfig + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestDefaultMicroversionSerialization(t *testing.T) { + const input = `auth: + auth_url: https://example.org/v3 +compute_default_microversion: "2.87" +volumev3_default_microversion: "3.60" +shared_file_system_default_microversion: 2.10 +future_service_default_microversion: "1.4" +unrelated_setting: + nested: true +regions: + - name: mars + values: + compute_default_microversion: "2.79" +` + var cloud Cloud + if err := yaml.Unmarshal([]byte(input), &cloud); err != nil { + t.Fatal(err) + } + expected := map[string]string{"compute": "2.87", "volumev3": "3.60", "shared-file-system": "2.10", "future-service": "1.4"} + if !reflect.DeepEqual(cloud.DefaultMicroversions, expected) { + t.Fatalf("defaults: %#v", cloud.DefaultMicroversions) + } + for _, codec := range []struct { + name string + marshal func(any) ([]byte, error) + unmarshal func([]byte, any) error + }{{"yaml", yaml.Marshal, yaml.Unmarshal}, {"json", json.Marshal, json.Unmarshal}} { + t.Run(codec.name, func(t *testing.T) { + data, err := codec.marshal(cloud) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "volumev3_default_microversion") { + t.Fatalf("missing flat key: %s", data) + } + var got Cloud + if err := codec.unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cloud, got) { + t.Fatalf("roundtrip: %#v", got) + } + }) + } + for _, input := range []string{"compute_default_microversion: [2.87]", "compute_default_microversion: {bad: value}"} { + if err := yaml.Unmarshal([]byte(input), &Cloud{}); err == nil { + t.Fatalf("accepted invalid value: %s", input) + } + } +} + +func TestDefaultMicroversionMerging(t *testing.T) { + base := Cloud{DefaultMicroversions: map[string]string{"compute": "2.87", "volumev3": "3.60"}} + override := Cloud{DefaultMicroversions: map[string]string{"compute": "2.79", "placement": "1.20"}} + got, err := mergeClouds(&override, &base) + if err != nil { + t.Fatal(err) + } + expected := map[string]string{"compute": "2.79", "volumev3": "3.60", "placement": "1.20"} + if !reflect.DeepEqual(got.DefaultMicroversions, expected) { + t.Fatalf("merged defaults: %#v", got.DefaultMicroversions) + } +} + +func TestDefaultMicroversionAliases(t *testing.T) { + for _, alias := range []string{"block-storage", "block-store", "volume", "volumev2", "volumev3"} { + t.Run(alias, func(t *testing.T) { + defaults := map[string]string{alias: "3.60"} + for _, requested := range []string{"block-storage", "volume", "volumev3"} { + if got := (Cloud{DefaultMicroversions: defaults}).DefaultMicroversion(requested); got != "3.60" { + t.Fatalf("%s: %q", requested, got) + } + } + }) + } + defaults := map[string]string{"block-storage": "3.60", "block-store": "3.50", "volume": "3.40", "volumev3": "3.30"} + requested := "volumev3" + if got := (Cloud{DefaultMicroversions: defaults}).DefaultMicroversion(requested); got != "3.60" { + t.Fatalf("canonical precedence: %q", got) + } + defaults["block-storage"] = "" + if got := (Cloud{DefaultMicroversions: defaults}).DefaultMicroversion(requested); got != "" { + t.Fatalf("empty canonical default: %q", got) + } + delete(defaults, "block-storage") + if got := (Cloud{DefaultMicroversions: defaults}).DefaultMicroversion(requested); got != "3.50" { + t.Fatalf("alias precedence: %q", got) + } +} + +// Static configuration lets the test exercise authentication and requests without files. +type microversionYAML struct{ cloud Cloud } + +func (m microversionYAML) LoadCloudsYAML() (map[string]Cloud, error) { + return map[string]Cloud{"test": m.cloud}, nil +} +func (m microversionYAML) LoadSecureCloudsYAML() (map[string]Cloud, error) { return nil, nil } +func (m microversionYAML) LoadPublicCloudsYAML() (map[string]Cloud, error) { return nil, nil } + +func TestConfiguredMicroversionsReachRequests(t *testing.T) { + t.Setenv("OS_CLOUD", "") + for _, tc := range []struct{ service, serviceType, key, version, header, value string }{ + {"compute", "compute", "compute", "2.79", "OpenStack-API-Version", "compute 2.79"}, + {"volume", "volumev3", "volumev3", "3.60", "X-OpenStack-Volume-API-Version", "3.60"}, + {"sharev2", "sharev2", "shared-file-system", "2.65", "X-OpenStack-Manila-API-Version", "2.65"}, + {"placement", "placement", "placement", "1.20", "OpenStack-API-Version", "placement 1.20"}, + {"baremetal", "baremetal", "bare-metal", "1.80", "X-OpenStack-Ironic-API-Version", "1.80"}, + {"container", "container", "application-container", "1.40", "OpenStack-API-Version", "application-container 1.40"}, + {"container-infra", "container-infra", "container-infrastructure-management", "1.4", "OpenStack-API-Version", "container-infra 1.4"}, + {"image", "image", "compute", "", "OpenStack-API-Version", ""}, + } { + t.Run(tc.service, func(t *testing.T) { + for _, explicit := range []string{"", "9.9"} { + t.Run("override="+explicit, func(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + major := "2" + if tc.version != "" { + major = strings.SplitN(tc.version, ".", 2)[0] + } + mux.HandleFunc("/v3/auth/tokens", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Subject-Token", "test-token") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{"token":{"expires_at":"2100-01-01T00:00:00Z","catalog":[{"type":%q,"name":"service","endpoints":[{"interface":"public","region":"mars","url":%q}]}]}}`, tc.serviceType, server.URL+"/service/v"+major+"/") + }) + called := false + mux.HandleFunc("/service/", func(w http.ResponseWriter, r *http.Request) { + called = true + expected := tc.value + if explicit != "" { + if tc.header == "OpenStack-API-Version" { + prefix := tc.serviceType + if tc.value != "" { + prefix = strings.SplitN(tc.value, " ", 2)[0] + } + expected = prefix + " " + explicit + } else { + expected = explicit + } + } + if got := r.Header.Get(tc.header); got != expected { + t.Errorf("header: %q, want %q", got, expected) + } + w.WriteHeader(http.StatusOK) + }) + var cloud Cloud + if err := yaml.Unmarshal([]byte("compute_default_microversion: \"2.87\"\n"), &cloud); err != nil { + t.Fatal(err) + } + cloud.AuthInfo = &AuthInfo{AuthURL: server.URL + "/v3", Username: "user", Password: "password", DomainName: "default", ProjectName: "project"} + cloud.RegionName = "mars" + if tc.service != "image" { + cloud.DefaultMicroversions[tc.key] = tc.version + } + if tc.service == "compute" { + cloud.DefaultMicroversions["compute"] = "2.87" + cloud.Regions = []Region{{Name: "mars", Values: Cloud{DefaultMicroversions: map[string]string{"compute": tc.version}}}} + } + client, err := NewServiceClient(context.Background(), tc.service, &ClientOpts{Cloud: "test", RegionName: "mars", Microversion: explicit, YAMLOpts: microversionYAML{cloud}}) + if err != nil { + t.Fatal(err) + } + expected := tc.version + if explicit != "" { + expected = explicit + } + if client.Microversion != expected { + t.Fatalf("microversion: %q, want %q", client.Microversion, expected) + } + if _, err := client.Get(context.Background(), server.URL+"/service/", nil, nil); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("service request not received") + } + }) + } + }) + } +} diff --git a/openstack/clientconfig/requests.go b/openstack/clientconfig/requests.go index 6543738a..b5415a2f 100644 --- a/openstack/clientconfig/requests.go +++ b/openstack/clientconfig/requests.go @@ -71,6 +71,9 @@ type ClientOpts struct { // admin endpoint of a service. EndpointType string + // Microversion explicitly overrides the clouds.yaml service default. + Microversion string + // HTTPClient provides the ability customize the ProviderClient's // internal HTTP client. HTTPClient *http.Client @@ -911,6 +914,18 @@ func NewServiceClient(ctx context.Context, service string, opts *ClientOpts) (*g Availability: GetEndpointType(endpointType), } + sc, err := newServiceClient(service, pClient, eo, cloud) + if err != nil { + return nil, err + } + sc.Microversion = opts.Microversion + if sc.Microversion == "" { + sc.Microversion = cloud.DefaultMicroversion(sc.Type) + } + return sc, nil +} + +func newServiceClient(service string, pClient *gophercloud.ProviderClient, eo gophercloud.EndpointOpts, cloud *Cloud) (*gophercloud.ServiceClient, error) { switch service { case "baremetal": return openstack.NewBareMetalV1(pClient, eo) diff --git a/openstack/clientconfig/results.go b/openstack/clientconfig/results.go index 537bcdbe..7faff717 100644 --- a/openstack/clientconfig/results.go +++ b/openstack/clientconfig/results.go @@ -35,8 +35,10 @@ type Cloud struct { IdentityAPIVersion string `yaml:"identity_api_version,omitempty" json:"identity_api_version,omitempty"` VolumeAPIVersion string `yaml:"volume_api_version,omitempty" json:"volume_api_version,omitempty"` - // Service default microversion overrides. - ComputeDefaultMicroversion string `yaml:"compute_default_microversion,omitempty" json:"compute_default_microversion,omitempty"` + // DefaultMicroversions maps service types (including aliases) to their default + // microversions. YAML and JSON use {service_type}_default_microversion keys, + // replacing hyphens in service types with underscores. + DefaultMicroversions map[string]string `yaml:"-" json:"-"` // Verify whether or not SSL API requests should be verified. Verify *bool `yaml:"verify,omitempty" json:"verify,omitempty"` diff --git a/openstack/clientconfig/testing/fixtures.go b/openstack/clientconfig/testing/fixtures.go index 224edce8..f51f57a1 100644 --- a/openstack/clientconfig/testing/fixtures.go +++ b/openstack/clientconfig/testing/fixtures.go @@ -51,13 +51,13 @@ var PhiladelphiaComplexPhl1CloudYAML = clientconfig.Cloud{ Password: "password", ProjectName: "Some Project", }, - ComputeDefaultMicroversion: "2.79", + DefaultMicroversions: map[string]string{"compute": "2.79"}, Regions: []clientconfig.Region{ { Name: "PHL1", Values: clientconfig.Cloud{ - AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}, - ComputeDefaultMicroversion: "2.79", + AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}, + DefaultMicroversions: map[string]string{"compute": "2.79"}, }, }, { @@ -75,13 +75,13 @@ var PhiladelphiaComplexPhl2CloudYAML = clientconfig.Cloud{ Password: "password", ProjectName: "Some Project", }, - ComputeDefaultMicroversion: "2.87", + DefaultMicroversions: map[string]string{"compute": "2.87"}, Regions: []clientconfig.Region{ { Name: "PHL1", Values: clientconfig.Cloud{ - AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}, - ComputeDefaultMicroversion: "2.79", + AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}, + DefaultMicroversions: map[string]string{"compute": "2.79"}, }, }, { @@ -178,8 +178,8 @@ var ChicagoCloudUseProfileYAML = clientconfig.Cloud{ } var HawaiiCloudYAML = clientconfig.Cloud{ - RegionName: "HNL", - ComputeDefaultMicroversion: "2.87", + RegionName: "HNL", + DefaultMicroversions: map[string]string{"compute": "2.87"}, AuthInfo: &clientconfig.AuthInfo{ AuthURL: "https://hi.example.com:5000/v3", Username: "jdoe", diff --git a/openstack/clientconfig/testing/results_test.go b/openstack/clientconfig/testing/results_test.go index 90f9c3f9..edd8208f 100644 --- a/openstack/clientconfig/testing/results_test.go +++ b/openstack/clientconfig/testing/results_test.go @@ -29,8 +29,8 @@ var HawaiiExpected = `clouds: project_name: Some Project domain_name: default region_name: HNL - compute_default_microversion: "2.87" verify: true + compute_default_microversion: "2.87" ` func TestMarshallCloudToYaml(t *testing.T) { From e2ed7d53d6a228e70aa8f1b88cfa37271461838a Mon Sep 17 00:00:00 2001 From: Sharpz7 Date: Wed, 30 Sep 2026 03:17:25 +0000 Subject: [PATCH 3/3] Match SDK alias precedence and microversion override semantics --- openstack/clientconfig/doc.go | 4 +- openstack/clientconfig/microversions.go | 42 +- openstack/clientconfig/microversions_test.go | 56 ++- openstack/clientconfig/requests.go | 5 +- openstack/clientconfig/results.go | 3 + .../generate_openstacksdk_defaults.py | 57 +++ .../testdata/openstacksdk-defaults.json | 465 ++++++++++++++++++ openstack/clientconfig/utils.go | 5 + 8 files changed, 623 insertions(+), 14 deletions(-) create mode 100644 openstack/clientconfig/testdata/generate_openstacksdk_defaults.py create mode 100644 openstack/clientconfig/testdata/openstacksdk-defaults.json diff --git a/openstack/clientconfig/doc.go b/openstack/clientconfig/doc.go index 245050c0..44ace564 100644 --- a/openstack/clientconfig/doc.go +++ b/openstack/clientconfig/doc.go @@ -51,7 +51,9 @@ types become underscores in configuration keys. Canonical types and aliases are accepted; block_storage_default_microversion, block_store_default_microversion, volume_default_microversion, volumev2_default_microversion and volumev3_default_microversion all configure block storage. Canonical names take -precedence, followed by aliases in gophercloud.ServiceTypeAliases order. +precedence, followed by aliases in OpenStack Service Types Authority order. +For Cinder this is volumev3, volumev2, volume, then block-store. YAML null defaults +are skipped during lookup, while an explicitly empty string clears the default. Defaults survive profile, secure.yaml and regional configuration merging. ClientOpts.Microversion overrides the configured service default. An unset or diff --git a/openstack/clientconfig/microversions.go b/openstack/clientconfig/microversions.go index b620fdc9..a29be5a5 100644 --- a/openstack/clientconfig/microversions.go +++ b/openstack/clientconfig/microversions.go @@ -22,7 +22,15 @@ func (c *Cloud) UnmarshalYAML(unmarshal func(any) error) error { return err } for key, node := range fields { - if service, ok := strings.CutSuffix(key, defaultMicroversionSuffix); ok && service != "" { + if service, ok := strings.CutSuffix(strings.ReplaceAll(key, "-", "_"), defaultMicroversionSuffix); ok && service != "" { + service = strings.ReplaceAll(service, "_", "-") + if node.Tag == "!!null" { + if value.nullDefaultMicroversions == nil { + value.nullDefaultMicroversions = make(map[string]bool) + } + value.nullDefaultMicroversions[service] = true + continue + } var version string if err := node.Decode(&version); err != nil { return err @@ -30,7 +38,7 @@ func (c *Cloud) UnmarshalYAML(unmarshal func(any) error) error { if value.DefaultMicroversions == nil { value.DefaultMicroversions = make(map[string]string) } - value.DefaultMicroversions[strings.ReplaceAll(service, "_", "-")] = version + value.DefaultMicroversions[service] = version } } *c = Cloud(value) @@ -40,13 +48,16 @@ func (c *Cloud) UnmarshalYAML(unmarshal func(any) error) error { // MarshalYAML keeps service defaults in the clouds.yaml format. func (c Cloud) MarshalYAML() (any, error) { type plain Cloud - fields := make(map[string]string, len(c.DefaultMicroversions)) + fields := make(map[string]any, len(c.DefaultMicroversions)+len(c.nullDefaultMicroversions)) + for service := range c.nullDefaultMicroversions { + fields[strings.ReplaceAll(service, "-", "_")+defaultMicroversionSuffix] = nil + } for service, version := range c.DefaultMicroversions { fields[strings.ReplaceAll(service, "-", "_")+defaultMicroversionSuffix] = version } return struct { - Cloud plain `yaml:",inline"` - Defaults map[string]string `yaml:",inline"` + Cloud plain `yaml:",inline"` + Defaults map[string]any `yaml:",inline"` }{plain(c), fields}, nil } @@ -61,6 +72,9 @@ func (c Cloud) MarshalJSON() ([]byte, error) { if err := json.Unmarshal(data, &fields); err != nil { return nil, err } + for service := range c.nullDefaultMicroversions { + fields[strings.ReplaceAll(service, "-", "_")+defaultMicroversionSuffix] = json.RawMessage("null") + } for service, version := range c.DefaultMicroversions { data, err := json.Marshal(version) if err != nil { @@ -83,7 +97,15 @@ func (c *Cloud) UnmarshalJSON(data []byte) error { return err } for key, data := range fields { - if service, ok := strings.CutSuffix(key, defaultMicroversionSuffix); ok && service != "" { + if service, ok := strings.CutSuffix(strings.ReplaceAll(key, "-", "_"), defaultMicroversionSuffix); ok && service != "" { + service = strings.ReplaceAll(service, "_", "-") + if strings.TrimSpace(string(data)) == "null" { + if value.nullDefaultMicroversions == nil { + value.nullDefaultMicroversions = make(map[string]bool) + } + value.nullDefaultMicroversions[service] = true + continue + } var version string if err := json.Unmarshal(data, &version); err != nil { return err @@ -91,7 +113,7 @@ func (c *Cloud) UnmarshalJSON(data []byte) error { if value.DefaultMicroversions == nil { value.DefaultMicroversions = make(map[string]string) } - value.DefaultMicroversions[strings.ReplaceAll(service, "_", "-")] = version + value.DefaultMicroversions[service] = version } } *c = Cloud(value) @@ -100,8 +122,14 @@ func (c *Cloud) UnmarshalJSON(data []byte) error { // DefaultMicroversion resolves the official service name before its aliases. func (c Cloud) DefaultMicroversion(serviceType string) string { + serviceType = strings.ReplaceAll(serviceType, "_", "-") types := gophercloud.EndpointOpts{Type: serviceType} types.ApplyDefaults(serviceType) + // Released Gophercloud versions list Cinder aliases in a different order. + // Follow os-service-types priority for configuration lookup. + if types.Type == "block-storage" { + types.Aliases = []string{"volumev3", "volumev2", "volume", "block-store"} + } for _, service := range types.Types() { if version, ok := c.DefaultMicroversions[service]; ok { return version diff --git a/openstack/clientconfig/microversions_test.go b/openstack/clientconfig/microversions_test.go index 42857d86..0216d3a8 100644 --- a/openstack/clientconfig/microversions_test.go +++ b/openstack/clientconfig/microversions_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "reflect" "strings" "testing" @@ -18,6 +19,7 @@ func TestDefaultMicroversionSerialization(t *testing.T) { auth_url: https://example.org/v3 compute_default_microversion: "2.87" volumev3_default_microversion: "3.60" +block_storage_default_microversion: null shared_file_system_default_microversion: 2.10 future_service_default_microversion: "1.4" unrelated_setting: @@ -98,19 +100,23 @@ func TestDefaultMicroversionAliases(t *testing.T) { t.Fatalf("empty canonical default: %q", got) } delete(defaults, "block-storage") - if got := (Cloud{DefaultMicroversions: defaults}).DefaultMicroversion(requested); got != "3.50" { + if got := (Cloud{DefaultMicroversions: defaults}).DefaultMicroversion(requested); got != "3.30" { t.Fatalf("alias precedence: %q", got) } } // Static configuration lets the test exercise authentication and requests without files. -type microversionYAML struct{ cloud Cloud } +type microversionYAML struct{ cloud, secure, public Cloud } func (m microversionYAML) LoadCloudsYAML() (map[string]Cloud, error) { return map[string]Cloud{"test": m.cloud}, nil } -func (m microversionYAML) LoadSecureCloudsYAML() (map[string]Cloud, error) { return nil, nil } -func (m microversionYAML) LoadPublicCloudsYAML() (map[string]Cloud, error) { return nil, nil } +func (m microversionYAML) LoadSecureCloudsYAML() (map[string]Cloud, error) { + return map[string]Cloud{"test": m.secure}, nil +} +func (m microversionYAML) LoadPublicCloudsYAML() (map[string]Cloud, error) { + return map[string]Cloud{"example": m.public}, nil +} func TestConfiguredMicroversionsReachRequests(t *testing.T) { t.Setenv("OS_CLOUD", "") @@ -120,6 +126,7 @@ func TestConfiguredMicroversionsReachRequests(t *testing.T) { {"sharev2", "sharev2", "shared-file-system", "2.65", "X-OpenStack-Manila-API-Version", "2.65"}, {"placement", "placement", "placement", "1.20", "OpenStack-API-Version", "placement 1.20"}, {"baremetal", "baremetal", "bare-metal", "1.80", "X-OpenStack-Ironic-API-Version", "1.80"}, + {"baremetal-introspection", "baremetal-introspection", "baremetal-introspection", "1.12", "X-OpenStack-Ironic-Inspector-API-Version", "1.12"}, {"container", "container", "application-container", "1.40", "OpenStack-API-Version", "application-container 1.40"}, {"container-infra", "container-infra", "container-infrastructure-management", "1.4", "OpenStack-API-Version", "container-infra 1.4"}, {"image", "image", "compute", "", "OpenStack-API-Version", ""}, @@ -172,7 +179,7 @@ func TestConfiguredMicroversionsReachRequests(t *testing.T) { cloud.DefaultMicroversions["compute"] = "2.87" cloud.Regions = []Region{{Name: "mars", Values: Cloud{DefaultMicroversions: map[string]string{"compute": tc.version}}}} } - client, err := NewServiceClient(context.Background(), tc.service, &ClientOpts{Cloud: "test", RegionName: "mars", Microversion: explicit, YAMLOpts: microversionYAML{cloud}}) + client, err := NewServiceClient(context.Background(), tc.service, &ClientOpts{Cloud: "test", RegionName: "mars", Microversion: explicit, YAMLOpts: microversionYAML{cloud: cloud}}) if err != nil { t.Fatal(err) } @@ -194,3 +201,42 @@ func TestConfiguredMicroversionsReachRequests(t *testing.T) { }) } } + +// Expectations are generated by the SDK itself; Python is not required for Go tests. +func TestOpenStackSDKDefaultMicroversions(t *testing.T) { + data, err := os.ReadFile("testdata/openstacksdk-defaults.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + Cases []struct { + Name string + Cloud, Secure, Public json.RawMessage + Region, Service, Expected string + } + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + for _, tc := range fixture.Cases { + t.Run(tc.Name, func(t *testing.T) { + var input microversionYAML + for _, pair := range []struct { + data json.RawMessage + cloud *Cloud + }{{tc.Cloud, &input.cloud}, {tc.Secure, &input.secure}, {tc.Public, &input.public}} { + if err := yaml.Unmarshal(pair.data, pair.cloud); err != nil { + t.Fatal(err) + } + } + cloud, err := GetCloudFromYAML(&ClientOpts{Cloud: "test", RegionName: tc.Region, YAMLOpts: input}) + if err != nil { + t.Fatal(err) + } + got := cloud.DefaultMicroversion(tc.Service) + if got != tc.Expected { + t.Fatalf("SDK expects %q, got %q", tc.Expected, got) + } + }) + } +} diff --git a/openstack/clientconfig/requests.go b/openstack/clientconfig/requests.go index b5415a2f..67b09b59 100644 --- a/openstack/clientconfig/requests.go +++ b/openstack/clientconfig/requests.go @@ -316,7 +316,10 @@ func GetCloudFromYAML(opts *ClientOpts) (*Cloud, error) { if opts.RegionName != "" { for _, v := range cloud.Regions { if opts.RegionName == v.Name { - cloud, err = mergeClouds(v.Values, cloud) + // Region values of null leave the cloud-level default intact, as in openstacksdk. + values := v.Values + values.nullDefaultMicroversions = nil + cloud, err = mergeClouds(values, cloud) break } } diff --git a/openstack/clientconfig/results.go b/openstack/clientconfig/results.go index 7faff717..bb61f276 100644 --- a/openstack/clientconfig/results.go +++ b/openstack/clientconfig/results.go @@ -40,6 +40,9 @@ type Cloud struct { // replacing hyphens in service types with underscores. DefaultMicroversions map[string]string `yaml:"-" json:"-"` + // Null defaults must survive merging, but are skipped during service lookup. + nullDefaultMicroversions map[string]bool + // Verify whether or not SSL API requests should be verified. Verify *bool `yaml:"verify,omitempty" json:"verify,omitempty"` diff --git a/openstack/clientconfig/testdata/generate_openstacksdk_defaults.py b/openstack/clientconfig/testdata/generate_openstacksdk_defaults.py new file mode 100644 index 00000000..0331642c --- /dev/null +++ b/openstack/clientconfig/testdata/generate_openstacksdk_defaults.py @@ -0,0 +1,57 @@ +"""Generate configuration expectations with openstacksdk (optional test tooling). + +Install the versions used by the checked-in fixture in a virtual environment: + pip install openstacksdk==4.20.0 os-service-types==1.9.0 PyYAML==6.0.3 +Then regenerate: + python generate_openstacksdk_defaults.py > openstacksdk-defaults.json +Go tests consume the checked-in JSON; they do not require Python or an SDK install. +""" +import importlib.metadata +import json +import tempfile +from pathlib import Path + +import yaml +from openstack.config.loader import OpenStackConfig + +cases = [] +def add(name, cloud, service='block-storage', secure=None, public=None, region='mars'): + cases.append(dict(name=name, cloud=cloud, secure=secure or {}, public=public or {}, region=region, service=service)) + +aliases = ['block_storage', 'volumev3', 'volumev2', 'volume', 'block_store'] +for alias in aliases: + add('alias-' + alias, {alias + '_default_microversion': '3.60'}) +for i, alias in enumerate(aliases[:-1]): + add('precedence-' + alias, {key + '_default_microversion': '3.' + str(60 + j) for j, key in enumerate(aliases[i:])}, service='volumev3') +add('empty-canonical', {'block_storage_default_microversion':'', 'volumev3_default_microversion':'3.60'}) +add('null-canonical', {'block_storage_default_microversion':None, 'volumev3_default_microversion':'3.60'}) +add('missing', {'compute_default_microversion':'2.87'}) +add('global-is-not-default', {'default_microversion':'3.60'}) +add('unknown-service', {'future_service_default_microversion':'1.4'}, service='future-service') +add('hyphenated-key', {'block-storage-default-microversion':'3.60'}, service='block_storage') +add('quoted-trailing-zero', {'compute_default_microversion':'2.10'}, service='compute') +add('secure-override', {'compute_default_microversion':'2.87'}, service='compute', secure={'compute_default_microversion':'2.79'}) +add('secure-null', {'block_storage_default_microversion':'3.60','volumev3_default_microversion':'3.50'}, secure={'block_storage_default_microversion':None}) +add('regional-override', {'compute_default_microversion':'2.87','regions':[{'name':'mars','values':{'compute_default_microversion':'2.79'}}]}, service='compute') +add('regional-null-inherits', {'compute_default_microversion':'2.87','regions':[{'name':'mars','values':{'compute_default_microversion':None}}]}, service='compute') +add('regional-empty-clears', {'compute_default_microversion':'2.87','regions':[{'name':'mars','values':{'compute_default_microversion':''}}]}, service='compute') +add('regional-alias-vs-canonical', {'block_storage_default_microversion':'3.60','regions':[{'name':'mars','values':{'volumev3_default_microversion':'3.50'}}]}) +add('secure-and-region', {'compute_default_microversion':'2.87','regions':[{'name':'mars','values':{'compute_default_microversion':'2.79'}}]},service='compute',secure={'compute_default_microversion':'2.80'}) +add('profile-default', {'profile':'example'},service='compute',public={'compute_default_microversion':'2.87'}) +add('profile-cloud-override', {'profile':'example','compute_default_microversion':'2.79'},service='compute',public={'compute_default_microversion':'2.87'}) +add('profile-secure-override', {'profile':'example','compute_default_microversion':'2.79'},service='compute',public={'compute_default_microversion':'2.87'},secure={'compute_default_microversion':'2.80'}) + +with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for case in cases: + cloud = dict(case['cloud']) + cloud.setdefault('auth', {'auth_url': 'https://example.org/v3'}) + cloud.setdefault('region_name', 'mars') + case['cloud'] = cloud + (root/'clouds.yaml').write_text(yaml.safe_dump({'clouds': {'test': cloud}})) + (root/'secure.yaml').write_text(yaml.safe_dump({'clouds': {'test': case['secure']}})) + (root/'clouds-public.yaml').write_text(yaml.safe_dump({'public-clouds': {'example': case['public']}})) + config = OpenStackConfig(config_files=[str(root/'clouds.yaml')], secure_files=[str(root/'secure.yaml')],vendor_files=[str(root/'clouds-public.yaml')],load_envvars=False) + case['expected'] = config.get_one('test', region_name=case['region'], validate=False).get_default_microversion(case['service']) or '' + +print(json.dumps({'openstacksdk':importlib.metadata.version('openstacksdk'),'os_service_types':importlib.metadata.version('os-service-types'),'cases':cases},indent=2)) diff --git a/openstack/clientconfig/testdata/openstacksdk-defaults.json b/openstack/clientconfig/testdata/openstacksdk-defaults.json new file mode 100644 index 00000000..8639d1f1 --- /dev/null +++ b/openstack/clientconfig/testdata/openstacksdk-defaults.json @@ -0,0 +1,465 @@ +{ + "openstacksdk": "4.20.0", + "os_service_types": "1.9.0", + "cases": [ + { + "name": "alias-block_storage", + "cloud": { + "block_storage_default_microversion": "3.60", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "3.60" + }, + { + "name": "alias-volumev3", + "cloud": { + "volumev3_default_microversion": "3.60", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "3.60" + }, + { + "name": "alias-volumev2", + "cloud": { + "volumev2_default_microversion": "3.60", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "3.60" + }, + { + "name": "alias-volume", + "cloud": { + "volume_default_microversion": "3.60", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "3.60" + }, + { + "name": "alias-block_store", + "cloud": { + "block_store_default_microversion": "3.60", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "3.60" + }, + { + "name": "precedence-block_storage", + "cloud": { + "block_storage_default_microversion": "3.60", + "volumev3_default_microversion": "3.61", + "volumev2_default_microversion": "3.62", + "volume_default_microversion": "3.63", + "block_store_default_microversion": "3.64", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "volumev3", + "expected": "3.60" + }, + { + "name": "precedence-volumev3", + "cloud": { + "volumev3_default_microversion": "3.60", + "volumev2_default_microversion": "3.61", + "volume_default_microversion": "3.62", + "block_store_default_microversion": "3.63", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "volumev3", + "expected": "3.60" + }, + { + "name": "precedence-volumev2", + "cloud": { + "volumev2_default_microversion": "3.60", + "volume_default_microversion": "3.61", + "block_store_default_microversion": "3.62", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "volumev3", + "expected": "3.60" + }, + { + "name": "precedence-volume", + "cloud": { + "volume_default_microversion": "3.60", + "block_store_default_microversion": "3.61", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "volumev3", + "expected": "3.60" + }, + { + "name": "empty-canonical", + "cloud": { + "block_storage_default_microversion": "", + "volumev3_default_microversion": "3.60", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "" + }, + { + "name": "null-canonical", + "cloud": { + "block_storage_default_microversion": null, + "volumev3_default_microversion": "3.60", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "3.60" + }, + { + "name": "missing", + "cloud": { + "compute_default_microversion": "2.87", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "" + }, + { + "name": "global-is-not-default", + "cloud": { + "default_microversion": "3.60", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "" + }, + { + "name": "unknown-service", + "cloud": { + "future_service_default_microversion": "1.4", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "future-service", + "expected": "1.4" + }, + { + "name": "hyphenated-key", + "cloud": { + "block-storage-default-microversion": "3.60", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block_storage", + "expected": "3.60" + }, + { + "name": "quoted-trailing-zero", + "cloud": { + "compute_default_microversion": "2.10", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "compute", + "expected": "2.10" + }, + { + "name": "secure-override", + "cloud": { + "compute_default_microversion": "2.87", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": { + "compute_default_microversion": "2.79" + }, + "public": {}, + "region": "mars", + "service": "compute", + "expected": "2.79" + }, + { + "name": "secure-null", + "cloud": { + "block_storage_default_microversion": "3.60", + "volumev3_default_microversion": "3.50", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": { + "block_storage_default_microversion": null + }, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "3.50" + }, + { + "name": "regional-override", + "cloud": { + "compute_default_microversion": "2.87", + "regions": [ + { + "name": "mars", + "values": { + "compute_default_microversion": "2.79" + } + } + ], + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "compute", + "expected": "2.79" + }, + { + "name": "regional-null-inherits", + "cloud": { + "compute_default_microversion": "2.87", + "regions": [ + { + "name": "mars", + "values": { + "compute_default_microversion": null + } + } + ], + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "compute", + "expected": "2.87" + }, + { + "name": "regional-empty-clears", + "cloud": { + "compute_default_microversion": "2.87", + "regions": [ + { + "name": "mars", + "values": { + "compute_default_microversion": "" + } + } + ], + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "compute", + "expected": "" + }, + { + "name": "regional-alias-vs-canonical", + "cloud": { + "block_storage_default_microversion": "3.60", + "regions": [ + { + "name": "mars", + "values": { + "volumev3_default_microversion": "3.50" + } + } + ], + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": {}, + "region": "mars", + "service": "block-storage", + "expected": "3.60" + }, + { + "name": "secure-and-region", + "cloud": { + "compute_default_microversion": "2.87", + "regions": [ + { + "name": "mars", + "values": { + "compute_default_microversion": "2.79" + } + } + ], + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": { + "compute_default_microversion": "2.80" + }, + "public": {}, + "region": "mars", + "service": "compute", + "expected": "2.79" + }, + { + "name": "profile-default", + "cloud": { + "profile": "example", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": { + "compute_default_microversion": "2.87" + }, + "region": "mars", + "service": "compute", + "expected": "2.87" + }, + { + "name": "profile-cloud-override", + "cloud": { + "profile": "example", + "compute_default_microversion": "2.79", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": {}, + "public": { + "compute_default_microversion": "2.87" + }, + "region": "mars", + "service": "compute", + "expected": "2.79" + }, + { + "name": "profile-secure-override", + "cloud": { + "profile": "example", + "compute_default_microversion": "2.79", + "auth": { + "auth_url": "https://example.org/v3" + }, + "region_name": "mars" + }, + "secure": { + "compute_default_microversion": "2.80" + }, + "public": { + "compute_default_microversion": "2.87" + }, + "region": "mars", + "service": "compute", + "expected": "2.80" + } + ] +} diff --git a/openstack/clientconfig/utils.go b/openstack/clientconfig/utils.go index 6eece9f0..9298b6b0 100644 --- a/openstack/clientconfig/utils.go +++ b/openstack/clientconfig/utils.go @@ -7,6 +7,7 @@ import ( "os/user" "path/filepath" "reflect" + "strings" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/utils/v2/env" @@ -66,6 +67,10 @@ func mergeInterfaces(overridingInterface, inferiorInterface any) any { } for k, v := range interfaceMap { if overridingValue, ok := overriding[k]; ok { + // Explicit empty and null microversion defaults replace inherited values. + if strings.HasSuffix(k, defaultMicroversionSuffix) { + continue + } overriding[k] = mergeInterfaces(overridingValue, v) } else { overriding[k] = v