From cdc8373d2a910260af557e26a186068898bd48b4 Mon Sep 17 00:00:00 2001 From: Andrei <46914650+alxrxs@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:21:41 +0300 Subject: [PATCH 1/3] common: add strip_mgmt_interface_config and a qemu-guest-agent channel Two additions to the shared launcher library, both used by the per-vendor launcher changes that follow. strip_mgmt_interface_config(config_text, mgmt_intf, style) removes the management interface's address configuration from a chunk of device config -- a user-supplied or previously-saved startup-config that a launcher appends to / merges with its own launcher-generated management stanza. Every vrnetlab launcher configures the node's management interface itself, each boot, from the container's actual IP; if the appended config also sets an address on that interface (very likely if it came from `containerlab save`, which captures the full running-config, or was hand-written), whichever the device applies last wins, and a stale value leaves the node "healthy" but unreachable at the address clab/DNS expect. It is keyed on the interface NAME, not the address value, deliberately: the stale address is by definition a *different* value than the one the launcher just configured, so a value-based filter can't catch it -- only the interface identity is invariant. It drops every address the appended config puts on that interface, whatever the value, leaving the launcher's own address the only one. Structure-aware: "ios" walks the `interface ` block (whitespace/case-insensitive name match, since IOS-XR accepts a space in the name and different launchers format it both ways) and drops `ip|ipv4|ipv6 address` leaves while keeping the header; "junos" handles both hierarchical (` { ... address x; ... }`, brace-scoped, `address x { ... }` option-blocks dropped whole) and flat set-style, matching the `family inet[6] address` element so a `description` mentioning "address" is left alone. The qemu-guest-agent channel is a virtio-serial chardev on a per-VM unix socket (/run/qga.sock) wired to org.qemu.guest_agent.0, alongside the existing monitor/serial chardevs. A unix socket rather than a TCP port avoids any collision with the host-forwarded mgmt ports regardless of how many VMs a node runs. It gives an out-of-band path into the guest -- guest-exec over the guest-agent protocol -- that works even when the node's management plane hasn't come up and the serial console has no interactive getty. Inert unless qemu-guest-agent is installed in the guest. --- common/vrnetlab.py | 140 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/common/vrnetlab.py b/common/vrnetlab.py index 170a4401..1e48448a 100644 --- a/common/vrnetlab.py +++ b/common/vrnetlab.py @@ -56,6 +56,135 @@ def natural_sort_key(s, _nsre=re.compile("([0-9]+)")): return [int(text) if text.isdigit() else text.lower() for text in _nsre.split(s)] +def _norm_cfg_token(s): + """Collapse all whitespace out of a config line/token and lowercase it, so + interface-name matching is insensitive to spacing and case. IOS-XR's + interface-name parser accepts "MgmtEth 0/RP0/CPU0/0" and "MgmtEth0/RP0/CPU0/0" + interchangeably, and different launchers format it both ways; this makes the + match indifferent to that.""" + return "".join(s.split()).lower() + + +_IOS_INTF_ADDR_RE = re.compile(r"(?i)^\s*(?:ip|ipv4|ipv6)\s+address\b") + + +def strip_mgmt_interface_config(config_text, mgmt_intf, style): + """Remove the address configuration of management interface `mgmt_intf` from + a chunk of device config -- a user-supplied or previously-saved + startup-config that a launcher appends to / merges with its own + launcher-generated management stanza. + + Every vrnetlab launcher configures the node's management interface itself, + from the container's *actual* IP -- that's the single source of truth. If the + appended config *also* sets an address on that interface (very likely if it + came from `containerlab save`, which captures the full running-config, or was + hand-written), it competes with the launcher's: on IOS-family platforms a + later address under the interface *replaces* it, so a stale one makes the + node unreachable; on Junos it is *added alongside* (Junos keeps multiple + addresses per interface), so the node stays reachable via the launcher's + address but carries a stale/foreign one. Either way the launcher's address + should be the interface's only one -- and a node that came up "healthy" (the + NOS booted fine) but on the wrong address is exactly the failure this avoids. + + Keyed on the interface NAME, not the address value, on purpose. The stale + address is by definition *different* from the current one the launcher just + configured, so a value-based filter can't catch it -- only the interface + identity is invariant. Dropping every address the appended config puts on + this interface, whatever the value, guarantees the launcher's own address is + the only one left. (containerlab's save-side counterpart keys on the IP + instead, and correctly so: it runs against the *running* config, where the + interface holds the current address, and strips it so it is never persisted.) + + `style` selects the config grammar: + "ios" -- IOS / IOS-XE / IOS-XR / NX-OS / ASA: "interface " followed by + indented "ip|ipv4|ipv6 address ..." leaves, ended by "!" or a + dedent to a new top-level statement. The interface header is + kept (only address leaves under it are dropped) so any other + per-interface config the user set is preserved. + "junos" -- Junos: both hierarchical (" { ... address x; ... }", + brace-scoped, "address x { ... }" option-blocks dropped whole) + and flat set-style ("set interfaces ... address x"). + + Whitespace and case in the interface name are ignored (see _norm_cfg_token). + """ + target = _norm_cfg_token(mgmt_intf) + lines = config_text.split("\n") + out = [] + + if style == "ios": + in_if = False + for line in lines: + norm = _norm_cfg_token(line) + if norm == "interface" + target: + in_if = True + out.append(line) # keep the mgmt interface header + continue + if in_if: + # The mgmt interface stanza ends at an explicit "!" delimiter or + # at the next "interface ..." header (some saved configs omit the + # "!"). A mere dedent does NOT end it, so a flush-left address + # leaf under the interface is still stripped -- a bare + # "ip address" is never a valid *global* command, and no + # non-interface global statement matches the address regex, so + # nothing outside the stanza is dropped. + if line.strip() == "!" or norm.startswith("interface"): + in_if = False # stanza ended; keep this line (fall through) + elif _IOS_INTF_ADDR_RE.match(line) and "description" not in line.lower(): + continue # drop an address leaf under the mgmt interface + out.append(line) + return "\n".join(out) + + if style == "junos": + i = 0 + n = len(lines) + while i < n: + line = lines[i] + norm = _norm_cfg_token(line) + # flat set-style: "set interfaces unit N family inet[6] + # address x". Match the "family inet[6] address" element specifically, + # and never a line carrying "description" -- so a + # `set interfaces description "... family inet address ..."` + # (or any other statement that merely mentions the words) is left + # alone. A real address statement never contains "description". + if ( + norm.startswith("setinterfaces" + target) + and ("familyinetaddress" in norm or "familyinet6address" in norm) + and "description" not in norm + ): + i += 1 + continue + # hierarchical interface block opener: " {" + if norm == target + "{": + out.append(line) + depth = 1 + i += 1 + while i < n and depth > 0: + inner = lines[i] + if ( + _norm_cfg_token(inner).startswith("address") + and "description" not in inner.lower() + ): + d = inner.count("{") - inner.count("}") + if d > 0: # "address x { ... }" -> drop the whole block + bd = d + i += 1 + while i < n and bd > 0: + bd += lines[i].count("{") - lines[i].count("}") + i += 1 + else: # "address x;" leaf + i += 1 + continue + depth += inner.count("{") - inner.count("}") + out.append(inner) + i += 1 + continue + out.append(line) + i += 1 + return "\n".join(out) + + raise ValueError("unknown config style %r" % style) + + def run_command(cmd, cwd=None, background=False, shell=False): res = None try: @@ -384,6 +513,17 @@ def __init__( "-monitor chardev:monitor0", f"-chardev socket,id=serial0,host=::,port=50{self.num:02d},server=on,wait=off,telnet=on", "-serial chardev:serial0", + # qemu-guest-agent channel: out-of-band exec into the guest (e.g. + # `docker exec xrd /pkg/bin/xr_cli ...`) even when the node's + # management plane is down and the serial getty is masked. A per-VM + # UNIX socket rather than a TCP port -- nothing in the launcher + # connects to it (it is reached by `docker exec`-ing into the + # container and talking to the socket), and a unix path avoids any + # collision with the host-forwarded mgmt TCP ports regardless of how + # many VMs a node runs. + f"-chardev socket,id=qga0,path=/run/qga{self.num}.sock,server=on,wait=off", + "-device virtio-serial", + "-device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0", "-m", # memory str(self.ram), "-cpu", # cpu type From bcf6a3e163271603e4cda33eb751af11f3f4db40 Mon Sep 17 00:00:00 2001 From: Andrei <46914650+alxrxs@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:21:41 +0300 Subject: [PATCH 2/3] cisco, juniper: strip mgmt interface address from appended startup-configs Wire strip_mgmt_interface_config into every launcher that applies a user/saved startup-config on top of its own launcher-generated management stanza, passing that platform's own management interface name and config style so the launcher's address always wins: ios: xrd-vrouter/xrv9k MgmtEth0/RP0/CPU0/0, xrv MgmtEth0/0/CPU0/0, csr1000v/c8000v GigabitEthernet1, cat9kv GigabitEthernet0/0, n9kv/nxos mgmt0, asav Management0/0 junos: vjunosevolved re0:mgmt-0; vjunosrouter/vjunosswitch/vsrx/vmx fxp0; vqfx em0 Names are taken from each launcher's own generated stanza / init.conf, so they match whatever the device emits into a saved config. The four "cat-style" juniper launchers that built juniper.conf via a shell `cat init.conf $STARTUP_CONFIG_FILE >> juniper.conf` are changed to an explicit read/filter/write so the filter can run in between. Deliberately not touched: vios (its entire config, management interface included, comes from the startup-config alone -- there is no launcher- owned stanza to protect, so stripping would remove the only mgmt config), and c8000v's MIME-wrapped startup-config branch (same reason for that path). Two supporting bits: - xrd-vrouter also gains an `ipv6 address` line on its MgmtEth stanza (when the clab network has IPv6), matching xrv9k/xrv: the strip removes every ip/ipv4/ipv6 address the appended config sets on MgmtEth, so without the launcher writing IPv6 too a dual-stack node would lose its IPv6 management address with nothing re-adding it. - xrd-vrouter's Dockerfile now names vrnetlab.py in its COPY. The generic build stages common/vrnetlab.py into the build context, and kinds that use `COPY *.py /` pick it up automatically, but this Dockerfile lists files explicitly -- so without naming it, the image kept the older /vrnetlab.py from the pinned base and launch.py's call into the current common lib would fail with AttributeError. --- cisco/asav/docker/launch.py | 10 ++++++++++ cisco/c8000v/docker/launch.py | 13 +++++++++++-- cisco/cat9kv/docker/launch.py | 11 ++++++++++- cisco/csr1000v/docker/launch.py | 13 ++++++++++++- cisco/n9kv/docker/launch.py | 11 ++++++++++- cisco/nxos/docker/launch.py | 11 ++++++++++- cisco/xrd-vrouter/docker/Dockerfile | 9 ++++++++- cisco/xrd-vrouter/docker/launch.py | 25 ++++++++++++++++++++++--- cisco/xrv/docker/launch.py | 12 +++++++++++- cisco/xrv9k/docker/launch.py | 11 ++++++++++- juniper/vjunosevolved/docker/launch.py | 20 +++++++++++++++++--- juniper/vjunosrouter/docker/launch.py | 20 +++++++++++++++++--- juniper/vjunosswitch/docker/launch.py | 20 +++++++++++++++++--- juniper/vmx/docker/launch.py | 12 ++++++++++-- juniper/vqfx/docker/launch.py | 11 +++++++++-- juniper/vsrx/docker/launch.py | 20 +++++++++++++++++--- 16 files changed, 201 insertions(+), 28 deletions(-) diff --git a/cisco/asav/docker/launch.py b/cisco/asav/docker/launch.py index 41a5f089..66a95e39 100755 --- a/cisco/asav/docker/launch.py +++ b/cisco/asav/docker/launch.py @@ -184,6 +184,16 @@ def _open(conn): with open(STARTUP_CONFIG_FILE, "r") as config: startup_config = config.read() self.logger.debug("Applying startup configuration") + # Strip any address the startup config sets on the mgmt + # interface before applying it, so a stale saved/hand-written + # mgmt address can never override the interface Management0/0 + # stanza pushed above in this same session -- otherwise the + # firewall comes up "healthy" but unreachable at the address + # clab/DNS expect. Keyed on interface name, not address value + # (the stale value differs from the current one). + startup_config = vrnetlab.strip_mgmt_interface_config( + startup_config, "Management0/0", "ios" + ) con.send_configs(startup_config.splitlines()) else: self.logger.info("User provided startup configuration is not found.") diff --git a/cisco/c8000v/docker/launch.py b/cisco/c8000v/docker/launch.py index 9e045d6d..aea74391 100755 --- a/cisco/c8000v/docker/launch.py +++ b/cisco/c8000v/docker/launch.py @@ -87,8 +87,17 @@ def __init__(self, hostname, username, password, conn_mode, install_mode=False): self.logger.info("MIME-wrapped config detected, using as-is") cfg = startup_cfg else: - # Otherwise, append to bootstrap config - cfg = self.gen_bootstrap_config() + startup_cfg + # Otherwise, append to bootstrap config. Strip any address + # the appended config sets on the mgmt interface so a stale + # saved/hand-written mgmt address can never re-set it after + # the bootstrap GigabitEthernet1 stanza (IOS-XE applies day0 + # config top to bottom, so a later "ip address" under the + # same interface wins) -- otherwise the router comes up + # "healthy" but unreachable at the address clab/DNS expect. + # Keyed on interface name, not address value. + cfg = self.gen_bootstrap_config() + vrnetlab.strip_mgmt_interface_config( + startup_cfg, "GigabitEthernet1", "ios" + ) else: self.logger.warning("User provided startup configuration is not found.") cfg = self.gen_bootstrap_config() diff --git a/cisco/cat9kv/docker/launch.py b/cisco/cat9kv/docker/launch.py index 2ae2d7e3..836d46e0 100755 --- a/cisco/cat9kv/docker/launch.py +++ b/cisco/cat9kv/docker/launch.py @@ -143,7 +143,16 @@ def create_boot_image(self): if os.path.exists(STARTUP_CONFIG_FILE): self.logger.info("Startup configuration file found") with open(STARTUP_CONFIG_FILE, "r") as startup_config: - cat9kv_config += startup_config.read() + # Strip any address the appended startup-config sets on the mgmt + # interface so a stale saved/hand-written mgmt address can never + # re-set it after the GigabitEthernet0/0 stanza configured above + # (IOS-XE applies day0 config top to bottom, so a later "ip + # address"/"ipv6 address" under the same interface wins) -- + # otherwise the switch comes up "healthy" but unreachable at the + # address clab/DNS expect. Keyed on interface name, not value. + cat9kv_config += vrnetlab.strip_mgmt_interface_config( + startup_config.read(), "GigabitEthernet0/0", "ios" + ) else: self.logger.warning(f"User provided startup configuration is not found.") diff --git a/cisco/csr1000v/docker/launch.py b/cisco/csr1000v/docker/launch.py index fd75ccf3..bc6fd6c1 100755 --- a/cisco/csr1000v/docker/launch.py +++ b/cisco/csr1000v/docker/launch.py @@ -74,7 +74,18 @@ def __init__( if os.path.exists(STARTUP_CONFIG_FILE): self.logger.info("Startup configuration file found") with open(STARTUP_CONFIG_FILE, "r") as startup_config: - cfg += startup_config.read() + # Strip any address the appended startup-config sets on the + # mgmt interface so a stale saved/hand-written mgmt address + # can never re-set it after the GigabitEthernet1 stanza + # configured above (IOS-XE applies day0 config top to bottom, + # so a later "ip address" under the same interface wins) -- + # otherwise the router comes up "healthy" but unreachable at + # the address clab/DNS expect. Keyed on interface name, not + # address value (the stale value differs from the current + # one, so only the name is invariant). + cfg += vrnetlab.strip_mgmt_interface_config( + startup_config.read(), "GigabitEthernet1", "ios" + ) else: self.logger.warning( f"User provided startup configuration is not found." diff --git a/cisco/n9kv/docker/launch.py b/cisco/n9kv/docker/launch.py index 7bd223ab..a68909e6 100755 --- a/cisco/n9kv/docker/launch.py +++ b/cisco/n9kv/docker/launch.py @@ -184,7 +184,16 @@ def apply_config(self): if os.path.exists(STARTUP_CONFIG_FILE): self.logger.info("Startup configuration file found") with open(STARTUP_CONFIG_FILE, "r") as config: - n9kv_config += config.read() + # Strip any address the appended startup-config sets on the mgmt + # interface so a stale saved/hand-written mgmt address can never + # re-set it after the interface mgmt0 stanza configured above + # (config is applied top to bottom, so a later "ip address"/"ipv6 + # address" under the same interface wins) -- otherwise the switch + # comes up "healthy" but unreachable at the address clab/DNS + # expect. Keyed on interface name, not address value. + n9kv_config += vrnetlab.strip_mgmt_interface_config( + config.read(), "mgmt0", "ios" + ) else: self.logger.warning("User provided startup configuration is not found.") diff --git a/cisco/nxos/docker/launch.py b/cisco/nxos/docker/launch.py index 268bb297..e335b3d2 100755 --- a/cisco/nxos/docker/launch.py +++ b/cisco/nxos/docker/launch.py @@ -142,7 +142,16 @@ def apply_config(self): if os.path.exists(STARTUP_CONFIG_FILE): self.logger.info("Startup configuration file found") with open(STARTUP_CONFIG_FILE, "r") as config: - nxos_config += config.read() + # Strip any address the appended startup-config sets on the mgmt + # interface so a stale saved/hand-written mgmt address can never + # re-set it after the interface mgmt0 stanza configured above + # (config is applied top to bottom, so a later "ip address"/"ipv6 + # address" under the same interface wins) -- otherwise the switch + # comes up "healthy" but unreachable at the address clab/DNS + # expect. Keyed on interface name, not address value. + nxos_config += vrnetlab.strip_mgmt_interface_config( + config.read(), "mgmt0", "ios" + ) else: self.logger.warning("User provided startup configuration is not found.") diff --git a/cisco/xrd-vrouter/docker/Dockerfile b/cisco/xrd-vrouter/docker/Dockerfile index 78a473c3..1b5017c7 100644 --- a/cisco/xrd-vrouter/docker/Dockerfile +++ b/cisco/xrd-vrouter/docker/Dockerfile @@ -4,8 +4,15 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends cloud-image-utils curl ca-certificates && \ rm -rf /var/lib/apt/lists/* +# vrnetlab.py is the shared launcher library. The generic build step +# (docker-build-common in makefile.include) stages common/vrnetlab.py into this +# build context, but because this Dockerfile lists files explicitly rather than +# `COPY *.py /`, vrnetlab.py must be named here too -- otherwise it is NOT copied +# in and the image keeps the (older) /vrnetlab.py baked into vrnetlab-base, so +# launch.py's calls into the current common lib (strip_mgmt_interface_config, +# the qemu-guest-agent channel) fail with AttributeError. COPY make-golden.sh guest-init.sh xrd-guest-init.service netplan-mgmt.yaml \ - cloud-init-disable-net.cfg xrd-unconfined launch.py / + cloud-init-disable-net.cfg xrd-unconfined launch.py vrnetlab.py / RUN chmod +x /make-golden.sh EXPOSE 22 161/udp 830 9339 57400 10000-10099 diff --git a/cisco/xrd-vrouter/docker/launch.py b/cisco/xrd-vrouter/docker/launch.py index 54e0c5bb..b87ba5d6 100755 --- a/cisco/xrd-vrouter/docker/launch.py +++ b/cisco/xrd-vrouter/docker/launch.py @@ -124,8 +124,16 @@ def _gen_xr_config(self): so no default route is needed and the data routing table stays clean. Data IPs come from the user's startup-config.""" import ipaddress - mgmt_if = ipaddress.ip_interface(self.mgmt_address_ipv4) # e.g. 10.0.0.15/24 + mgmt_if = ipaddress.ip_interface(self.mgmt_address_ipv4) # real clab IP, e.g. 172.20.20.2/24 mgmt_v4 = f"{mgmt_if.ip} {mgmt_if.netmask}" + # Also configure IPv6 mgmt when the clab network provides it, so the + # launcher owns both families on MgmtEth (matches xrv9k/xrv). Without + # this, strip_mgmt_interface_config -- which removes every ip/ipv4/ipv6 + # address the appended startup-config sets on MgmtEth -- would leave a + # dual-stack node with no launcher-managed IPv6 mgmt address. + mgmt_v6_line = "" + if self.mgmt_address_ipv6 and self.mgmt_address_ipv6 != "dhcp": + mgmt_v6_line = f"\n ipv6 address {self.mgmt_address_ipv6}" # Modelled on containerlab's official nodes/xrd/xrd.cfg. The bits XRd # needs for working SSH: `line default / transport input ssh` and # `secret` (not `secret 0`). MgmtEth gets the clab management address @@ -144,7 +152,7 @@ def _gen_xr_config(self): ssh ! interface MgmtEth0/RP0/CPU0/0 - ipv4 address {mgmt_v4} + ipv4 address {mgmt_v4}{mgmt_v6_line} no shutdown ! ssh server v2 @@ -158,7 +166,18 @@ def _gen_xr_config(self): """ if os.path.exists(STARTUP_CONFIG_FILE): with open(STARTUP_CONFIG_FILE) as f: - cfg += f.read() + startup = f.read() + # The launcher owns MgmtEth0/RP0/CPU0/0 (configured above from the + # container's *actual* IP). Strip any address the appended + # startup-config sets on that interface so a stale saved/hand-written + # mgmt address can never win -- otherwise XR comes up on the old IP: + # "healthy" (XR is up) but unreachable at the address clab/DNS + # expect. Keyed on the interface name, not the address value: the + # stale value differs from the one just configured, so only the + # interface identity is invariant. + cfg += vrnetlab.strip_mgmt_interface_config( + startup, "MgmtEth0/RP0/CPU0/0", "ios" + ) if not cfg.rstrip().endswith("end"): cfg += "\nend\n" else: diff --git a/cisco/xrv/docker/launch.py b/cisco/xrv/docker/launch.py index c80904f7..4987010a 100755 --- a/cisco/xrv/docker/launch.py +++ b/cisco/xrv/docker/launch.py @@ -181,7 +181,17 @@ def apply_config(self): if os.path.exists(STARTUP_CONFIG_FILE): self.logger.info("Startup configuration file found") with open(STARTUP_CONFIG_FILE, "r") as config: - xrv_config += config.read() + # Strip any address the appended startup-config sets on the mgmt + # interface so a stale saved/hand-written mgmt address can never + # win over the interface MgmtEth stanza configured above + # (last-applied wins under IOS-XR) -- otherwise XR comes up + # "healthy" but unreachable at the address clab/DNS expect. + # Keyed on interface name, not address value (the stale value + # differs from the current one, so only the name is invariant). + # Note: classic XRv's mgmt is MgmtEth0/0/CPU0/0, not .../RP0/... . + xrv_config += vrnetlab.strip_mgmt_interface_config( + config.read(), "MgmtEth0/0/CPU0/0", "ios" + ) else: self.logger.warning("User provided startup configuration is not found.") diff --git a/cisco/xrv9k/docker/launch.py b/cisco/xrv9k/docker/launch.py index 369c368e..b3833be5 100755 --- a/cisco/xrv9k/docker/launch.py +++ b/cisco/xrv9k/docker/launch.py @@ -293,7 +293,16 @@ def apply_config(self): if os.path.exists(STARTUP_CONFIG_FILE): self.logger.info("Startup configuration file found") with open(STARTUP_CONFIG_FILE, "r") as config: - xrv9k_config += config.read() + # Strip any address the appended startup-config sets on the mgmt + # interface so a stale saved/hand-written mgmt address can never + # win over the interface MgmtEth stanza configured above + # (last-applied wins under IOS-XR) -- otherwise XR comes up + # "healthy" but unreachable at the address clab/DNS expect. + # Keyed on interface name, not address value (the stale value + # differs from the current one, so only the name is invariant). + xrv9k_config += vrnetlab.strip_mgmt_interface_config( + config.read(), "MgmtEth0/RP0/CPU0/0", "ios" + ) else: self.logger.warning("User provided startup configuration is not found.") diff --git a/juniper/vjunosevolved/docker/launch.py b/juniper/vjunosevolved/docker/launch.py index dc05ac9d..e4d63f32 100755 --- a/juniper/vjunosevolved/docker/launch.py +++ b/juniper/vjunosevolved/docker/launch.py @@ -143,9 +143,23 @@ def startup_config(self): self.logger.trace( f"Startup config file {STARTUP_CONFIG_FILE} found, appending initial configuration" ) - # append startup cfg to inital configuration - append_cfg = f"cat init.conf {STARTUP_CONFIG_FILE} >> juniper.conf" - subprocess.run(append_cfg, shell=True) + # Append startup cfg to initial configuration. Strip any address the + # appended startup config sets on the mgmt interface so re0:mgmt-0 + # carries only the launcher's own address (rendered into init.conf + # above). Junos keeps multiple addresses per interface, so an + # appended one is added alongside the launcher's rather than + # replacing it, leaving a stale/foreign address on the management + # interface. Keyed on the interface name, not the address value (the + # stale value differs from the current one). + with open("init.conf", "r") as f: + base_cfg = f.read() + with open(STARTUP_CONFIG_FILE, "r") as f: + startup_cfg = vrnetlab.strip_mgmt_interface_config( + f.read(), "re0:mgmt-0", "junos" + ) + with open("juniper.conf", "w") as f: + f.write(base_cfg) + f.write(startup_cfg) # generate mountable config disk based on juniper.conf file with base vrnetlab configs subprocess.run(["./make-config.sh", "juniper.conf", "config.img"], check=True) diff --git a/juniper/vjunosrouter/docker/launch.py b/juniper/vjunosrouter/docker/launch.py index bd05f603..1a4a7abf 100755 --- a/juniper/vjunosrouter/docker/launch.py +++ b/juniper/vjunosrouter/docker/launch.py @@ -114,9 +114,23 @@ def startup_config(self): self.logger.trace( f"Startup config file {STARTUP_CONFIG_FILE} found, appending initial configuration" ) - # append startup cfg to inital configuration - append_cfg = f"cat init.conf {STARTUP_CONFIG_FILE} >> juniper.conf" - subprocess.run(append_cfg, shell=True) + # Append startup cfg to initial configuration. Strip any address + # the appended startup config sets on the mgmt interface so fxp0 + # carries only the launcher's own address (rendered into init.conf + # above). Junos keeps multiple addresses per interface, so an + # appended one is added alongside the launcher's rather than + # replacing it, leaving a stale/foreign address on the management + # interface. Keyed on the interface name, not the address value (the + # stale value differs from the current one). + with open("init.conf", "r") as f: + base_cfg = f.read() + with open(STARTUP_CONFIG_FILE, "r") as f: + startup_cfg = vrnetlab.strip_mgmt_interface_config( + f.read(), "fxp0", "junos" + ) + with open("juniper.conf", "w") as f: + f.write(base_cfg) + f.write(startup_cfg) # generate mountable config disk based on juniper.conf file with base vrnetlab configs subprocess.run(["./make-config.sh", "juniper.conf", "config.img"], check=True) diff --git a/juniper/vjunosswitch/docker/launch.py b/juniper/vjunosswitch/docker/launch.py index 80daa508..9d134458 100755 --- a/juniper/vjunosswitch/docker/launch.py +++ b/juniper/vjunosswitch/docker/launch.py @@ -114,9 +114,23 @@ def startup_config(self): self.logger.trace( f"Startup config file {STARTUP_CONFIG_FILE} found, appending initial configuration" ) - # append startup cfg to inital configuration - append_cfg = f"cat init.conf {STARTUP_CONFIG_FILE} >> juniper.conf" - subprocess.run(append_cfg, shell=True) + # Append startup cfg to initial configuration. Strip any address + # the appended startup config sets on the mgmt interface so fxp0 + # carries only the launcher's own address (rendered into init.conf + # above). Junos keeps multiple addresses per interface, so an + # appended one is added alongside the launcher's rather than + # replacing it, leaving a stale/foreign address on the management + # interface. Keyed on the interface name, not the address value (the + # stale value differs from the current one). + with open("init.conf", "r") as f: + base_cfg = f.read() + with open(STARTUP_CONFIG_FILE, "r") as f: + startup_cfg = vrnetlab.strip_mgmt_interface_config( + f.read(), "fxp0", "junos" + ) + with open("juniper.conf", "w") as f: + f.write(base_cfg) + f.write(startup_cfg) # generate mountable config disk based on juniper.conf file with base vrnetlab configs subprocess.run(["./make-config.sh", "juniper.conf", "config.img"], check=True) diff --git a/juniper/vmx/docker/launch.py b/juniper/vmx/docker/launch.py index 6007b99e..544231c6 100755 --- a/juniper/vmx/docker/launch.py +++ b/juniper/vmx/docker/launch.py @@ -230,8 +230,16 @@ def startup_config(self): self.logger.trace(f"Startup config file {STARTUP_CONFIG_FILE} exists") with open(STARTUP_CONFIG_FILE) as file: - config_lines = file.readlines() - config_lines = [line.rstrip() for line in config_lines] + startup_cfg = file.read() + # bootstrap_config() (run just before this) commits fxp0 to a fixed + # 10.0.0.15/24 that the qemu hostfwd targets. Strip any fxp0 address + # from the appended startup config so fxp0 keeps only that address; + # Junos keeps multiple addresses per interface, so an appended one + # would be added alongside 10.0.0.15, leaving a stale/foreign + # address on the management interface. Keyed on the interface name + # so it catches whatever address the config sets there. + startup_cfg = vrnetlab.strip_mgmt_interface_config(startup_cfg, "fxp0", "junos") + config_lines = startup_cfg.splitlines() self.logger.trace(f"Parsed startup config file {STARTUP_CONFIG_FILE}") self.logger.info(f"Writing lines from {STARTUP_CONFIG_FILE}") diff --git a/juniper/vqfx/docker/launch.py b/juniper/vqfx/docker/launch.py index 0c7a4ced..3e169127 100755 --- a/juniper/vqfx/docker/launch.py +++ b/juniper/vqfx/docker/launch.py @@ -164,8 +164,15 @@ def startup_config(self): self.logger.trace("Config File %s exists" % STARTUP_CONFIG_FILE) with open(STARTUP_CONFIG_FILE) as file: self.logger.trace("Opening Config File %s" % STARTUP_CONFIG_FILE) - config_lines = file.readlines() - config_lines = [line.rstrip() for line in config_lines] + # bootstrap_config() (run just before this) commits em0 to a + # fixed 10.0.0.15/24 that the qemu hostfwd targets. Strip any em0 + # address from the startup config so em0 keeps only that address; + # Junos keeps multiple addresses per interface, so an appended + # one would be added alongside 10.0.0.15, leaving a stale/foreign + # address on the management interface. Keyed on the interface + # name so it catches whatever address the config sets there. + startup_cfg = vrnetlab.strip_mgmt_interface_config(file.read(), "em0", "junos") + config_lines = startup_cfg.splitlines() self.logger.trace("Parsed Config File %s" % STARTUP_CONFIG_FILE) self.logger.info("Writing lines from %s" % STARTUP_CONFIG_FILE) diff --git a/juniper/vsrx/docker/launch.py b/juniper/vsrx/docker/launch.py index 6df420cf..a308b7b5 100755 --- a/juniper/vsrx/docker/launch.py +++ b/juniper/vsrx/docker/launch.py @@ -97,9 +97,23 @@ def startup_config(self): self.logger.trace( f"Startup config file {STARTUP_CONFIG_FILE} found, appending initial configuration" ) - # append startup cfg to inital configuration - append_cfg = f"cat init.conf {STARTUP_CONFIG_FILE} >> juniper.conf" - subprocess.run(append_cfg, shell=True) + # Append startup cfg to initial configuration. Strip any address + # the appended startup config sets on the mgmt interface so fxp0 + # carries only the launcher's own address (rendered into init.conf + # above). Junos keeps multiple addresses per interface, so an + # appended one is added alongside the launcher's rather than + # replacing it, leaving a stale/foreign address on the management + # interface. Keyed on the interface name, not the address value (the + # stale value differs from the current one). + with open("init.conf", "r") as f: + base_cfg = f.read() + with open(STARTUP_CONFIG_FILE, "r") as f: + startup_cfg = vrnetlab.strip_mgmt_interface_config( + f.read(), "fxp0", "junos" + ) + with open("juniper.conf", "w") as f: + f.write(base_cfg) + f.write(startup_cfg) # generate mountable config disk based on juniper.conf file with base vrnetlab configs subprocess.run( From 230126739ceec2e98aadc905034bf48be0e0f5ce Mon Sep 17 00:00:00 2001 From: Andrei <46914650+alxrxs@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:21:41 +0300 Subject: [PATCH 3/3] xrd-vrouter: don't report healthy with management down; add diagnostics guest-init.sh's wait loop only broke early on MgmtEth reaching Up/Up; on the iteration-count timeout it fell through and emitted the CLAB_XRD_READY marker unconditionally anyway. The launcher sets running=True on that marker and writes "0 running" to /health regardless of whether management came up -- so a node whose MgmtEth never got assigned still reported "healthy" to docker/containerlab, with nothing to say why SSH wasn't answering. Now the marker is emitted only once "show ipv4 vrf all interface brief" confirms MgmtEth is Up/Up. On failure guest-init dumps diagnostics to the console instead (show configuration failed startup, show running-config/interfaces/ipv4 for MgmtEth, show logging) and exits without signalling ready, so the node correctly stays unhealthy and the reason is visible in `docker logs` on every boot attempt. make-golden.sh installs and enables qemu-guest-agent in the golden image, so the guest-agent channel added to common/vrnetlab.py has something answering on it for this kind: `guest-exec docker exec xrd /pkg/bin/xr_cli ...` works even when management is down and the golden image's serial console has no interactive getty (serial-getty@ttyS0 is masked by design during provisioning). --- cisco/xrd-vrouter/docker/guest-init.sh | 31 +++++++++++++++++++++---- cisco/xrd-vrouter/docker/make-golden.sh | 6 +++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/cisco/xrd-vrouter/docker/guest-init.sh b/cisco/xrd-vrouter/docker/guest-init.sh index d3d00c91..d6b357eb 100755 --- a/cisco/xrd-vrouter/docker/guest-init.sh +++ b/cisco/xrd-vrouter/docker/guest-init.sh @@ -96,14 +96,15 @@ xrcli(){ timeout 25 docker exec xrd /pkg/bin/xr_cli "$1" 2>/dev/null; } # containerlab healthcheck, so we do not probe the address from the Linux netns # (XR owns it, so it does not live there). echo "waiting for XR control plane + management..." +mgmt_up=0 for i in $(seq 1 120); do if xrcli "show version" | grep -qi "cisco IOS XR"; then # '|| true' is essential: under `set -e`, a non-matching grep would - # otherwise abort guest-init before it ever emits the readiness marker. + # otherwise abort guest-init before it decides success/failure. mgmtline=$(xrcli "show ipv4 vrf all interface brief" | grep -i Mgmt || true) echo "[t=$((i*10))s] XR up; MgmtEth: ${mgmtline:-pending}" case "$mgmtline" in - *Up*Up*) echo "XR management is up"; break ;; + *Up*Up*) echo "XR management is up"; mgmt_up=1; break ;; esac else echo "[t=$((i*10))s] XR converging..." @@ -111,12 +112,32 @@ for i in $(seq 1 120); do sleep 10 done -# Emit the readiness marker on the serial console for the vrnetlab launcher. -# Repeat a few times so the launcher's serial poller reliably catches it. +if [ "$mgmt_up" != 1 ]; then + # Management never came up. Emitting the readiness marker here is exactly what + # made the node report "healthy" while unreachable — the launcher sets + # running=True on the marker and writes "0 running" to /health regardless of + # mgmt. So we DON'T signal ready: the launcher keeps waiting (and eventually + # restarts the VM), and the node stays unhealthy. First, dump why — this goes + # to the console, so `docker logs`/serial shows the root cause on every attempt. + echo "!!! MgmtEth never reached Up/Up — NOT signalling ready. Diagnostics: !!!" + xrcli "show configuration failed startup" || true + xrcli "show running-config interface MgmtEth0/RP0/CPU0/0" || true + xrcli "show interfaces MgmtEth0/RP0/CPU0/0" || true + xrcli "show ipv4 interface MgmtEth0/RP0/CPU0/0 brief" || true + xrcli "show logging last 80" || true + echo "=== guest-init FAILED (management down) ===" + # For interactive triage, reach the guest out-of-band via the qemu-guest-agent + # channel (the /run/qga.sock unix socket inside the launcher container): + # guest-exec docker exec xrd /pkg/bin/xr_cli. + exit 1 +fi + +# Management is up — signal readiness to the vrnetlab launcher. Repeat a few +# times so the launcher's serial poller reliably catches it. for n in 1 2 3 4 5; do for tty in /dev/ttyS0 /dev/console; do [ -w "$tty" ] && echo "${READY_MARKER}:${NODENAME}" > "$tty" 2>/dev/null || true done sleep 2 done -echo "=== guest-init done ===" +echo "=== guest-init done (management up) ===" diff --git a/cisco/xrd-vrouter/docker/make-golden.sh b/cisco/xrd-vrouter/docker/make-golden.sh index 53332635..d64a2b67 100644 --- a/cisco/xrd-vrouter/docker/make-golden.sh +++ b/cisco/xrd-vrouter/docker/make-golden.sh @@ -99,6 +99,12 @@ write_files: set -euo pipefail curl -fsSL https://get.docker.com | sh systemctl restart docker + # qemu-guest-agent: lets the host drive guest-exec over the virtio-serial + # channel org.qemu.guest_agent.0 the launcher now attaches -- out-of-band + # access into the guest even when management is down and no serial getty. + apt-get update + apt-get install -y --no-install-recommends qemu-guest-agent + systemctl enable qemu-guest-agent mount -t 9p -o trans=virtio,version=9p2000.L,ro xrdshare /mnt docker load -i /mnt/xrd.tar umount /mnt