Files
at-container-registry/cmd/credential-helper/config.go
T

263 lines
6.7 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"time"
)
// Config is the top-level credential helper configuration (v2).
type Config struct {
Version int `json:"version"`
Registries map[string]*RegistryConfig `json:"registries"`
}
// RegistryConfig holds accounts for a single registry.
type RegistryConfig struct {
Active string `json:"active"`
Accounts map[string]*Account `json:"accounts"`
}
// Account holds credentials for a single identity on a registry.
type Account struct {
Handle string `json:"handle"`
DID string `json:"did,omitempty"`
DeviceSecret string `json:"device_secret"`
}
// UpdateCheckCache stores the last update check result.
type UpdateCheckCache struct {
CheckedAt time.Time `json:"checked_at"`
Latest string `json:"latest"`
Current string `json:"current"`
}
// loadConfig loads the config from disk, auto-migrating old formats.
// Returns a valid Config (possibly empty) even on error.
func loadConfig() (*Config, error) {
path := getConfigPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return newConfig(), nil
}
return newConfig(), err
}
// Try v2 format first
var cfg Config
if err := json.Unmarshal(data, &cfg); err == nil && cfg.Version == 2 && cfg.Registries != nil {
return &cfg, nil
}
// Try current multi-registry format: {"credentials": {"url": {...}}}
var multiCreds struct {
Credentials map[string]struct {
Handle string `json:"handle"`
DID string `json:"did"`
DeviceSecret string `json:"device_secret"`
AppViewURL string `json:"appview_url"`
} `json:"credentials"`
}
if err := json.Unmarshal(data, &multiCreds); err == nil && multiCreds.Credentials != nil {
migrated := newConfig()
for appViewURL, cred := range multiCreds.Credentials {
handle := cred.Handle
if handle == "" {
continue
}
registryURL := appViewURL
reg := migrated.getOrCreateRegistry(registryURL)
reg.Accounts[handle] = &Account{
Handle: handle,
DID: cred.DID,
DeviceSecret: cred.DeviceSecret,
}
if reg.Active == "" {
reg.Active = handle
}
}
if err := migrated.save(); err != nil {
return migrated, fmt.Errorf("saving migrated config: %w", err)
}
return migrated, nil
}
// Try legacy single-device format: {"handle": "...", "device_secret": "...", "appview_url": "..."}
var legacy struct {
Handle string `json:"handle"`
DeviceSecret string `json:"device_secret"`
AppViewURL string `json:"appview_url"`
}
if err := json.Unmarshal(data, &legacy); err == nil && legacy.DeviceSecret != "" {
migrated := newConfig()
handle := legacy.Handle
registryURL := legacy.AppViewURL
if registryURL == "" {
registryURL = "https://atcr.io"
}
reg := migrated.getOrCreateRegistry(registryURL)
reg.Accounts[handle] = &Account{
Handle: handle,
DeviceSecret: legacy.DeviceSecret,
}
reg.Active = handle
if err := migrated.save(); err != nil {
return migrated, fmt.Errorf("saving migrated config: %w", err)
}
return migrated, nil
}
return newConfig(), fmt.Errorf("unrecognized config format")
}
func newConfig() *Config {
return &Config{
Version: 2,
Registries: make(map[string]*RegistryConfig),
}
}
// save writes the config to disk.
func (c *Config) save() error {
path := getConfigPath()
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// getOrCreateRegistry returns (or creates) a RegistryConfig for the given URL.
func (c *Config) getOrCreateRegistry(registryURL string) *RegistryConfig {
reg, ok := c.Registries[registryURL]
if !ok {
reg = &RegistryConfig{
Accounts: make(map[string]*Account),
}
c.Registries[registryURL] = reg
}
return reg
}
// findRegistry looks up a RegistryConfig by registry URL.
func (c *Config) findRegistry(registryURL string) *RegistryConfig {
return c.Registries[registryURL]
}
// resolveAccount determines which account to use for a given registry.
// Priority:
// 1. Identity detected from parent process command line
// 2. Active account (set by `switch`)
// 3. Sole account (if only one exists)
// 4. Error
func (c *Config) resolveAccount(registryURL, serverURL string) (*Account, error) {
reg := c.findRegistry(registryURL)
if reg == nil || len(reg.Accounts) == 0 {
return nil, fmt.Errorf("no accounts configured for %s\nRun: docker-credential-atcr login", serverURL)
}
// 1. Try to detect identity from parent process
ref := detectImageRef(serverURL)
if ref != nil && ref.Identity != "" {
if acct, ok := reg.Accounts[ref.Identity]; ok {
return acct, nil
}
// Identity detected but no matching account — fall through to active
}
// 2. Active account
if reg.Active != "" {
if acct, ok := reg.Accounts[reg.Active]; ok {
return acct, nil
}
}
// 3. Sole account
if len(reg.Accounts) == 1 {
for _, acct := range reg.Accounts {
return acct, nil
}
}
// 4. Ambiguous
return nil, fmt.Errorf("multiple accounts configured for %s\nRun: docker-credential-atcr switch", serverURL)
}
// addAccount adds or updates an account in a registry and sets it active.
func (c *Config) addAccount(registryURL string, acct *Account) {
reg := c.getOrCreateRegistry(registryURL)
reg.Accounts[acct.Handle] = acct
reg.Active = acct.Handle
}
// removeAccount removes an account from a registry.
// If it was the active account, clears active (or sets to remaining account if exactly one left).
func (c *Config) removeAccount(registryURL, handle string) {
reg := c.findRegistry(registryURL)
if reg == nil {
return
}
delete(reg.Accounts, handle)
if reg.Active == handle {
reg.Active = ""
if len(reg.Accounts) == 1 {
for h := range reg.Accounts {
reg.Active = h
}
}
}
// Clean up empty registries
if len(reg.Accounts) == 0 {
delete(c.Registries, registryURL)
}
}
// getUpdateCheckCachePath returns the path to the update check cache file
func getUpdateCheckCachePath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return fmt.Sprintf("%s/.atcr/update-check.json", homeDir)
}
// loadUpdateCheckCache loads the update check cache from disk
func loadUpdateCheckCache() *UpdateCheckCache {
path := getUpdateCheckCachePath()
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var cache UpdateCheckCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil
}
return &cache
}
// saveUpdateCheckCache saves the update check cache to disk
func saveUpdateCheckCache(cache *UpdateCheckCache) {
path := getUpdateCheckCachePath()
if path == "" {
return
}
data, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return
}
os.WriteFile(path, data, 0600) //nolint:errcheck
}