volumes: fix error-handling when removing volumes with swarm enabled
- relates to https://github.com/moby/moby/pull/44224
- fixes https://github.com/docker/cli/issues/4082
- fixes https://github.com/moby/moby/issues/45037


### integration/volumes: TestVolumesRemove: add coverage for force/no-force

Add additional test-cases for deleting non-existing volumes (with/without force).

With this patch:

    make TEST_FILTER=TestVolumesRemove DOCKER_GRAPHDRIVER=vfs test-integration

    Running /go/src/github.com/docker/docker/integration/volume (arm64.integration.volume) flags=-test.v -test.timeout=10m  -test.run TestVolumesRemove
    ...
    === RUN   TestVolumesRemove
    === RUN   TestVolumesRemove/volume_in_use
    === RUN   TestVolumesRemove/volume_not_in_use
    === RUN   TestVolumesRemove/non-existing_volume
    === RUN   TestVolumesRemove/non-existing_volume_force
    --- PASS: TestVolumesRemove (0.04s)
        --- PASS: TestVolumesRemove/volume_in_use (0.00s)
        --- PASS: TestVolumesRemove/volume_not_in_use (0.01s)
        --- PASS: TestVolumesRemove/non-existing_volume (0.00s)
        --- PASS: TestVolumesRemove/non-existing_volume_force (0.00s)
    PASS


### volumes: fix error-handling when removing volumes with swarm enabled

Commit 3246db3755698455740c812c6477f43e74924fa7 (https://github.com/moby/moby/pull/44224) added handling for removing
cluster volumes, but in some conditions, this resulted in errors not being
returned if the volume was in use;

    docker swarm init
    docker volume create foo
    docker create -v foo:/foo busybox top
    docker volume rm foo


This patch changes the logic for ignoring "local" volume errors if swarm
is enabled (and cluster volumes supported).

While working on this fix, I also discovered that Cluster.RemoveVolume()
did not handle the "force" option correctly; while swarm correctly handled
these, the cluster backend performs a lookup of the volume first (to obtain
its ID), which would fail if the volume didn't exist.

Before this patch:

    make TEST_FILTER=TestVolumesRemoveSwarmEnabled DOCKER_GRAPHDRIVER=vfs test-integration
    ...
    Running /go/src/github.com/docker/docker/integration/volume (arm64.integration.volume) flags=-test.v -test.timeout=10m  -test.run TestVolumesRemoveSwarmEnabled
    ...
    === RUN   TestVolumesRemoveSwarmEnabled
    === PAUSE TestVolumesRemoveSwarmEnabled
    === CONT  TestVolumesRemoveSwarmEnabled
    === RUN   TestVolumesRemoveSwarmEnabled/volume_in_use
        volume_test.go:122: assertion failed: error is nil, not errdefs.IsConflict
        volume_test.go:123: assertion failed: expected an error, got nil
    === RUN   TestVolumesRemoveSwarmEnabled/volume_not_in_use
    === RUN   TestVolumesRemoveSwarmEnabled/non-existing_volume
    === RUN   TestVolumesRemoveSwarmEnabled/non-existing_volume_force
        volume_test.go:143: assertion failed: error is not nil: Error response from daemon: volume no_such_volume not found
    --- FAIL: TestVolumesRemoveSwarmEnabled (1.57s)
        --- FAIL: TestVolumesRemoveSwarmEnabled/volume_in_use (0.00s)
        --- PASS: TestVolumesRemoveSwarmEnabled/volume_not_in_use (0.01s)
        --- PASS: TestVolumesRemoveSwarmEnabled/non-existing_volume (0.00s)
        --- FAIL: TestVolumesRemoveSwarmEnabled/non-existing_volume_force (0.00s)
    FAIL

With this patch:


    make TEST_FILTER=TestVolumesRemoveSwarmEnabled DOCKER_GRAPHDRIVER=vfs test-integration
    ...
    Running /go/src/github.com/docker/docker/integration/volume (arm64.integration.volume) flags=-test.v -test.timeout=10m  -test.run TestVolumesRemoveSwarmEnabled
    ...
    === RUN   TestVolumesRemoveSwarmEnabled
    === PAUSE TestVolumesRemoveSwarmEnabled
    === CONT  TestVolumesRemoveSwarmEnabled
    === RUN   TestVolumesRemoveSwarmEnabled/volume_in_use
    === RUN   TestVolumesRemoveSwarmEnabled/volume_not_in_use
    === RUN   TestVolumesRemoveSwarmEnabled/non-existing_volume
    === RUN   TestVolumesRemoveSwarmEnabled/non-existing_volume_force
    --- PASS: TestVolumesRemoveSwarmEnabled (1.53s)
        --- PASS: TestVolumesRemoveSwarmEnabled/volume_in_use (0.00s)
        --- PASS: TestVolumesRemoveSwarmEnabled/volume_not_in_use (0.01s)
        --- PASS: TestVolumesRemoveSwarmEnabled/non-existing_volume (0.00s)
        --- PASS: TestVolumesRemoveSwarmEnabled/non-existing_volume_force (0.00s)
    PASS




**- Description for the changelog**
<!--
Write a short (one line) summary that describes the changes in this
pull request for inclusion in the changelog:
-->

```
Fix removing volumes not returning an error if swarm is enabled
```


**- A picture of a cute animal (not mandatory but encouraged)**


integration/volume/volume_test.go
package volume

import (
	"context"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/filters"
	"github.com/docker/docker/api/types/volume"
	clientpkg "github.com/docker/docker/client"
	"github.com/docker/docker/integration/internal/container"
	"github.com/docker/docker/testutil/request"
	"github.com/google/go-cmp/cmp/cmpopts"
	"gotest.tools/v3/assert"
	"gotest.tools/v3/assert/cmp"
	is "gotest.tools/v3/assert/cmp"
)

func TestVolumesCreateAndList(t *testing.T) {
	defer setupTest(t)()
	client := testEnv.APIClient()
	ctx := context.Background()

	name := t.Name()
	// Windows file system is case insensitive
	if testEnv.OSType == "windows" {
		name = strings.ToLower(name)
	}
	vol, err := client.VolumeCreate(ctx, volume.CreateOptions{
		Name: name,
	})
	assert.NilError(t, err)

	expected := volume.Volume{
		// Ignore timestamp of CreatedAt
		CreatedAt:  vol.CreatedAt,
		Driver:     "local",
		Scope:      "local",
		Name:       name,
		Mountpoint: filepath.Join(testEnv.DaemonInfo.DockerRootDir, "volumes", name, "_data"),
	}
	assert.Check(t, is.DeepEqual(vol, expected, cmpopts.EquateEmpty()))

	volList, err := client.VolumeList(ctx, volume.ListOptions{})
	assert.NilError(t, err)
	assert.Assert(t, len(volList.Volumes) > 0)

	volumes := volList.Volumes[:0]
	for _, v := range volList.Volumes {
		if v.Name == vol.Name {
			volumes = append(volumes, v)
		}
	}

	assert.Check(t, is.Equal(len(volumes), 1))
	assert.Check(t, volumes[0] != nil)
	assert.Check(t, is.DeepEqual(*volumes[0], expected, cmpopts.EquateEmpty()))
}

func TestVolumesRemove(t *testing.T) {
	defer setupTest(t)()
	client := testEnv.APIClient()
	ctx := context.Background()

	prefix, slash := getPrefixAndSlashFromDaemonPlatform()

	id := container.Create(ctx, t, client, container.WithVolume(prefix+slash+"foo"))

	c, err := client.ContainerInspect(ctx, id)
	assert.NilError(t, err)
	vname := c.Mounts[0].Name

	err = client.VolumeRemove(ctx, vname, false)
	assert.Check(t, is.ErrorContains(err, "volume is in use"))

	err = client.ContainerRemove(ctx, id, types.ContainerRemoveOptions{
		Force: true,
	})
	assert.NilError(t, err)

	err = client.VolumeRemove(ctx, vname, false)
	assert.NilError(t, err)
}

func TestVolumesInspect(t *testing.T) {
	defer setupTest(t)()
	client := testEnv.APIClient()
	ctx := context.Background()

	now := time.Now()
	vol, err := client.VolumeCreate(ctx, volume.CreateOptions{})
	assert.NilError(t, err)

	inspected, err := client.VolumeInspect(ctx, vol.Name)
	assert.NilError(t, err)

	assert.Check(t, is.DeepEqual(inspected, vol, cmpopts.EquateEmpty()))

	// comparing CreatedAt field time for the new volume to now. Truncate to 1 minute precision to avoid false positive
	createdAt, err := time.Parse(time.RFC3339, strings.TrimSpace(inspected.CreatedAt))
	assert.NilError(t, err)
	assert.Check(t, createdAt.Unix()-now.Unix() < 60, "CreatedAt (%s) exceeds creation time (%s) 60s", createdAt, now)

	// update atime and mtime for the "_data" directory (which would happen during volume initialization)
	modifiedAt := time.Now().Local().Add(5 * time.Hour)
	err = os.Chtimes(inspected.Mountpoint, modifiedAt, modifiedAt)
	assert.NilError(t, err)

	inspected, err = client.VolumeInspect(ctx, vol.Name)
	assert.NilError(t, err)

	createdAt2, err := time.Parse(time.RFC3339, strings.TrimSpace(inspected.CreatedAt))
	assert.NilError(t, err)

	// Check that CreatedAt didn't change after updating atime and mtime of the "_data" directory
	// Related issue: #38274
	assert.Equal(t, createdAt, createdAt2)
}

// TestVolumesInvalidJSON tests that POST endpoints that expect a body return
// the correct error when sending invalid JSON requests.
func TestVolumesInvalidJSON(t *testing.T) {
	defer setupTest(t)()

	// POST endpoints that accept / expect a JSON body;
	endpoints := []string{"/volumes/create"}

	for _, ep := range endpoints {
		ep := ep
		t.Run(ep[1:], func(t *testing.T) {
			t.Parallel()

			t.Run("invalid content type", func(t *testing.T) {
				res, body, err := request.Post(ep, request.RawString("{}"), request.ContentType("text/plain"))
				assert.NilError(t, err)
				assert.Check(t, is.Equal(res.StatusCode, http.StatusBadRequest))

				buf, err := request.ReadBody(body)
				assert.NilError(t, err)
				assert.Check(t, is.Contains(string(buf), "unsupported Content-Type header (text/plain): must be 'application/json'"))
			})

			t.Run("invalid JSON", func(t *testing.T) {
				res, body, err := request.Post(ep, request.RawString("{invalid json"), request.JSON)
				assert.NilError(t, err)
				assert.Check(t, is.Equal(res.StatusCode, http.StatusBadRequest))

				buf, err := request.ReadBody(body)
				assert.NilError(t, err)
				assert.Check(t, is.Contains(string(buf), "invalid JSON: invalid character 'i' looking for beginning of object key string"))
			})

			t.Run("extra content after JSON", func(t *testing.T) {
				res, body, err := request.Post(ep, request.RawString(`{} trailing content`), request.JSON)
				assert.NilError(t, err)
				assert.Check(t, is.Equal(res.StatusCode, http.StatusBadRequest))

				buf, err := request.ReadBody(body)
				assert.NilError(t, err)
				assert.Check(t, is.Contains(string(buf), "unexpected content after JSON"))
			})

			t.Run("empty body", func(t *testing.T) {
				// empty body should not produce an 500 internal server error, or
				// any 5XX error (this is assuming the request does not produce
				// an internal server error for another reason, but it shouldn't)
				res, _, err := request.Post(ep, request.RawString(``), request.JSON)
				assert.NilError(t, err)
				assert.Check(t, res.StatusCode < http.StatusInternalServerError)
			})
		})
	}
}

func getPrefixAndSlashFromDaemonPlatform() (prefix, slash string) {
	if testEnv.OSType == "windows" {
		return "c:", `\`
	}
	return "", "/"
}

func TestVolumePruneAnonymous(t *testing.T) {
	defer setupTest(t)()

	client := testEnv.APIClient()
	ctx := context.Background()

	// Create an anonymous volume
	v, err := client.VolumeCreate(ctx, volume.CreateOptions{})
	assert.NilError(t, err)

	// Create a named volume
	vNamed, err := client.VolumeCreate(ctx, volume.CreateOptions{
		Name: "test",
	})
	assert.NilError(t, err)

	// Prune anonymous volumes
	pruneReport, err := client.VolumesPrune(ctx, filters.Args{})
	assert.NilError(t, err)
	assert.Check(t, is.Equal(len(pruneReport.VolumesDeleted), 1))
	assert.Check(t, is.Equal(pruneReport.VolumesDeleted[0], v.Name))

	_, err = client.VolumeInspect(ctx, vNamed.Name)
	assert.NilError(t, err)

	// Prune all volumes
	_, err = client.VolumeCreate(ctx, volume.CreateOptions{})
	assert.NilError(t, err)

	pruneReport, err = client.VolumesPrune(ctx, filters.NewArgs(filters.Arg("all", "1")))
	assert.NilError(t, err)
	assert.Check(t, is.Equal(len(pruneReport.VolumesDeleted), 2))

	// Validate that older API versions still have the old behavior of pruning all local volumes
	clientOld, err := clientpkg.NewClientWithOpts(clientpkg.FromEnv, clientpkg.WithVersion("1.41"))
	assert.NilError(t, err)
	defer clientOld.Close()
	assert.Equal(t, clientOld.ClientVersion(), "1.41")

	v, err = client.VolumeCreate(ctx, volume.CreateOptions{})
	assert.NilError(t, err)
	vNamed, err = client.VolumeCreate(ctx, volume.CreateOptions{Name: "test-api141"})
	assert.NilError(t, err)

	pruneReport, err = clientOld.VolumesPrune(ctx, filters.Args{})
	assert.NilError(t, err)
	assert.Check(t, is.Equal(len(pruneReport.VolumesDeleted), 2))
	assert.Check(t, cmp.Contains(pruneReport.VolumesDeleted, v.Name))
	assert.Check(t, cmp.Contains(pruneReport.VolumesDeleted, vNamed.Name))
}

package volume

import (
	"context"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/filters"
	"github.com/docker/docker/api/types/volume"
	clientpkg "github.com/docker/docker/client"
	"github.com/docker/docker/errdefs"
	"github.com/docker/docker/integration/internal/container"
	"github.com/docker/docker/testutil/daemon"
	"github.com/docker/docker/testutil/request"
	"github.com/google/go-cmp/cmp/cmpopts"
	"gotest.tools/v3/assert"
	"gotest.tools/v3/assert/cmp"
	is "gotest.tools/v3/assert/cmp"
	"gotest.tools/v3/skip"
)

func TestVolumesCreateAndList(t *testing.T) {
	defer setupTest(t)()
	client := testEnv.APIClient()
	ctx := context.Background()

	name := t.Name()
	// Windows file system is case insensitive
	if testEnv.OSType == "windows" {
		name = strings.ToLower(name)
	}
	vol, err := client.VolumeCreate(ctx, volume.CreateOptions{
		Name: name,
	})
	assert.NilError(t, err)

	expected := volume.Volume{
		// Ignore timestamp of CreatedAt
		CreatedAt:  vol.CreatedAt,
		Driver:     "local",
		Scope:      "local",
		Name:       name,
		Mountpoint: filepath.Join(testEnv.DaemonInfo.DockerRootDir, "volumes", name, "_data"),
	}
	assert.Check(t, is.DeepEqual(vol, expected, cmpopts.EquateEmpty()))

	volList, err := client.VolumeList(ctx, volume.ListOptions{})
	assert.NilError(t, err)
	assert.Assert(t, len(volList.Volumes) > 0)

	volumes := volList.Volumes[:0]
	for _, v := range volList.Volumes {
		if v.Name == vol.Name {
			volumes = append(volumes, v)
		}
	}

	assert.Check(t, is.Equal(len(volumes), 1))
	assert.Check(t, volumes[0] != nil)
	assert.Check(t, is.DeepEqual(*volumes[0], expected, cmpopts.EquateEmpty()))
}

func TestVolumesRemove(t *testing.T) {
	defer setupTest(t)()
	client := testEnv.APIClient()
	ctx := context.Background()

	prefix, slash := getPrefixAndSlashFromDaemonPlatform()

	id := container.Create(ctx, t, client, container.WithVolume(prefix+slash+"foo"))

	c, err := client.ContainerInspect(ctx, id)
	assert.NilError(t, err)
	vname := c.Mounts[0].Name

	t.Run("volume in use", func(t *testing.T) {
		err = client.VolumeRemove(ctx, vname, false)
		assert.Check(t, is.ErrorType(err, errdefs.IsConflict))
		assert.Check(t, is.ErrorContains(err, "volume is in use"))
	})

	t.Run("volume not in use", func(t *testing.T) {
		err = client.ContainerRemove(ctx, id, types.ContainerRemoveOptions{
			Force: true,
		})
		assert.NilError(t, err)

		err = client.VolumeRemove(ctx, vname, false)
		assert.NilError(t, err)
	})

	t.Run("non-existing volume", func(t *testing.T) {
		err = client.VolumeRemove(ctx, "no_such_volume", false)
		assert.Check(t, is.ErrorType(err, errdefs.IsNotFound))
	})

	t.Run("non-existing volume force", func(t *testing.T) {
		err = client.VolumeRemove(ctx, "no_such_volume", true)
		assert.NilError(t, err)
	})
}

// TestVolumesRemoveSwarmEnabled tests that an error is returned if a volume
// is in use, also if swarm is enabled (and cluster volumes are supported).
//
// Regression test for https://github.com/docker/cli/issues/4082
func TestVolumesRemoveSwarmEnabled(t *testing.T) {
	skip.If(t, testEnv.IsRemoteDaemon, "cannot run daemon when remote daemon")
	skip.If(t, testEnv.OSType == "windows", "TODO enable on windows")
	t.Parallel()
	defer setupTest(t)()

	// Spin up a new daemon, so that we can run this test in parallel (it's a slow test)
	d := daemon.New(t)
	d.StartAndSwarmInit(t)
	defer d.Stop(t)

	client := d.NewClientT(t)

	ctx := context.Background()
	prefix, slash := getPrefixAndSlashFromDaemonPlatform()
	id := container.Create(ctx, t, client, container.WithVolume(prefix+slash+"foo"))

	c, err := client.ContainerInspect(ctx, id)
	assert.NilError(t, err)
	vname := c.Mounts[0].Name

	t.Run("volume in use", func(t *testing.T) {
		err = client.VolumeRemove(ctx, vname, false)
		assert.Check(t, is.ErrorType(err, errdefs.IsConflict))
		assert.Check(t, is.ErrorContains(err, "volume is in use"))
	})

	t.Run("volume not in use", func(t *testing.T) {
		err = client.ContainerRemove(ctx, id, types.ContainerRemoveOptions{
			Force: true,
		})
		assert.NilError(t, err)

		err = client.VolumeRemove(ctx, vname, false)
		assert.NilError(t, err)
	})

	t.Run("non-existing volume", func(t *testing.T) {
		err = client.VolumeRemove(ctx, "no_such_volume", false)
		assert.Check(t, is.ErrorType(err, errdefs.IsNotFound))
	})

	t.Run("non-existing volume force", func(t *testing.T) {
		err = client.VolumeRemove(ctx, "no_such_volume", true)
		assert.NilError(t, err)
	})
}

func TestVolumesInspect(t *testing.T) {
	defer setupTest(t)()
	client := testEnv.APIClient()
	ctx := context.Background()

	now := time.Now()
	vol, err := client.VolumeCreate(ctx, volume.CreateOptions{})
	assert.NilError(t, err)

	inspected, err := client.VolumeInspect(ctx, vol.Name)
	assert.NilError(t, err)

	assert.Check(t, is.DeepEqual(inspected, vol, cmpopts.EquateEmpty()))

	// comparing CreatedAt field time for the new volume to now. Truncate to 1 minute precision to avoid false positive
	createdAt, err := time.Parse(time.RFC3339, strings.TrimSpace(inspected.CreatedAt))
	assert.NilError(t, err)
	assert.Check(t, createdAt.Unix()-now.Unix() < 60, "CreatedAt (%s) exceeds creation time (%s) 60s", createdAt, now)

	// update atime and mtime for the "_data" directory (which would happen during volume initialization)
	modifiedAt := time.Now().Local().Add(5 * time.Hour)
	err = os.Chtimes(inspected.Mountpoint, modifiedAt, modifiedAt)
	assert.NilError(t, err)

	inspected, err = client.VolumeInspect(ctx, vol.Name)
	assert.NilError(t, err)

	createdAt2, err := time.Parse(time.RFC3339, strings.TrimSpace(inspected.CreatedAt))
	assert.NilError(t, err)

	// Check that CreatedAt didn't change after updating atime and mtime of the "_data" directory
	// Related issue: #38274
	assert.Equal(t, createdAt, createdAt2)
}

// TestVolumesInvalidJSON tests that POST endpoints that expect a body return
// the correct error when sending invalid JSON requests.
func TestVolumesInvalidJSON(t *testing.T) {
	defer setupTest(t)()

	// POST endpoints that accept / expect a JSON body;
	endpoints := []string{"/volumes/create"}

	for _, ep := range endpoints {
		ep := ep
		t.Run(ep[1:], func(t *testing.T) {
			t.Parallel()

			t.Run("invalid content type", func(t *testing.T) {
				res, body, err := request.Post(ep, request.RawString("{}"), request.ContentType("text/plain"))
				assert.NilError(t, err)
				assert.Check(t, is.Equal(res.StatusCode, http.StatusBadRequest))

				buf, err := request.ReadBody(body)
				assert.NilError(t, err)
				assert.Check(t, is.Contains(string(buf), "unsupported Content-Type header (text/plain): must be 'application/json'"))
			})

			t.Run("invalid JSON", func(t *testing.T) {
				res, body, err := request.Post(ep, request.RawString("{invalid json"), request.JSON)
				assert.NilError(t, err)
				assert.Check(t, is.Equal(res.StatusCode, http.StatusBadRequest))

				buf, err := request.ReadBody(body)
				assert.NilError(t, err)
				assert.Check(t, is.Contains(string(buf), "invalid JSON: invalid character 'i' looking for beginning of object key string"))
			})

			t.Run("extra content after JSON", func(t *testing.T) {
				res, body, err := request.Post(ep, request.RawString(`{} trailing content`), request.JSON)
				assert.NilError(t, err)
				assert.Check(t, is.Equal(res.StatusCode, http.StatusBadRequest))

				buf, err := request.ReadBody(body)
				assert.NilError(t, err)
				assert.Check(t, is.Contains(string(buf), "unexpected content after JSON"))
			})

			t.Run("empty body", func(t *testing.T) {
				// empty body should not produce an 500 internal server error, or
				// any 5XX error (this is assuming the request does not produce
				// an internal server error for another reason, but it shouldn't)
				res, _, err := request.Post(ep, request.RawString(``), request.JSON)
				assert.NilError(t, err)
				assert.Check(t, res.StatusCode < http.StatusInternalServerError)
			})
		})
	}
}

func getPrefixAndSlashFromDaemonPlatform() (prefix, slash string) {
	if testEnv.OSType == "windows" {
		return "c:", `\`
	}
	return "", "/"
}

func TestVolumePruneAnonymous(t *testing.T) {
	defer setupTest(t)()

	client := testEnv.APIClient()
	ctx := context.Background()

	// Create an anonymous volume
	v, err := client.VolumeCreate(ctx, volume.CreateOptions{})
	assert.NilError(t, err)

	// Create a named volume
	vNamed, err := client.VolumeCreate(ctx, volume.CreateOptions{
		Name: "test",
	})
	assert.NilError(t, err)

	// Prune anonymous volumes
	pruneReport, err := client.VolumesPrune(ctx, filters.Args{})
	assert.NilError(t, err)
	assert.Check(t, is.Equal(len(pruneReport.VolumesDeleted), 1))
	assert.Check(t, is.Equal(pruneReport.VolumesDeleted[0], v.Name))

	_, err = client.VolumeInspect(ctx, vNamed.Name)
	assert.NilError(t, err)

	// Prune all volumes
	_, err = client.VolumeCreate(ctx, volume.CreateOptions{})
	assert.NilError(t, err)

	pruneReport, err = client.VolumesPrune(ctx, filters.NewArgs(filters.Arg("all", "1")))
	assert.NilError(t, err)
	assert.Check(t, is.Equal(len(pruneReport.VolumesDeleted), 2))

	// Validate that older API versions still have the old behavior of pruning all local volumes
	clientOld, err := clientpkg.NewClientWithOpts(clientpkg.FromEnv, clientpkg.WithVersion("1.41"))
	assert.NilError(t, err)
	defer clientOld.Close()
	assert.Equal(t, clientOld.ClientVersion(), "1.41")

	v, err = client.VolumeCreate(ctx, volume.CreateOptions{})
	assert.NilError(t, err)
	vNamed, err = client.VolumeCreate(ctx, volume.CreateOptions{Name: "test-api141"})
	assert.NilError(t, err)

	pruneReport, err = clientOld.VolumesPrune(ctx, filters.Args{})
	assert.NilError(t, err)
	assert.Check(t, is.Equal(len(pruneReport.VolumesDeleted), 2))
	assert.Check(t, cmp.Contains(pruneReport.VolumesDeleted, v.Name))
	assert.Check(t, cmp.Contains(pruneReport.VolumesDeleted, vNamed.Name))
}

I'm not sure how this error check works?
`errdefs.IsNotFound` is a function