Skip to content
Open
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
18 changes: 18 additions & 0 deletions openstack/clientconfig/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
98 changes: 98 additions & 0 deletions openstack/clientconfig/microversions.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
221 changes: 221 additions & 0 deletions openstack/clientconfig/microversions_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
20 changes: 19 additions & 1 deletion openstack/clientconfig/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading