diff --git a/config/auth.go b/config/auth.go index e74c02ee..12fed357 100644 --- a/config/auth.go +++ b/config/auth.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "github.com/moby/moby/api/pkg/authconfig" @@ -17,6 +18,10 @@ const tokenUsername = "" // The returned map is keyed by the registry registry hostname for each image. func AuthConfigs(images ...string) (map[string]registry.AuthConfig, error) { cfg, err := Load() + if errors.Is(err, ErrConfigFileNotFound) { + cfg = Config{} + err = nil + } if err != nil { return nil, fmt.Errorf("load config: %w", err) } @@ -30,6 +35,10 @@ func AuthConfigs(images ...string) (map[string]registry.AuthConfig, error) { // If the config doesn't exist, it will attempt to load registry credentials using the default credential helper for the platform. func AuthConfigForHostname(hostname string) (registry.AuthConfig, error) { cfg, err := Load() + if errors.Is(err, ErrConfigFileNotFound) { + cfg = Config{} + err = nil + } if err != nil { return registry.AuthConfig{}, fmt.Errorf("load config: %w", err) } diff --git a/config/auth_missing_config_test.go b/config/auth_missing_config_test.go new file mode 100644 index 00000000..ec07bd4d --- /dev/null +++ b/config/auth_missing_config_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "errors" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// setupConfigDirWithoutFile creates a temporary directory and sets DOCKER_CONFIG +// to point to it, but does NOT create a config.json file. This exercises the +// ErrConfigFileNotFound code path in Load/Filepath. +func setupConfigDirWithoutFile(t *testing.T) { + t.Helper() + t.Setenv(EnvOverrideDir, t.TempDir()) +} + +// setupNonExistentConfigDir points DOCKER_CONFIG at a path that does not exist, +// exercising the hard-fail code path in Dir() when an explicitly overridden +// config directory is missing. Unlike the default ~/.docker case, an explicit +// DOCKER_CONFIG pointing at a non-existent path is a user error and must NOT +// surface ErrConfigFileNotFound. +func setupNonExistentConfigDir(t *testing.T) { + t.Helper() + t.Setenv(EnvOverrideDir, filepath.Join(t.TempDir(), "does-not-exist")) +} + +func TestAuthConfigs_ConfigNotFound(t *testing.T) { + setupConfigDirWithoutFile(t) + mockExecCommand(t) + + authConfigs, err := AuthConfigs("some.io/repo/image:tag") + require.NoError(t, err) + require.Contains(t, authConfigs, "some.io") + require.Empty(t, authConfigs["some.io"].Username) + require.Empty(t, authConfigs["some.io"].Password) +} + +func TestAuthConfigs_ConfigNotFound_FallsBackToCredentialHelper(t *testing.T) { + setupConfigDirWithoutFile(t) + + execLookPath = func(string) (string, error) { + return "", errors.New("helper unreachable") + } + t.Cleanup(func() { execLookPath = exec.LookPath }) + + _, err := AuthConfigs("some.io/repo/image:tag") + require.Error(t, err) + require.ErrorContains(t, err, "helper unreachable") +} + +func TestAuthConfigForHostname_ConfigNotFound(t *testing.T) { + setupConfigDirWithoutFile(t) + mockExecCommand(t) + + creds, err := AuthConfigForHostname("some.io") + require.NoError(t, err) + require.Empty(t, creds.Username) + require.Empty(t, creds.Password) +} + +func TestAuthConfigForHostname_ConfigNotFound_FallsBackToCredentialHelper(t *testing.T) { + setupConfigDirWithoutFile(t) + + execLookPath = func(string) (string, error) { + return "", errors.New("helper unreachable") + } + t.Cleanup(func() { execLookPath = exec.LookPath }) + + _, err := AuthConfigForHostname("some.io") + require.Error(t, err) + require.ErrorContains(t, err, "helper unreachable") +} + +func TestLoad_ConfigNotFound_ReturnsSentinel(t *testing.T) { + setupConfigDirWithoutFile(t) + + _, err := Load() + require.ErrorIs(t, err, ErrConfigFileNotFound) +} + +func TestFilepath_ConfigNotFound_ReturnsSentinel(t *testing.T) { + setupConfigDirWithoutFile(t) + + _, err := Filepath() + require.ErrorIs(t, err, ErrConfigFileNotFound) + require.Contains(t, err.Error(), "config file does not exist") +} + +func TestDir_OverriddenConfigDirNotFound_NoSentinel(t *testing.T) { + setupNonExistentConfigDir(t) + + _, err := Dir() + require.Error(t, err) + require.NotErrorIs(t, err, ErrConfigFileNotFound) + require.Contains(t, err.Error(), "file does not exist") +} + +func TestDir_DefaultConfigDirNotFound_ReturnsSentinel(t *testing.T) { + // Point HOME at a temp dir without a .docker subdirectory, and clear + // DOCKER_CONFIG so Dir() falls back to the default ~/.docker path. + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) // Windows support + t.Setenv(EnvOverrideDir, "") + + _, err := Dir() + require.ErrorIs(t, err, ErrConfigFileNotFound) + require.Contains(t, err.Error(), "file does not exist") +} + +func TestFilepath_OverriddenConfigDirNotFound_NoSentinel(t *testing.T) { + setupNonExistentConfigDir(t) + + _, err := Filepath() + require.Error(t, err) + require.NotErrorIs(t, err, ErrConfigFileNotFound) +} + +func TestLoad_OverriddenConfigDirNotFound_NoSentinel(t *testing.T) { + setupNonExistentConfigDir(t) + + _, err := Load() + require.Error(t, err) + require.NotErrorIs(t, err, ErrConfigFileNotFound) +} diff --git a/config/errors.go b/config/errors.go new file mode 100644 index 00000000..b936d193 --- /dev/null +++ b/config/errors.go @@ -0,0 +1,7 @@ +package config + +import "errors" + +// ErrConfigFileNotFound is used as a sentinel error to distinguish when a docker config file is not present, +// so that cases without a config file work, but cases with an invalid config file still fail +var ErrConfigFileNotFound = errors.New("config file not found") diff --git a/config/load.go b/config/load.go index 116155f1..c2be6d87 100644 --- a/config/load.go +++ b/config/load.go @@ -59,7 +59,7 @@ func Dir() (string, error) { configDir := filepath.Join(home, configFileDir) if !fileExists(configDir) { - return "", fmt.Errorf("file does not exist (%s)", configDir) + return "", errors.Join(fmt.Errorf("file does not exist (%s)", configDir), ErrConfigFileNotFound) } return configDir, nil @@ -83,7 +83,7 @@ func Filepath() (string, error) { configFilePath := filepath.Join(dir, FileName) if !fileExists(configFilePath) { - return "", fmt.Errorf("config file does not exist (%s)", configFilePath) + return "", errors.Join(fmt.Errorf("config file does not exist (%s)", configFilePath), ErrConfigFileNotFound) } return configFilePath, nil @@ -109,6 +109,10 @@ func Load() (Config, error) { } cfg, err = loadFromFilepath(p) + if errors.Is(err, os.ErrNotExist) { + cfg.filepath = p + return cfg, nil + } if err != nil { return cfg, fmt.Errorf("load config: %w", err) } diff --git a/context/context.add.go b/context/context.add.go index ecdb232b..c3528e5a 100644 --- a/context/context.add.go +++ b/context/context.add.go @@ -64,6 +64,9 @@ func New(name string, opts ...CreateContextOption) (*Context, error) { // set the context as the current context if the option is set if defaultOptions.current { cfg, err := config.Load() + if errors.Is(err, config.ErrConfigFileNotFound) { + return ctx, nil + } if err != nil { return nil, fmt.Errorf("load config: %w", err) } diff --git a/context/context.delete.go b/context/context.delete.go index 062e7c95..36d37e13 100644 --- a/context/context.delete.go +++ b/context/context.delete.go @@ -30,6 +30,10 @@ func (ctx *Context) Delete() error { if ctx.isCurrent { // reset the current context to the default context cfg, err := config.Load() + if errors.Is(err, config.ErrConfigFileNotFound) { + return nil + } + if err != nil { return fmt.Errorf("load config: %w", err) } diff --git a/context/current.go b/context/current.go index 7e86deed..dbab38cd 100644 --- a/context/current.go +++ b/context/current.go @@ -1,6 +1,7 @@ package context import ( + "errors" "fmt" "net/url" "os" @@ -25,6 +26,9 @@ func Current() (string, error) { if os.IsNotExist(err) { return DefaultContextName, nil } + if errors.Is(err, config.ErrConfigFileNotFound) { + return DefaultContextName, nil + } return "", fmt.Errorf("load docker config: %w", err) } diff --git a/context/missing_config_test.go b/context/missing_config_test.go new file mode 100644 index 00000000..e3c3add8 --- /dev/null +++ b/context/missing_config_test.go @@ -0,0 +1,106 @@ +package context + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/docker/go-sdk/config" +) + +// setupDockerDirWithoutConfigFile creates a ~/.docker directory in a temp home +// so that config.Dir() succeeds, but does NOT create config.json, so that +// config.Load() returns ErrConfigFileNotFound. +func setupDockerDirWithoutConfigFile(tb testing.TB) { + tb.Helper() + tmpDir := tb.TempDir() + tb.Setenv("HOME", tmpDir) + tb.Setenv("USERPROFILE", tmpDir) // Windows support + require.NoError(tb, os.MkdirAll(filepath.Join(tmpDir, ".docker"), 0o755)) +} + +// removeConfigFile deletes the config.json from the current DOCKER_CONFIG dir. +func removeConfigFile(tb testing.TB) { + tb.Helper() + dir, err := config.Dir() + require.NoError(tb, err) + require.NoError(tb, os.Remove(filepath.Join(dir, config.FileName))) +} + +func TestCurrent_ConfigNotFound(t *testing.T) { + setupDockerDirWithoutConfigFile(t) + + current, err := Current() + require.NoError(t, err) + require.Equal(t, DefaultContextName, current) +} + +func TestInspect_ConfigNotFound(t *testing.T) { + SetupTestDockerContexts(t, 1, 3) // creates config.json with currentContext=context1 + removeConfigFile(t) // simulate a fresh install without config.json + + ctx, err := Inspect("context1") + require.NoError(t, err) + require.Equal(t, "context1", ctx.Name) + require.Equal(t, "tcp://127.0.0.1:1", ctx.Endpoints["docker"].Host) + + require.NotEmpty(t, ctx.encodedName, "encodedName should be set even when config is missing") + require.False(t, ctx.isCurrent, "isCurrent should be false when config file is missing") +} + +func TestStore_Inspect_ConfigNotFound(t *testing.T) { + SetupTestDockerContexts(t, 1, 3) + removeConfigFile(t) + + metaDir, err := metaRoot() + require.NoError(t, err) + s := &store{root: metaDir} + + ctx, err := s.inspect("context1") + require.NoError(t, err) + require.Equal(t, "context1", ctx.Name) + require.NotEmpty(t, ctx.encodedName) + require.False(t, ctx.isCurrent) +} + +func TestNew_AsCurrent_ConfigNotFound(t *testing.T) { + setupDockerDirWithoutConfigFile(t) + + ctx, err := New("newctx", + WithHost("tcp://127.0.0.1:9999"), + AsCurrent(), + ) + require.NoError(t, err) + defer func() { require.NoError(t, ctx.Delete()) }() + + require.Equal(t, "newctx", ctx.Name) + require.False(t, ctx.isCurrent, "isCurrent should be false when config file is missing") + + list, err := List() + require.NoError(t, err) + require.Contains(t, list, "newctx") + + current, err := Current() + require.NoError(t, err) + require.NotEqual(t, "newctx", current, "current should not be the new context without a config file") +} + +func TestDelete_CurrentContext_ConfigNotFound(t *testing.T) { + SetupTestDockerContexts(t, 1, 3) // creates config.json + contexts + + ctx, err := New("deleteme", + WithHost("tcp://127.0.0.1:9999"), + AsCurrent(), + ) + require.NoError(t, err) + require.True(t, ctx.isCurrent, "new context should be current") + + removeConfigFile(t) + + require.NoError(t, ctx.Delete(), "delete should not fail when config file is missing") + + _, err = Inspect("deleteme") + require.ErrorIs(t, err, ErrDockerContextNotFound) +} diff --git a/context/store.go b/context/store.go index 8acaf1e1..98560020 100644 --- a/context/store.go +++ b/context/store.go @@ -139,14 +139,17 @@ func (s *store) inspect(ctxName string) (Context, error) { return Context{}, ErrDockerHostNotSet } + ctx.encodedName = digest.FromString(ctx.Name).Encoded() + cfg, err := config.Load() + if errors.Is(err, config.ErrConfigFileNotFound) { + return *ctx, nil + } if err != nil { return Context{}, fmt.Errorf("load config: %w", err) } ctx.isCurrent = cfg.CurrentContext == ctx.Name - ctx.encodedName = digest.FromString(ctx.Name).Encoded() - return *ctx, nil } }