From acfe687c2345e5f15d20b400d09581be810791a6 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 14 Aug 2026 10:28:13 +0200 Subject: [PATCH 1/2] vendor: moby api and client with BindOptions.IDMapping (temporary) Vendor the api and client modules from the moby/moby branch introducing BindOptions.IDMapping for id-mapped bind mounts, through temporary replace rules pointing at the contributor fork. This commit must be dropped and replaced by a vendoring of tagged api/client releases once the engine-side change is merged. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- vendor.mod | 4 ++ vendor.sum | 8 ++-- .../moby/moby/api/types/mount/mount.go | 41 +++++++++++++++++++ vendor/github.com/moby/moby/client/client.go | 2 +- vendor/modules.txt | 6 ++- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/vendor.mod b/vendor.mod index bdf576c62718..36a73fbdfab1 100644 --- a/vendor.mod +++ b/vendor.mod @@ -111,3 +111,7 @@ require ( google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) + +replace github.com/moby/moby/api => github.com/ndeloof/docker/api v0.0.0-20260814082315-ecec044ee64e + +replace github.com/moby/moby/client => github.com/ndeloof/docker/client v0.0.0-20260814082315-ecec044ee64e diff --git a/vendor.sum b/vendor.sum index 5909c8727b74..87ff159898c2 100644 --- a/vendor.sum +++ b/vendor.sum @@ -109,10 +109,6 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= -github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= -github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= -github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/swarmkit/v2 v2.1.2 h1:1WDZAI6HVYNKdCG4zlXnTAPyLsLwuhRGWlHoOUf5Z6I= @@ -146,6 +142,10 @@ github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDk github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/ndeloof/docker/api v0.0.0-20260814082315-ecec044ee64e h1:Mdy2uGEATfETjYk1qlrumzqYZDJ1hR51FGloJOMT9+0= +github.com/ndeloof/docker/api v0.0.0-20260814082315-ecec044ee64e/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/ndeloof/docker/client v0.0.0-20260814082315-ecec044ee64e h1:G8SMRFexOCX0Eu7WErs8NCB98EairvH9M6KbmkNlpqY= +github.com/ndeloof/docker/client v0.0.0-20260814082315-ecec044ee64e/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= diff --git a/vendor/github.com/moby/moby/api/types/mount/mount.go b/vendor/github.com/moby/moby/api/types/mount/mount.go index 090d436c6267..83bd0b17d506 100644 --- a/vendor/github.com/moby/moby/api/types/mount/mount.go +++ b/vendor/github.com/moby/moby/api/types/mount/mount.go @@ -93,6 +93,47 @@ type BindOptions struct { ReadOnlyNonRecursive bool `json:",omitempty"` // ReadOnlyForceRecursive raises an error if the mount cannot be made recursively read-only. ReadOnlyForceRecursive bool `json:",omitempty"` + // IDMapping, when set, makes the mount an id-mapped mount: file + // ownership within the mount is translated according to the mapping + // selected by its Source, without changing ownership on the backing + // filesystem. + // + // Requires API v1.56 or newer, a Linux daemon running rootful, a kernel + // with MOUNT_ATTR_IDMAP support for the backing filesystem (5.12 or + // newer for most filesystems), and an OCI runtime with id-mapped mount + // support (runc 1.2 or newer). + IDMapping *IDMapping `json:"IDMapping,omitzero"` +} + +// IDMappingSource selects how an id-mapped mount derives its ID mappings. +type IDMappingSource string + +const ( + // IDMappingSourceMatchUser derives the mapping from the intended + // result: the owner of the mount's source appears, inside the + // container, as the container user — the user configured in + // IDMapping.User when set, or the container's running user (from the + // container configuration or the image) otherwise. Files created + // through the mount by that container user are owned by the source's + // owner on the backing filesystem. + IDMappingSourceMatchUser IDMappingSource = "match-user" + // IDMappingSourceUserns follows the mapping of the container's private + // user namespace, which requires the container to run in one (e.g. + // userns-remap). + IDMappingSourceUserns IDMappingSource = "userns" +) + +// IDMapping makes a bind mount id-mapped: file ownership within the mount +// is translated according to the mapping selected by Source, without +// changing ownership on the backing filesystem. +type IDMapping struct { + // Source selects how the mapping is derived. + Source IDMappingSource `json:"Source"` + // User is the container user (name, UID, or UID:GID, as accepted for + // running a container) the mount source's owner is presented as when + // Source is "match-user". When empty, the container's running user is + // used. Must be empty for other sources. + User string `json:"User,omitzero"` } // VolumeOptions represents the options for a mount of type volume. diff --git a/vendor/github.com/moby/moby/client/client.go b/vendor/github.com/moby/moby/client/client.go index 4b4ef976a31e..3dbcacdeb9d4 100644 --- a/vendor/github.com/moby/moby/client/client.go +++ b/vendor/github.com/moby/moby/client/client.go @@ -109,7 +109,7 @@ const DummyHost = "api.moby.localhost" // overriding the version and disable API-version negotiation. // // This version may be lower than the version of the api library module used. -const MaxAPIVersion = "1.55" +const MaxAPIVersion = "1.56" // MinAPIVersion is the minimum API version supported by the client. API versions // below this version are not considered when performing API-version negotiation. diff --git a/vendor/modules.txt b/vendor/modules.txt index 82bd1c3b65ea..9dc14463560b 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -168,7 +168,7 @@ github.com/moby/go-archive github.com/moby/go-archive/compression github.com/moby/go-archive/internal/archiveoptions github.com/moby/go-archive/tarheader -# github.com/moby/moby/api v1.55.0 +# github.com/moby/moby/api v1.55.0 => github.com/ndeloof/docker/api v0.0.0-20260814082315-ecec044ee64e ## explicit; go 1.24 github.com/moby/moby/api/pkg/authconfig github.com/moby/moby/api/pkg/stdcopy @@ -190,7 +190,7 @@ github.com/moby/moby/api/types/storage github.com/moby/moby/api/types/swarm github.com/moby/moby/api/types/system github.com/moby/moby/api/types/volume -# github.com/moby/moby/client v0.5.1 +# github.com/moby/moby/client v0.5.1 => github.com/ndeloof/docker/client v0.0.0-20260814082315-ecec044ee64e ## explicit; go 1.24 github.com/moby/moby/client github.com/moby/moby/client/internal @@ -567,3 +567,5 @@ gotest.tools/v3/skip # tags.cncf.io/container-device-interface v1.1.0 ## explicit; go 1.21 tags.cncf.io/container-device-interface/pkg/parser +# github.com/moby/moby/api => github.com/ndeloof/docker/api v0.0.0-20260814082315-ecec044ee64e +# github.com/moby/moby/client => github.com/ndeloof/docker/client v0.0.0-20260814082315-ecec044ee64e From fca46e632badef57d3ca762ae383e0271cd611ae Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 14 Aug 2026 10:28:13 +0200 Subject: [PATCH 2/2] opts: add bind-idmap option to --mount Expose the Engine API's BindOptions.IDMapping (API v1.56) for bind mounts: bind-idmap makes the mount an id-mapped mount, translating file ownership within the mount without changing it on the backing filesystem. The option is intent-based: bind-idmap (or bind-idmap=match-user) presents the mount source's owner as the container's running user, so host files behave as the container user's own, and files created by the container land as the source's owner on the host. bind-idmap=match-user:USER targets an explicit container user (name, UID, or UID:GID, as accepted by --user), and bind-idmap=userns makes the mount follow the container's private user namespace (userns-remap), covering the whole ID range. Co-Authored-By: Claude Fable 5 Signed-off-by: Nicolas De Loof --- man/docker-run.1.md | 15 +++++++++++ opts/mount.go | 12 ++++++++- opts/mount_test.go | 61 +++++++++++++++++++++++++++++++++++++++++++++ opts/mount_utils.go | 38 ++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/man/docker-run.1.md b/man/docker-run.1.md index 492b4e26eb32..6c8bfca491f0 100644 --- a/man/docker-run.1.md +++ b/man/docker-run.1.md @@ -496,6 +496,21 @@ according to RFC4862. If set to `disabled`, submounts are not recursively bind-mounted. If set to `writable`, submounts are recursively bind-mounted but not made recursively read-only. If set to `readonly`, submounts are recursively bind-mounted and forcibly made recursively read-only. + * `bind-idmap`: make the mount an id-mapped mount, translating file + ownership within the mount without changing it on the backing + filesystem. `bind-idmap` (or `bind-idmap=match-user`) presents the + mount source's owner as the container's running user: host files + owned by the source's owner appear owned by the container user, and + files created by the container user through the mount are owned by + the source's owner on the host. `bind-idmap=match-user:USER` targets + an explicit container user instead (a name, UID, or UID:GID, as + accepted by `--user`); files with other owners within the mount + appear unmapped. `bind-idmap=userns` makes the mount follow the + mapping of the container's private user namespace (which requires the + container to run in one, e.g. userns-remap), covering the container's + whole ID range. Requires Engine API v1.56 or newer, a Linux rootful + daemon, kernel support for id-mapped mounts on the backing filesystem + (Linux 5.12 or newer for most filesystems), and runc 1.2 or newer. Options specific to `volume`: diff --git a/opts/mount.go b/opts/mount.go index 60ca36665d27..31f99b6e1154 100644 --- a/opts/mount.go +++ b/opts/mount.go @@ -57,7 +57,7 @@ func (m *MountOpt) Set(value string) error { if !hasValue { switch key { - case "readonly", "ro", "volume-nocopy", "bind-nonrecursive", "bind-create-src": + case "readonly", "ro", "volume-nocopy", "bind-nonrecursive", "bind-create-src", "bind-idmap": // boolean values default: return fmt.Errorf("invalid field '%s' must be a key=value pair", field) @@ -107,6 +107,16 @@ func (m *MountOpt) Set(value string) error { if err != nil { return err } + case "bind-idmap": + // Without a value, default to presenting the mount source's + // owner as the container's running user. + idMapping := ensureBindIDMapping(&mount) + idMapping.Source = mounttypes.IDMappingSourceMatchUser + if hasValue { + if err := parseIDMapValue(idMapping, val); err != nil { + return err + } + } case "volume-subpath": ensureVolumeOptions(&mount).Subpath = val case "volume-nocopy": diff --git a/opts/mount_test.go b/opts/mount_test.go index 2014c98d55c4..f743e938c785 100644 --- a/opts/mount_test.go +++ b/opts/mount_test.go @@ -586,3 +586,64 @@ func TestMountOptSetBindRecursive(t *testing.T) { }, m.Value())) }) } + +func TestMountOptSetBindIDMap(t *testing.T) { + tests := []struct { + value string + exp *mount.IDMapping + expErr string + }{ + { + value: "bind-idmap", + exp: &mount.IDMapping{Source: mount.IDMappingSourceMatchUser}, + }, + { + value: "bind-idmap=match-user", + exp: &mount.IDMapping{Source: mount.IDMappingSourceMatchUser}, + }, + { + value: "bind-idmap=match-user:nginx", + exp: &mount.IDMapping{Source: mount.IDMappingSourceMatchUser, User: "nginx"}, + }, + { + value: "bind-idmap=match-user:1234:5678", + exp: &mount.IDMapping{Source: mount.IDMappingSourceMatchUser, User: "1234:5678"}, + }, + { + value: "bind-idmap=userns", + exp: &mount.IDMapping{Source: mount.IDMappingSourceUserns}, + }, + { + value: "bind-idmap=userns:nginx", + expErr: `invalid value for 'bind-idmap': "userns:nginx" ("userns" does not take a user)`, + }, + { + value: "bind-idmap=match-user:", + expErr: `invalid value for 'bind-idmap': "match-user:" (empty user after "match-user:")`, + }, + { + value: "bind-idmap=auto", + expErr: `invalid value for 'bind-idmap': "auto" (must be "match-user", "match-user:USER", or "userns")`, + }, + } + + for _, tc := range tests { + t.Run(tc.value, func(t *testing.T) { + var m MountOpt + err := m.Set("type=bind,source=/foo,target=/bar," + tc.value) + if tc.expErr != "" { + assert.Error(t, err, tc.expErr) + return + } + assert.NilError(t, err) + assert.Assert(t, m.values[0].BindOptions != nil) + assert.Check(t, is.DeepEqual(m.values[0].BindOptions.IDMapping, tc.exp)) + }) + } + + t.Run("not a bind mount", func(t *testing.T) { + var m MountOpt + err := m.Set("type=volume,source=foo,target=/bar,bind-idmap") + assert.Error(t, err, "cannot mix 'bind-*' options with mount type 'volume'") + }) +} diff --git a/opts/mount_utils.go b/opts/mount_utils.go index 974f54dc0405..61d7cd881946 100644 --- a/opts/mount_utils.go +++ b/opts/mount_utils.go @@ -115,6 +115,44 @@ func ensureBindOptions(m *mount.Mount) *mount.BindOptions { return m.BindOptions } +func ensureBindIDMapping(m *mount.Mount) *mount.IDMapping { + bindOptions := ensureBindOptions(m) + if bindOptions.IDMapping == nil { + bindOptions.IDMapping = &mount.IDMapping{} + } + return bindOptions.IDMapping +} + +// parseIDMapValue parses the value of the "bind-idmap" mount option into +// idMapping: +// +// - "match-user", optionally followed by ":USER" (a name, UID, or UID:GID, +// as accepted by --user): the mount source's owner appears inside the +// container as USER, or as the container's running user when omitted. +// - "userns": the mount follows the mapping of the container's private +// user namespace (e.g. userns-remap). +func parseIDMapValue(idMapping *mount.IDMapping, val string) error { + source, user, hasUser := strings.Cut(val, ":") + switch mount.IDMappingSource(source) { + case mount.IDMappingSourceMatchUser: + idMapping.Source = mount.IDMappingSourceMatchUser + if hasUser { + if user == "" { + return fmt.Errorf(`invalid value for 'bind-idmap': %q (empty user after "match-user:")`, val) + } + idMapping.User = user + } + case mount.IDMappingSourceUserns: + if hasUser { + return fmt.Errorf(`invalid value for 'bind-idmap': %q ("userns" does not take a user)`, val) + } + idMapping.Source = mount.IDMappingSourceUserns + default: + return fmt.Errorf(`invalid value for 'bind-idmap': %q (must be "match-user", "match-user:USER", or "userns")`, val) + } + return nil +} + func ensureTmpfsOptions(m *mount.Mount) *mount.TmpfsOptions { if m.TmpfsOptions == nil { m.TmpfsOptions = &mount.TmpfsOptions{}