client: fix connection-errors being shadowed by API version mismatch errors

Commit e6907243af applied a fix for situations
where the client was configured with API-version negotiation, but did not yet
negotiate a version.

However, the checkVersion() function that was implemented copied the semantics
of cli.NegotiateAPIVersion, which ignored connection failures with the
assumption that connection errors would still surface further down.

However, when using the result of a failed negotiation for NewVersionError,
an API version mismatch error would be produced, masking the actual connection
error.

This patch changes the signature of checkVersion to return unexpected errors,
including failures to connect to the API.

Before this patch:

    docker -H unix:///no/such/socket.sock secret ls
    "secret list" requires API version 1.25, but the Docker daemon API version is 1.24

With this patch applied:

    docker -H unix:///no/such/socket.sock secret ls
    Cannot connect to the Docker daemon at unix:///no/such/socket.sock. Is the docker daemon running?

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Sebastiaan van Stijn 2024-02-23 12:20:06 +01:00
parent 913478b428
commit 6aea26b431
No known key found for this signature in database
GPG key ID: 76698F39D527CE8C
23 changed files with 179 additions and 26 deletions

View file

@ -265,17 +265,22 @@ func (cli *Client) Close() error {
// This allows for version-dependent code to use the same version as will // This allows for version-dependent code to use the same version as will
// be negotiated when making the actual requests, and for which cases // be negotiated when making the actual requests, and for which cases
// we cannot do the negotiation lazily. // we cannot do the negotiation lazily.
func (cli *Client) checkVersion(ctx context.Context) { func (cli *Client) checkVersion(ctx context.Context) error {
if cli.negotiateVersion && !cli.negotiated { if !cli.manualOverride && cli.negotiateVersion && !cli.negotiated {
cli.NegotiateAPIVersion(ctx) ping, err := cli.Ping(ctx)
if err != nil {
return err
}
cli.negotiateAPIVersionPing(ping)
} }
return nil
} }
// getAPIPath returns the versioned request path to call the API. // getAPIPath returns the versioned request path to call the API.
// It appends the query parameters to the path if they are not empty. // It appends the query parameters to the path if they are not empty.
func (cli *Client) getAPIPath(ctx context.Context, p string, query url.Values) string { func (cli *Client) getAPIPath(ctx context.Context, p string, query url.Values) string {
var apiPath string var apiPath string
cli.checkVersion(ctx) _ = cli.checkVersion(ctx)
if cli.version != "" { if cli.version != "" {
v := strings.TrimPrefix(cli.version, "v") v := strings.TrimPrefix(cli.version, "v")
apiPath = path.Join(cli.basePath, "/v"+v, p) apiPath = path.Join(cli.basePath, "/v"+v, p)

View file

@ -359,7 +359,7 @@ func TestNegotiateAPVersionOverride(t *testing.T) {
func TestNegotiateAPVersionConnectionFailure(t *testing.T) { func TestNegotiateAPVersionConnectionFailure(t *testing.T) {
const expected = "9.99" const expected = "9.99"
client, err := NewClientWithOpts(WithHost("unix:///no-such-socket")) client, err := NewClientWithOpts(WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err) assert.NilError(t, err)
client.version = expected client.version = expected

View file

@ -28,7 +28,9 @@ func (cli *Client) ContainerCreate(ctx context.Context, config *container.Config
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return response, err
}
if err := cli.NewVersionError(ctx, "1.25", "stop timeout"); config != nil && config.StopTimeout != nil && err != nil { if err := cli.NewVersionError(ctx, "1.25", "stop timeout"); config != nil && config.StopTimeout != nil && err != nil {
return response, err return response, err

View file

@ -113,3 +113,15 @@ func TestContainerCreateAutoRemove(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
// TestContainerCreateConnection verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestContainerCreateConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
_, err = client.ContainerCreate(context.Background(), nil, nil, nil, nil, "")
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}

View file

@ -18,7 +18,9 @@ func (cli *Client) ContainerExecCreate(ctx context.Context, container string, co
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return response, err
}
if err := cli.NewVersionError(ctx, "1.25", "env"); len(config.Env) != 0 && err != nil { if err := cli.NewVersionError(ctx, "1.25", "env"); len(config.Env) != 0 && err != nil {
return response, err return response, err

View file

@ -24,6 +24,18 @@ func TestContainerExecCreateError(t *testing.T) {
assert.Check(t, is.ErrorType(err, errdefs.IsSystem)) assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
} }
// TestContainerExecCreateConnectionError verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestContainerExecCreateConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
_, err = client.ContainerExecCreate(context.Background(), "", types.ExecConfig{})
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}
func TestContainerExecCreate(t *testing.T) { func TestContainerExecCreate(t *testing.T) {
expectedURL := "/containers/container_id/exec" expectedURL := "/containers/container_id/exec"
client := &Client{ client := &Client{

View file

@ -23,7 +23,9 @@ func (cli *Client) ContainerRestart(ctx context.Context, containerID string, opt
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return err
}
if versions.GreaterThanOrEqualTo(cli.version, "1.42") { if versions.GreaterThanOrEqualTo(cli.version, "1.42") {
query.Set("signal", options.Signal) query.Set("signal", options.Signal)
} }

View file

@ -23,6 +23,18 @@ func TestContainerRestartError(t *testing.T) {
assert.Check(t, is.ErrorType(err, errdefs.IsSystem)) assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
} }
// TestContainerRestartConnectionError verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestContainerRestartConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
err = client.ContainerRestart(context.Background(), "nothing", container.StopOptions{})
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}
func TestContainerRestart(t *testing.T) { func TestContainerRestart(t *testing.T) {
const expectedURL = "/v1.42/containers/container_id/restart" const expectedURL = "/v1.42/containers/container_id/restart"
client := &Client{ client := &Client{

View file

@ -27,7 +27,9 @@ func (cli *Client) ContainerStop(ctx context.Context, containerID string, option
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return err
}
if versions.GreaterThanOrEqualTo(cli.version, "1.42") { if versions.GreaterThanOrEqualTo(cli.version, "1.42") {
query.Set("signal", options.Signal) query.Set("signal", options.Signal)
} }

View file

@ -23,6 +23,18 @@ func TestContainerStopError(t *testing.T) {
assert.Check(t, is.ErrorType(err, errdefs.IsSystem)) assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
} }
// TestContainerStopConnectionError verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestContainerStopConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
err = client.ContainerStop(context.Background(), "nothing", container.StopOptions{})
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}
func TestContainerStop(t *testing.T) { func TestContainerStop(t *testing.T) {
const expectedURL = "/v1.42/containers/container_id/stop" const expectedURL = "/v1.42/containers/container_id/stop"
client := &Client{ client := &Client{

View file

@ -30,19 +30,22 @@ const containerWaitErrorMsgLimit = 2 * 1024 /* Max: 2KiB */
// synchronize ContainerWait with other calls, such as specifying a // synchronize ContainerWait with other calls, such as specifying a
// "next-exit" condition before issuing a ContainerStart request. // "next-exit" condition before issuing a ContainerStart request.
func (cli *Client) ContainerWait(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error) { func (cli *Client) ContainerWait(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error) {
resultC := make(chan container.WaitResponse)
errC := make(chan error, 1)
// Make sure we negotiated (if the client is configured to do so), // Make sure we negotiated (if the client is configured to do so),
// as code below contains API-version specific handling of options. // as code below contains API-version specific handling of options.
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
errC <- err
return resultC, errC
}
if versions.LessThan(cli.ClientVersion(), "1.30") { if versions.LessThan(cli.ClientVersion(), "1.30") {
return cli.legacyContainerWait(ctx, containerID) return cli.legacyContainerWait(ctx, containerID)
} }
resultC := make(chan container.WaitResponse)
errC := make(chan error, 1)
query := url.Values{} query := url.Values{}
if condition != "" { if condition != "" {
query.Set("condition", string(condition)) query.Set("condition", string(condition))

View file

@ -34,6 +34,23 @@ func TestContainerWaitError(t *testing.T) {
} }
} }
// TestContainerWaitConnectionError verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestContainerWaitConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
resultC, errC := client.ContainerWait(context.Background(), "nothing", "")
select {
case result := <-resultC:
t.Fatalf("expected to not get a wait result, got %d", result.StatusCode)
case err := <-errC:
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}
}
func TestContainerWait(t *testing.T) { func TestContainerWait(t *testing.T) {
expectedURL := "/containers/container_id/wait" expectedURL := "/containers/container_id/wait"
client := &Client{ client := &Client{

View file

@ -67,7 +67,9 @@ func (cli *Client) NewVersionError(ctx context.Context, APIrequired, feature str
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return err
}
if cli.version != "" && versions.LessThan(cli.version, APIrequired) { if cli.version != "" && versions.LessThan(cli.version, APIrequired) {
return fmt.Errorf("%q requires API version %s, but the Docker daemon API version is %s", feature, APIrequired, cli.version) return fmt.Errorf("%q requires API version %s, but the Docker daemon API version is %s", feature, APIrequired, cli.version)
} }

View file

@ -12,14 +12,17 @@ import (
// ImageList returns a list of images in the docker host. // ImageList returns a list of images in the docker host.
func (cli *Client) ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) { func (cli *Client) ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) {
var images []image.Summary
// Make sure we negotiated (if the client is configured to do so), // Make sure we negotiated (if the client is configured to do so),
// as code below contains API-version specific handling of options. // as code below contains API-version specific handling of options.
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return images, err
}
var images []image.Summary
query := url.Values{} query := url.Values{}
optionFilters := options.Filters optionFilters := options.Filters

View file

@ -27,6 +27,18 @@ func TestImageListError(t *testing.T) {
assert.Check(t, is.ErrorType(err, errdefs.IsSystem)) assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
} }
// TestImageListConnectionError verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestImageListConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
_, err = client.ImageList(context.Background(), image.ListOptions{})
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}
func TestImageList(t *testing.T) { func TestImageList(t *testing.T) {
const expectedURL = "/images/json" const expectedURL = "/images/json"

View file

@ -10,12 +10,16 @@ import (
// NetworkCreate creates a new network in the docker host. // NetworkCreate creates a new network in the docker host.
func (cli *Client) NetworkCreate(ctx context.Context, name string, options types.NetworkCreate) (types.NetworkCreateResponse, error) { func (cli *Client) NetworkCreate(ctx context.Context, name string, options types.NetworkCreate) (types.NetworkCreateResponse, error) {
var response types.NetworkCreateResponse
// Make sure we negotiated (if the client is configured to do so), // Make sure we negotiated (if the client is configured to do so),
// as code below contains API-version specific handling of options. // as code below contains API-version specific handling of options.
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return response, err
}
networkCreateRequest := types.NetworkCreateRequest{ networkCreateRequest := types.NetworkCreateRequest{
NetworkCreate: options, NetworkCreate: options,
@ -25,7 +29,6 @@ func (cli *Client) NetworkCreate(ctx context.Context, name string, options types
networkCreateRequest.CheckDuplicate = true //nolint:staticcheck // ignore SA1019: CheckDuplicate is deprecated since API v1.44. networkCreateRequest.CheckDuplicate = true //nolint:staticcheck // ignore SA1019: CheckDuplicate is deprecated since API v1.44.
} }
var response types.NetworkCreateResponse
serverResp, err := cli.post(ctx, "/networks/create", nil, networkCreateRequest, nil) serverResp, err := cli.post(ctx, "/networks/create", nil, networkCreateRequest, nil)
defer ensureReaderClosed(serverResp) defer ensureReaderClosed(serverResp)
if err != nil { if err != nil {

View file

@ -25,6 +25,18 @@ func TestNetworkCreateError(t *testing.T) {
assert.Check(t, is.ErrorType(err, errdefs.IsSystem)) assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
} }
// TestNetworkCreateConnectionError verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestNetworkCreateConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
_, err = client.NetworkCreate(context.Background(), "mynetwork", types.NetworkCreate{})
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}
func TestNetworkCreate(t *testing.T) { func TestNetworkCreate(t *testing.T) {
expectedURL := "/networks/create" expectedURL := "/networks/create"

View file

@ -25,7 +25,9 @@ func (cli *Client) ServiceCreate(ctx context.Context, service swarm.ServiceSpec,
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return response, err
}
// Make sure containerSpec is not nil when no runtime is set or the runtime is set to container // Make sure containerSpec is not nil when no runtime is set or the runtime is set to container
if service.TaskTemplate.ContainerSpec == nil && (service.TaskTemplate.Runtime == "" || service.TaskTemplate.Runtime == swarm.RuntimeContainer) { if service.TaskTemplate.ContainerSpec == nil && (service.TaskTemplate.Runtime == "" || service.TaskTemplate.Runtime == swarm.RuntimeContainer) {

View file

@ -28,6 +28,18 @@ func TestServiceCreateError(t *testing.T) {
assert.Check(t, is.ErrorType(err, errdefs.IsSystem)) assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
} }
// TestServiceCreateConnectionError verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestServiceCreateConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
_, err = client.ServiceCreate(context.Background(), swarm.ServiceSpec{}, types.ServiceCreateOptions{})
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}
func TestServiceCreate(t *testing.T) { func TestServiceCreate(t *testing.T) {
expectedURL := "/services/create" expectedURL := "/services/create"
client := &Client{ client := &Client{

View file

@ -16,18 +16,18 @@ import (
// It should be the value as set *before* the update. You can find this value in the Meta field // It should be the value as set *before* the update. You can find this value in the Meta field
// of swarm.Service, which can be found using ServiceInspectWithRaw. // of swarm.Service, which can be found using ServiceInspectWithRaw.
func (cli *Client) ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { func (cli *Client) ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) {
response := swarm.ServiceUpdateResponse{}
// Make sure we negotiated (if the client is configured to do so), // Make sure we negotiated (if the client is configured to do so),
// as code below contains API-version specific handling of options. // as code below contains API-version specific handling of options.
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return response, err
var ( }
query = url.Values{}
response = swarm.ServiceUpdateResponse{}
)
query := url.Values{}
if options.RegistryAuthFrom != "" { if options.RegistryAuthFrom != "" {
query.Set("registryAuthFrom", options.RegistryAuthFrom) query.Set("registryAuthFrom", options.RegistryAuthFrom)
} }

View file

@ -25,6 +25,18 @@ func TestServiceUpdateError(t *testing.T) {
assert.Check(t, is.ErrorType(err, errdefs.IsSystem)) assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
} }
// TestServiceUpdateConnectionError verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestServiceUpdateConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
_, err = client.ServiceUpdate(context.Background(), "service_id", swarm.Version{}, swarm.ServiceSpec{}, types.ServiceUpdateOptions{})
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}
func TestServiceUpdate(t *testing.T) { func TestServiceUpdate(t *testing.T) {
expectedURL := "/services/service_id/update" expectedURL := "/services/service_id/update"

View file

@ -16,7 +16,9 @@ func (cli *Client) VolumeRemove(ctx context.Context, volumeID string, force bool
// //
// Normally, version-negotiation (if enabled) would not happen until // Normally, version-negotiation (if enabled) would not happen until
// the API request is made. // the API request is made.
cli.checkVersion(ctx) if err := cli.checkVersion(ctx); err != nil {
return err
}
if versions.GreaterThanOrEqualTo(cli.version, "1.25") { if versions.GreaterThanOrEqualTo(cli.version, "1.25") {
query.Set("force", "1") query.Set("force", "1")
} }

View file

@ -23,6 +23,18 @@ func TestVolumeRemoveError(t *testing.T) {
assert.Check(t, is.ErrorType(err, errdefs.IsSystem)) assert.Check(t, is.ErrorType(err, errdefs.IsSystem))
} }
// TestVolumeRemoveConnectionError verifies that connection errors occurring
// during API-version negotiation are not shadowed by API-version errors.
//
// Regression test for https://github.com/docker/cli/issues/4890
func TestVolumeRemoveConnectionError(t *testing.T) {
client, err := NewClientWithOpts(WithAPIVersionNegotiation(), WithHost("tcp://no-such-host.invalid"))
assert.NilError(t, err)
err = client.VolumeRemove(context.Background(), "volume_id", false)
assert.Check(t, is.ErrorType(err, IsErrConnectionFailed))
}
func TestVolumeRemove(t *testing.T) { func TestVolumeRemove(t *testing.T) {
expectedURL := "/volumes/volume_id" expectedURL := "/volumes/volume_id"