Skip to content

Commit 5b89285

Browse files
committed
Consider API major version, not API microversion major version
In commit f28c963, we added support for discovering API versions. To do this, we added a dependency on the 'version' and 'min_version' headers, which are used by services with microversion support to document the maximum and minimum API versions supported, respectively. However, not all services support API microversions: services like Glance and Designate use API versions as a signal of a new feature (or, in Glance's case, as a signal that a feature is not enabled), others like Keystone just support a single version, while Neutron does its own thing with API extensions [1]. Given this fact, relying on these fields is a mistake. Instead, we should be relying on the 'id' field. Per the api-sig guidelines [2], this should be the API major version [3]. We continue parsing the microversion-related headers, since it will be useful later on if/when we want to do versioned discovery. [1] https://that.guru/blog/api-versioning-in-openstack/ [2] https://specs.openstack.org/openstack/api-sig/guidelines/discoverability.html [3] The studious among you may notice that the api-sig guidelines indicate that the maximum API microversion should be exposed via the 'max_version' header. However, in practice, virtually everyone uses 'version' instead. Why? Who knows. Best to just take these things on the chin and move on with our lives. [4] The term "major version" is a bit loaded. Consider Nova: at the time of writing, it exposes two "major versions": v2.0 and v2.1. v2.0 does not support microversions. v2.1 does. For v2.1 you therefore also have microversions to content with and at the time of writing it supports a minimum microversion of 2.1 and a maximum microversion of 2.100 (no 'v' prefix here). Normalizing these as we've done here gives us a major version of 2, a minor version of 1, a major maximum microversion of 2, a minor maximum microversion of 100, a major minimum microversion of 2, and a minor minimum microversion of 1 🤯. Signed-off-by: Stephen Finucane <stephenfin@redhat.com>
1 parent 9c18abd commit 5b89285

File tree

3 files changed

+278
-71
lines changed

3 files changed

+278
-71
lines changed

openstack/endpoint.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,18 @@ func endpointSupportsVersion(ctx context.Context, client *gophercloud.ProviderCl
6565
return false, err
6666
}
6767

68-
supportedMicroversions, err := utils.GetServiceVersions(ctx, client, endpointURL)
68+
supportedVersions, err := utils.GetServiceVersions(ctx, client, endpointURL)
6969
if err != nil {
7070
return false, err
7171
}
7272

73-
return supportedMicroversions.MinMajor == 0 || supportedMicroversions.MinMajor == expectedVersion, nil
73+
for _, supportedVersion := range supportedVersions {
74+
if supportedVersion.Major == expectedVersion {
75+
return true, nil
76+
}
77+
}
78+
79+
return false, nil
7480
}
7581

7682
/*

openstack/utils/discovery.go

Lines changed: 152 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,78 +3,151 @@ package utils
33
import (
44
"context"
55
"fmt"
6+
"regexp"
7+
"sort"
68
"strconv"
79
"strings"
810

911
"github.com/gophercloud/gophercloud/v2"
1012
)
1113

14+
type Status string
15+
16+
const (
17+
StatusCurrent Status = "CURRENT"
18+
StatusSupported Status = "SUPPORTED"
19+
StatusDeprecated Status = "DEPRECATED"
20+
StatusExperimental Status = "EXPERIMENTAL"
21+
StatusUnknown Status = ""
22+
)
23+
24+
// SupportedVersion stores a normalized form of the API version data. It handles APIs that
25+
// support microversions as well as those that do not.
26+
type SupportedVersion struct {
27+
// Major is the major version number of the API
28+
Major int
29+
// Minor is the minor version number of the API
30+
Minor int
31+
// Status is the status of the API
32+
Status Status
33+
SupportedMicroversions
34+
}
35+
36+
// SupportedMicroversions stores a normalized form of the maximum and minimum API microversions
37+
// supported by a given service.
1238
type SupportedMicroversions struct {
39+
// MaxMajor is the major version number of the maximum supported API microversion
1340
MaxMajor int
41+
// MaxMinor is the minor version number of the maximum supported API microversion
1442
MaxMinor int
43+
// MinMajor is the major version number of the minimum supported API microversion
1544
MinMajor int
45+
// MinMinor is the minor version number of the minimum supported API microversion
1646
MinMinor int
1747
}
1848

19-
// GetServiceVersions returns the minimum and maximum microversion that is supported by the ServiceClient Endpoint.
20-
func GetServiceVersions(ctx context.Context, client *gophercloud.ProviderClient, endpointURL string) (SupportedMicroversions, error) {
49+
// GetServiceVersions returns the versions supported by the ServiceClient Endpoint.
50+
// If the endpoint resolves to an unversioned discovery API, this should return one or more supported versions.
51+
// If the endpoint resolves to a versioned discovery API, this should return exactly one supported version.
52+
func GetServiceVersions(ctx context.Context, client *gophercloud.ProviderClient, endpointURL string) ([]SupportedVersion, error) {
2153
type valueResp struct {
2254
ID string `json:"id"`
2355
Status string `json:"status"`
2456
Version string `json:"version"`
2557
MinVersion string `json:"min_version"`
2658
}
27-
2859
type response struct {
2960
Version valueResp `json:"version"`
3061
Versions []valueResp `json:"versions"`
3162
}
32-
var minVersion, maxVersion string
33-
var supportedMicroversions SupportedMicroversions
63+
64+
var supportedVersions []SupportedVersion
65+
3466
var resp response
3567
_, err := client.Request(ctx, "GET", endpointURL, &gophercloud.RequestOpts{
3668
JSONResponse: &resp,
3769
OkCodes: []int{200, 300},
3870
})
39-
4071
if err != nil {
41-
return supportedMicroversions, err
72+
return supportedVersions, err
4273
}
4374

75+
var versions []valueResp
4476
if len(resp.Versions) > 0 {
45-
// We are dealing with an unversioned endpoint
46-
// We only handle the case when there is exactly one, and assume it is the correct one
47-
if len(resp.Versions) > 1 {
48-
return supportedMicroversions, fmt.Errorf("unversioned endpoint with multiple alternatives not supported")
49-
}
50-
minVersion = resp.Versions[0].MinVersion
51-
maxVersion = resp.Versions[0].Version
77+
versions = resp.Versions
5278
} else {
53-
minVersion = resp.Version.MinVersion
54-
maxVersion = resp.Version.Version
79+
versions = append(versions, resp.Version)
5580
}
5681

57-
// Return early if the endpoint does not support microversions
58-
if minVersion == "" && maxVersion == "" {
59-
return supportedMicroversions, fmt.Errorf("microversions not supported by endpoint")
82+
for _, version := range versions {
83+
majorVersion, minorVersion, err := ParseVersion(version.ID)
84+
if err != nil {
85+
return supportedVersions, err
86+
}
87+
88+
status, err := ParseStatus(version.Status)
89+
if err != nil {
90+
return supportedVersions, err
91+
}
92+
93+
supportedVersion := SupportedVersion{
94+
Major: majorVersion,
95+
Minor: minorVersion,
96+
Status: status,
97+
}
98+
99+
// Only normalize the microversion if there is a microversion to normalize
100+
if version.MinVersion != "" && version.Version != "" {
101+
supportedVersion.MinMajor, supportedVersion.MinMinor, err = ParseMicroversion(version.MinVersion)
102+
if err != nil {
103+
return supportedVersions, err
104+
}
105+
106+
supportedVersion.MaxMajor, supportedVersion.MaxMinor, err = ParseMicroversion(version.Version)
107+
if err != nil {
108+
return supportedVersions, err
109+
}
110+
}
111+
112+
supportedVersions = append(supportedVersions, supportedVersion)
60113
}
61114

62-
supportedMicroversions.MinMajor, supportedMicroversions.MinMinor, err = ParseMicroversion(minVersion)
115+
sort.Slice(supportedVersions, func(i, j int) bool {
116+
return supportedVersions[i].Major > supportedVersions[j].Major || (supportedVersions[i].Major == supportedVersions[j].Major &&
117+
supportedVersions[i].Minor > supportedVersions[j].Minor)
118+
})
119+
120+
return supportedVersions, nil
121+
}
122+
123+
// GetSupportedMicroversions returns the minimum and maximum microversion that is supported by the ServiceClient Endpoint.
124+
func GetSupportedMicroversions(ctx context.Context, client *gophercloud.ServiceClient) (SupportedMicroversions, error) {
125+
var supportedMicroversions SupportedMicroversions
126+
127+
supportedVersions, err := GetServiceVersions(ctx, client.ProviderClient, client.Endpoint)
63128
if err != nil {
64129
return supportedMicroversions, err
65130
}
66131

67-
supportedMicroversions.MaxMajor, supportedMicroversions.MaxMinor, err = ParseMicroversion(maxVersion)
68-
if err != nil {
69-
return supportedMicroversions, err
132+
// If there are multiple versions then we were handed an unversioned endpoint. These don't
133+
// provide microversion information, so we need to fail. Likewise, if there are no versions
134+
// then something has gone wrong and we also need to fail.
135+
if len(supportedVersions) > 1 {
136+
return supportedMicroversions, fmt.Errorf("unversioned endpoint with multiple alternatives not supported")
137+
} else if len(supportedVersions) == 0 {
138+
return supportedMicroversions, fmt.Errorf("microversions not supported by endpoint")
70139
}
71140

72-
return supportedMicroversions, nil
73-
}
141+
supportedMicroversions = supportedVersions[0].SupportedMicroversions
74142

75-
// GetSupportedMicroversions returns the minimum and maximum microversion that is supported by the ServiceClient Endpoint.
76-
func GetSupportedMicroversions(ctx context.Context, client *gophercloud.ServiceClient) (SupportedMicroversions, error) {
77-
return GetServiceVersions(ctx, client.ProviderClient, client.Endpoint)
143+
if supportedMicroversions.MaxMajor == 0 &&
144+
supportedMicroversions.MaxMinor == 0 &&
145+
supportedMicroversions.MinMajor == 0 &&
146+
supportedMicroversions.MinMinor == 0 {
147+
return supportedMicroversions, fmt.Errorf("microversions not supported by endpoint")
148+
}
149+
150+
return supportedMicroversions, err
78151
}
79152

80153
// RequireMicroversion checks that the required microversion is supported and
@@ -117,6 +190,42 @@ func (supported SupportedMicroversions) IsSupported(version string) (bool, error
117190
return false, nil
118191
}
119192

193+
// ParseVersion parsed the version strings v{MAJOR} and v{MAJOR}.{MINOR} into separate integers
194+
// major and minor.
195+
// For example, "v2.1" becomes 2 and 1, "v3" becomes 3 and 0, and "1" becomes 1 and 0.
196+
func ParseVersion(version string) (major, minor int, err error) {
197+
if version == "" {
198+
return 0, 0, fmt.Errorf("empty version provided")
199+
}
200+
201+
// We use the regex indicated by the version discovery guidelines.
202+
//
203+
// https://specs.openstack.org/openstack/api-sig/guidelines/consuming-catalog/version-discovery.html#inferring-version
204+
//
205+
// However, we diverge slightly since not all services include the 'v' prefix (glares at zaqar)
206+
versionRe := regexp.MustCompile(`^v?(?P<major>[0-9]+)(\.(?P<minor>[0-9]+))?$`)
207+
208+
match := versionRe.FindStringSubmatch(version)
209+
if len(match) == 0 {
210+
return 0, 0, fmt.Errorf("invalid format: %q", version)
211+
}
212+
213+
major, err = strconv.Atoi(match[versionRe.SubexpIndex("major")])
214+
if err != nil {
215+
return 0, 0, err
216+
}
217+
218+
minor = 0
219+
if match[versionRe.SubexpIndex("minor")] != "" {
220+
minor, err = strconv.Atoi(match[versionRe.SubexpIndex("minor")])
221+
if err != nil {
222+
return 0, 0, err
223+
}
224+
}
225+
226+
return major, minor, nil
227+
}
228+
120229
// ParseMicroversion parses the version major.minor into separate integers major and minor.
121230
// For example, "2.53" becomes 2 and 53.
122231
func ParseMicroversion(version string) (major int, minor int, err error) {
@@ -134,3 +243,18 @@ func ParseMicroversion(version string) (major int, minor int, err error) {
134243
}
135244
return major, minor, nil
136245
}
246+
247+
func ParseStatus(status string) (Status, error) {
248+
switch strings.ToUpper(status) {
249+
case "CURRENT", "STABLE": // keystone uses STABLE instead of CURRENT
250+
return StatusCurrent, nil
251+
case "SUPPORTED":
252+
return StatusSupported, nil
253+
case "DEPRECATED":
254+
return StatusDeprecated, nil
255+
case "":
256+
return StatusUnknown, nil
257+
default:
258+
return StatusUnknown, fmt.Errorf("invalid status: %q", status)
259+
}
260+
}

0 commit comments

Comments
 (0)