diff --git a/go.mod b/go.mod index 4d7fc577..5b1034f1 100644 --- a/go.mod +++ b/go.mod @@ -85,7 +85,7 @@ require ( golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/sync v0.21.0 golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/internal/openstack/service_client.go b/internal/openstack/service_client.go index 0a935a28..58d4ccad 100644 --- a/internal/openstack/service_client.go +++ b/internal/openstack/service_client.go @@ -22,13 +22,83 @@ import ( "os" "os/exec" "strings" + "sync" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/utils/v2/openstack/clientconfig" + "golang.org/x/sync/singleflight" ) -// GetServiceClient returns an gophercloud ServiceClient for the given serviceType. +// providerCache caches ProviderClients keyed by a string derived from the +// authInfo, so multiple GetServiceClient calls with the same credentials +// only authenticate against Keystone once. +var ( + providerCacheMu sync.Mutex + providerCache = map[string]*gophercloud.ProviderClient{} + providerGroup singleflight.Group +) + +func cacheKey(authInfo *clientconfig.AuthInfo) string { + if authInfo == nil { + return "" + } + // Include all fields that affect the resulting Keystone token and catalog + // so a future auth context differing only in user, domain, or AuthURL + // doesn't silently reuse the wrong cached provider. + return strings.Join([]string{ + authInfo.AuthURL, + authInfo.ProjectName, + authInfo.ProjectDomainName, + authInfo.Username, + authInfo.UserDomainName, + }, "\x00") +} + +// GetServiceClient returns a ServiceClient for the given serviceType. +// Providers are cached per auth context so Keystone is only hit once per +// unique set of credentials across all SetupWithManager calls. Concurrent +// callers with the same key share a single in-flight request via singleflight, +// preventing duplicate Keystone round-trips on startup. func GetServiceClient(ctx context.Context, serviceType string, authInfo *clientconfig.AuthInfo) (*gophercloud.ServiceClient, error) { + key := cacheKey(authInfo) + + providerCacheMu.Lock() + provider, ok := providerCache[key] + providerCacheMu.Unlock() + + if !ok { + v, err, _ := providerGroup.Do(key, func() (any, error) { + // Re-check under the group: another goroutine may have populated + // the cache while we were waiting for the singleflight slot. + providerCacheMu.Lock() + if p, hit := providerCache[key]; hit { + providerCacheMu.Unlock() + return p, nil + } + providerCacheMu.Unlock() + + p, err := NewProviderClient(ctx, authInfo) + if err != nil { + return nil, err + } + providerCacheMu.Lock() + providerCache[key] = p + providerCacheMu.Unlock() + return p, nil + }) + if err != nil { + return nil, err + } + provider = v.(*gophercloud.ProviderClient) + } + + return ServiceClientFromProvider(provider, serviceType) +} + +// NewProviderClient authenticates against OpenStack and returns a ProviderClient +// that can be reused across multiple service clients via ServiceClientFromProvider, +// avoiding repeated Keystone round-trips and catalog deserialisations. +func NewProviderClient(ctx context.Context, authInfo *clientconfig.AuthInfo) (*gophercloud.ProviderClient, error) { if authInfo == nil { authInfo = &clientconfig.AuthInfo{} } @@ -46,19 +116,18 @@ func GetServiceClient(ctx context.Context, serviceType string, authInfo *clientc var clientOpts clientconfig.ClientOpts clientOpts.AuthInfo = authInfo - provider, err := clientconfig.AuthenticatedClient(ctx, &clientOpts) - if err != nil { - return nil, err - } + return clientconfig.AuthenticatedClient(ctx, &clientOpts) +} + +// ServiceClientFromProvider returns a ServiceClient for the given serviceType +// using an already-authenticated ProviderClient. +func ServiceClientFromProvider(provider *gophercloud.ProviderClient, serviceType string) (*gophercloud.ServiceClient, error) { eo := gophercloud.EndpointOpts{} eo.ApplyDefaults(serviceType) - - // Override endpoint? - var url string - if url, err = provider.EndpointLocator(eo); err != nil { + url, err := provider.EndpointLocator(eo) + if err != nil { return nil, err } - return &gophercloud.ServiceClient{ ProviderClient: provider, Endpoint: url,