From 84dd0e86af63fed417f0d17e2f64f2abbebd33fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Thu, 16 Jul 2026 13:17:02 +0200 Subject: [PATCH 1/3] integration: Handle user namespace restrictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User namespace remapping rejects privileged containers and host network or PID modes, disables containerd snapshotters, and leaves ping_group_range unchanged in private user namespaces. Tests that assume rootful daemon behavior therefore fail in userns integration mode. Skip only the incompatible cases while retaining compatible subtests and the ip_unprivileged_port_start assertion. Make the NRI writable bind fixture independent of host and remapped UID ownership by restoring its write bits after applying the process umask. Signed-off-by: Paweł Gronowski --- integration/container/create_test.go | 2 ++ integration/container/inspect_test.go | 1 + integration/container/pidmode_linux_test.go | 1 + integration/container/run_linux_test.go | 16 +++++++++------- integration/daemon/default_storage_test.go | 3 +++ integration/daemon/migration_test.go | 1 + integration/daemon/nri/nri_test.go | 7 ++++++- 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/integration/container/create_test.go b/integration/container/create_test.go index dc53de864e..18ee24731a 100644 --- a/integration/container/create_test.go +++ b/integration/container/create_test.go @@ -279,6 +279,7 @@ func TestCreateWithCustomMaskedPaths(t *testing.T) { for i, tc := range testCases { t.Run(tc.doc, func(t *testing.T) { + skip.If(t, tc.privileged && testEnv.IsUserNamespace(), "privileged mode is incompatible with user namespaces") t.Parallel() // Create the container. @@ -352,6 +353,7 @@ func TestCreateWithCustomReadonlyPaths(t *testing.T) { for i, tc := range testCases { t.Run(tc.doc, func(t *testing.T) { + skip.If(t, tc.privileged && testEnv.IsUserNamespace(), "privileged mode is incompatible with user namespaces") t.Parallel() ctr, err := apiClient.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: &container.Config{ diff --git a/integration/container/inspect_test.go b/integration/container/inspect_test.go index 22760634ed..0f13f05e79 100644 --- a/integration/container/inspect_test.go +++ b/integration/container/inspect_test.go @@ -52,6 +52,7 @@ func TestNetworkAliasesAreEmpty(t *testing.T) { for _, nwMode := range netModes { t.Run(nwMode, func(t *testing.T) { + skip.If(t, nwMode == "host" && testEnv.IsUserNamespace(), "host network mode is incompatible with user namespaces") ctr := container.Create(ctx, t, apiClient, container.WithName("ctr-"+nwMode), container.WithImage("busybox:latest"), diff --git a/integration/container/pidmode_linux_test.go b/integration/container/pidmode_linux_test.go index 9d1a8a54cb..5cc8e6c58a 100644 --- a/integration/container/pidmode_linux_test.go +++ b/integration/container/pidmode_linux_test.go @@ -15,6 +15,7 @@ import ( func TestPIDModeHost(t *testing.T) { skip.If(t, testEnv.DaemonInfo.OSType != "linux") skip.If(t, testEnv.IsRemoteDaemon()) + skip.If(t, testEnv.IsUserNamespace, "host PID mode is incompatible with user namespaces") hostPid, err := os.Readlink("/proc/1/ns/pid") assert.NilError(t, err) diff --git a/integration/container/run_linux_test.go b/integration/container/run_linux_test.go index d8252e75c2..9c349e120a 100644 --- a/integration/container/run_linux_test.go +++ b/integration/container/run_linux_test.go @@ -114,15 +114,17 @@ func TestUnprivilegedPortsAndPing(t *testing.T) { c.Config.User = "1000:1000" }) - // Check net.ipv4.ping_group_range. - res, err := container.Exec(ctx, apiClient, cID, []string{"cat", "/proc/sys/net/ipv4/ping_group_range"}) - assert.NilError(t, err) - assert.Assert(t, is.Len(res.Stderr(), 0)) - assert.Equal(t, 0, res.ExitCode) - assert.Equal(t, `0 2147483647`, strings.TrimSpace(res.Stdout())) + if !testEnv.IsUserNamespace() { + // Check net.ipv4.ping_group_range. + res, err := container.Exec(ctx, apiClient, cID, []string{"cat", "/proc/sys/net/ipv4/ping_group_range"}) + assert.NilError(t, err) + assert.Assert(t, is.Len(res.Stderr(), 0)) + assert.Equal(t, 0, res.ExitCode) + assert.Equal(t, `0 2147483647`, strings.TrimSpace(res.Stdout())) + } // Check net.ipv4.ip_unprivileged_port_start. - res, err = container.Exec(ctx, apiClient, cID, []string{"cat", "/proc/sys/net/ipv4/ip_unprivileged_port_start"}) + res, err := container.Exec(ctx, apiClient, cID, []string{"cat", "/proc/sys/net/ipv4/ip_unprivileged_port_start"}) assert.NilError(t, err) assert.Assert(t, is.Len(res.Stderr(), 0)) assert.Equal(t, 0, res.ExitCode) diff --git a/integration/daemon/default_storage_test.go b/integration/daemon/default_storage_test.go index 180b6a45e1..6ed2a27ecf 100644 --- a/integration/daemon/default_storage_test.go +++ b/integration/daemon/default_storage_test.go @@ -15,6 +15,7 @@ import ( func TestDefaultStorageDriver(t *testing.T) { skip.If(t, testEnv.DaemonInfo.OSType == "windows", "Windows does not support running sub-daemons") + skip.If(t, testEnv.IsUserNamespace(), "containerd snapshotters are disabled with user namespace remapping") t.Setenv("DOCKER_DRIVER", "") t.Setenv("DOCKER_GRAPHDRIVER", "") t.Setenv("TEST_INTEGRATION_USE_GRAPHDRIVER", "") @@ -127,6 +128,8 @@ func TestInspectGraphDriverAPIBC(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + skip.If(t, tc.expContainerdSnapshotter && testEnv.IsUserNamespace(), "containerd snapshotters are disabled with user namespace remapping") + d := daemon.New(t) defer d.Stop(t) d.StartWithBusybox(ctx, t, "--iptables=false", "--ip6tables=false", "--storage-driver="+tc.storageDriver) diff --git a/integration/daemon/migration_test.go b/integration/daemon/migration_test.go index 6f4989e0de..78ea4daf77 100644 --- a/integration/daemon/migration_test.go +++ b/integration/daemon/migration_test.go @@ -25,6 +25,7 @@ func TestMigrateNativeSnapshotter(t *testing.T) { func testMigrateSnapshotter(t *testing.T, graphdriver, snapshotter string) { skip.If(t, runtime.GOOS != "linux") + skip.If(t, testEnv.IsUserNamespace(), "containerd snapshotters are disabled with user namespace remapping") t.Setenv("DOCKER_MIGRATE_SNAPSHOTTER_THRESHOLD", "200M") t.Setenv("DOCKER_DRIVER", "") diff --git a/integration/daemon/nri/nri_test.go b/integration/daemon/nri/nri_test.go index 49040677bd..d5fb988ca1 100644 --- a/integration/daemon/nri/nri_test.go +++ b/integration/daemon/nri/nri_test.go @@ -158,7 +158,12 @@ func TestNRIContainerCreateAddMount(t *testing.T) { // Create and populate a directory for containers to mount. dirToMount := t.TempDir() - if err := os.WriteFile(filepath.Join(dirToMount, "testfile.txt"), []byte("hello\n"), 0o644); err != nil { + testFile := filepath.Join(dirToMount, "testfile.txt") + if err := os.WriteFile(testFile, []byte("hello\n"), 0o666); err != nil { + assert.NilError(t, err) + } + // The process umask may remove write bits needed by remapped container root. + if err := os.Chmod(testFile, 0o666); err != nil { assert.NilError(t, err) } const ( From a446d73c3398699e3d5d0d1ca7151d616a575613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 15 Jul 2026 18:19:05 +0200 Subject: [PATCH 2/3] gha/test: Add userns integration mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User namespace remapping currently has no full integration-suite CI coverage. Add a graphdriver mode that starts dockerd with DOCKER_REMAP_ROOT=default. Skip tests that require privileged or host namespaces, and avoid the host network optimization in TestUpdatePidsLimit, because dockerd rejects those combinations when remapping is enabled. Signed-off-by: Paweł Gronowski --- .github/workflows/.test.yml | 3 +++ .github/workflows/test.yml | 2 +- integration/container/links_linux_test.go | 1 + integration/container/mounts_linux_test.go | 1 + integration/container/run_cgroupns_linux_test.go | 2 ++ integration/container/run_linux_test.go | 3 +++ integration/container/update_linux_test.go | 6 +++++- integration/network/bridge/bridge_linux_test.go | 2 ++ integration/network/service_test.go | 1 + 9 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/.test.yml b/.github/workflows/.test.yml index 32667d37b4..1d5fc86995 100644 --- a/.github/workflows/.test.yml +++ b/.github/workflows/.test.yml @@ -216,6 +216,9 @@ jobs: if [[ "$MODE" == *"rootless"* ]]; then echo "DOCKER_ROOTLESS=1" >> $GITHUB_ENV fi + if [[ "$MODE" == *"userns"* ]]; then + echo "DOCKER_REMAP_ROOT=default" >> $GITHUB_ENV + fi if [[ "$MODE" == *"systemd"* ]]; then echo "SYSTEMD=true" >> $GITHUB_ENV CACHE_DEV_SCOPE="dev-systemd-${ARCH}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a6a8d852db..65f32fb02a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -96,7 +96,7 @@ jobs: - arch: amd64 storage: graphdriver runnerSuffix: "" - extraModes: '["rootless", "systemd"]' + extraModes: '["rootless", "systemd", "userns"]' - arch: arm64 storage: snapshotter runnerSuffix: "-arm" diff --git a/integration/container/links_linux_test.go b/integration/container/links_linux_test.go index 3bb4f49e80..a22eac6400 100644 --- a/integration/container/links_linux_test.go +++ b/integration/container/links_linux_test.go @@ -13,6 +13,7 @@ import ( func TestLinksEtcHostsContentMatch(t *testing.T) { skip.If(t, testEnv.IsRemoteDaemon) + skip.If(t, testEnv.IsUserNamespace, "host network mode is incompatible with user namespaces") hosts, err := os.ReadFile("/etc/hosts") skip.If(t, os.IsNotExist(err)) diff --git a/integration/container/mounts_linux_test.go b/integration/container/mounts_linux_test.go index ffdd664ab2..d725c7a0f3 100644 --- a/integration/container/mounts_linux_test.go +++ b/integration/container/mounts_linux_test.go @@ -350,6 +350,7 @@ func TestMountDaemonRoot(t *testing.T) { func TestContainerBindMountNonRecursive(t *testing.T) { skip.If(t, testEnv.IsRemoteDaemon) skip.If(t, testEnv.IsRootless, "cannot be tested because RootlessKit executes the daemon in private mount namespace (https://github.com/rootless-containers/rootlesskit/issues/97)") + skip.If(t, testEnv.IsUserNamespace, "non-recursive bind mounts of directories containing submounts fail with EINVAL in a user namespace") ctx := setupTest(t) diff --git a/integration/container/run_cgroupns_linux_test.go b/integration/container/run_cgroupns_linux_test.go index 004068545f..c7cb9e1856 100644 --- a/integration/container/run_cgroupns_linux_test.go +++ b/integration/container/run_cgroupns_linux_test.go @@ -60,6 +60,7 @@ func TestCgroupNamespacesRunPrivileged(t *testing.T) { skip.If(t, testEnv.IsRemoteDaemon()) skip.If(t, !requirement.CgroupNamespacesEnabled()) skip.If(t, testEnv.DaemonInfo.CgroupVersion == "2", "on cgroup v2, privileged containers use private cgroupns") + skip.If(t, testEnv.IsUserNamespace, "privileged mode is incompatible with user namespaces") t.Parallel() @@ -120,6 +121,7 @@ func TestCgroupNamespacesRunPrivilegedAndPrivate(t *testing.T) { skip.If(t, testEnv.DaemonInfo.OSType != "linux") skip.If(t, testEnv.IsRemoteDaemon()) skip.If(t, !requirement.CgroupNamespacesEnabled()) + skip.If(t, testEnv.IsUserNamespace, "privileged mode is incompatible with user namespaces") t.Parallel() diff --git a/integration/container/run_linux_test.go b/integration/container/run_linux_test.go index 9c349e120a..cfdd936bb3 100644 --- a/integration/container/run_linux_test.go +++ b/integration/container/run_linux_test.go @@ -33,6 +33,7 @@ import ( func TestNISDomainname(t *testing.T) { skip.If(t, testEnv.DaemonInfo.OSType != "linux") + skip.If(t, testEnv.IsUserNamespace, "user namespaces cannot write the kernel domainname sysctl") // Rootless supports custom Hostname but doesn't support custom Domainname // OCI runtime create failed: container_linux.go:349: starting container process caused "process_linux.go:449: container init caused \ @@ -136,6 +137,7 @@ func TestPrivilegedHostDevices(t *testing.T) { // so needs to be same host. skip.If(t, testEnv.IsRemoteDaemon) skip.If(t, testEnv.DaemonInfo.OSType != "linux") + skip.If(t, testEnv.IsUserNamespace, "privileged mode is incompatible with user namespaces") ctx := setupTest(t) apiClient := testEnv.APIClient() @@ -375,6 +377,7 @@ func TestWorkingDirNormalization(t *testing.T) { func TestSeccomp(t *testing.T) { skip.If(t, testEnv.DaemonInfo.OSType != "linux") + skip.If(t, testEnv.IsUserNamespace, "privileged test cases are incompatible with user namespaces") ctx := setupTest(t) apiClient := testEnv.APIClient() diff --git a/integration/container/update_linux_test.go b/integration/container/update_linux_test.go index 5e91865ca3..91e15c16e3 100644 --- a/integration/container/update_linux_test.go +++ b/integration/container/update_linux_test.go @@ -186,8 +186,12 @@ func TestUpdatePidsLimit(t *testing.T) { t.Run(test.desc, func(t *testing.T) { ctx := testutil.StartSpan(ctx, t) + opts := []func(*container.TestContainerConfig){container.WithPidsLimit(test.initial)} // Using "network=host" to speed up creation (13.96s vs 6.54s) - cID := container.Run(ctx, t, apiClient, container.WithPidsLimit(test.initial), container.WithNetworkMode("host")) + if !testEnv.IsUserNamespace() { + opts = append(opts, container.WithNetworkMode("host")) + } + cID := container.Run(ctx, t, apiClient, opts...) _, err := c.ContainerUpdate(ctx, cID, client.ContainerUpdateOptions{ Resources: &containertypes.Resources{ diff --git a/integration/network/bridge/bridge_linux_test.go b/integration/network/bridge/bridge_linux_test.go index c344457f02..dfee01d873 100644 --- a/integration/network/bridge/bridge_linux_test.go +++ b/integration/network/bridge/bridge_linux_test.go @@ -1231,6 +1231,8 @@ func TestBridgeIPAMStatus(t *testing.T) { // rolled back properly - the failed connection should not show up in container // or network inspect, and the container should not gain a network interface. func TestJoinError(t *testing.T) { + skip.If(t, testEnv.IsUserNamespace, "privileged mode is incompatible with user namespaces") + ctx := setupTest(t) d := daemon.New(t) d.StartWithBusybox(ctx, t) diff --git a/integration/network/service_test.go b/integration/network/service_test.go index b1b1c76957..23d725db7d 100644 --- a/integration/network/service_test.go +++ b/integration/network/service_test.go @@ -226,6 +226,7 @@ func TestDaemonWithBipAndDefaultNetworkPool(t *testing.T) { func TestServiceWithPredefinedNetwork(t *testing.T) { skip.If(t, testEnv.DaemonInfo.OSType == "windows") skip.If(t, testEnv.IsRootless, "rootless mode doesn't support Swarm-mode") + skip.If(t, testEnv.IsUserNamespace, "host network mode is incompatible with user namespaces") ctx := setupTest(t) d := swarm.NewSwarm(ctx, t, testEnv) From 5ae10bbd368904b8ffce733d4e6b575c816ee8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Thu, 16 Jul 2026 14:25:34 +0200 Subject: [PATCH 3/3] integration/volume: Make escaping image symlink storage-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image subpath test uses a fixed number of parent traversals to reach an existing path outside the image root. User namespace remapping adds another directory to the graph root, so the link instead resolves to a nonexistent path and returns ErrNotAccessible. Signed-off-by: Paweł Gronowski --- integration/volume/mount_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/volume/mount_test.go b/integration/volume/mount_test.go index cacb863cdb..9abf5bd8de 100644 --- a/integration/volume/mount_test.go +++ b/integration/volume/mount_test.go @@ -325,7 +325,7 @@ func setupTestImage(t *testing.T, ctx context.Context, apiClient client.APIClien FROM busybox as symlink RUN mkdir /hack \ && ln -s "../subdir" /hack/good \ - && ln -s "../../../../../docker" /hack/bad + && ln -s ../../../../../ /hack/bad #-- FROM scratch COPY foo /