Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Add versioned endpoint discovery #3351

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 10, 2025
Merged
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
5 changes: 5 additions & 0 deletions endpoint_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ type EndpointOpts struct {
// Required only for services that span multiple regions.
Region string

// Version [optional] is the major version of the service required. It it not
// a microversion. Use this to ensure the correct endpoint is selected when
// multiple API versions are available.
Version int

// Availability [optional] is the visibility of the endpoint to be returned.
// Valid types include the constants AvailabilityPublic, AvailabilityInternal,
// or AvailabilityAdmin from this package.
Expand Down
54 changes: 31 additions & 23 deletions openstack/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package openstack

import (
"context"
"errors"
"fmt"
"reflect"
"strings"
Expand Down Expand Up @@ -162,7 +163,7 @@ func v2auth(ctx context.Context, client *gophercloud.ProviderClient, endpoint st
}
}
client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
return V2EndpointURL(catalog, opts)
return V2Endpoint(ctx, client, catalog, opts)
}

return nil
Expand Down Expand Up @@ -283,7 +284,7 @@ func v3auth(ctx context.Context, client *gophercloud.ProviderClient, endpoint st
}
}
client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
return V3EndpointURL(catalog, opts)
return V3Endpoint(ctx, client, catalog, opts)
}

return nil
Expand Down Expand Up @@ -345,13 +346,20 @@ func NewIdentityV3(client *gophercloud.ProviderClient, eo gophercloud.EndpointOp
}

// TODO(stephenfin): Allow passing aliases to all New${SERVICE}V${VERSION} methods in v3
func initClientOpts(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts, clientType string) (*gophercloud.ServiceClient, error) {
func initClientOpts(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts, clientType string, version int) (*gophercloud.ServiceClient, error) {
sc := new(gophercloud.ServiceClient)

eo.ApplyDefaults(clientType)
if eo.Version != 0 && eo.Version != version {
return sc, errors.New("Conflict between requested service major version and manually set version")
}
eo.Version = version

url, err := client.EndpointLocator(eo)
if err != nil {
return sc, err
}

sc.ProviderClient = client
sc.Endpoint = url
sc.Type = clientType
Expand All @@ -361,7 +369,7 @@ func initClientOpts(client *gophercloud.ProviderClient, eo gophercloud.EndpointO
// NewBareMetalV1 creates a ServiceClient that may be used with the v1
// bare metal package.
func NewBareMetalV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
sc, err := initClientOpts(client, eo, "baremetal")
sc, err := initClientOpts(client, eo, "baremetal", 1)
if !strings.HasSuffix(strings.TrimSuffix(sc.Endpoint, "/"), "v1") {
sc.ResourceBase = sc.Endpoint + "v1/"
}
Expand All @@ -371,25 +379,25 @@ func NewBareMetalV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointO
// NewBareMetalIntrospectionV1 creates a ServiceClient that may be used with the v1
// bare metal introspection package.
func NewBareMetalIntrospectionV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "baremetal-introspection")
return initClientOpts(client, eo, "baremetal-introspection", 1)
}

// NewObjectStorageV1 creates a ServiceClient that may be used with the v1
// object storage package.
func NewObjectStorageV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "object-store")
return initClientOpts(client, eo, "object-store", 1)
}

// NewComputeV2 creates a ServiceClient that may be used with the v2 compute
// package.
func NewComputeV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "compute")
return initClientOpts(client, eo, "compute", 2)
}

// NewNetworkV2 creates a ServiceClient that may be used with the v2 network
// package.
func NewNetworkV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
sc, err := initClientOpts(client, eo, "network")
sc, err := initClientOpts(client, eo, "network", 2)
sc.ResourceBase = sc.Endpoint + "v2.0/"
return sc, err
}
Expand All @@ -398,56 +406,56 @@ func NewNetworkV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpt
// NewBlockStorageV1 creates a ServiceClient that may be used to access the v1
// block storage service.
func NewBlockStorageV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "volume")
return initClientOpts(client, eo, "volume", 1)
}

// NewBlockStorageV2 creates a ServiceClient that may be used to access the v2
// block storage service.
func NewBlockStorageV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "block-storage")
return initClientOpts(client, eo, "block-storage", 2)
}

// NewBlockStorageV3 creates a ServiceClient that may be used to access the v3 block storage service.
func NewBlockStorageV3(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "block-storage")
return initClientOpts(client, eo, "block-storage", 3)
}

// NewSharedFileSystemV2 creates a ServiceClient that may be used to access the v2 shared file system service.
func NewSharedFileSystemV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "shared-file-system")
return initClientOpts(client, eo, "shared-file-system", 2)
}

// NewOrchestrationV1 creates a ServiceClient that may be used to access the v1
// orchestration service.
func NewOrchestrationV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "orchestration")
return initClientOpts(client, eo, "orchestration", 1)
}

// NewDBV1 creates a ServiceClient that may be used to access the v1 DB service.
func NewDBV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "database")
return initClientOpts(client, eo, "database", 1)
}

// NewDNSV2 creates a ServiceClient that may be used to access the v2 DNS
// service.
func NewDNSV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
sc, err := initClientOpts(client, eo, "dns")
sc, err := initClientOpts(client, eo, "dns", 2)
sc.ResourceBase = sc.Endpoint + "v2/"
return sc, err
}

// NewImageV2 creates a ServiceClient that may be used to access the v2 image
// service.
func NewImageV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
sc, err := initClientOpts(client, eo, "image")
sc, err := initClientOpts(client, eo, "image", 2)
sc.ResourceBase = sc.Endpoint + "v2/"
return sc, err
}

// NewLoadBalancerV2 creates a ServiceClient that may be used to access the v2
// load balancer service.
func NewLoadBalancerV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
sc, err := initClientOpts(client, eo, "load-balancer")
sc, err := initClientOpts(client, eo, "load-balancer", 2)

// Fixes edge case having an OpenStack lb endpoint with trailing version number.
endpoint := strings.Replace(sc.Endpoint, "v2.0/", "", -1)
Expand All @@ -459,36 +467,36 @@ func NewLoadBalancerV2(client *gophercloud.ProviderClient, eo gophercloud.Endpoi
// NewMessagingV2 creates a ServiceClient that may be used with the v2 messaging
// service.
func NewMessagingV2(client *gophercloud.ProviderClient, clientID string, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
sc, err := initClientOpts(client, eo, "message")
sc, err := initClientOpts(client, eo, "message", 2)
sc.MoreHeaders = map[string]string{"Client-ID": clientID}
return sc, err
}

// NewContainerV1 creates a ServiceClient that may be used with v1 container package
func NewContainerV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "application-container")
return initClientOpts(client, eo, "application-container", 1)
}

// NewKeyManagerV1 creates a ServiceClient that may be used with the v1 key
// manager service.
func NewKeyManagerV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
sc, err := initClientOpts(client, eo, "key-manager")
sc, err := initClientOpts(client, eo, "key-manager", 1)
sc.ResourceBase = sc.Endpoint + "v1/"
return sc, err
}

// NewContainerInfraV1 creates a ServiceClient that may be used with the v1 container infra management
// package.
func NewContainerInfraV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "container-infrastructure-management")
return initClientOpts(client, eo, "container-infrastructure-management", 1)
}

// NewWorkflowV2 creates a ServiceClient that may be used with the v2 workflow management package.
func NewWorkflowV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "workflow")
return initClientOpts(client, eo, "workflow", 2)
}

// NewPlacementV1 creates a ServiceClient that may be used with the placement package.
func NewPlacementV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
return initClientOpts(client, eo, "placement")
return initClientOpts(client, eo, "placement", 1)
}
184 changes: 184 additions & 0 deletions openstack/endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package openstack

import (
"context"
"regexp"
"slices"
"strconv"

"github.com/gophercloud/gophercloud/v2"
tokens2 "github.com/gophercloud/gophercloud/v2/openstack/identity/v2/tokens"
tokens3 "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens"
"github.com/gophercloud/gophercloud/v2/openstack/utils"
)

var versionedServiceTypeAliasRegexp = regexp.MustCompile(`^.*v(\d)$`)

func extractServiceTypeVersion(serviceType string) int {
matches := versionedServiceTypeAliasRegexp.FindAllStringSubmatch(serviceType, 1)
if matches != nil {
// no point converting to an int
ret, err := strconv.Atoi(matches[0][1])
if err != nil {
return 0
}
return ret
}
return 0
}

func endpointSupportsVersion(ctx context.Context, client *gophercloud.ProviderClient, serviceType, endpointURL string, expectedVersion int) (bool, error) {
// Swift doesn't support version discovery :(
if expectedVersion == 0 || serviceType == "object-store" {
return true, nil
}

// Repeating verbatim from keystoneauth1 [1]:
//
// > The sins of our fathers become the blood on our hands.
// > If a user requests an old-style service type such as volumev2, then they
// > are inherently requesting the major API version 2. It's not a good
// > interface, but it's the one that was imposed on the world years ago
// > because the client libraries all hid the version discovery document.
// > In order to be able to ensure that a user who requests volumev2 does not
// > get a block-storage endpoint that only provides v3 of the block-storage
// > service, we need to pull the version out of the service_type. The
// > service-types-authority will prevent the growth of new monstrosities such
// > as this, but in order to move forward without breaking people, we have
// > to just cry in the corner while striking ourselves with thorned branches.
// > That said, for sure only do this hack for officially known service_types.
//
// So yeah, what mordred said.
//
// https://github.com/openstack/keystoneauth/blob/5.10.0/keystoneauth1/discover.py#L270-L290
impliedVersion := extractServiceTypeVersion(serviceType)
if impliedVersion != 0 && impliedVersion != expectedVersion {
return false, nil
}

// NOTE(stephenfin) In addition to the above, keystoneauth also supports a URL
// hack whereby it will extract the version from the URL. We may wish to
// implement this too.

endpointURL, err := utils.BaseVersionedEndpoint(endpointURL)
if err != nil {
return false, err
}

supportedMicroversions, err := utils.GetServiceVersions(ctx, client, endpointURL)
if err != nil {
return false, err
}

return supportedMicroversions.MinMajor == 0 || supportedMicroversions.MinMajor == expectedVersion, nil
}

/*
V2Endpoint discovers the endpoint URL for a specific service from a
ServiceCatalog acquired during the v2 identity service.

The specified EndpointOpts are used to identify a unique, unambiguous endpoint
to return. It's an error both when multiple endpoints match the provided
criteria and when none do. The minimum that can be specified is a Type, but you
will also often need to specify a Name and/or a Region depending on what's
available on your OpenStack deployment.
*/
func V2Endpoint(ctx context.Context, client *gophercloud.ProviderClient, catalog *tokens2.ServiceCatalog, opts gophercloud.EndpointOpts) (string, error) {
// Extract Endpoints from the catalog entries that match the requested Type, Name if provided, and Region if provided.
//
// If multiple endpoints are found, we return the first result and disregard the rest.
// This behavior matches the Python library. See GH-1764.
for _, entry := range catalog.Entries {
if (slices.Contains(opts.Types(), entry.Type)) && (opts.Name == "" || entry.Name == opts.Name) {
for _, endpoint := range entry.Endpoints {
if opts.Region != "" && endpoint.Region != opts.Region {
continue
}

var endpointURL string
switch opts.Availability {
case gophercloud.AvailabilityPublic:
endpointURL = gophercloud.NormalizeURL(endpoint.PublicURL)
case gophercloud.AvailabilityInternal:
endpointURL = gophercloud.NormalizeURL(endpoint.InternalURL)
case gophercloud.AvailabilityAdmin:
endpointURL = gophercloud.NormalizeURL(endpoint.AdminURL)
default:
err := &ErrInvalidAvailabilityProvided{}
err.Argument = "Availability"
err.Value = opts.Availability
return "", err
}

endpointSupportsVersion, err := endpointSupportsVersion(ctx, client, entry.Type, endpointURL, opts.Version)
if err != nil {
return "", err
}
if !endpointSupportsVersion {
continue
}

return endpointURL, nil
}
}
}

// Report an error if there were no matching endpoints.
err := &gophercloud.ErrEndpointNotFound{}
return "", err
}

/*
V3Endpoint discovers the endpoint URL for a specific service from a Catalog
acquired during the v3 identity service.

The specified EndpointOpts are used to identify a unique, unambiguous endpoint
to return. It's an error both when multiple endpoints match the provided
criteria and when none do. The minimum that can be specified is a Type, but you
will also often need to specify a Name and/or a Region depending on what's
available on your OpenStack deployment.
*/
func V3Endpoint(ctx context.Context, client *gophercloud.ProviderClient, catalog *tokens3.ServiceCatalog, opts gophercloud.EndpointOpts) (string, error) {
if opts.Availability != gophercloud.AvailabilityAdmin &&
opts.Availability != gophercloud.AvailabilityPublic &&
opts.Availability != gophercloud.AvailabilityInternal {
err := &ErrInvalidAvailabilityProvided{}
err.Argument = "Availability"
err.Value = opts.Availability
return "", err
}

// Extract Endpoints from the catalog entries that match the requested Type, Interface,
// Name if provided, and Region if provided.
//
// If multiple endpoints are found, we return the first result and disregard the rest.
// This behavior matches the Python library. See GH-1764.
for _, entry := range catalog.Entries {
if (slices.Contains(opts.Types(), entry.Type)) && (opts.Name == "" || entry.Name == opts.Name) {
for _, endpoint := range entry.Endpoints {
if opts.Availability != gophercloud.Availability(endpoint.Interface) {
continue
}
if opts.Region != "" && endpoint.Region != opts.Region && endpoint.RegionID != opts.Region {
continue
}

endpointURL := gophercloud.NormalizeURL(endpoint.URL)

endpointSupportsVersion, err := endpointSupportsVersion(ctx, client, entry.Type, endpointURL, opts.Version)
if err != nil {
return "", err
}
if !endpointSupportsVersion {
continue
}

return endpointURL, nil
}
}
}

// Report an error if there were no matching endpoints.
err := &gophercloud.ErrEndpointNotFound{}
return "", err
}
Loading
Loading