Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -125,6 +126,7 @@ var (
)

type client struct {
socketPath string
resolverClient secrets.Resolver
engineClient pluginsv1connect.PluginManagementServiceClient
versionClient healthv1connect.VersionServiceClient
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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{
Expand All @@ -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"),
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
94 changes: 90 additions & 4 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ package client

import (
"context"
"errors"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -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)
})
}

Expand All @@ -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)
})
}

Expand All @@ -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) {
Expand All @@ -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)
})
}
8 changes: 4 additions & 4 deletions client/dockerhub/dockerhub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}

Expand Down Expand Up @@ -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)
})
}

Expand Down
22 changes: 22 additions & 0 deletions client/errno_unix.go
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions client/errno_windows.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
Benehiko marked this conversation as resolved.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested on Windows: a Windows 11 VM (build 26100, ARM64), running the test binaries cross-compiled with GOOS=windows GOARCH=arm64 go test -c.

TestConnectErrorFromSocket dials real AF_UNIX sockets:

  • Stale socket (SetUnlinkOnClose(false), then close the listener): Winsock returns WSAECONNREFUSED (10061), so this constant matches and the error is ReasonNotRunning. syscall.ECONNREFUSED would not have matched.
  • Missing socket: reported as fs.ErrNotExist via syscall.Errno.Is, also ReasonNotRunning.

The full client, client/dockerhub and plugins/pass/commands suites pass on the VM.

Not covered here: a deny-all ACL on the socket for the current user did not stop the connection, so ReasonPermissionDenied does not show up on Windows in that setup.

Loading
Loading