mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
fix: run _fix_permissions container-side on /gns3volumes mount targets
The host-side pass could not work for unprivileged GNS3 processes: the .gns3_perms marker is created root-owned by the container-side touch, and chowning root-owned files from the host requires root. Rewrite VendorDockerVM._fix_permissions to run the busybox record/chmod/chown script inside the container (as root) on the /gns3volumes bind-mount targets — they exist for the container's whole lifetime and do not depend on the mount --bind bridge, so a container restart can no longer make the fix hit the overlay copy. A stopped/exited container is skipped (logged) instead of restarted; the next start's pass fixes ownership.
This commit is contained in:
parent
2f36471a55
commit
3455da7da3
@ -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<path>` 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<path>`) | 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. |
|
||||
|
||||
@ -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<volume>`) 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):
|
||||
"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user