diff --git a/openstack/clientconfig/doc.go b/openstack/clientconfig/doc.go index 3f85b67..44ace56 100644 --- a/openstack/clientconfig/doc.go +++ b/openstack/clientconfig/doc.go @@ -42,5 +42,23 @@ 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 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 +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 0000000..abf9abb --- /dev/null +++ b/openstack/clientconfig/microversions.go @@ -0,0 +1,98 @@ +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(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 + } + if value.DefaultMicroversions == nil { + value.DefaultMicroversions = make(map[string]string) + } + value.DefaultMicroversions[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]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]any `yaml:",inline"` + }{plain(c), fields}, nil +} + +// MarshalJSON uses the same flat keys and scalar types as the YAML representation. +func (c Cloud) MarshalJSON() ([]byte, error) { + var node yaml.Node + if err := node.Encode(c); err != nil { + return nil, err + } + var fields map[string]any + if err := node.Decode(&fields); err != nil { + return nil, err + } + return json.Marshal(fields) +} + +// UnmarshalJSON shares YAML's handling of service keys and nullable defaults. +func (c *Cloud) UnmarshalJSON(data []byte) error { + return yaml.Unmarshal(data, c) +} + +// 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 + } + } + return "" +} diff --git a/openstack/clientconfig/microversions_test.go b/openstack/clientconfig/microversions_test.go new file mode 100644 index 0000000..519cbc6 --- /dev/null +++ b/openstack/clientconfig/microversions_test.go @@ -0,0 +1,221 @@ +package clientconfig + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "os" + "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" +block_storage_default_microversion: null +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) + } + } +} + +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 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", "") + 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"}, + {"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", ""}, + } { + 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: 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") + } + }) + } + }) + } +} + +// 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.yaml") + if err != nil { + t.Fatal(err) + } + var fixture struct { + Defaults struct { + Region, Service string + Cloud map[string]any + } + Cases []struct { + Name string + Cloud, Secure, Public map[string]any + Region, Service, Expected string + } + } + if err := yaml.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + for _, tc := range fixture.Cases { + t.Run(tc.Name, func(t *testing.T) { + if tc.Region == "" { + tc.Region = fixture.Defaults.Region + } + if tc.Service == "" { + tc.Service = fixture.Defaults.Service + } + fields := maps.Clone(fixture.Defaults.Cloud) + maps.Copy(fields, tc.Cloud) + tc.Cloud = fields + + var input microversionYAML + for _, pair := range []struct { + data map[string]any + cloud *Cloud + }{{tc.Cloud, &input.cloud}, {tc.Secure, &input.secure}, {tc.Public, &input.public}} { + data, err := yaml.Marshal(pair.data) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(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 6543738..67b09b5 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 @@ -313,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 } } @@ -911,6 +917,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 0f21fbf..bb61f27 100644 --- a/openstack/clientconfig/results.go +++ b/openstack/clientconfig/results.go @@ -35,6 +35,14 @@ 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"` + // 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:"-"` + + // 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 0000000..d8adacb --- /dev/null +++ b/openstack/clientconfig/testdata/generate_openstacksdk_defaults.py @@ -0,0 +1,58 @@ +"""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.yaml +Go tests consume the checked-in YAML; they do not require Python or an SDK install. +""" +import importlib.metadata +import tempfile +from pathlib import Path + +import yaml +from openstack.config.loader import OpenStackConfig + +defaults = {'region': 'mars', 'service': 'block-storage', 'cloud': {'auth': {'auth_url': 'https://example.org/v3'}, 'region_name': 'mars'}} +cases = [] +def add(name, cloud, service='block-storage', secure=None, public=None, region='mars'): + case = dict(name=name, cloud=cloud) + for key, value, default in [('service', service, defaults['service']), ('region', region, defaults['region']), ('secure', secure, None), ('public', public, None)]: + if value != default: + case[key] = value + cases.append(case) + +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 = {**defaults['cloud'], **case['cloud']} + (root/'clouds.yaml').write_text(yaml.safe_dump({'clouds': {'test': cloud}})) + (root/'secure.yaml').write_text(yaml.safe_dump({'clouds': {'test': case.get('secure', {})}})) + (root/'clouds-public.yaml').write_text(yaml.safe_dump({'public-clouds': {'example': case.get('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.get('region', defaults['region']), validate=False).get_default_microversion(case.get('service', defaults['service'])) or '' + +print(yaml.safe_dump({'openstacksdk':importlib.metadata.version('openstacksdk'),'os_service_types':importlib.metadata.version('os-service-types'),'defaults':defaults,'cases':cases},sort_keys=False),end='') diff --git a/openstack/clientconfig/testdata/openstacksdk-defaults.yaml b/openstack/clientconfig/testdata/openstacksdk-defaults.yaml new file mode 100644 index 0000000..023f086 --- /dev/null +++ b/openstack/clientconfig/testdata/openstacksdk-defaults.yaml @@ -0,0 +1,178 @@ +openstacksdk: 4.20.0 +os_service_types: 1.9.0 +defaults: + region: mars + service: block-storage + cloud: + auth: + auth_url: https://example.org/v3 + region_name: mars +cases: +- name: alias-block_storage + cloud: + block_storage_default_microversion: '3.60' + expected: '3.60' +- name: alias-volumev3 + cloud: + volumev3_default_microversion: '3.60' + expected: '3.60' +- name: alias-volumev2 + cloud: + volumev2_default_microversion: '3.60' + expected: '3.60' +- name: alias-volume + cloud: + volume_default_microversion: '3.60' + expected: '3.60' +- name: alias-block_store + cloud: + block_store_default_microversion: '3.60' + 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' + 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' + 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' + service: volumev3 + expected: '3.60' +- name: precedence-volume + cloud: + volume_default_microversion: '3.60' + block_store_default_microversion: '3.61' + service: volumev3 + expected: '3.60' +- name: empty-canonical + cloud: + block_storage_default_microversion: '' + volumev3_default_microversion: '3.60' + expected: '' +- name: null-canonical + cloud: + block_storage_default_microversion: null + volumev3_default_microversion: '3.60' + expected: '3.60' +- name: missing + cloud: + compute_default_microversion: '2.87' + expected: '' +- name: global-is-not-default + cloud: + default_microversion: '3.60' + expected: '' +- name: unknown-service + cloud: + future_service_default_microversion: '1.4' + service: future-service + expected: '1.4' +- name: hyphenated-key + cloud: + block-storage-default-microversion: '3.60' + service: block_storage + expected: '3.60' +- name: quoted-trailing-zero + cloud: + compute_default_microversion: '2.10' + service: compute + expected: '2.10' +- name: secure-override + cloud: + compute_default_microversion: '2.87' + service: compute + secure: + compute_default_microversion: '2.79' + expected: '2.79' +- name: secure-null + cloud: + block_storage_default_microversion: '3.60' + volumev3_default_microversion: '3.50' + secure: + block_storage_default_microversion: null + expected: '3.50' +- name: regional-override + cloud: + compute_default_microversion: '2.87' + regions: + - name: mars + values: + compute_default_microversion: '2.79' + service: compute + expected: '2.79' +- name: regional-null-inherits + cloud: + compute_default_microversion: '2.87' + regions: + - name: mars + values: + compute_default_microversion: null + service: compute + expected: '2.87' +- name: regional-empty-clears + cloud: + compute_default_microversion: '2.87' + regions: + - name: mars + values: + compute_default_microversion: '' + service: compute + expected: '' +- name: regional-alias-vs-canonical + cloud: + block_storage_default_microversion: '3.60' + regions: + - name: mars + values: + volumev3_default_microversion: '3.50' + expected: '3.60' +- name: secure-and-region + cloud: + compute_default_microversion: '2.87' + regions: + - name: mars + values: + compute_default_microversion: '2.79' + service: compute + secure: + compute_default_microversion: '2.80' + expected: '2.79' +- name: profile-default + cloud: + profile: example + service: compute + public: + compute_default_microversion: '2.87' + expected: '2.87' +- name: profile-cloud-override + cloud: + profile: example + compute_default_microversion: '2.79' + service: compute + public: + compute_default_microversion: '2.87' + expected: '2.79' +- name: profile-secure-override + cloud: + profile: example + compute_default_microversion: '2.79' + service: compute + secure: + compute_default_microversion: '2.80' + public: + compute_default_microversion: '2.87' + expected: '2.80' diff --git a/openstack/clientconfig/testing/clouds.yaml b/openstack/clientconfig/testing/clouds.yaml index fe2d26d..5fb4789 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 053fa37..f51f57a 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", }, + 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"}}, + Name: "PHL1", + Values: clientconfig.Cloud{ + AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}, + DefaultMicroversions: map[string]string{"compute": "2.79"}, + }, }, { Name: "PHL2", @@ -71,10 +75,14 @@ var PhiladelphiaComplexPhl2CloudYAML = clientconfig.Cloud{ Password: "password", ProjectName: "Some Project", }, + 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"}}, + Name: "PHL1", + Values: clientconfig.Cloud{ + AuthInfo: &clientconfig.AuthInfo{AuthURL: "https://phl1.example.com:5000/v3"}, + DefaultMicroversions: map[string]string{"compute": "2.79"}, + }, }, { Name: "PHL2", @@ -170,7 +178,8 @@ var ChicagoCloudUseProfileYAML = clientconfig.Cloud{ } var HawaiiCloudYAML = clientconfig.Cloud{ - RegionName: "HNL", + 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 0149190..edd8208 100644 --- a/openstack/clientconfig/testing/results_test.go +++ b/openstack/clientconfig/testing/results_test.go @@ -30,6 +30,7 @@ var HawaiiExpected = `clouds: domain_name: default region_name: HNL verify: true + compute_default_microversion: "2.87" ` func TestMarshallCloudToYaml(t *testing.T) { diff --git a/openstack/clientconfig/utils.go b/openstack/clientconfig/utils.go index 6eece9f..9298b6b 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