Skip to content
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.11.1] - 2026-10-23

### Fixed
- Reuse authenticated OpenStack clients across reconciliations with identical provider credentials, reducing Keystone authentication requests. Failed authentication is retried on the next reconciliation, cached clients expire before their tokens and are invalidated on HTTP 401, and shared reauthentication is limited to 30 seconds. Swift authentication remains uncached.

## [0.11.0] - 2026-10-07

### Added
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ require (
github.com/crossplane/crossplane-runtime/v2 v2.1.0
github.com/crossplane/crossplane-tools v0.0.0-20251017183449-dd4517244339
github.com/crossplane/upjet/v2 v2.2.0
github.com/gophercloud/gophercloud/v2 v2.8.0
github.com/hashicorp/go-cty v1.5.0
github.com/hashicorp/terraform-json v0.27.2
github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.2
github.com/pkg/errors v0.9.1
github.com/terraform-provider-openstack/terraform-provider-openstack/v3 v3.4.0
golang.org/x/sync v0.19.0
google.golang.org/grpc v1.79.2
k8s.io/api v0.35.1
k8s.io/apiextensions-apiserver v0.35.1
Expand Down Expand Up @@ -56,7 +58,6 @@ require (
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gophercloud/gophercloud/v2 v2.8.0 // indirect
github.com/gophercloud/utils/v2 v2.0.0-20250710092215-8f6f0255f600 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-plugin v1.7.0 // indirect
Expand Down Expand Up @@ -118,7 +119,6 @@ require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.33.0 // indirect
Expand Down
254 changes: 254 additions & 0 deletions internal/clients/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package clients

import (
"container/list"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"sync"
"time"

tokens2 "github.com/gophercloud/gophercloud/v2/openstack/identity/v2/tokens"
tokens3 "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens"
osprovider "github.com/terraform-provider-openstack/terraform-provider-openstack/v3/openstack"
"golang.org/x/sync/singleflight"
)

const (
defaultNoForkClientCacheSize = 128
noForkTokenExpiryClockSkew = 5 * time.Minute
noForkReauthTimeout = 30 * time.Second
)

var errNoForkInitializationCanceled = errors.New("OpenStack client initialization canceled")

// noForkClientCache shares authenticated provider Meta values across reconciles
// with identical configurations. Only successful Keystone authentication with a
// known token expiry is cached; failures and Swift authentication stay uncached.
// A separate configured provider copy is still needed for each configuration so
// that credentials from different ProviderConfigs cannot mix.
type noForkClientCache struct {
maxSize int
now func() time.Time
mu sync.Mutex
entries map[string]*list.Element
// order contains exactly one element per entry, oldest first. Expiration
// and eviction remove entries from both structures under the same lock.
order list.List
inflight singleflight.Group
}

type noForkClientCacheEntry struct {
key string
meta any
expires time.Time
}

func newNoForkClientCache(maxSize int) *noForkClientCache {
if maxSize <= 0 {
maxSize = defaultNoForkClientCacheSize
}
return &noForkClientCache{
maxSize: maxSize,
now: time.Now,
entries: make(map[string]*list.Element),
}
}

// getOrCreate coalesces simultaneous misses for one configuration without
// blocking initialization of other configurations. A waiting reconcile can
// cancel independently. The creator must not mutate the caller's Setup because
// creation may finish after that caller has canceled.
func (c *noForkClientCache) getOrCreate(ctx context.Context, config map[string]any, create func() (any, error)) (any, error) {
key, err := hashNoForkClientConfig(config)
if err != nil {
return nil, err
}
for {
if err := ctx.Err(); err != nil {
return nil, err
}
if meta, ok := c.get(key); ok {
return meta, nil
}

result := c.inflight.DoChan(key, func() (any, error) {
return c.initialize(ctx, key, create)
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-result:
// A live caller must not inherit the initializing caller's canceled
// context. The failed flight is already removed, so retrying here
// coalesces a fresh initialization under a live caller's context.
if errors.Is(r.Err, errNoForkInitializationCanceled) {
continue
}
return r.Val, r.Err
}
}
}

func (c *noForkClientCache) initialize(ctx context.Context, key string, create func() (any, error)) (any, error) {
// Another creator may have populated the cache since the first check.
if meta, ok := c.get(key); ok {
return meta, nil
}
meta, err := create()
if err != nil {
if ctx.Err() != nil {
return nil, errors.Join(errNoForkInitializationCanceled, ctx.Err(), err)
}
return nil, err
}
if _, ok := tokenExpiry(meta, c.now()); ok {
cfg := meta.(*osprovider.Config)
base := cfg.OsClient.HTTPClient.Transport
if base == nil {
base = http.DefaultTransport
}
// Install hooks before publication. A revoked token must not remain
// sticky until its scheduled expiry, even with allow_reauth=false.
cfg.OsClient.HTTPClient.Transport = &noForkClientTransport{
base: base,
invalidate: func() { c.invalidate(key, cfg) },
}
cfg.OsClient.ReauthFunc = withReauthTimeout(cfg.OsClient.ReauthFunc, noForkReauthTimeout)
}
c.set(key, meta)
return meta, nil
}

func (c *noForkClientCache) get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()

element, ok := c.entries[key]
if !ok {
return nil, false
}
entry := element.Value.(*noForkClientCacheEntry)
if !c.now().Before(entry.expires) {
c.remove(element)
return nil, false
}
return entry.meta, true
}

func (c *noForkClientCache) set(key string, meta any) {
expires, ok := tokenExpiry(meta, c.now())
if !ok {
return
}

c.mu.Lock()
defer c.mu.Unlock()

entry := &noForkClientCacheEntry{key: key, meta: meta, expires: expires}
if element, ok := c.entries[key]; ok {
element.Value = entry
return
}
if len(c.entries) >= c.maxSize {
c.remove(c.order.Front())
}
c.entries[key] = c.order.PushBack(entry)
}

// remove requires c.mu to be held.
func (c *noForkClientCache) remove(element *list.Element) {
delete(c.entries, element.Value.(*noForkClientCacheEntry).key)
c.order.Remove(element)
}

func (c *noForkClientCache) invalidate(key string, meta *osprovider.Config) {
c.mu.Lock()
defer c.mu.Unlock()
// In-flight operations may still use an evicted client. A late 401 from
// that generation must not invalidate its replacement.
if element, ok := c.entries[key]; ok && element.Value.(*noForkClientCacheEntry).meta == meta {
c.remove(element)
}
}

type noForkClientTransport struct {
base http.RoundTripper
invalidate func()
}

func (t *noForkClientTransport) RoundTrip(request *http.Request) (*http.Response, error) {
response, err := t.base.RoundTrip(request)
if response != nil && response.StatusCode == http.StatusUnauthorized {
t.invalidate()
}
return response, err
}

// Gophercloud's shared reauthentication wait does not observe each waiting
// request's context. Bound the authentication itself so a stalled Keystone
// request cannot hold other reconciles until an async operation's hour deadline.
func withReauthTimeout(reauth func(context.Context) error, timeout time.Duration) func(context.Context) error {
if reauth == nil {
return nil
}
return func(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return reauth(ctx)
}
}

// JSON orders map keys deterministically. Hash the whole provider configuration
// so credential rotation or a change in scope/region never reuses an old client.
// Only the digest is retained as the key; credentials are never logged.
func hashNoForkClientConfig(config map[string]any) (string, error) {
data, err := json.Marshal(config)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}

// tokenExpiry uses the token obtained before cache insertion. Without a known
// expiry we cannot safely reuse a client, particularly with allow_reauth=false.
func tokenExpiry(meta any, now time.Time) (time.Time, bool) {
cfg, ok := meta.(*osprovider.Config)
if !ok || cfg == nil || cfg.Swauth || cfg.OsClient == nil || cfg.OsClient.Token() == "" {
return time.Time{}, false
}

expiresAt := keystoneTokenExpiry(cfg.OsClient.GetAuthResult())
remaining := expiresAt.Sub(now)
if remaining <= 0 {
return time.Time{}, false
}
// A fixed five-minute margin would disable caching for short-lived tokens.
// Reserve ten percent of their remaining lifetime, capped at five minutes.
margin := min(noForkTokenExpiryClockSkew, remaining/10)
return expiresAt.Add(-margin), true
}

func keystoneTokenExpiry(result any) time.Time {
switch result := result.(type) {
case interface {
ExtractToken() (*tokens3.Token, error)
}:
token, err := result.ExtractToken()
if err == nil && token != nil {
return token.ExpiresAt
}
case interface {
ExtractToken() (*tokens2.Token, error)
}:
token, err := result.ExtractToken()
if err == nil && token != nil {
return token.ExpiresAt
}
}
return time.Time{}
}
Loading
Loading