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
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:
@@ -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'",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 === "") {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
381
bastion/tests/integration/vyos-provision.test.ts
Normal file
381
bastion/tests/integration/vyos-provision.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user