diff --git a/client/client.go b/client/client.go index 3cb51295..83bff96a 100644 --- a/client/client.go +++ b/client/client.go @@ -49,9 +49,8 @@ var ( ParsePattern = secrets.ParsePattern MustParsePattern = secrets.MustParsePattern - ErrSecretNotFound = secrets.ErrNotFound - ErrAccessDenied = secrets.ErrAccessDenied - ErrSecretsEngineNotAvailable = errors.New("secrets engine is not available") + ErrSecretNotFound = secrets.ErrNotFound + ErrAccessDenied = secrets.ErrAccessDenied ) var _ secrets.Resolver = &client{} @@ -66,6 +65,7 @@ func WithSocketPath(path string) Option { if s.dialContext != nil { return errors.New("cannot set socket path and dial") } + s.socketPath = path s.dialContext = dialFromPath(path) return nil } @@ -114,6 +114,7 @@ func WithResponseTimeout(responseTimeout time.Duration) Option { type dial func(ctx context.Context, network, addr string) (net.Conn, error) type config struct { + socketPath string dialContext dial requestTimeout time.Duration responseTimeout time.Duration @@ -125,6 +126,7 @@ var ( ) type client struct { + socketPath string resolverClient secrets.Resolver engineClient pluginsv1connect.PluginManagementServiceClient versionClient healthv1connect.VersionServiceClient @@ -136,7 +138,7 @@ type client struct { func (c client) Authorize(ctx context.Context, patterns ...secrets.Pattern) (secrets.AuthorizeResponse, error) { resp, err := c.authorizerClient.Authorize(ctx, patterns...) if isDialError(err) { - return secrets.AuthorizeResponse{}, fmt.Errorf("%w: %w", ErrSecretsEngineNotAvailable, err) + return secrets.AuthorizeResponse{}, c.connectError(err) } if err != nil { return secrets.AuthorizeResponse{}, err @@ -147,7 +149,7 @@ func (c client) Authorize(ctx context.Context, patterns ...secrets.Pattern) (sec func (c client) GetSecrets(ctx context.Context, pattern secrets.Pattern) ([]secrets.Envelope, error) { envelopes, err := c.resolverClient.GetSecrets(ctx, pattern) if isDialError(err) { - return nil, fmt.Errorf("%w: %w", ErrSecretsEngineNotAvailable, err) + return nil, c.connectError(err) } if err != nil { return nil, err @@ -162,7 +164,7 @@ func (c client) HubAuth(opts ...dockerhub.Option) dockerhub.ClientAuth { func (c client) Version(ctx context.Context) (DaemonVersion, error) { resp, err := c.versionClient.GetVersion(ctx, connect.NewRequest(healthv1.GetVersionRequest_builder{}.Build())) if isDialError(err) { - return DaemonVersion{}, fmt.Errorf("%w: %w", ErrSecretsEngineNotAvailable, err) + return DaemonVersion{}, c.connectError(err) } if err != nil { return DaemonVersion{}, err @@ -211,6 +213,13 @@ func isDialError(err error) bool { return false } +func (c client) connectError(err error) error { + if errors.Is(err, context.Canceled) { + return err + } + return &ConnectError{Reason: connectReason(err), SocketPath: c.socketPath, Err: err} +} + // New creates a client that connects to [api.StandaloneSocketPath] by default. // To connect to Docker Desktop, use WithSocketPath(api.DesktopSocketPath()). func New(options ...Option) (Client, error) { @@ -224,7 +233,8 @@ func New(options ...Option) (Client, error) { } } if cfg.dialContext == nil { - cfg.dialContext = dialFromPath(api.StandaloneSocketPath()) + cfg.socketPath = api.StandaloneSocketPath() + cfg.dialContext = dialFromPath(cfg.socketPath) } c := &http.Client{ Transport: &http.Transport{ @@ -248,6 +258,7 @@ func New(options ...Option) (Client, error) { Timeout: cfg.requestTimeout, } return &client{ + socketPath: cfg.socketPath, resolverClient: resolver.NewResolverClient(c), engineClient: pluginsv1connect.NewPluginManagementServiceClient(c, "http://unix"), versionClient: healthv1connect.NewVersionServiceClient(c, "http://unix"), @@ -259,7 +270,7 @@ func (c client) ListPlugins(ctx context.Context) ([]PluginInfo, error) { req := connect.NewRequest(pluginsv1.ListPluginsRequest_builder{}.Build()) resp, err := c.engineClient.ListPlugins(ctx, req) if isDialError(err) { - return nil, fmt.Errorf("%w: %w", ErrSecretsEngineNotAvailable, err) + return nil, c.connectError(err) } if err != nil { return nil, err @@ -303,7 +314,7 @@ func (c client) EnablePlugin(ctx context.Context, name string) error { r.SetName(name) _, err := c.engineClient.EnablePlugin(ctx, connect.NewRequest(r)) if isDialError(err) { - return fmt.Errorf("%w: %w", ErrSecretsEngineNotAvailable, err) + return c.connectError(err) } return err } @@ -313,7 +324,7 @@ func (c client) DisablePlugin(ctx context.Context, name string) error { r.SetName(name) _, err := c.engineClient.DisablePlugin(ctx, connect.NewRequest(r)) if isDialError(err) { - return fmt.Errorf("%w: %w", ErrSecretsEngineNotAvailable, err) + return c.connectError(err) } return err } diff --git a/client/client_test.go b/client/client_test.go index d9d7471e..0cd9c19b 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -16,10 +16,13 @@ package client import ( "context" + "errors" "net" "net/http" "os" "path/filepath" + "runtime" + "syscall" "testing" "time" @@ -321,7 +324,7 @@ func Test_Authorize(t *testing.T) { c, err := New(WithSocketPath(socketPath)) require.NoError(t, err) _, err = c.Authorize(t.Context(), MustParsePattern("docker/auth/hub/joe")) - require.ErrorIs(t, err, ErrSecretsEngineNotAvailable) + require.ErrorIs(t, err, ErrSecretsEngineNotRunning) }) } @@ -342,7 +345,7 @@ func Test_Version(t *testing.T) { c, err := New(WithSocketPath(socketPath)) require.NoError(t, err) _, err = c.Version(t.Context()) - require.ErrorIs(t, err, ErrSecretsEngineNotAvailable) + require.ErrorIs(t, err, ErrSecretsEngineNotRunning) }) } @@ -353,9 +356,9 @@ func TestSecretsEngineUnavailable(t *testing.T) { m, err := PluginManagementFromClient(client) require.NoError(t, err) _, err = m.ListPlugins(t.Context()) - require.ErrorIs(t, err, ErrSecretsEngineNotAvailable) + require.ErrorIs(t, err, ErrSecretsEngineNotRunning) _, err = client.GetSecrets(t.Context(), secrets.MustParsePattern("**")) - require.ErrorIs(t, err, ErrSecretsEngineNotAvailable) + require.ErrorIs(t, err, ErrSecretsEngineNotRunning) } func TestHubAuth(t *testing.T) { @@ -374,3 +377,86 @@ func TestIsDialError(t *testing.T) { })) require.False(t, isDialError(nil)) } + +func TestConnectError(t *testing.T) { + dialErr := func(err error) error { + return &net.OpError{Op: "dial", Net: "unix", Err: os.NewSyscallError("connect", err)} + } + sentinels := []error{ErrSecretsEngineNotRunning, ErrSecretsEnginePermissionDenied, ErrSecretsEngineTimeout} + tests := []struct { + name string + err error + reason ConnectReason + sentinel error + }{ + {name: "missing socket", err: dialErr(syscall.ENOENT), reason: ReasonNotRunning, sentinel: ErrSecretsEngineNotRunning}, + {name: "nothing listening", err: dialErr(errConnRefused), reason: ReasonNotRunning, sentinel: ErrSecretsEngineNotRunning}, + {name: "permission denied", err: dialErr(syscall.EACCES), reason: ReasonPermissionDenied, sentinel: ErrSecretsEnginePermissionDenied}, + {name: "i/o timeout", err: &net.OpError{Op: "dial", Net: "unix", Err: os.ErrDeadlineExceeded}, reason: ReasonTimeout, sentinel: ErrSecretsEngineTimeout}, + {name: "context deadline", err: &net.OpError{Op: "dial", Net: "unix", Err: context.DeadlineExceeded}, reason: ReasonTimeout, sentinel: ErrSecretsEngineTimeout}, + {name: "other", err: dialErr(syscall.ECONNRESET), reason: ReasonUnknown}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := client{socketPath: "/run/engine.sock"}.connectError(tc.err) + ce, ok := errors.AsType[*ConnectError](err) + require.True(t, ok) + assert.Equal(t, tc.reason, ce.Reason) + assert.Equal(t, "/run/engine.sock", ce.SocketPath) + require.ErrorIs(t, err, tc.err, "the dial error stays inspectable") + for _, s := range sentinels { + assert.Equal(t, s == tc.sentinel, errors.Is(err, s), "errors.Is(err, %q)", s) + } + }) + } + t.Run("cancelled context is not an engine error", func(t *testing.T) { + in := &net.OpError{Op: "dial", Net: "unix", Err: context.Canceled} + err := client{socketPath: "/run/engine.sock"}.connectError(in) + assert.Same(t, in, err) + }) + t.Run("message", func(t *testing.T) { + err := client{socketPath: "/run/engine.sock"}.connectError(dialErr(syscall.ENOENT)) + assert.Equal(t, "secrets engine is not running at /run/engine.sock: dial unix: connect: "+syscall.ENOENT.Error(), err.Error()) + err = client{}.connectError(dialErr(syscall.ECONNRESET)) + assert.Equal(t, "cannot connect to the secrets engine: dial unix: connect: "+syscall.ECONNRESET.Error(), err.Error()) + }) +} + +func TestConnectErrorFromSocket(t *testing.T) { + getSecrets := func(t *testing.T, socketPath string) error { + t.Helper() + c, err := New(WithSocketPath(socketPath)) + require.NoError(t, err) + _, err = c.GetSecrets(t.Context(), secrets.MustParsePattern("**")) + return err + } + + t.Run("missing socket", func(t *testing.T) { + err := getSecrets(t, testhelper.RandomShortSocketName()) + require.ErrorIs(t, err, ErrSecretsEngineNotRunning) + }) + t.Run("nothing listening", func(t *testing.T) { + socketPath := testhelper.RandomShortSocketName() + l, err := net.Listen("unix", socketPath) + require.NoError(t, err) + l.(*net.UnixListener).SetUnlinkOnClose(false) + require.NoError(t, l.Close()) + t.Cleanup(func() { _ = os.Remove(socketPath) }) + + err = getSecrets(t, socketPath) + require.ErrorIs(t, err, ErrSecretsEngineNotRunning) + }) + t.Run("permission denied", func(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("needs Unix file permissions enforced for the current user") + } + socketPath := testhelper.RandomShortSocketName() + l, err := net.Listen("unix", socketPath) + require.NoError(t, err) + t.Cleanup(func() { _ = l.Close() }) + require.NoError(t, os.Chmod(socketPath, 0o000)) + + err = getSecrets(t, socketPath) + require.ErrorIs(t, err, ErrSecretsEnginePermissionDenied) + }) +} diff --git a/client/dockerhub/dockerhub_test.go b/client/dockerhub/dockerhub_test.go index 70904fd8..672a4d6f 100644 --- a/client/dockerhub/dockerhub_test.go +++ b/client/dockerhub/dockerhub_test.go @@ -211,8 +211,8 @@ func TestGetSession(t *testing.T) { require.ErrorContains(t, err, "must not contain '/'") }) t.Run("engine unavailable", func(t *testing.T) { - _, err := hub(t, fakeEngine{err: client.ErrSecretsEngineNotAvailable}).GetSession(t.Context(), "alice") - require.ErrorIs(t, err, client.ErrSecretsEngineNotAvailable) + _, err := hub(t, fakeEngine{err: client.ErrSecretsEngineNotRunning}).GetSession(t.Context(), "alice") + require.ErrorIs(t, err, client.ErrSecretsEngineNotRunning) }) } @@ -390,8 +390,8 @@ func TestListProfiles(t *testing.T) { require.ErrorContains(t, err, "decode profile metadata") }) t.Run("engine unavailable", func(t *testing.T) { - _, err := hub(t, fakeEngine{err: client.ErrSecretsEngineNotAvailable}).ListProfiles(t.Context()) - require.ErrorIs(t, err, client.ErrSecretsEngineNotAvailable) + _, err := hub(t, fakeEngine{err: client.ErrSecretsEngineNotRunning}).ListProfiles(t.Context()) + require.ErrorIs(t, err, client.ErrSecretsEngineNotRunning) }) } diff --git a/client/errno_unix.go b/client/errno_unix.go new file mode 100644 index 00000000..512f6aa7 --- /dev/null +++ b/client/errno_unix.go @@ -0,0 +1,22 @@ +// Copyright 2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package client + +import "syscall" + +// errConnRefused is returned when dialing a socket nothing listens on. +const errConnRefused = syscall.ECONNREFUSED diff --git a/client/errno_windows.go b/client/errno_windows.go new file mode 100644 index 00000000..f305cf75 --- /dev/null +++ b/client/errno_windows.go @@ -0,0 +1,20 @@ +// Copyright 2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import "syscall" + +// errConnRefused is WSAECONNREFUSED; Winsock never returns syscall.ECONNREFUSED. +const errConnRefused = syscall.Errno(10061) diff --git a/client/errors.go b/client/errors.go new file mode 100644 index 00000000..6aac009b --- /dev/null +++ b/client/errors.go @@ -0,0 +1,104 @@ +// Copyright 2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "context" + "errors" + "io/fs" + "net" +) + +var ( + ErrSecretsEngineNotRunning = errors.New("secrets engine is not running") + ErrSecretsEnginePermissionDenied = errors.New("permission denied connecting to the secrets engine") + ErrSecretsEngineTimeout = errors.New("secrets engine did not respond in time") +) + +type ConnectReason int + +const ( + ReasonUnknown ConnectReason = iota + ReasonNotRunning + ReasonPermissionDenied + ReasonTimeout +) + +func (r ConnectReason) String() string { + switch r { + case ReasonNotRunning: + return "not running" + case ReasonPermissionDenied: + return "permission denied" + case ReasonTimeout: + return "timeout" + default: + return "unknown" + } +} + +func (r ConnectReason) sentinel() error { + switch r { + case ReasonNotRunning: + return ErrSecretsEngineNotRunning + case ReasonPermissionDenied: + return ErrSecretsEnginePermissionDenied + case ReasonTimeout: + return ErrSecretsEngineTimeout + default: + return nil + } +} + +type ConnectError struct { + Reason ConnectReason + SocketPath string + Err error +} + +func (e *ConnectError) Error() string { + msg := "cannot connect to the secrets engine" + if s := e.Reason.sentinel(); s != nil { + msg = s.Error() + } + if e.SocketPath != "" { + msg += " at " + e.SocketPath + } + if e.Err != nil { + msg += ": " + e.Err.Error() + } + return msg +} + +func (e *ConnectError) Unwrap() error { return e.Err } + +func (e *ConnectError) Is(target error) bool { + s := e.Reason.sentinel() + return s != nil && target == s +} + +func connectReason(err error) ConnectReason { + var ne net.Error + switch { + case errors.Is(err, fs.ErrNotExist), errors.Is(err, errConnRefused): + return ReasonNotRunning + case errors.Is(err, fs.ErrPermission): + return ReasonPermissionDenied + case errors.Is(err, context.DeadlineExceeded), errors.As(err, &ne) && ne.Timeout(): + return ReasonTimeout + default: + return ReasonUnknown + } +} diff --git a/plugins/pass/commands/client.go b/plugins/pass/commands/client.go index 6f74b721..4e66918b 100644 --- a/plugins/pass/commands/client.go +++ b/plugins/pass/commands/client.go @@ -37,19 +37,24 @@ func (o clientOpts) isUnbound() bool { return o.timeout == 0 } -// preflightPing fails fast when the engine is unreachable, so an unbounded -// client cannot hang resolution indefinitely. -// TODO: move into client/client.go +var errPingTimeout = errors.New("preflight ping timed out") + +// preflightPing fails fast when the engine is unreachable. TODO: move into client/client.go func preflightPing(ctx context.Context, c client.Client, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(ctx, timeout) + pingCtx, cancel := context.WithTimeoutCause(ctx, timeout, errPingTimeout) defer cancel() - if _, err := c.Version(ctx); err != nil { - if !errors.Is(err, client.ErrSecretsEngineNotAvailable) { - err = fmt.Errorf("%w: %w", client.ErrSecretsEngineNotAvailable, err) - } + _, err := c.Version(pingCtx) + if err == nil { + return nil + } + if _, ok := errors.AsType[*client.ConnectError](err); ok { return fmt.Errorf("preflight ping: %w", err) } - return nil + if errors.Is(context.Cause(pingCtx), errPingTimeout) { + // Our timeout fired, not the caller's: the engine did not answer in time. + err = &client.ConnectError{Reason: client.ReasonTimeout, Err: err} + } + return fmt.Errorf("preflight ping: %w", err) } type ClientOption func(*clientOpts) error diff --git a/plugins/pass/commands/command_test.go b/plugins/pass/commands/command_test.go index 09644bdd..d35ee86f 100644 --- a/plugins/pass/commands/command_test.go +++ b/plugins/pass/commands/command_test.go @@ -17,7 +17,6 @@ package commands import ( "bytes" "errors" - "path/filepath" "testing" "time" @@ -31,6 +30,7 @@ import ( "github.com/docker/secrets-engine/store" "github.com/docker/secrets-engine/store/keychain" "github.com/docker/secrets-engine/x/secrets" + "github.com/docker/secrets-engine/x/testhelper" ) var mockInfo = VersionInfo{ @@ -212,7 +212,7 @@ func Test_RmCommand(t *testing.T) { mock := twoSecrets() cmd := mustRmCommand(t, WithTimeout(time.Second), WithSocketPath(deadSocket(t))) out, err := execute(t, cmd, mock, "foo") - assert.ErrorIs(t, err, client.ErrSecretsEngineNotAvailable) + assert.ErrorIs(t, err, client.ErrSecretsEngineNotRunning) assert.NotContains(t, out, "RM:") assert.Equal(t, 2, remaining(t, mock)) }) @@ -337,14 +337,14 @@ func Test_GetCommand(t *testing.T) { t.Run("--reveal fails when the engine is unreachable", func(t *testing.T) { cmd := mustGetCommand(t, WithTimeout(time.Second), WithSocketPath(deadSocket(t))) out, err := execute(t, cmd, fooStore(), "--reveal", "foo") - assert.ErrorIs(t, err, client.ErrSecretsEngineNotAvailable) + assert.ErrorIs(t, err, client.ErrSecretsEngineNotRunning) assert.ErrorContains(t, err, "authorizing:") assert.NotContains(t, out, "bar") }) t.Run("--reveal pings the engine first when requests are unbounded", func(t *testing.T) { cmd := mustGetCommand(t, WithSocketPath(deadSocket(t))) out, err := execute(t, cmd, fooStore(), "--reveal", "foo") - assert.ErrorIs(t, err, client.ErrSecretsEngineNotAvailable) + assert.ErrorIs(t, err, client.ErrSecretsEngineNotRunning) assert.ErrorContains(t, err, "preflight ping") assert.NotContains(t, out, "bar") }) @@ -371,7 +371,7 @@ func mustRmCommand(t *testing.T, options ...ClientOption) *cobra.Command { func deadSocket(t *testing.T) string { t.Helper() - return filepath.Join(t.TempDir(), "dead.sock") + return testhelper.RandomShortSocketName() } func engineOpts(t *testing.T, engine *mockEngine) []ClientOption { @@ -379,12 +379,6 @@ func engineOpts(t *testing.T, engine *mockEngine) []ClientOption { return []ClientOption{WithTimeout(time.Second), WithSocketPath(engine.serve(t))} } -// execute runs cmd as if it were the root command: it attaches mock to the -// command context so RunE bodies can pull it via StoreFrom, captures stdout -// and stderr into one buffer (mirroring how cobra collapses both onto the -// user's terminal), and silences usage so error output stays minimal. -// -// Pass mock == nil for commands that do not consult the store. func execute(t *testing.T, cmd *cobra.Command, mock store.Store, args ...string) (string, error) { t.Helper() return runCobra(t, cmd, mock, nil, args) @@ -414,9 +408,6 @@ func runCobra(t *testing.T, cmd *cobra.Command, mock store.Store, stdin *bytes.B return buf.String(), err } -// assertStoredValue asserts the secret stored under id "foo" — every set-test -// uses that id, so the helpers hard-code it rather than carrying a parameter -// that always receives the same string. func assertStoredValue(t *testing.T, kc store.Store, want string) { t.Helper() s, err := kc.Get(t.Context(), secrets.MustParseID("foo")) diff --git a/plugins/pass/commands/run_test.go b/plugins/pass/commands/run_test.go index 1814d8da..a4215095 100644 --- a/plugins/pass/commands/run_test.go +++ b/plugins/pass/commands/run_test.go @@ -46,11 +46,6 @@ import ( "github.com/docker/secrets-engine/x/testhelper" ) -// Sentinels driving TestMain modes: -// -// helperWrapperEnv: act as a RunCommand wrapper — invoke RunCommand with -// args=[exe] so it execs this test binary again as a grandchild. -// helperActiveEnv: act as the leaf child — exit with the requested code. const ( helperWrapperEnv = "GO_PASS_RUN_WRAPPER" helperActiveEnv = "GO_PASS_RUN_HELPER_ACTIVE" @@ -96,10 +91,6 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -// runAsWrapper invokes RunCommand with args=[exe], so RunCommand execs the -// test binary again as a grandchild. The grandchild's exit code propagates: -// RunCommand calls os.Exit(code), so this wrapper process exits with the same -// code, which the outer test then observes via exec.ExitError. func runAsWrapper() { exe, err := os.Executable() if err != nil { @@ -177,7 +168,6 @@ func TestParseEnv(t *testing.T) { }) } -// mustParseEnv parses env, failing the test on an invalid reference. func mustParseEnv(t *testing.T, env []string) []envVar { t.Helper() vars, err := parseEnv(env) @@ -372,8 +362,6 @@ func TestRunCommandOptions(t *testing.T) { }) } -// TestRunCommand covers cobra-level behavior against a mock engine or none. -// TestParseEnv and TestResolveEnv cover the details. func TestRunCommand(t *testing.T) { exe, err := os.Executable() require.NoError(t, err) @@ -546,7 +534,6 @@ func (e *mockEngine) recorded() []string { return slices.Clone(e.calls) } -// serve starts the engine on a fresh socket and returns the socket path. func (e *mockEngine) serve(t *testing.T) string { t.Helper() mux := http.NewServeMux() @@ -561,7 +548,6 @@ func (e *mockEngine) serve(t *testing.T) string { return socket } -// pingClient adapts a Version func to client.Client. type pingClient struct { testhelper.MockResolver ping func(context.Context) (client.DaemonVersion, error) @@ -590,9 +576,9 @@ func TestPreflightPing(t *testing.T) { require.NoError(t, preflightPing(t.Context(), c, time.Second)) }) - t.Run("fails when the engine is unreachable", func(t *testing.T) { + t.Run("fails when the engine is not running", func(t *testing.T) { t.Parallel() - engineErr := errors.New("connection refused") + engineErr := &client.ConnectError{Reason: client.ReasonNotRunning, Err: errors.New("connection refused")} c := pingClient{ping: func(_ context.Context) (client.DaemonVersion, error) { return client.DaemonVersion{}, engineErr }} @@ -600,7 +586,19 @@ func TestPreflightPing(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "preflight ping") assert.ErrorIs(t, err, engineErr) - assert.ErrorIs(t, err, client.ErrSecretsEngineNotAvailable) + assert.ErrorIs(t, err, client.ErrSecretsEngineNotRunning) + }) + + t.Run("passes through engine errors that are not connection failures", func(t *testing.T) { + t.Parallel() + engineErr := errors.New("unimplemented") + c := pingClient{ping: func(_ context.Context) (client.DaemonVersion, error) { + return client.DaemonVersion{}, engineErr + }} + err := preflightPing(t.Context(), c, time.Second) + require.ErrorIs(t, err, engineErr) + _, ok := errors.AsType[*client.ConnectError](err) + assert.False(t, ok) }) t.Run("gives up after the timeout when the engine hangs", func(t *testing.T) { @@ -618,11 +616,28 @@ func TestPreflightPing(t *testing.T) { require.Error(t, err) require.Less(t, time.Since(start), 2*time.Second) assert.ErrorIs(t, err, context.DeadlineExceeded) - assert.ErrorIs(t, err, client.ErrSecretsEngineNotAvailable) + assert.ErrorIs(t, err, client.ErrSecretsEngineTimeout) + }) + + t.Run("does not report an engine timeout when the caller's deadline fires first", func(t *testing.T) { + t.Parallel() + c := pingClient{ping: func(ctx context.Context) (client.DaemonVersion, error) { + <-ctx.Done() + return client.DaemonVersion{}, ctx.Err() + }} + callerCtx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + start := time.Now() + err := preflightPing(callerCtx, c, 5*time.Second) + require.Error(t, err) + require.Less(t, time.Since(start), 2*time.Second) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.NotErrorIs(t, err, client.ErrSecretsEngineTimeout) + _, ok := errors.AsType[*client.ConnectError](err) + assert.False(t, ok) }) } -// testWriter forwards cobra output to t.Log so it does not leak onto stderr. type testWriter struct{ t *testing.T } func (w testWriter) Write(p []byte) (int, error) {