diff --git a/cmd/hauler/cli/cli.go b/cmd/hauler/cli/cli.go index 8435fbe5..3830500f 100644 --- a/cmd/hauler/cli/cli.go +++ b/cmd/hauler/cli/cli.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "hauler.dev/go/hauler/v2/internal/flags" "hauler.dev/go/hauler/v2/pkg/consts" + "hauler.dev/go/hauler/v2/pkg/content" "hauler.dev/go/hauler/v2/pkg/log" ) @@ -46,6 +47,10 @@ func New(ctx context.Context, ro *flags.CliRootOpts) *cobra.Command { l.SetLevel(ro.LogLevel) l.Debugf("running cli command [%s]", cmd.CommandPath()) + if dir, set := content.SetDefaultDockerConfig(); set { + l.Debugf("defaulted $DOCKER_CONFIG to [%s] for registry credential resolution", dir) + } + if ro.LogLevel == "debug" { logrus.SetLevel(logrus.DebugLevel) } else { diff --git a/pkg/content/dockerconfig.go b/pkg/content/dockerconfig.go new file mode 100644 index 00000000..0a090edc --- /dev/null +++ b/pkg/content/dockerconfig.go @@ -0,0 +1,54 @@ +package content + +import ( + "os" + "os/user" + "path/filepath" +) + +// currentUser is a seam so tests can simulate a running UID with no passwd +// entry (where user.Current() returns an error). +var currentUser = user.Current + +// SetDefaultDockerConfig defaults the DOCKER_CONFIG environment variable to the +// directory where `hauler login` (crane -> docker/cli) writes credentials, so +// that go-containerregistry's authn.DefaultKeychain -- used to resolve registry +// credentials during `hauler store copy registry://`, `add`, and `sync` -- can +// find them even when $HOME is unset. +// +// It replicates docker/cli's config.Dir()/getHomeDir() resolution using only the +// standard library (no docker/cli dependency): DOCKER_CONFIG if already set, +// otherwise /.docker, where home is os.UserHomeDir() ($HOME on Unix, +// %USERPROFILE% on Windows) with an /etc/passwd fallback via os/user.Current() +// when $HOME is empty -- exactly the passwd fallback `hauler login` uses. +// +// When no home directory can be resolved at all ($HOME empty AND +// user.Current() fails or returns an empty HomeDir), this mirrors docker/cli's +// own config.Dir(), which computes filepath.Join(getHomeDir(), ".docker"): with +// getHomeDir() == "", that join collapses to the relative path ".docker". So +// DOCKER_CONFIG is defaulted to the relative path ".docker" in that case too, +// keeping `hauler login`'s (relative) write and the keychain's (relative) read +// in agreement as long as both run from the same working directory. +// +// The only remaining no-op case is when DOCKER_CONFIG is already explicitly +// set (an explicit value always wins). Setting DOCKER_CONFIG does not disable +// DefaultKeychain's $REGISTRY_AUTH_FILE / Podman auth.json fallbacks: those +// still apply when no config.json exists at the set path. +// +// Returns the directory it set and true, or "" and false if it made no change. +func SetDefaultDockerConfig() (string, bool) { + if os.Getenv("DOCKER_CONFIG") != "" { + return "", false + } + + home, err := os.UserHomeDir() + if err != nil || home == "" { + if u, uerr := currentUser(); uerr == nil { + home = u.HomeDir + } + } + + dir := filepath.Join(home, ".docker") + os.Setenv("DOCKER_CONFIG", dir) + return dir, true +} diff --git a/pkg/content/registry.go b/pkg/content/registry.go index e4b148d7..5d5b98af 100644 --- a/pkg/content/registry.go +++ b/pkg/content/registry.go @@ -100,15 +100,19 @@ func NewRegistryTarget(host string, opts RegistryOptions, client *http.Client) * // Bridge to go-containerregistry's keychain for credential lookup. reg, err := goname.NewRegistry(h, goname.Insecure) if err != nil { - return "", "", nil + return "", "", fmt.Errorf("parsing registry host [%s] for credential lookup: %w", h, err) } a, err := goauthn.DefaultKeychain.Resolve(reg) - if err != nil || a == goauthn.Anonymous { + if err != nil { + // don't fall back to anonymous on a real resolution error + return "", "", fmt.Errorf("resolving credentials for [%s]: %w", h, err) + } + if a == goauthn.Anonymous { return "", "", nil } cfg, err := a.Authorization() if err != nil { - return "", "", nil + return "", "", fmt.Errorf("reading resolved authorization for [%s]: %w", h, err) } return cfg.Username, cfg.Password, nil }),