From 4b239cc11a72211b198c0e9460c6bd2526e2b494 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 12 Sep 2026 23:15:52 +0800 Subject: [PATCH] fix: run the reclaim helper as root regardless of the image's default USER MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-shot reclaim container inherited the image's baked-in USER: ghcr.io/nokia/srlinux runs as "user:user", so the "privileged" helper was exactly as unprivileged as the server itself — chmod/chown on files written by other uids (srlinux writes as a large internal uid) failed with EPERM and node/project deletion still broke, just with a different error. Pass --user 0:0 explicitly so the helper is root no matter what the image declares, and fix the manual reclaim hint the same way. Validated live on two stuck srlinux node directories (257/258 foreign-owned entries reclaimed to 0 in ~0.5 s each). --- gns3server/compute/docker/docker_vm.py | 10 +++++++--- tests/compute/docker/test_docker_vm.py | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 797f8fa7d..7f4812e7b 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -1092,10 +1092,14 @@ class DockerVM(BaseNode): # as the container-side pass in _fix_permissions). --pull=never keeps # a stale tag reference from turning into a registry pull attempt, # and the create-time image ID (when known) is immune to retagging. + # --user 0:0 overrides a USER baked into the image (e.g. + # ghcr.io/nokia/srlinux runs as "user"): without it the "privileged" + # helper is exactly as unprivileged as the server itself and + # chmod/chown fail with EPERM on files written by other uids. image_ref = self._image_id or self._image try: process = await asyncio.subprocess.create_subprocess_exec( - "docker", "run", "--rm", "--network", "none", "--pull", "never", + "docker", "run", "--rm", "--network", "none", "--pull", "never", "--user", "0:0", "--entrypoint", "/gns3/bin/busybox", "-v", f"{resources_path}:/gns3:ro", "-v", f"{directory}:/target", @@ -2049,7 +2053,7 @@ class DockerVM(BaseNode): pass raise ComputeError( f"Could not delete the node directory '{self.working_dir}': files left owned by " - f"root could not be reclaimed ({e}). Reclaim them manually with: " - f"docker run --rm -v \"{self.working_dir}\":/target --entrypoint /bin/sh " + f"another user could not be reclaimed ({e}). Reclaim them manually with: " + f"docker run --rm --user 0:0 -v \"{self.working_dir}\":/target --entrypoint /bin/sh " f"{self._image} -c 'chown -R {os.getuid()}:{os.getgid()} /target'" ) diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 8d04b5f83..92e790913 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -2093,7 +2093,7 @@ async def test_reclaim_runs_helper_container(vm, tmp_path): assert await vm._reclaim_directory_ownership(str(directory)) is True args, _ = mock_exec.call_args - assert args[:7] == ("docker", "run", "--rm", "--network", "none", "--pull", "never") + assert args[:9] == ("docker", "run", "--rm", "--network", "none", "--pull", "never", "--user", "0:0") assert args[args.index("--entrypoint") + 1] == "/gns3/bin/busybox" assert "/gns3-share:/gns3:ro" in args assert f"{directory}:/target" in args @@ -2149,7 +2149,7 @@ async def test_delete_reports_root_files_when_reclaim_fails(vm): with patch.object(vm, "_reclaim_directory_ownership", new_callable=AsyncioMagicMock, return_value=False): with patch("gns3server.compute.base_node.shutil.rmtree", side_effect=OSError("permission denied")): - with pytest.raises(ComputeError, match="owned by root"): + with pytest.raises(ComputeError, match="owned by another user"): await vm.delete()