diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index 549bbbda4..d61bd2097 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -223,27 +223,30 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m - restores the permissions recorded in `.gns3_perms` at the previous stop (best-effort). -2. **Host-side `_fix_permissions()` override** — `DockerVM._fix_permissions` - is container-side (busybox via `docker exec`) and restarts an exited - container just to chown; after a restart the `mount --bind` bridge is gone, - so it would fix the overlay copy and not the host files. The override - instead walks the host-side directories under the node's project directory - directly (they *are* the Docker bind-mount sources), records - `mode:uid:gid:path` into `.gns3_perms` and chowns to the GNS3 user — - no running container required, no restart. It runs both at start (so the - controller can read project files while the node runs) and at stop. +2. **Container-side `_fix_permissions()` override targeting `/gns3volumes`** + — `DockerVM._fix_permissions` operates on the in-container paths + (`/etc/opt/srlinux`, …), which only resolve to persistent storage while + the `mount --bind` bridge is up; after a container restart the bridge is + gone and it would chown the overlay copy instead of the host files. It + also restarts an exited container just to chown. The override instead + runs the same busybox record/chmod/chown script **inside the container + (as root) on the `/gns3volumes` paths** — the Docker bind-mount + targets, which exist for the whole container lifetime and need no bridge. + A stopped/exited container is **not** restarted: the pass is skipped and + the next start fixes ownership. It runs at start (so the controller can + read project files while the node runs) and at stop (for files written + during runtime). -> Rootful-Docker assumption: the `.gns3_perms` uid/gid values are recorded from -> the host's view. With rootful Docker (no userns remap) in-container and host -> ids coincide, so restore semantics are identical to init.sh's. This would -> need revisiting for userns-remapped daemons. +> The fix must run container-side: files written by the container are +> host-side root-owned, and an unprivileged GNS3 process cannot chown them +> from the host. Container-side root (with GNS3's `UsernsMode: host`) can. ### Lifecycle summary | Phase | Normal Docker node | `VendorDockerVM` + `GNS3_SKIP_INIT` | |-------|--------------------|--------------------------------------| -| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then host-side chown | -| stop | container-side `_fix_permissions` (restarts an exited container) | host-side `_fix_permissions` (no container needed) | +| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then container-side chown on `/gns3volumes` | +| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on `/gns3volumes` paths (skips dead containers, no restart) | | volume config | identical `_mount_binds` (host → `/gns3volumes`) | identical | ## Troubleshooting @@ -282,7 +285,7 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m **8. Controller logs `Permission denied` reading files under the node's project directory while the node runs** -- Root-written files inside a persistent volume. The host-side +- Root-written files inside a persistent volume. The container-side `_fix_permissions` pass runs at start (fixes the seeded files) and at stop; files created by the container *during* runtime become readable after the next stop. @@ -303,8 +306,9 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m 3. **Prototype knobs.** `GNS3_SKIP_INIT` / `GNS3_INTERFACE_NAMES` / `GNS3_CONSOLE_CMD` are environment-driven; they are not yet first-class node schema fields and are not declared in the appliance (`gns3a`) schema. -4. **Rootful-Docker assumption** for the host-side `.gns3_perms` recording - (see the volume-persistence section). +4. **Rootful-Docker assumption** (`UsernsMode: host`, set for all GNS3 + Docker nodes) so the container-side chown acts on the host files' real + uid/gid (see the volume-persistence section). 5. **Post-boot volume bridge.** The bind-mount bridge is established after the vendor entrypoint has started (init.sh would do it before). A NOS that strictly requires its persisted files at its very first read may need a @@ -314,7 +318,8 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m - `gns3server/compute/docker/vendor_docker_vm.py` — `VendorDockerVM`: `_start_docker_exec_console`, `_LazyExecTelnetServer`, - `_setup_skip_init_volumes`, host-side `_fix_permissions`, `start()`. + `_setup_skip_init_volumes`, container-side `_fix_permissions` on + `/gns3volumes`, `start()`. - `gns3server/compute/docker/docker_vm.py` — `DockerVM` extension hooks (`_prepare_init_and_interface_env`, `_start_console_server`, `_get_container_ifname`, `_cleanup_console_resources`). @@ -329,5 +334,6 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m | Version | Date | Changes | |---------|------|---------| -| 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + host-side `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. | +| 1.2 | 2026-08-12 | `_fix_permissions` rewritten: container-side (as root) on the `/gns3volumes` bind-mount targets instead of host-side — host-side chown cannot touch root-owned files when GNS3 is unprivileged. Dead containers are skipped instead of restarted. | +| 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. | | 1.0 | 2026-08-12 | Initial documentation of the `docker_exec` console and vendor NOS knobs. | diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index a7e5674e8..b3a9009ce 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -30,11 +30,10 @@ import asyncio import json import logging import os -import stat from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.compute.docker.docker_vm import DockerVM -from gns3server.compute.docker.docker_error import DockerError +from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error log = logging.getLogger(__name__) @@ -130,71 +129,64 @@ class VendorDockerVM(DockerVM): async def _fix_permissions(self): """ - Host-side override of DockerVM._fix_permissions for SKIP_INIT - containers. The persistent volumes are Docker bind mounts of - directories under the node's project directory, so ownership is fixed - directly on the host — no docker exec, no container restart required - (the base implementation restarts an exited container just to chown, - which is wasteful for vendor NOS images). + Container-side override of DockerVM._fix_permissions for vendor NOS + containers. It targets the Docker bind-mount paths + (`/gns3volumes`) directly instead of the in-container paths: + the in-container paths only resolve to persistent storage while the + `mount --bind` bridge from _setup_skip_init_volumes is up, and after a + container restart the bridge is gone — the base implementation would + then chown the overlay copy instead of the host files. - Two passes per volume, mirroring the base/busybox behaviour: + The busybox script runs inside the container as root (a host-side + GNS3 process may be unprivileged and cannot chown root-owned files). - 1. record each entry's container-visible mode/uid/gid into - `.gns3_perms` (same `mode:uid:gid:path` format init.sh consumes, - paths are in-container absolute so the restore inside the - container resolves them); - 2. chmod u+rX + chown to the host user so the GNS3 process can read - and delete files from the project directory. + Unlike the base implementation, a stopped/exited container is NOT + restarted just to fix permissions (vendor NOS images are heavy to + boot): the pass is skipped and the next start fixes ownership. """ + try: + state = await self._get_container_state() + except DockerHttp404Error: + log.warning("Container '%s' does not exist, skipping permission fix", self._name) + return + if state == "stopped" or state == "exited": + log.info( + "Container '%s' is %s, skipping permission fix (next start will fix)", + self._name, state, + ) + return + uid, gid = os.getuid(), os.getgid() for volume in self._volumes: - path = os.path.join(self.working_dir, os.path.relpath(volume, "/")) - if not os.path.isdir(path): - continue - - def onerror(exc): - log.debug("Could not walk '%s' for container '%s': %s", exc.filename, self._name, exc) - - # 1. record container-visible permissions for restore at next start + target = f"/gns3volumes{volume}" + log.debug("Docker container '%s' fix ownership on %s", self._name, target) try: - with open(os.path.join(path, ".gns3_perms"), "w") as perms_file: - for root, dirs, files in os.walk(path, onerror=onerror): - for entry in dirs + files: - entry_path = os.path.join(root, entry) - try: - st = os.lstat(entry_path) - except OSError: - continue - container_path = os.path.join(volume, os.path.relpath(entry_path, path)) - perms_file.write( - f"{stat.S_IMODE(st.st_mode):o}:{st.st_uid}:{st.st_gid}:{container_path}\n" - ) - except OSError as e: - log.warning( - "Could not record permissions for '%s' on container '%s': %s", path, self._name, e + process = await asyncio.subprocess.create_subprocess_exec( + "docker", + "exec", + self._cid, + "/gns3/bin/busybox", + "sh", + "-c", + "(" + f'/gns3/bin/busybox find "{target}" -depth -print0' + f" | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c '%a:%u:%g:%n' > \"{target}/.gns3_perms\"" + ")" + f' && /gns3/bin/busybox chmod -R u+rX "{target}"' + f' && /gns3/bin/busybox chown {uid}:{gid} -R "{target}"', + stderr=asyncio.subprocess.PIPE, ) - continue - - # 2. chmod u+rX + chown to the host user - for root, dirs, files in os.walk(path, onerror=onerror): - for entry in dirs + files: - entry_path = os.path.join(root, entry) - try: - st = os.lstat(entry_path) - is_link = stat.S_ISLNK(st.st_mode) - if not is_link: - mode = stat.S_IMODE(st.st_mode) - new_mode = mode | 0o400 # u+r - if stat.S_ISDIR(st.st_mode) or (mode & 0o111): # u+X - new_mode |= 0o100 - os.chmod(entry_path, new_mode) - os.lchown(entry_path, uid, gid) - except OSError as e: - log.debug( - "Could not fix permissions on '%s' for container '%s': %s", - entry_path, self._name, e, - ) - self._permissions_fixed = True + except OSError as e: + raise DockerError(f"Could not fix permissions for {volume}: {e}") + await process.wait() + if process.returncode != 0: + stderr = (await process.stderr.read()).decode(errors="replace").strip() + log.error( + "Failed to fix permissions on '%s' for container '%s': %s", + volume, self._name, stderr or f"exit code {process.returncode}", + ) + else: + self._permissions_fixed = True async def _setup_skip_init_volumes(self): """