mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-23 12:24:57 -04:00
Concurrent cache misses for the same region now share a single GetAuthorizationToken call instead of each issuing their own, avoiding a request burst against the ECR API at cold start and at each 12-hour refresh. golang.org/x/sync is already a direct dependency.
126 lines
3.6 KiB
Go
126 lines
3.6 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
|
"github.com/aws/aws-sdk-go-v2/service/ecr"
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
const (
|
|
ecrTokenTimeout = 10 * time.Second
|
|
ecrTokenSkew = 5 * time.Minute
|
|
ecrDefaultTokenLifetime = 12 * time.Hour
|
|
)
|
|
|
|
var errEmptyECRToken = errors.New("empty ECR authorization token")
|
|
|
|
// ecrTokens caches AWS ECR authorization tokens per region and refreshes them
|
|
// on demand when they expire. Tokens are obtained via the AWS SDK default
|
|
// credential chain, so IAM roles for service accounts, instance profiles, and
|
|
// environment credentials all work without extra configuration.
|
|
type ecrTokens struct {
|
|
logger *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
cache map[string]ecrToken
|
|
sf singleflight.Group
|
|
|
|
// getToken fetches a fresh authorization token for the given region and
|
|
// returns the raw base64 "AWS:password" value plus its expiry. Overridable
|
|
// in tests.
|
|
getToken func(ctx context.Context, region string) (string, time.Time, error)
|
|
}
|
|
|
|
type ecrToken struct {
|
|
value string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
func newECRTokens(logger *slog.Logger) *ecrTokens {
|
|
return &ecrTokens{
|
|
logger: logger,
|
|
cache: make(map[string]ecrToken),
|
|
getToken: fetchECRToken,
|
|
}
|
|
}
|
|
|
|
// header returns an Authorization header for the given region, fetching and
|
|
// caching a token on first use and after expiry. Concurrent misses for the
|
|
// same region share a single GetAuthorizationToken call. On failure it logs
|
|
// and returns empty strings so the request proceeds unauthenticated; the OCI
|
|
// transport then follows the Bearer challenge and surfaces the token-endpoint
|
|
// error, matching the behaviour of any other misconfigured upstream credential.
|
|
func (e *ecrTokens) header(region string) (name, value string) {
|
|
if tok, ok := e.cached(region); ok {
|
|
return "Authorization", tok.value
|
|
}
|
|
|
|
v, err, _ := e.sf.Do(region, func() (any, error) {
|
|
if tok, ok := e.cached(region); ok {
|
|
return tok, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), ecrTokenTimeout)
|
|
defer cancel()
|
|
|
|
raw, expiresAt, err := e.getToken(ctx, region)
|
|
if err != nil {
|
|
e.logger.Error("fetching ECR authorization token", "region", region, "error", err)
|
|
return ecrToken{}, err
|
|
}
|
|
if raw == "" {
|
|
e.logger.Error("ECR authorization token response was empty", "region", region)
|
|
return ecrToken{}, errEmptyECRToken
|
|
}
|
|
|
|
tok := ecrToken{value: "Basic " + raw, expiresAt: expiresAt.Add(-ecrTokenSkew)}
|
|
e.mu.Lock()
|
|
e.cache[region] = tok
|
|
e.mu.Unlock()
|
|
return tok, nil
|
|
})
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
|
|
return "Authorization", v.(ecrToken).value
|
|
}
|
|
|
|
func (e *ecrTokens) cached(region string) (ecrToken, bool) {
|
|
e.mu.Lock()
|
|
tok, ok := e.cache[region]
|
|
e.mu.Unlock()
|
|
return tok, ok && time.Now().Before(tok.expiresAt)
|
|
}
|
|
|
|
func fetchECRToken(ctx context.Context, region string) (string, time.Time, error) {
|
|
var opts []func(*awsconfig.LoadOptions) error
|
|
if region != "" {
|
|
opts = append(opts, awsconfig.WithRegion(region))
|
|
}
|
|
cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
|
|
out, err := ecr.NewFromConfig(cfg).GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{})
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
if len(out.AuthorizationData) == 0 || out.AuthorizationData[0].AuthorizationToken == nil {
|
|
return "", time.Time{}, nil
|
|
}
|
|
|
|
data := out.AuthorizationData[0]
|
|
expiresAt := time.Now().Add(ecrDefaultTokenLifetime)
|
|
if data.ExpiresAt != nil {
|
|
expiresAt = *data.ExpiresAt
|
|
}
|
|
return *data.AuthorizationToken, expiresAt, nil
|
|
}
|