mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 04:04:15 +00:00
263 lines
6.7 KiB
Go
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
|
|
}
|