Enables fallback for DHI and Scout registries and repo/scope specific credentials like supported for builds. Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
289 lines
6.5 KiB
Go
289 lines
6.5 KiB
Go
package dockerconfig
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/docker/buildx/util/confutil"
|
|
"github.com/docker/cli/cli/command"
|
|
"github.com/docker/cli/cli/config"
|
|
"github.com/docker/cli/cli/config/configfile"
|
|
"github.com/docker/cli/cli/config/types"
|
|
"github.com/moby/buildkit/session/auth/authprovider"
|
|
)
|
|
|
|
const (
|
|
dockerHubRegistryHost = "registry-1.docker.io"
|
|
dockerDHIRegistryHost = "dhi.io"
|
|
dockerScoutRegistryHost = "registry.scout.docker.com"
|
|
)
|
|
|
|
func LoadAuthConfig(cli command.Cli) authprovider.AuthConfigProvider {
|
|
acp := &authConfigProvider{
|
|
buildxConfig: confutil.NewConfig(cli),
|
|
defaultConfig: cli.ConfigFile(),
|
|
authConfigCache: map[string]authConfigCacheEntry{},
|
|
}
|
|
return acp.load
|
|
}
|
|
|
|
type authConfigProvider struct {
|
|
initOnce sync.Once
|
|
defaultConfig *configfile.ConfigFile
|
|
buildxConfig *confutil.Config
|
|
authConfigCache map[string]authConfigCacheEntry
|
|
mu sync.Mutex // mutex for authConfigCache
|
|
alternativeConfigs []*alternativeConfig
|
|
}
|
|
|
|
func (ap *authConfigProvider) load(ctx context.Context, host string, scopes []string, cacheExpireCheck authprovider.ExpireCachedAuthCheck) (types.AuthConfig, error) {
|
|
if cacheExpireCheck == nil {
|
|
cacheExpireCheck = func(created time.Time, _ string) bool {
|
|
// Tokens for Google Artifact Registry via Workload Identity expire after 5 minutes.
|
|
return time.Since(created) > 4*time.Minute+50*time.Second
|
|
}
|
|
}
|
|
|
|
ac, err := ap.loadHost(ctx, host, scopes, cacheExpireCheck)
|
|
if err != nil {
|
|
return types.AuthConfig{}, err
|
|
}
|
|
if ac == (types.AuthConfig{}) {
|
|
// DHI and Scout are also Hub backed registries by Docker, fallback if no specific auth found
|
|
switch host {
|
|
case dockerDHIRegistryHost, dockerScoutRegistryHost:
|
|
ac, err := ap.loadHost(ctx, dockerHubRegistryHost, scopes, cacheExpireCheck)
|
|
if err != nil {
|
|
return types.AuthConfig{}, nil
|
|
}
|
|
return ac, nil
|
|
default:
|
|
return types.AuthConfig{}, err
|
|
}
|
|
}
|
|
return ac, nil
|
|
}
|
|
|
|
func (ap *authConfigProvider) loadHost(_ context.Context, host string, scopes []string, cacheExpireCheck authprovider.ExpireCachedAuthCheck) (types.AuthConfig, error) {
|
|
ap.initOnce.Do(func() {
|
|
ap.init()
|
|
})
|
|
|
|
ap.mu.Lock()
|
|
defer ap.mu.Unlock()
|
|
|
|
candidates := []*alternativeConfig{}
|
|
parsedScopes := parseScopes(scopes)
|
|
|
|
if len(parsedScopes) == 1 {
|
|
for _, cfg := range ap.alternativeConfigs {
|
|
if cfg.host != host {
|
|
continue
|
|
}
|
|
if cfg.matchesScopes(parsedScopes) {
|
|
candidates = append(candidates, cfg)
|
|
}
|
|
}
|
|
}
|
|
key := host
|
|
cfg := ap.defaultConfig
|
|
if len(candidates) > 0 {
|
|
// matches with repo before those without repo
|
|
// matches with scope set sorted before those without scope
|
|
slices.SortFunc(candidates, func(a, b *alternativeConfig) int {
|
|
return cmp.Or(
|
|
strings.Compare(b.repo, a.repo),
|
|
cmp.Compare(len(b.scope), len(a.scope)),
|
|
)
|
|
})
|
|
candidates = candidates[:1]
|
|
key += "|" + candidates[0].dir
|
|
if candidates[0].configFile == nil {
|
|
if cfgDir, err := config.Load(candidates[0].dir); err == nil {
|
|
cfg = cfgDir
|
|
candidates[0].configFile = cfg
|
|
}
|
|
} else {
|
|
cfg = candidates[0].configFile
|
|
}
|
|
}
|
|
|
|
entry, exists := ap.authConfigCache[key]
|
|
if exists && (cacheExpireCheck == nil || !cacheExpireCheck(entry.Created, key)) {
|
|
return *entry.Auth, nil
|
|
}
|
|
|
|
hostKey := host
|
|
if host == authprovider.DockerHubRegistryHost {
|
|
hostKey = authprovider.DockerHubConfigfileKey
|
|
}
|
|
|
|
ac, err := cfg.GetAuthConfig(hostKey)
|
|
if err != nil {
|
|
return types.AuthConfig{}, err
|
|
}
|
|
|
|
entry = authConfigCacheEntry{
|
|
Created: time.Now(),
|
|
Auth: &ac,
|
|
}
|
|
|
|
ap.authConfigCache[key] = entry
|
|
|
|
return ac, nil
|
|
}
|
|
|
|
func (ap *authConfigProvider) init() error {
|
|
base := filepath.Join(ap.buildxConfig.Dir(), "config")
|
|
return filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if d.Name() != config.ConfigFileName {
|
|
return nil
|
|
}
|
|
dir := filepath.Dir(path)
|
|
rdir, err := filepath.Rel(base, dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg := parseConfigKey(rdir)
|
|
cfg.dir = dir
|
|
ap.alternativeConfigs = append(ap.alternativeConfigs, &cfg)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func parseConfigKey(key string) alternativeConfig {
|
|
var out alternativeConfig
|
|
|
|
var mainPart, scopePart string
|
|
if before, after, ok := strings.Cut(key, "@"); ok {
|
|
mainPart = before
|
|
scopePart = after
|
|
} else {
|
|
mainPart = key
|
|
}
|
|
|
|
if scopePart != "" {
|
|
out.scope = make(map[string]struct{})
|
|
for s := range strings.SplitSeq(scopePart, ",") {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
out.scope[s] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
if mainPart == "" {
|
|
return out
|
|
}
|
|
|
|
before, after, ok := strings.Cut(mainPart, "/")
|
|
if !ok {
|
|
out.host = mainPart
|
|
return out
|
|
}
|
|
|
|
out.host = before
|
|
out.repo = after
|
|
|
|
return out
|
|
}
|
|
|
|
type alternativeConfig struct {
|
|
dir string
|
|
|
|
host string
|
|
repo string
|
|
scope map[string]struct{}
|
|
|
|
configFile *configfile.ConfigFile
|
|
}
|
|
|
|
func (a *alternativeConfig) matchesScopes(q scopes) bool {
|
|
if a.repo != "" {
|
|
if _, ok := q["repository:"+a.repo]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if len(a.scope) > 0 {
|
|
if a.repo == "" {
|
|
// no repo means one query must match all scopes
|
|
for _, scopeActions := range q {
|
|
ok := true
|
|
for s := range a.scope {
|
|
if _, exists := scopeActions[s]; !exists {
|
|
ok = false
|
|
break
|
|
}
|
|
}
|
|
if ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
for s := range a.scope {
|
|
for k, scopeActions := range q {
|
|
if k == "repository:"+a.repo {
|
|
if _, ok := scopeActions[s]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
type authConfigCacheEntry struct {
|
|
Created time.Time
|
|
Auth *types.AuthConfig
|
|
}
|
|
|
|
type scopes map[string]map[string]struct{}
|
|
|
|
func parseScopes(s []string) scopes {
|
|
// https://distribution.github.io/distribution/spec/auth/scope/
|
|
m := map[string]map[string]struct{}{}
|
|
for _, scopeStr := range s {
|
|
if scopeStr == "" {
|
|
return nil
|
|
}
|
|
// The scopeStr may have strings that contain multiple scopes separated by a space.
|
|
for scope := range strings.SplitSeq(scopeStr, " ") {
|
|
parts := strings.SplitN(scope, ":", 3)
|
|
names := []string{parts[0]}
|
|
if len(parts) > 1 {
|
|
names = append(names, parts[1])
|
|
}
|
|
var actions []string
|
|
if len(parts) == 3 {
|
|
actions = append(actions, strings.Split(parts[2], ",")...)
|
|
}
|
|
name := strings.Join(names, ":")
|
|
ma, ok := m[name]
|
|
if !ok {
|
|
ma = map[string]struct{}{}
|
|
m[name] = ma
|
|
}
|
|
|
|
for _, a := range actions {
|
|
ma[a] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
return m
|
|
}
|