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{} 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