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: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": "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: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": "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: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'",
|
"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;
|
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
|
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
|
// 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);
|
void triggerPostProvisionK3s(installedInfo.hostname, ip, installedInfo.role, admin, mac);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ exec python3 /tmp/vyos-install.py
|
|||||||
mac,
|
mac,
|
||||||
serverIp: config.serverIp,
|
serverIp: config.serverIp,
|
||||||
httpPort: config.httpPort,
|
httpPort: config.httpPort,
|
||||||
|
role: queueEntry?.role ?? "vanilla",
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.type("text/plain").send(script);
|
return reply.type("text/plain").send(script);
|
||||||
|
|||||||
@@ -24,8 +24,16 @@ export interface VyosConfigSpec {
|
|||||||
console: "K" | "S";
|
console: "K" | "S";
|
||||||
/** Target disk name (e.g. "nvme0n1"); "" accepts the installer's first-disk default. */
|
/** Target disk name (e.g. "nvme0n1"); "" accepts the installer's first-disk default. */
|
||||||
disk: string;
|
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. */
|
/** Whether to accept RAID-1 when the installer finds more than one disk. */
|
||||||
raid: boolean;
|
raid: boolean;
|
||||||
|
/** Overwrite the installed config.boot with the generated one on reinstall. */
|
||||||
|
freshConfig: boolean;
|
||||||
sets: VyosSetOp[];
|
sets: VyosSetOp[];
|
||||||
/** Paths that are VyOS tag nodes — must be marked as such in the ConfigTree. */
|
/** Paths that are VyOS tag nodes — must be marked as such in the ConfigTree. */
|
||||||
tags: string[][];
|
tags: string[][];
|
||||||
@@ -190,7 +198,10 @@ export function buildVyosConfigSpec(params: {
|
|||||||
password: spec.password ?? params.defaultPassword,
|
password: spec.password ?? params.defaultPassword,
|
||||||
console: "K",
|
console: "K",
|
||||||
disk: normalizeDiskPath(params.disk),
|
disk: normalizeDiskPath(params.disk),
|
||||||
|
// Static mgmt address wins; under DHCP the driver detects the live IP.
|
||||||
|
reportAddress: mgmtAddress.includes("/") ? (mgmtAddress.split("/")[0] ?? "") : "",
|
||||||
raid: false,
|
raid: false,
|
||||||
|
freshConfig: spec.freshConfig ?? false,
|
||||||
sets,
|
sets,
|
||||||
tags,
|
tags,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function renderVyosInstallPy(params: {
|
|||||||
mac: string;
|
mac: string;
|
||||||
serverIp: string;
|
serverIp: string;
|
||||||
httpPort: number;
|
httpPort: number;
|
||||||
|
role: string;
|
||||||
}): string {
|
}): string {
|
||||||
// Base64 so arbitrary values (passwords, descriptions, SSH keys) can never
|
// Base64 so arbitrary values (passwords, descriptions, SSH keys) can never
|
||||||
// terminate the Python string literal that carries them.
|
// terminate the Python string literal that carries them.
|
||||||
@@ -41,6 +42,7 @@ import urllib.request
|
|||||||
SPEC = json.loads(base64.b64decode("${specB64}").decode("utf-8"))
|
SPEC = json.loads(base64.b64decode("${specB64}").decode("utf-8"))
|
||||||
BASTION = "http://${params.serverIp}:${params.httpPort}"
|
BASTION = "http://${params.serverIp}:${params.httpPort}"
|
||||||
MAC = "${params.mac}"
|
MAC = "${params.mac}"
|
||||||
|
ROLE = ${JSON.stringify(params.role ?? "vanilla")}
|
||||||
|
|
||||||
INSTALLER = "/usr/libexec/vyos/op_mode/image_installer.py"
|
INSTALLER = "/usr/libexec/vyos/op_mode/image_installer.py"
|
||||||
CONFIG_DIR = "/opt/vyatta/etc/config"
|
CONFIG_DIR = "/opt/vyatta/etc/config"
|
||||||
@@ -60,8 +62,86 @@ DEFAULT_CONFIG_CANDIDATES = [
|
|||||||
STALL_TIMEOUT = 900 # seconds without installer output before giving up
|
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=""):
|
def report(stage, detail=""):
|
||||||
"""Best-effort progress callback; never fatal."""
|
"""Best-effort progress callback; never fatal."""
|
||||||
|
STREAM.flush()
|
||||||
try:
|
try:
|
||||||
body = json.dumps({"mac": MAC, "stage": stage, "detail": detail}).encode()
|
body = json.dumps({"mac": MAC, "stage": stage, "detail": detail}).encode()
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
@@ -87,7 +167,7 @@ def build_config():
|
|||||||
if default_config is None:
|
if default_config is None:
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
"no config.boot.default found (tried %s)" % ", ".join(DEFAULT_CONFIG_CANDIDATES))
|
"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:
|
with open(default_config) as handle:
|
||||||
config = ConfigTree(handle.read())
|
config = ConfigTree(handle.read())
|
||||||
@@ -104,7 +184,7 @@ def build_config():
|
|||||||
try:
|
try:
|
||||||
config.set_tag(tag)
|
config.set_tag(tag)
|
||||||
except Exception as err:
|
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)
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||||
target = os.path.join(CONFIG_DIR, "config.boot")
|
target = os.path.join(CONFIG_DIR, "config.boot")
|
||||||
@@ -120,9 +200,9 @@ def build_config():
|
|||||||
info = version_info_from_system()
|
info = version_info_from_system()
|
||||||
info.update_config_body(body)
|
info.update_config_body(body)
|
||||||
info.write(target)
|
info.write(target)
|
||||||
print("wrote %s (footer: %s)" % (target, info.release))
|
say("wrote %s (footer: %s)" % (target, info.release))
|
||||||
except Exception as err:
|
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:
|
with open(target, "w") as handle:
|
||||||
handle.write(body)
|
handle.write(body)
|
||||||
return target
|
return target
|
||||||
@@ -158,14 +238,14 @@ def ensure_rootfs():
|
|||||||
return
|
return
|
||||||
src = find_live_squashfs()
|
src = find_live_squashfs()
|
||||||
if src is None:
|
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"
|
src = "/tmp/filesystem.squashfs"
|
||||||
urllib.request.urlretrieve(SQUASHFS_URL, src)
|
urllib.request.urlretrieve(SQUASHFS_URL, src)
|
||||||
os.makedirs(os.path.dirname(ROOTFS_EXPECTED), exist_ok=True)
|
os.makedirs(os.path.dirname(ROOTFS_EXPECTED), exist_ok=True)
|
||||||
if os.path.lexists(ROOTFS_EXPECTED):
|
if os.path.lexists(ROOTFS_EXPECTED):
|
||||||
os.remove(ROOTFS_EXPECTED)
|
os.remove(ROOTFS_EXPECTED)
|
||||||
os.symlink(src, 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():
|
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 confirm password for the .vyos. user:"), password),
|
||||||
(re.compile(rb"Please enter a 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"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"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"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"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"),
|
(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()
|
sys.stdout.buffer.flush()
|
||||||
buf += chunk
|
buf += chunk
|
||||||
transcript = (transcript + chunk)[-8000:]
|
transcript = (transcript + chunk)[-8000:]
|
||||||
|
STREAM.feed(chunk)
|
||||||
last_output = time.time()
|
last_output = time.time()
|
||||||
|
|
||||||
# Answer every prompt currently in the buffer, earliest first, so
|
# Answer every prompt currently in the buffer, earliest first, so
|
||||||
@@ -240,6 +333,7 @@ def run_installer():
|
|||||||
found, response = best
|
found, response = best
|
||||||
os.write(master, response)
|
os.write(master, response)
|
||||||
transcript = (transcript + b"\\n>>> answered: " + response)[-8000:]
|
transcript = (transcript + b"\\n>>> answered: " + response)[-8000:]
|
||||||
|
STREAM.line(">>> answered: " + response.decode("utf-8", "replace").strip())
|
||||||
buf = buf[found.end():]
|
buf = buf[found.end():]
|
||||||
|
|
||||||
# Bound memory if the installer emits a lot without prompting.
|
# Bound memory if the installer emits a lot without prompting.
|
||||||
@@ -249,6 +343,8 @@ def run_installer():
|
|||||||
elif proc.poll() is not None:
|
elif proc.poll() is not None:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
STREAM.maybe_flush()
|
||||||
|
|
||||||
if time.time() - last_output > STALL_TIMEOUT:
|
if time.time() - last_output > STALL_TIMEOUT:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
raise SystemExit("installer produced no output for %ds" % STALL_TIMEOUT)
|
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")
|
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():
|
def main():
|
||||||
report("vyos-install", "building config.boot")
|
report("vyos-install", "building config.boot")
|
||||||
try:
|
try:
|
||||||
@@ -281,9 +486,19 @@ def main():
|
|||||||
report("error", "install image exited %d | tail: %s" % (code, transcript[-4000:]))
|
report("error", "install image exited %d | tail: %s" % (code, transcript[-4000:]))
|
||||||
raise SystemExit(code)
|
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
|
# "complete" is the stage the bastion uses to move a machine out of the
|
||||||
# install queue into installed state -- see routes/api.ts.
|
# install queue into installed state, and "ready at <ip>" is the exact
|
||||||
report("complete", "VyOS installed, rebooting")
|
# 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")
|
os.system("sync")
|
||||||
# --force: this driver is a child of live-config.service, whose start job is
|
# --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:
|
# 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 { createApp } from "../src/server.js";
|
||||||
import type { StateManager } from "../src/services/state.js";
|
import type { StateManager } from "../src/services/state.js";
|
||||||
import { buildVyosConfigSpec } from "../src/templates/vyos-config-spec.js";
|
import { buildVyosConfigSpec } from "../src/templates/vyos-config-spec.js";
|
||||||
|
import { renderVyosInstallPy } from "../src/templates/vyos-install.py.js";
|
||||||
|
|
||||||
function createTestConfig(testDir: string): BastionConfig {
|
function createTestConfig(testDir: string): BastionConfig {
|
||||||
return {
|
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();
|
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("--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-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-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: {
|
.action(async (mac: string, hostname: string, opts: {
|
||||||
role: string;
|
role: string;
|
||||||
os: string;
|
os: string;
|
||||||
@@ -102,6 +103,7 @@ export function registerInstallCommand(parent: Command): void {
|
|||||||
vyosMgmtVlan?: string;
|
vyosMgmtVlan?: string;
|
||||||
vyosPassword?: string;
|
vyosPassword?: string;
|
||||||
vyosHwid?: Record<string, string>;
|
vyosHwid?: Record<string, string>;
|
||||||
|
vyosFreshConfig?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (!isValidOsId(opts.os)) {
|
if (!isValidOsId(opts.os)) {
|
||||||
console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`);
|
console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`);
|
||||||
@@ -156,6 +158,7 @@ export function registerInstallCommand(parent: Command): void {
|
|||||||
? { hwIds: opts.vyosHwid } : {}),
|
? { hwIds: opts.vyosHwid } : {}),
|
||||||
...(opts.vyosMgmtVlan !== undefined && opts.vyosMgmtVlan !== ""
|
...(opts.vyosMgmtVlan !== undefined && opts.vyosMgmtVlan !== ""
|
||||||
? { mgmtVlan: parseVlan(opts.vyosMgmtVlan)[0] as VyosVlanSpec } : {}),
|
? { mgmtVlan: parseVlan(opts.vyosMgmtVlan)[0] as VyosVlanSpec } : {}),
|
||||||
|
...(opts.vyosFreshConfig === true ? { freshConfig: true } : {}),
|
||||||
};
|
};
|
||||||
const hasVyosOptions = Object.keys(vyos).length > 0;
|
const hasVyosOptions = Object.keys(vyos).length > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,14 @@ export function registerRecheckCommand(parent: Command): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build list of machines to check
|
// 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)) {
|
for (const [mac, info] of Object.entries(state.installed)) {
|
||||||
if (!info.ip) continue;
|
if (!info.ip) continue;
|
||||||
if (opts.target && info.hostname !== opts.target && mac !== opts.target) 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) {
|
if (targets.length === 0) {
|
||||||
@@ -61,12 +64,12 @@ export function registerRecheckCommand(parent: Command): void {
|
|||||||
let updated = 0;
|
let updated = 0;
|
||||||
let failed = 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} `);
|
process.stdout.write(` ${hostname.padEnd(24)} ${DIM}(${ip})${RESET} `);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const t0 = Date.now();
|
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;
|
const elapsed = Date.now() - t0;
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
console.log(`${RED}SSH failed (exit ${result.exitCode}, ${elapsed}ms)${RESET}`);
|
console.log(`${RED}SSH failed (exit ${result.exitCode}, ${elapsed}ms)${RESET}`);
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ function roleTable(): string {
|
|||||||
function resolveTarget(
|
function resolveTarget(
|
||||||
target: string,
|
target: string,
|
||||||
state: BastionState,
|
state: BastionState,
|
||||||
): { mac: string; hostname: string; ip: string } | null {
|
): { mac: string; hostname: string; ip: string; os?: string } | null {
|
||||||
const normalized = target.toLowerCase().replace(/-/g, ":");
|
const normalized = target.toLowerCase().replace(/-/g, ":");
|
||||||
|
|
||||||
if (state.installed[normalized]) {
|
if (state.installed[normalized]) {
|
||||||
const info = 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]) {
|
if (state.discovered[normalized]) {
|
||||||
@@ -38,13 +38,13 @@ function resolveTarget(
|
|||||||
|
|
||||||
for (const [mac, info] of Object.entries(state.installed)) {
|
for (const [mac, info] of Object.entries(state.installed)) {
|
||||||
if (info.hostname === target || info.hostname.startsWith(target + ".")) {
|
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)) {
|
for (const [mac, info] of Object.entries(state.installed)) {
|
||||||
if (info.ip === target) {
|
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("--role <role>", "Machine role (see below)").choices([...SUPPORTED_ROLES]).default("worker"))
|
||||||
.addOption(new Option("--os <os>", "Operating system").choices([...SUPPORTED_OS]).default("fedora-43"))
|
.addOption(new Option("--os <os>", "Operating system").choices([...SUPPORTED_OS]).default("fedora-43"))
|
||||||
.option("--disk <device>", "Target disk device (auto-detect if omitted)")
|
.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: {
|
.action(async (target: string, hostnameOverride: string | undefined, opts: {
|
||||||
role: string;
|
role: string;
|
||||||
os: string;
|
os: string;
|
||||||
disk?: string;
|
disk?: string;
|
||||||
|
user?: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (!isValidOsId(opts.os)) {
|
if (!isValidOsId(opts.os)) {
|
||||||
console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`);
|
console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`);
|
||||||
@@ -123,7 +125,11 @@ export function registerReprovisionCommand(parent: Command): void {
|
|||||||
return;
|
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;
|
const effectiveUser = adminUser === "root" ? "" : adminUser;
|
||||||
|
|
||||||
if (effectiveUser === "") {
|
if (effectiveUser === "") {
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ export interface VyosInstallSpec {
|
|||||||
mgmtVlan?: VyosVlanSpec;
|
mgmtVlan?: VyosVlanSpec;
|
||||||
/** Password for the "vyos" user. Falls back to the bastion default. */
|
/** Password for the "vyos" user. Falls back to the bastion default. */
|
||||||
password?: string;
|
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.
|
* 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