docker: replace vendor SKIP_INIT exec volume bridge with create-time direct binds

The SKIP_INIT volume bridge replicated init.sh's seed + mount --bind script
via docker exec *after* the container started. That copied the mechanism but
not the invariant that makes init.sh safe — the entrypoint position, which
guarantees the volume is in place before the application runs. The exec runs
concurrently with the NOS boot, so whether the NOS loaded its persisted
config or the overlay's factory copy was a timing race:

- single node stop/start on an idle system won it (exec ~1s, SR Linux reads
  its startup config at ~2-4s) — the save/stop/start round-trip passed;
- a server restart + project reload lost it (concurrent node starts queue on
  the Docker API, delaying the exec by seconds) — SR Linux booted factory
  while the persisted config.json sat intact on the host;
- XRd was immune (systemd boots tens of seconds before XR touches
  /xr-storage), which is why the race was never observed on it.

Replace the bridge entirely:

- new DockerVM._prepare_volumes hook (no-op in the base class) runs in
  create() after the image is present, before the container is created;
  VendorDockerVM overrides it to seed each volume's host directory from the
  image (throwaway docker create container + docker cp -a, nothing
  executes). The .gns3_perms marker gates the seeding: a volume that ever
  started is never re-seeded, so saved configuration is never overwritten
  with factory content (also the upgrade path for existing nodes).
- VendorDockerVM._mount_binds now binds the volumes directly at their real
  in-container paths (/etc/opt/srlinux) instead of /gns3volumes aliases, so
  the persisted config is visible to the NOS from the very first process.
- _setup_skip_init_volumes and its start() call are gone; the container-side
  _fix_permissions targets the volume paths directly (the direct binds
  exist for the whole container lifetime, unlike the old bridge).

The volume-list computation (validation + overlap de-duplication) moves
into DockerVM._persistent_volume_list so create-time seeding and _mount_binds
cannot drift apart.
This commit is contained in:
YueGuobin committed 2026-08-22 00:09:13 +08:00
1 parent 6aeb5dbda5
commit 89d7f866cb
5 files changed
+402 -197

No files matched your search

+50 -20
View File
@@ -379,6 +379,51 @@ class DockerVM(BaseNode):
result = await self.manager.query("GET", f"images/{self._image}/json")
return result
def _persistent_volume_list(self, image_info, include_network_config=True):
"""
The in-container paths that get a persistent volume mount: GNS3's
/etc/network, every VOLUME declared by the image and the node's
extra_volumes. Overlapping paths are de-duplicated so that a path
covered by a more general volume is not mounted twice.
:param include_network_config: include GNS3's hardcoded /etc/network
volume (consumed by init.sh; subclasses that skip init.sh pass
False so the list matches the mounts they actually create).
"""
for volume in self._extra_volumes:
if not volume.strip() or volume[0] != "/" or volume.find("..") >= 0:
raise DockerError(
f"Persistent volume '{volume}' has invalid format. It must start with a '/' and not contain '..'."
)
volumes = []
if include_network_config:
volumes.append("/etc/network")
volumes.extend((image_info.get("Config", {}).get("Volumes") or {}).keys())
volumes.extend(self._extra_volumes)
deduped = []
# define lambdas for validation checks
nf = lambda x: re.sub(r"//+", "/", (x if x.endswith("/") else x + "/"))
generalises = lambda v1, v2: nf(v2).startswith(nf(v1))
for volume in volumes:
# remove any mount that is equal or more specific, then append this one
deduped = list(filter(lambda v: not generalises(volume, v), deduped))
# if there is nothing more general, append this mount
if not [v for v in deduped if generalises(v, volume)]:
deduped.append(volume)
return deduped
async def _prepare_volumes(self, image_info):
"""
Hook: prepare persistent volumes before the container (and its
mounts) are created. The default implementation does nothing —
init.sh performs the first-copy seeding inside the container at
boot. Subclasses that skip init.sh override this to seed the host
directories from the image instead, so their mounts can be bound
directly at the real in-container paths from the very first process.
"""
def _mount_binds(self, image_info):
"""
:returns: Return the path that we need to map to local folders
@@ -402,26 +447,7 @@ class DockerVM(BaseNode):
self._create_network_config()
except OSError as e:
raise DockerError(f"Could not create network config in the container: {e}")
volumes = ["/etc/network"]
volumes.extend((image_info.get("Config", {}).get("Volumes") or {}).keys())
for volume in self._extra_volumes:
if not volume.strip() or volume[0] != "/" or volume.find("..") >= 0:
raise DockerError(
f"Persistent volume '{volume}' has invalid format. It must start with a '/' and not contain '..'."
)
volumes.extend(self._extra_volumes)
self._volumes = []
# define lambdas for validation checks
nf = lambda x: re.sub(r"//+", "/", (x if x.endswith("/") else x + "/"))
generalises = lambda v1, v2: nf(v2).startswith(nf(v1))
for volume in volumes:
# remove any mount that is equal or more specific, then append this one
self._volumes = list(filter(lambda v: not generalises(volume, v), self._volumes))
# if there is nothing more general, append this mount
if not [v for v in self._volumes if generalises(v, volume)]:
self._volumes.append(volume)
self._volumes = self._persistent_volume_list(image_info)
for volume in self._volumes:
source = os.path.join(self.working_dir, os.path.relpath(volume, "/"))
@@ -544,6 +570,10 @@ class DockerVM(BaseNode):
f"(max available is {available_cpus} CPUs)"
)
# Prepare persistent volume content before the container and its
# mounts are created (no-op for the init.sh path).
await self._prepare_volumes(image_infos)
params = {
"Hostname": self._name,
"Image": self._image,