feat(bastion): bring VyOS provisioning to Fedora-grade quality
Some checks failed
CI/CD / typecheck (pull_request) Failing after 10s
CI/CD / test (pull_request) Failing after 9s
CI/CD / lint (pull_request) Failing after 24s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped

Ports the Fedora provisioning features that matter for a router onto the
VyOS path, and adds the libvirt integration test that proves them.

- Live install logs: the driver streams the installer pty (ANSI-stripped,
  batched, best-effort) to POST /api/log, so `labctl provision logs -f`
  works during a VyOS install the way Anaconda's syslog does for Fedora.
- installed.ip: report "ready at <ip>" -- the exact detail format
  routes/api.ts parses -- using the static mgmt address when known, else
  the live DHCP address. Without it VyOS machines landed with an empty IP,
  breaking provision list, logs-by-IP, recheck and reprovision.
  api.ts also guards the complete handler: VyOS boxes get the "vyos" SSH
  hint and never trigger the k3s post-provision.
- EFI network-first boot order: port of the Fedora %post efibootmgr step,
  run from the live env after install (NVRAM, not disk). Best-effort.
- Reinstall semantics: VyOS's installer already carries the previous
  config and SSH host keys forward -- the analog of Fedora's LV
  preservation -- so that stays the default. New --vyos-fresh-config
  overwrites the installed config.boot with the generated one instead,
  via a post-install target mount that also writes /config/lab-provisioned
  (mirrors Fedora's /etc/lab-provisioned, survives image upgrades).
- reprovision/recheck default to the "vyos" SSH user for VyOS machines.

Two hangs found by the VM test and fixed:
- On reinstall the installer asks "Would you like to copy data to the new
  image?" (search_previous_installation). Unanswered, the driver blocked
  on stdin until its stall timeout -- a silent 15-minute hang.
- The RAID regex missed "Would you like to choose two disks for RAID-1
  mirroring?", which would wedge any multi-disk box. Both prompts default
  to yes, so a miss also risks an unwanted mirror.

Both are now covered by a unit test asserting all 17 installer prompts
match exactly one rule -- verified to fail against the unfixed code, so
this class of bug is caught in a second instead of a 45-minute VM run.

tests/integration/vyos-provision.test.ts: fresh install, reinstall
preserves config + /config data, and freshConfig override. All 8 pass
against the real nightly ISO (EXIT=0). 273 unit tests pass; no new lint
errors in touched files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
This commit is contained in:
Michal
2026-08-11 11:16:25 +01:00
parent cb9d99dd69
commit 5d00c42f5a
11 changed files with 806 additions and 20 deletions

View File

@@ -21,6 +21,8 @@
"test:integration:pxe:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'PXE boot'",
"test:integration:iso": "vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'",
"test:integration:iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'",
"test:integration:vyos": "vitest run -c tests/integration/vitest.config.ts -t 'VyOS provisioning'",
"test:integration:vyos:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'VyOS provisioning'",
"test:integration:arm-iso": "vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'",
"test:integration:arm-iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'",
"test:integration:asahi": "vitest run -c tests/integration/vitest.config.ts -t 'asahi firstboot'",

View File

@@ -160,11 +160,17 @@ export function registerApiRoutes(
};
s.installed[mac] = installedInfo;
const admin = installedInfo.role !== "vanilla" && installedInfo.role !== "" ? "lab" : "root";
// VyOS: the only login user is "vyos", and a router never runs k3s —
// without this guard a non-vanilla role + recorded IP would trigger
// the k3s post-provision against a VyOS box.
const isVyos = (installedInfo.os ?? "").startsWith("vyos");
const admin = isVyos
? "vyos"
: installedInfo.role !== "vanilla" && installedInfo.role !== "" ? "lab" : "root";
console.log(`\n \x1b[0;32m\x1b[1m ssh ${admin}@${ip}\x1b[0m\n`); // eslint-disable-line no-console
// Auto-install k3s for non-vanilla roles
if (installedInfo.role !== "vanilla" && ip !== "") {
if (!isVyos && installedInfo.role !== "vanilla" && ip !== "") {
void triggerPostProvisionK3s(installedInfo.hostname, ip, installedInfo.role, admin, mac);
}
}

View File

@@ -63,6 +63,7 @@ exec python3 /tmp/vyos-install.py
mac,
serverIp: config.serverIp,
httpPort: config.httpPort,
role: queueEntry?.role ?? "vanilla",
});
return reply.type("text/plain").send(script);

View File

@@ -24,8 +24,16 @@ export interface VyosConfigSpec {
console: "K" | "S";
/** Target disk name (e.g. "nvme0n1"); "" accepts the installer's first-disk default. */
disk: string;
/**
* IP the driver should report in the "complete" callback ("ready at <ip>" —
* the exact format routes/api.ts parses installed.ip from). The mgmt
* address when static; "" means detect the live DHCP address at runtime.
*/
reportAddress: string;
/** Whether to accept RAID-1 when the installer finds more than one disk. */
raid: boolean;
/** Overwrite the installed config.boot with the generated one on reinstall. */
freshConfig: boolean;
sets: VyosSetOp[];
/** Paths that are VyOS tag nodes — must be marked as such in the ConfigTree. */
tags: string[][];
@@ -190,7 +198,10 @@ export function buildVyosConfigSpec(params: {
password: spec.password ?? params.defaultPassword,
console: "K",
disk: normalizeDiskPath(params.disk),
// Static mgmt address wins; under DHCP the driver detects the live IP.
reportAddress: mgmtAddress.includes("/") ? (mgmtAddress.split("/")[0] ?? "") : "",
raid: false,
freshConfig: spec.freshConfig ?? false,
sets,
tags,
};

View File

@@ -19,6 +19,7 @@ export function renderVyosInstallPy(params: {
mac: string;
serverIp: string;
httpPort: number;
role: string;
}): string {
// Base64 so arbitrary values (passwords, descriptions, SSH keys) can never
// terminate the Python string literal that carries them.
@@ -41,6 +42,7 @@ import urllib.request
SPEC = json.loads(base64.b64decode("${specB64}").decode("utf-8"))
BASTION = "http://${params.serverIp}:${params.httpPort}"
MAC = "${params.mac}"
ROLE = ${JSON.stringify(params.role ?? "vanilla")}
INSTALLER = "/usr/libexec/vyos/op_mode/image_installer.py"
CONFIG_DIR = "/opt/vyatta/etc/config"
@@ -60,8 +62,86 @@ DEFAULT_CONFIG_CANDIDATES = [
STALL_TIMEOUT = 900 # seconds without installer output before giving up
def detect_ip():
"""Best-effort local IP as seen on the route toward the bastion.
Matches Fedora's semantics (IP captured during install): under DHCP the
installed system will renew on the same NIC/subnet the live env used.
"""
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("${params.serverIp}", ${params.httpPort}))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return ""
class LogStreamer:
"""Stream install output to the bastion's /api/log so 'labctl provision
logs -f' works live for VyOS, like Anaconda's syslog does for Fedora.
Strictly best-effort: a failed POST drops the batch and must never stall
the pty read loop or fail the install.
"""
ANSI = re.compile(rb"\\x1b\\[[0-9;?]*[a-zA-Z]|\\x1b[=>]|\\r")
def __init__(self):
self.partial = b""
self.pending = []
self.last_flush = time.time()
def feed(self, chunk):
"""Raw pty bytes: split into lines, strip ANSI noise, queue."""
self.partial += chunk
while b"\\n" in self.partial:
raw, self.partial = self.partial.split(b"\\n", 1)
text = self.ANSI.sub(b"", raw).decode("utf-8", "replace").rstrip()
if text:
self.pending.append(text)
self.maybe_flush()
def line(self, text):
"""A driver-originated message (already a clean string)."""
self.pending.append(text)
self.maybe_flush()
def maybe_flush(self):
if len(self.pending) >= 20 or (self.pending and time.time() - self.last_flush >= 2):
self.flush()
def flush(self):
if not self.pending:
return
batch, self.pending = self.pending[:200], self.pending[200:]
self.last_flush = time.time()
try:
body = json.dumps({"mac": MAC, "lines": batch}).encode()
req = urllib.request.Request(
BASTION + "/api/log",
data=body,
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=5).read()
except Exception:
pass
STREAM = LogStreamer()
def say(msg):
"""Print locally and stream to the bastion log buffer."""
print(msg)
STREAM.line(str(msg))
def report(stage, detail=""):
"""Best-effort progress callback; never fatal."""
STREAM.flush()
try:
body = json.dumps({"mac": MAC, "stage": stage, "detail": detail}).encode()
req = urllib.request.Request(
@@ -87,7 +167,7 @@ def build_config():
if default_config is None:
raise FileNotFoundError(
"no config.boot.default found (tried %s)" % ", ".join(DEFAULT_CONFIG_CANDIDATES))
print("base config: %s" % default_config)
say("base config: %s" % default_config)
with open(default_config) as handle:
config = ConfigTree(handle.read())
@@ -104,7 +184,7 @@ def build_config():
try:
config.set_tag(tag)
except Exception as err:
print("warning: set_tag %s failed: %s" % (tag, err))
say("warning: set_tag %s failed: %s" % (tag, err))
os.makedirs(CONFIG_DIR, exist_ok=True)
target = os.path.join(CONFIG_DIR, "config.boot")
@@ -120,9 +200,9 @@ def build_config():
info = version_info_from_system()
info.update_config_body(body)
info.write(target)
print("wrote %s (footer: %s)" % (target, info.release))
say("wrote %s (footer: %s)" % (target, info.release))
except Exception as err:
print("warning: version footer failed (%s); writing bare config" % err)
say("warning: version footer failed (%s); writing bare config" % err)
with open(target, "w") as handle:
handle.write(body)
return target
@@ -158,14 +238,14 @@ def ensure_rootfs():
return
src = find_live_squashfs()
if src is None:
print("squashfs not in live mounts; re-fetching %s" % SQUASHFS_URL)
say("squashfs not in live mounts; re-fetching %s" % SQUASHFS_URL)
src = "/tmp/filesystem.squashfs"
urllib.request.urlretrieve(SQUASHFS_URL, src)
os.makedirs(os.path.dirname(ROOTFS_EXPECTED), exist_ok=True)
if os.path.lexists(ROOTFS_EXPECTED):
os.remove(ROOTFS_EXPECTED)
os.symlink(src, ROOTFS_EXPECTED)
print("rootfs source: %s -> %s" % (ROOTFS_EXPECTED, src))
say("rootfs source: %s -> %s" % (ROOTFS_EXPECTED, src))
def build_rules():
@@ -182,11 +262,23 @@ def build_rules():
(re.compile(rb"Please confirm password for the .vyos. user:"), password),
(re.compile(rb"Please enter a password for the .vyos. user:"), password),
(re.compile(rb"What console should be used by default"), console),
(re.compile(rb"Would you like to configure RAID-1 mirroring"), raid),
# Three RAID variants: "configure RAID-1 mirroring?", "...on them?",
# and "choose two disks for RAID-1 mirroring?" -- all default to YES,
# so a missed one both hangs the install and risks an unwanted mirror.
(re.compile(rb"Would you like to [^?]*RAID-1 mirroring"), raid),
(re.compile(rb"Installation will delete all data on (?:the drive|both drives)\\. Continue\\?"), b"yes\\n"),
(re.compile(rb"Which one should be used for installation\\?"), disk),
(re.compile(rb"Would you like to use all the free space on the drive\\?"), b"yes\\n"),
(re.compile(rb"Which file would you like as boot config\\?"), b"1\\n"),
# Reinstall path only (search_previous_installation): carrying the old
# /config and SSH host keys forward is VyOS's "reinstall without losing
# data". Always yes -- freshConfig replaces config.boot afterwards, so
# answering no here would also discard non-config data under /config.
(re.compile(rb"Would you like to copy data to the new image\\?"), b"yes\\n"),
(re.compile(rb"Would you like to copy the encrypted config to the new image\\?"), b"yes\\n"),
# More than one previous image found -- take the first offered.
(re.compile(rb"From which image would you like to save config information\\?"), b"1\\n"),
(re.compile(rb"From which image would you like to copy the encrypted config\\?"), b"1\\n"),
]
@@ -224,6 +316,7 @@ def run_installer():
sys.stdout.buffer.flush()
buf += chunk
transcript = (transcript + chunk)[-8000:]
STREAM.feed(chunk)
last_output = time.time()
# Answer every prompt currently in the buffer, earliest first, so
@@ -240,6 +333,7 @@ def run_installer():
found, response = best
os.write(master, response)
transcript = (transcript + b"\\n>>> answered: " + response)[-8000:]
STREAM.line(">>> answered: " + response.decode("utf-8", "replace").strip())
buf = buf[found.end():]
# Bound memory if the installer emits a lot without prompting.
@@ -249,6 +343,8 @@ def run_installer():
elif proc.poll() is not None:
break
STREAM.maybe_flush()
if time.time() - last_output > STALL_TIMEOUT:
proc.kill()
raise SystemExit("installer produced no output for %ds" % STALL_TIMEOUT)
@@ -257,6 +353,115 @@ def run_installer():
return proc.wait(), transcript.decode("utf-8", "replace")
def ensure_network_boot_first():
"""Keep network boot first so the bastion intercepts every reboot.
Port of the Fedora kickstart's %post efibootmgr step (install.ks.ts) --
what makes reprovision-by-reboot work. Best-effort: skipped on BIOS boots
or when efibootmgr is absent. Runs from the live env after the installer;
efibootmgr edits NVRAM, not the disk, so installer cleanup is irrelevant.
"""
import shutil
if not os.path.isdir("/sys/firmware/efi") or shutil.which("efibootmgr") is None:
say("boot order: skipped (BIOS boot or efibootmgr missing)")
return
try:
out = subprocess.run(["efibootmgr"], capture_output=True, text=True, timeout=30).stdout
order = []
network_entry = None
for line in out.splitlines():
m = re.match(r"^BootOrder:\\s*(.*)$", line)
if m:
order = [x.strip() for x in m.group(1).split(",") if x.strip()]
continue
m = re.match(r"^Boot([0-9A-Fa-f]{4})\\*?\\s+(.*)$", line)
if m and network_entry is None:
if re.search(r"network|pxe|ipv4|ipv6|http", m.group(2), re.IGNORECASE):
network_entry = m.group(1).upper()
if network_entry is None or not order:
say("boot order: no network boot entry found; leaving as is")
return
new_order = [network_entry] + [x for x in order if x.upper() != network_entry]
if [x.upper() for x in order] == [x.upper() for x in new_order]:
say("boot order: network entry Boot%s already first" % network_entry)
return
subprocess.run(["efibootmgr", "-o", ",".join(new_order)],
capture_output=True, timeout=30)
say("boot order: moved network entry Boot%s first" % network_entry)
except Exception as err:
say("warning: boot order adjustment failed: %s" % err)
def with_target_mounted(fn):
"""Mount the installed root partition, call fn(rw_dir), always unmount.
The installer has unmounted and cleaned the target by the time this runs,
so the block device is free. The partition holding boot/<image>/rw is the
VyOS root; the glob also yields the installed image's rw dir directly.
"""
import glob
disk = SPEC["disk"]
if not disk:
# No pinned disk (installer picked the default) -- enumerate all disks.
candidates = ["/dev/" + b for b in os.listdir("/sys/block")
if not b.startswith(("loop", "ram", "zram", "sr"))]
else:
candidates = [disk]
mnt = "/mnt/lab-target"
os.makedirs(mnt, exist_ok=True)
for dev in candidates:
name = os.path.basename(dev)
parts = sorted(p for p in os.listdir("/sys/block/%s" % name)
if p.startswith(name)) if os.path.isdir("/sys/block/%s" % name) else []
for part in parts:
pdev = "/dev/" + part
if subprocess.run(["mount", pdev, mnt], capture_output=True).returncode != 0:
continue
try:
rw_dirs = glob.glob(os.path.join(mnt, "boot", "*", "rw"))
if rw_dirs:
fn(rw_dirs[0])
return True
finally:
subprocess.run(["umount", mnt], capture_output=True)
return False
def post_install_target_steps():
"""Metadata + optional fresh-config overwrite inside the installed image."""
def apply(rw_dir):
config_dir = os.path.join(rw_dir, "opt/vyatta/etc/config")
os.makedirs(config_dir, exist_ok=True)
# /config/lab-provisioned -- survives VyOS image upgrades. Mirrors the
# Fedora kickstart's /etc/lab-provisioned.
try:
with open(os.path.join(config_dir, "lab-provisioned"), "w") as handle:
handle.write("hostname=%s\\n" % SPEC["hostname"])
handle.write("role=%s\\n" % ROLE)
handle.write("provisioned=%s\\n" % time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
handle.write("bastion=%s\\n" % BASTION)
say("wrote /config/lab-provisioned")
except Exception as err:
say("warning: lab-provisioned metadata failed: %s" % err)
# freshConfig: make the bastion-generated config win over the previous
# installation's carried-forward config. Explicit intent -- failure is
# fatal (raised out of with_target_mounted).
if SPEC.get("freshConfig"):
import shutil
shutil.copyfile(os.path.join(CONFIG_DIR, "config.boot"),
os.path.join(config_dir, "config.boot"))
say("freshConfig: replaced installed config.boot with generated config")
mounted = with_target_mounted(apply)
if not mounted:
if SPEC.get("freshConfig"):
raise RuntimeError("freshConfig requested but installed root partition not found")
say("warning: installed root partition not found; skipping metadata")
def main():
report("vyos-install", "building config.boot")
try:
@@ -281,9 +486,19 @@ def main():
report("error", "install image exited %d | tail: %s" % (code, transcript[-4000:]))
raise SystemExit(code)
report("post-install", "boot order + metadata")
ensure_network_boot_first()
try:
post_install_target_steps()
except Exception as err:
report("error", "post-install target steps failed: %s" % err)
raise
# "complete" is the stage the bastion uses to move a machine out of the
# install queue into installed state -- see routes/api.ts.
report("complete", "VyOS installed, rebooting")
# install queue into installed state, and "ready at <ip>" is the exact
# detail format it parses installed.ip from -- see routes/api.ts.
ip = SPEC.get("reportAddress") or detect_ip()
report("complete", "ready at %s" % ip if ip else "VyOS installed, rebooting")
os.system("sync")
# --force: this driver is a child of live-config.service, whose start job is
# still running -- a normal reboot deadlocks waiting for it (verified in VM:

View File

@@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify";
import { createApp } from "../src/server.js";
import type { StateManager } from "../src/services/state.js";
import { buildVyosConfigSpec } from "../src/templates/vyos-config-spec.js";
import { renderVyosInstallPy } from "../src/templates/vyos-install.py.js";
function createTestConfig(testDir: string): BastionConfig {
return {
@@ -363,3 +364,153 @@ lrwxrwxrwx 1 0 0 24 Aug 5 01:33 'initrd.img-link' -> 'in
expect(pickLargestInitrd("-r--r--r-- 1 0 0 0 Aug 5 01:33 'initrd.img'\n")).toBeUndefined();
});
});
describe("vyos fedora-parity features", () => {
it("computes reportAddress from a static mgmt address, empty for dhcp", () => {
const staticSpec = buildVyosConfigSpec({
hostname: "fw1", defaultPassword: "pw",
spec: { mgmtAddress: "192.168.8.2/23" },
});
expect(staticSpec.reportAddress).toBe("192.168.8.2");
const dhcpSpec = buildVyosConfigSpec({ hostname: "fw1", defaultPassword: "pw" });
expect(dhcpSpec.reportAddress).toBe("");
});
it("defaults freshConfig off (reinstall preserves the on-disk config)", () => {
expect(buildVyosConfigSpec({ hostname: "fw1", defaultPassword: "pw" }).freshConfig).toBe(false);
expect(buildVyosConfigSpec({
hostname: "fw1", defaultPassword: "pw", spec: { freshConfig: true },
}).freshConfig).toBe(true);
});
it("driver streams logs to /api/log and reports 'ready at' on completion", async () => {
const testDir = join(tmpdir(), `bastion-vyos-parity-${Date.now()}`);
mkdirSync(join(testDir, "http"), { recursive: true });
mkdirSync(join(testDir, "tftp"), { recursive: true });
const { app: parityApp, state: parityState } = createApp(createTestConfig(testDir));
try {
parityState.update((s) => {
s.install_queue["aa:bb:cc:44:55:66"] = {
hostname: "fw9", disk: "/dev/vda", role: "vanilla",
os: "vyos-rolling", queued_at: new Date().toISOString(),
};
});
const response = await parityApp.inject({
method: "GET", url: "/vyos/install.py?mac=aa:bb:cc:44:55:66",
});
expect(response.body).toContain("/api/log");
expect(response.body).toContain('"lines": batch');
expect(response.body).toContain('report("complete", "ready at %s"');
expect(response.body).toContain("ensure_network_boot_first");
expect(response.body).toContain("lab-provisioned");
expect(response.body).toContain('ROLE = "vanilla"');
} finally {
await parityApp.close();
rmSync(testDir, { recursive: true, force: true });
}
});
it("complete with 'ready at' records installed.ip for a vyos machine", async () => {
const testDir = join(tmpdir(), `bastion-vyos-complete-${Date.now()}`);
mkdirSync(join(testDir, "http"), { recursive: true });
mkdirSync(join(testDir, "tftp"), { recursive: true });
const { app: cApp, state: cState } = createApp(createTestConfig(testDir));
try {
const mac2 = "aa:bb:cc:77:88:99";
cState.update((s) => {
s.install_queue[mac2] = {
hostname: "fw1", disk: "/dev/vda", role: "vanilla",
os: "vyos-rolling", queued_at: new Date().toISOString(),
};
});
const response = await cApp.inject({
method: "POST", url: "/api/progress",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mac: mac2, stage: "complete", detail: "ready at 192.168.8.2" }),
});
expect(response.statusCode).toBe(200);
const installed = cState.load().installed[mac2];
expect(installed?.ip).toBe("192.168.8.2");
expect(installed?.os).toBe("vyos-rolling");
} finally {
await cApp.close();
rmSync(testDir, { recursive: true, force: true });
}
});
});
describe("vyos installer prompt coverage", () => {
// Every interactive prompt image_installer.py can emit, copied verbatim from
// the MSG_* constants (including the reinstall-only search_previous_installation
// ones). An unanswered prompt does not fail loudly -- the installer simply
// blocks on stdin until the driver's stall timeout, which is how the reinstall
// path silently hung for 15 minutes in the VM test.
const PROMPTS: Record<string, string> = {
continue: "Would you like to continue? [y/N] ",
imageName: "What would you like to name this image? (Default: 1.5-rolling) ",
password: 'Please enter a password for the "vyos" user: ',
passwordConfirm: 'Please confirm password for the "vyos" user: ',
console: "What console should be used by default? (K: KVM, S: Serial)? (Default: K) ",
raidConfigure: "Would you like to configure RAID-1 mirroring? [Y/n] ",
raidFoundDisks: "Would you like to configure RAID-1 mirroring on them? [Y/n] ",
raidChooseDisks: "Would you like to choose two disks for RAID-1 mirroring? [Y/n] ",
diskSelect: "Which one should be used for installation? (Default: /dev/vda) ",
diskConfirm: "Installation will delete all data on the drive. Continue? [y/N] ",
raidConfirm: "Installation will delete all data on both drives. Continue? [y/N] ",
rootSizeAll: "Would you like to use all the free space on the drive? [Y/n] ",
bootConfig: "Which file would you like as boot config? ",
copyData: "Would you like to copy data to the new image? [Y/n] ",
chooseCopyData: "From which image would you like to save config information? ",
copyEncData: "Would you like to copy the encrypted config to the new image? [Y/n] ",
chooseCopyEncData: "From which image would you like to copy the encrypted config? ",
};
it("answers every installer prompt exactly once", () => {
const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
const { writeFileSync, unlinkSync, mkdtempSync } = require("node:fs") as typeof import("node:fs");
// Skip cleanly where python3 is unavailable (same spirit as the
// ksvalidator-backed kickstart test).
try {
execFileSync("python3", ["--version"], { stdio: "pipe" });
} catch {
return;
}
const spec = buildVyosConfigSpec({
hostname: "fw1", defaultPassword: "pw", disk: "/dev/vda",
});
const driver = renderVyosInstallPy({
spec, mac: "aa:bb:cc:11:22:33", serverIp: "10.0.0.1", httpPort: 8080, role: "vanilla",
});
const dir = mkdtempSync(join(tmpdir(), "vyos-rules-"));
const driverPath = join(dir, "driver.py");
const checkPath = join(dir, "check.py");
writeFileSync(driverPath, driver);
writeFileSync(checkPath, `
import importlib.util, json, sys
spec = importlib.util.spec_from_file_location("drv", ${JSON.stringify(driverPath)})
drv = importlib.util.module_from_spec(spec); spec.loader.exec_module(drv)
rules = drv.build_rules()
prompts = json.loads(sys.argv[1])
out = {}
for label, text in prompts.items():
out[label] = len([r for p, r in rules if p.search(text.encode())])
print(json.dumps(out))
`);
try {
const stdout = execFileSync("python3", [checkPath, JSON.stringify(PROMPTS)], {
encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"],
});
const counts = JSON.parse(stdout) as Record<string, number>;
const unanswered = Object.entries(counts).filter(([, n]) => n !== 1);
expect(unanswered).toEqual([]);
} finally {
try { unlinkSync(driverPath); unlinkSync(checkPath); } catch { /* best effort */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
}
});
});

View File

@@ -87,6 +87,7 @@ export function registerInstallCommand(parent: Command): void {
.option("--vlan <id:cidr[:desc]>", "VyOS: tagged VLAN sub-interface on the bond (repeatable)", parseVlan)
.option("--vyos-password <password>", "VyOS: password for the 'vyos' user")
.option("--vyos-hwid <iface=mac>", "VyOS: pin an interface name to a MAC via hw-id (repeatable)", parseHwId)
.option("--vyos-fresh-config", "VyOS: on reinstall, overwrite the preserved config with the generated one")
.action(async (mac: string, hostname: string, opts: {
role: string;
os: string;
@@ -102,6 +103,7 @@ export function registerInstallCommand(parent: Command): void {
vyosMgmtVlan?: string;
vyosPassword?: string;
vyosHwid?: Record<string, string>;
vyosFreshConfig?: boolean;
}) => {
if (!isValidOsId(opts.os)) {
console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`);
@@ -156,6 +158,7 @@ export function registerInstallCommand(parent: Command): void {
? { hwIds: opts.vyosHwid } : {}),
...(opts.vyosMgmtVlan !== undefined && opts.vyosMgmtVlan !== ""
? { mgmtVlan: parseVlan(opts.vyosMgmtVlan)[0] as VyosVlanSpec } : {}),
...(opts.vyosFreshConfig === true ? { freshConfig: true } : {}),
};
const hasVyosOptions = Object.keys(vyos).length > 0;

View File

@@ -44,11 +44,14 @@ export function registerRecheckCommand(parent: Command): void {
}
// Build list of machines to check
const targets: Array<{ mac: string; hostname: string; ip: string }> = [];
const targets: Array<{ mac: string; hostname: string; ip: string; sshUser: string }> = [];
const userIsDefault = opts.user === "root";
for (const [mac, info] of Object.entries(state.installed)) {
if (!info.ip) continue;
if (opts.target && info.hostname !== opts.target && mac !== opts.target) continue;
targets.push({ mac, hostname: info.hostname, ip: info.ip });
// VyOS boxes only have the "vyos" login; honor an explicit --user.
const sshUser = userIsDefault && (info.os ?? "").startsWith("vyos") ? "vyos" : opts.user;
targets.push({ mac, hostname: info.hostname, ip: info.ip, sshUser });
}
if (targets.length === 0) {
@@ -61,12 +64,12 @@ export function registerRecheckCommand(parent: Command): void {
let updated = 0;
let failed = 0;
for (const { mac, hostname, ip } of targets) {
for (const { mac, hostname, ip, sshUser } of targets) {
process.stdout.write(` ${hostname.padEnd(24)} ${DIM}(${ip})${RESET} `);
try {
const t0 = Date.now();
const result = await sshExec(ip, opts.user, HW_COLLECT_SCRIPT, SSH_OPTS);
const result = await sshExec(ip, sshUser, HW_COLLECT_SCRIPT, SSH_OPTS);
const elapsed = Date.now() - t0;
if (result.exitCode !== 0) {
console.log(`${RED}SSH failed (exit ${result.exitCode}, ${elapsed}ms)${RESET}`);

View File

@@ -24,12 +24,12 @@ function roleTable(): string {
function resolveTarget(
target: string,
state: BastionState,
): { mac: string; hostname: string; ip: string } | null {
): { mac: string; hostname: string; ip: string; os?: string } | null {
const normalized = target.toLowerCase().replace(/-/g, ":");
if (state.installed[normalized]) {
const info = state.installed[normalized];
return { mac: normalized, hostname: info.hostname, ip: info.ip };
return { mac: normalized, hostname: info.hostname, ip: info.ip, ...(info.os !== undefined ? { os: info.os } : {}) };
}
if (state.discovered[normalized]) {
@@ -38,13 +38,13 @@ function resolveTarget(
for (const [mac, info] of Object.entries(state.installed)) {
if (info.hostname === target || info.hostname.startsWith(target + ".")) {
return { mac, hostname: info.hostname, ip: info.ip };
return { mac, hostname: info.hostname, ip: info.ip, ...(info.os !== undefined ? { os: info.os } : {}) };
}
}
for (const [mac, info] of Object.entries(state.installed)) {
if (info.ip === target) {
return { mac, hostname: info.hostname, ip: info.ip };
return { mac, hostname: info.hostname, ip: info.ip, ...(info.os !== undefined ? { os: info.os } : {}) };
}
}
@@ -60,10 +60,12 @@ export function registerReprovisionCommand(parent: Command): void {
.addOption(new Option("--role <role>", "Machine role (see below)").choices([...SUPPORTED_ROLES]).default("worker"))
.addOption(new Option("--os <os>", "Operating system").choices([...SUPPORTED_OS]).default("fedora-43"))
.option("--disk <device>", "Target disk device (auto-detect if omitted)")
.option("--user <user>", "SSH user for the reboot (default: vyos for VyOS machines, else current user)")
.action(async (target: string, hostnameOverride: string | undefined, opts: {
role: string;
os: string;
disk?: string;
user?: string;
}) => {
if (!isValidOsId(opts.os)) {
console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`);
@@ -123,7 +125,11 @@ export function registerReprovisionCommand(parent: Command): void {
return;
}
const adminUser = process.env["SUDO_USER"] ?? process.env["USER"] ?? "";
// SSH user: explicit flag > the machine's current OS (VyOS boxes only
// have the "vyos" login) > the invoking user.
const currentOsIsVyos = (resolved.os ?? "").startsWith("vyos");
const adminUser = opts.user
?? (currentOsIsVyos ? "vyos" : (process.env["SUDO_USER"] ?? process.env["USER"] ?? ""));
const effectiveUser = adminUser === "root" ? "" : adminUser;
if (effectiveUser === "") {

View File

@@ -125,6 +125,13 @@ export interface VyosInstallSpec {
mgmtVlan?: VyosVlanSpec;
/** Password for the "vyos" user. Falls back to the bastion default. */
password?: string;
/**
* On reinstall the VyOS installer carries the previous on-disk config (and
* SSH host keys) forward -- the "reinstall without losing data" default.
* Set true to make the bastion-generated config win instead: after install
* the driver overwrites the installed image's config.boot.
*/
freshConfig?: boolean;
/**
* VyOS interface name -> MAC, emitted as `hw-id` so names bind deterministically.
*

View File

@@ -0,0 +1,381 @@
// Integration test: full VyOS unattended provisioning flow.
//
// Validates the VyOS install path end-to-end, at the same depth as the Fedora
// pxe-provision test:
// 1. Bastion (HTTP + dnsmasq) on the isolated libvirt PXE network
// 2. Blank UEFI VM PXE boots -> Fedora-based discovery (OS-neutral)
// 3. Queue os=vyos-rolling -> live boot + live-config hook + pty driver
// 4. Fresh-install asserts: installed.ip, streamed logs, applied config,
// /config/lab-provisioned, boot-order handling
// 5. REINSTALL round: previous config + /config data carried forward
// ("reinstall without losing data", VyOS-flavored)
// 6. freshConfig round: bastion-generated config wins, /config data kept
//
// Prerequisites: libvirtd, OVMF, ipxe-bootimgs-x86, sudo, internet
// (first run downloads the ~600MB VyOS nightly ISO; artifacts are cached).
// Run: sudo pnpm run test:integration:vyos
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { readFileSync, existsSync, mkdirSync, rmSync, copyFileSync, symlinkSync, writeFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { join } from "node:path";
import { homedir, tmpdir } from "node:os";
import { log, waitForSsh } from "./helpers/libvirt.js";
import { ensurePxeNetwork, destroyPxeNetwork, deleteNftablesRejectRules, PXE_NETWORK_NAME, PXE_GATEWAY, PXE_SUBNET } from "./helpers/pxe-network.js";
import { createPxeVm, destroyPxeVm, getVmMac, rebootPxeVm } from "./helpers/pxe-vm.js";
import { sshExec } from "./helpers/ssh.js";
const VM_NAME = "lab-vyos-test";
const VM_MEMORY = 4096;
const VM_VCPUS = 4;
const VM_DISK_GB = 10; // VyOS image install needs ~2GB minimum
const HTTP_PORT = 8099;
const SSH_USER = "vyos"; // the only VyOS login user
const BASTION_IP = PXE_GATEWAY;
const DHCP_RANGE_START = `${PXE_SUBNET}.100`;
const DHCP_RANGE_END = `${PXE_SUBNET}.200`;
const DISCOVERY_TIMEOUT_MS = 5 * 60_000;
const INSTALL_TIMEOUT_MS = 15 * 60_000; // squashfs fetch + copy; much faster than Anaconda
const SSH_TIMEOUT_MS = 8 * 60_000;
const HOSTNAME_R1 = "vyos-r1";
const HOSTNAME_R2 = "vyos-r2";
const HOSTNAME_R3 = "vyos-r3";
function findSshKey(): { pubKey: string; keyPath: string } {
const homes = [homedir()];
const sudoUser = process.env["SUDO_USER"];
if (sudoUser) homes.push(join("/home", sudoUser));
if (process.env["SSH_KEY_PATH"]) {
const keyPath = process.env["SSH_KEY_PATH"];
const pubPath = `${keyPath}.pub`;
if (existsSync(keyPath) && existsSync(pubPath)) {
return { pubKey: readFileSync(pubPath, "utf-8").trim(), keyPath };
}
}
for (const home of homes) {
for (const name of ["id_ed25519", "id_ecdsa", "id_rsa"]) {
const keyPath = join(home, ".ssh", name);
const pubPath = `${keyPath}.pub`;
if (existsSync(keyPath) && existsSync(pubPath)) {
return { pubKey: readFileSync(pubPath, "utf-8").trim(), keyPath };
}
}
}
throw new Error("No SSH key found — set SSH_KEY_PATH or ensure keys exist in ~/.ssh/");
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function pollApi<T>(
url: string,
check: (data: T) => boolean,
timeoutMs: number,
intervalMs = 5000,
): Promise<T> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url);
if (res.ok) {
const data = (await res.json()) as T;
if (check(data)) return data;
}
} catch { /* not ready yet */ }
await sleep(intervalMs);
}
throw new Error(`Timeout after ${timeoutMs}ms polling ${url}`);
}
type LogsResponse = {
status: string;
progress: string;
progress_detail?: string;
ip?: string;
log_total?: number;
log_lines?: Array<{ line: string }>;
};
/** Queue a VyOS install, reboot the VM into PXE, wait for completion + SSH. */
async function installRound(opts: {
mac: string;
hostname: string;
freshConfig?: boolean;
}): Promise<string> {
const body = {
mac: opts.mac,
hostname: opts.hostname,
disk: "/dev/vda",
role: "vanilla",
os: "vyos-rolling",
vyos: {
mgmtInterface: "eth0",
mgmtAddress: "dhcp",
hwIds: { eth0: opts.mac },
...(opts.freshConfig ? { freshConfig: true } : {}),
},
};
const res = await fetch(`http://${BASTION_IP}:${HTTP_PORT}/api/install`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
log(`Install queued (${opts.hostname}): ${JSON.stringify(await res.json())}`);
await sleep(5_000);
rebootPxeVm(VM_NAME);
await sleep(3_000);
deleteNftablesRejectRules();
const finalState = await pollApi<LogsResponse>(
`http://${BASTION_IP}:${HTTP_PORT}/api/logs/${encodeURIComponent(opts.mac)}`,
(data) => data.status === "installed" || data.progress === "error",
INSTALL_TIMEOUT_MS,
10_000,
);
if (finalState.progress === "error") {
log(`INSTALL FAILED: ${JSON.stringify(finalState.progress_detail ?? finalState, null, 2)}`);
throw new Error(`VyOS install failed for ${opts.hostname}`);
}
const ip = finalState.ip ?? "";
log(`Install complete (${opts.hostname}). IP: ${ip}`);
// The driver force-reboots; the VM PXE boots, dispatch says installed ->
// localboot exit -> GRUB -> VyOS. nftables reject rules do not reappear
// (guest reboot, not a libvirt restart), but clearing is harmless.
deleteNftablesRejectRules();
await waitForSsh(ip, SSH_USER, SSH_TIMEOUT_MS, sshKeyPathGlobal);
return ip;
}
let sshKeyPathGlobal = "";
describe("VyOS provisioning", () => {
let bastionApp: { close: () => Promise<void> };
let testDir: string;
let vmMac: string;
let vmIp: string;
beforeAll(async () => {
const { pubKey, keyPath } = findSshKey();
sshKeyPathGlobal = keyPath;
log("Setting up PXE test network...");
ensurePxeNetwork();
testDir = join(tmpdir(), `lab-vyos-test-${Date.now()}`);
mkdirSync(join(testDir, "tftp"), { recursive: true });
mkdirSync(join(testDir, "http"), { recursive: true });
mkdirSync(join(testDir, "logs"), { recursive: true });
log("Starting bastion...");
const { createApp } = await import("../../src/bastion/src/server.js");
const { loadConfig } = await import("../../src/bastion/src/config.js");
const { generateDnsmasqConf, startDnsmasq } = await import("../../src/bastion/src/services/dnsmasq.js");
const { generateDiscoverKickstart } = await import("../../src/bastion/src/services/kickstart-generator.js");
const { renderBootIpxe } = await import("../../src/bastion/src/templates/boot.ipxe.js");
const { prepareVyosArtifacts } = await import("../../src/bastion/src/main.js");
const config = loadConfig({
bastionDir: testDir,
httpPort: HTTP_PORT,
iface: "virbr-pxe",
serverIp: BASTION_IP,
network: `${PXE_SUBNET}.0`,
gateway: BASTION_IP,
dhcpMode: "full",
dhcpRangeStart: DHCP_RANGE_START,
dhcpRangeEnd: DHCP_RANGE_END,
domain: "pxe-test.local",
sshKeys: [pubKey],
adminUser: "lab",
});
// iPXE binary
const ipxeSrc = "/usr/share/ipxe/ipxe-snponly-x86_64.efi";
if (!existsSync(ipxeSrc)) {
throw new Error(`iPXE not found: ${ipxeSrc}. Install: sudo dnf install ipxe-bootimgs-x86`);
}
copyFileSync(ipxeSrc, join(config.tftpDir, "ipxe.efi"));
try { symlinkSync(join(config.tftpDir, "ipxe.efi"), join(config.httpDir, "ipxe.efi")); } catch { /* exists */ }
const cacheDir = "/var/lib/libvirt/images/lab-pxe-cache";
execSync(`mkdir -p "${cacheDir}"`, { stdio: "pipe" });
// Fedora kernel+initrd for DISCOVERY (OS-neutral, same as pxe test)
const kernel = join(cacheDir, `vmlinuz-${config.fedoraVersion}`);
const initrd = join(cacheDir, `initrd-${config.fedoraVersion}.img`);
if (!existsSync(kernel)) {
log(`Downloading Fedora ${config.fedoraVersion} kernel (discovery)...`);
execSync(`curl -# -L -f -o "${kernel}" "${config.fedoraMirror}/images/pxeboot/vmlinuz"`, { stdio: "inherit", timeout: 300_000 });
}
if (!existsSync(initrd)) {
log(`Downloading Fedora ${config.fedoraVersion} initrd (discovery)...`);
execSync(`curl -# -L -f -o "${initrd}" "${config.fedoraMirror}/images/pxeboot/initrd.img"`, { stdio: "inherit", timeout: 300_000 });
}
copyFileSync(kernel, join(config.httpDir, "vmlinuz"));
copyFileSync(initrd, join(config.httpDir, "initrd.img"));
// VyOS netboot artifacts — cache the three extracted files across runs
const vyosCache = {
kernel: join(cacheDir, "vyos-vmlinuz"),
initrd: join(cacheDir, "vyos-initrd"),
squashfs: join(cacheDir, "vyos-filesystem.squashfs"),
};
if (Object.values(vyosCache).every((p) => existsSync(p))) {
log("VyOS netboot artifacts cached");
copyFileSync(vyosCache.kernel, join(config.httpDir, "vyos-vmlinuz"));
copyFileSync(vyosCache.initrd, join(config.httpDir, "vyos-initrd"));
copyFileSync(vyosCache.squashfs, join(config.httpDir, "vyos-filesystem.squashfs"));
} else {
log("Extracting VyOS artifacts from ISO (downloads ~600MB on first run)...");
prepareVyosArtifacts(config);
copyFileSync(join(config.httpDir, "vyos-vmlinuz"), vyosCache.kernel);
copyFileSync(join(config.httpDir, "vyos-initrd"), vyosCache.initrd);
copyFileSync(join(config.httpDir, "vyos-filesystem.squashfs"), vyosCache.squashfs);
}
writeFileSync(join(config.httpDir, "discover.ks"), generateDiscoverKickstart(config));
writeFileSync(join(config.httpDir, "boot.ipxe"), renderBootIpxe({ serverIp: config.serverIp, httpPort: config.httpPort }));
generateDnsmasqConf(config);
const { app, syslog } = createApp(config);
bastionApp = app;
await app.listen({ port: config.httpPort, host: "0.0.0.0" });
syslog.start();
log(`Bastion listening on :${HTTP_PORT}`);
log("Starting dnsmasq...");
startDnsmasq(config).catch((err) => {
log(`dnsmasq failed (expected without root): ${err instanceof Error ? err.message : String(err)}`);
});
await sleep(1000);
log("Creating PXE VM...");
createPxeVm({
name: VM_NAME,
memory: VM_MEMORY,
vcpus: VM_VCPUS,
diskSize: VM_DISK_GB,
network: PXE_NETWORK_NAME,
});
const mac = getVmMac(VM_NAME);
if (!mac) throw new Error("Could not determine VM MAC address");
vmMac = mac;
log(`VM MAC: ${vmMac}`);
log("Waiting for discovery...");
type MachinesResponse = { discovered: Record<string, unknown> };
await pollApi<MachinesResponse>(
`http://${BASTION_IP}:${HTTP_PORT}/api/machines`,
(data) => vmMac in data.discovered,
DISCOVERY_TIMEOUT_MS,
);
log("VM discovered. Running fresh VyOS install (round 1)...");
await sleep(15_000); // discovery reboot cycle
vmIp = await installRound({ mac: vmMac, hostname: HOSTNAME_R1 });
log("Round 1 (fresh install) complete.");
}, DISCOVERY_TIMEOUT_MS + INSTALL_TIMEOUT_MS + SSH_TIMEOUT_MS + 300_000);
afterAll(async () => {
log("Cleaning up...");
if (bastionApp) await bastionApp.close().catch(() => {});
const { stopDnsmasq } = await import("../../src/bastion/src/services/dnsmasq.js");
stopDnsmasq();
destroyPxeVm(VM_NAME);
destroyPxeNetwork();
if (testDir) rmSync(testDir, { recursive: true, force: true });
});
it("machine is installed with a real IP (WI-1: ready-at parsing)", async () => {
const res = await fetch(`http://${BASTION_IP}:${HTTP_PORT}/api/machines`);
const data = (await res.json()) as { installed: Record<string, { ip: string; os?: string }> };
const machine = data.installed[vmMac];
expect(machine).toBeDefined();
expect(machine.ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(machine.os).toBe("vyos-rolling");
});
it("install logs were streamed live (WI-2)", async () => {
const res = await fetch(`http://${BASTION_IP}:${HTTP_PORT}/api/logs/${encodeURIComponent(vmMac)}`);
const data = (await res.json()) as LogsResponse;
expect(data.log_total).toBeGreaterThan(0);
const lines = (data.log_lines ?? []).map((l) => l.line).join("\n");
// Installer transcript lines and driver messages both flow through /api/log
expect(lines).toMatch(/Welcome to VyOS installation|>>> answered|base config:/);
});
it("SSH works as the vyos user with the injected key", () => {
const result = sshExec(vmIp, SSH_USER, "whoami", { keyPath: sshKeyPathGlobal });
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("vyos");
});
it("generated config was adopted (hostname + ssh key)", () => {
const result = sshExec(vmIp, SSH_USER, "cat /opt/vyatta/etc/config/config.boot", { keyPath: sshKeyPathGlobal });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(`host-name "${HOSTNAME_R1}"`);
expect(result.stdout).toContain("public-keys");
});
it("boot-order step ran and reported (WI-3)", async () => {
const res = await fetch(`http://${BASTION_IP}:${HTTP_PORT}/api/logs/${encodeURIComponent(vmMac)}`);
const data = (await res.json()) as LogsResponse;
const lines = (data.log_lines ?? []).map((l) => l.line).join("\n");
expect(lines).toContain("boot order:");
});
it("provisioning metadata persisted to /config (WI-4)", () => {
const result = sshExec(vmIp, SSH_USER, "cat /config/lab-provisioned 2>/dev/null || cat /opt/vyatta/etc/config/lab-provisioned", { keyPath: sshKeyPathGlobal });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain(`hostname=${HOSTNAME_R1}`);
expect(result.stdout).toContain("role=vanilla");
expect(result.stdout).toContain(`bastion=http://${BASTION_IP}:${HTTP_PORT}`);
});
it("reinstall preserves config and /config data (round 2)", async () => {
// Drop a marker in /config — the installer's previous-installation copy
// must carry it (and the whole old config) into the new image.
// `sync` is REQUIRED: rebootPxeVm uses `virsh destroy` (a hard power-cut),
// so an unsynced write never reaches the disk and the marker vanishes for
// reasons that have nothing to do with the installer.
const marker = sshExec(vmIp, SSH_USER, "echo LAB-MARKER-R2 > /config/lab-marker && sync && cat /config/lab-marker", { keyPath: sshKeyPathGlobal });
expect(marker.exitCode).toBe(0);
expect(marker.stdout).toContain("LAB-MARKER-R2");
// Queue with a DIFFERENT hostname: with preserve semantics the previous
// config must win, so the hostname must NOT change.
vmIp = await installRound({ mac: vmMac, hostname: HOSTNAME_R2 });
// Assert the config carry-forward first — it is the primary preservation
// signal and does not depend on the marker mechanism above.
const cfg = sshExec(vmIp, SSH_USER, "cat /opt/vyatta/etc/config/config.boot", { keyPath: sshKeyPathGlobal });
expect(cfg.stdout).toContain(`host-name "${HOSTNAME_R1}"`); // old config carried
expect(cfg.stdout).not.toContain(`host-name "${HOSTNAME_R2}"`);
const markerAfter = sshExec(vmIp, SSH_USER, "cat /config/lab-marker", { keyPath: sshKeyPathGlobal });
expect(markerAfter.exitCode).toBe(0);
expect(markerAfter.stdout).toContain("LAB-MARKER-R2");
}, INSTALL_TIMEOUT_MS + SSH_TIMEOUT_MS + 60_000);
it("freshConfig makes the generated config win, data still kept (round 3)", async () => {
// Re-assert the marker is on disk and synced before the next power-cut.
const pre = sshExec(vmIp, SSH_USER, "sync && cat /config/lab-marker", { keyPath: sshKeyPathGlobal });
expect(pre.stdout).toContain("LAB-MARKER-R2");
vmIp = await installRound({ mac: vmMac, hostname: HOSTNAME_R3, freshConfig: true });
const cfg = sshExec(vmIp, SSH_USER, "cat /opt/vyatta/etc/config/config.boot", { keyPath: sshKeyPathGlobal });
expect(cfg.stdout).toContain(`host-name "${HOSTNAME_R3}"`); // generated config won
// The marker file (non-config data under /config) still survives —
// freshConfig replaces only config.boot, not the carried data.
const markerAfter = sshExec(vmIp, SSH_USER, "cat /config/lab-marker", { keyPath: sshKeyPathGlobal });
expect(markerAfter.exitCode).toBe(0);
expect(markerAfter.stdout).toContain("LAB-MARKER-R2");
}, INSTALL_TIMEOUT_MS + SSH_TIMEOUT_MS + 60_000);
});