diff --git a/labsim/console-apply.py b/labsim/console-apply.py new file mode 100755 index 0000000..82b1f42 --- /dev/null +++ b/labsim/console-apply.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Apply VyOS config to a labsim VM over its serial console. + +Needed because a freshly installed VyOS comes up holding the same addresses as +its peer, so there is a window where it cannot safely be reached over the +network at all. The console does not care. + + ./console-apply.py --vm labsim-vyos2 --config r2.conf +""" +from __future__ import annotations + +import argparse +import sys +import time + +import pexpect + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--vm", required=True) + ap.add_argument("--config", required=True) + ap.add_argument("--user", default="vyos") + ap.add_argument("--password", default="vyos") + args = ap.parse_args() + + cmds = [l.rstrip() for l in open(args.config) + if l.strip() and not l.lstrip().startswith("#")] + print(f"{len(cmds)} commands to apply to {args.vm}", file=sys.stderr) + + c = pexpect.spawn(f"virsh --connect qemu:///system console {args.vm}", + timeout=90, encoding="utf-8") + c.logfile_read = None + c.sendline("") + time.sleep(2) + c.sendline("") + + # Log in. A freshly booted box may still be starting services, so allow a + # generous window and re-prod the console rather than failing on the first + # miss. + for _ in range(40): + i = c.expect([r"login:", r"\$ ", r"# ", pexpect.TIMEOUT], timeout=15) + if i == 0: + c.sendline(args.user) + c.expect("Password:", timeout=30) + c.sendline(args.password) + c.expect(r"\$ ", timeout=60) + break + if i in (1, 2): + break + c.sendline("") + else: + print("never reached a prompt", file=sys.stderr) + return 1 + + c.sendline("configure") + c.expect(r"# ", timeout=60) + + for cmd in cmds: + c.sendline(cmd) + c.expect(r"# ", timeout=60) + out = c.before or "" + if "Set failed" in out or "not valid" in out or "Invalid" in out: + print(f"FAILED: {cmd}\n {out.strip()[:200]}", file=sys.stderr) + + print("committing...", file=sys.stderr) + c.sendline("commit") + c.expect(r"# ", timeout=300) + commit_out = c.before or "" + c.sendline("save") + c.expect(r"# ", timeout=120) + c.sendline("exit") + c.expect(r"\$ ", timeout=60) + c.sendline("exit") + c.close() + + bad = [l for l in commit_out.splitlines() + if "failed" in l.lower() or "error" in l.lower()] + if bad: + print("commit reported:", file=sys.stderr) + for l in bad[:10]: + print(f" {l.strip()}", file=sys.stderr) + return 1 + print("committed and saved", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/labsim/sim-ha-config.py b/labsim/sim-ha-config.py new file mode 100755 index 0000000..62389e4 --- /dev/null +++ b/labsim/sim-ha-config.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Generate the HA config for the labsim VyOS pair. + +Exists to answer one question that cannot be answered on a single router, and +that would otherwise only be discovered at cutover: with kea HA active-passive, +does exactly ONE box answer a DHCP request? + +Mirrors the production shape so the answer transfers: + + router1 172.31..252 priority 200 DHCP HA primary + router2 172.31..253 priority 100 DHCP HA secondary + VIP 172.31..1 (what clients use as their gateway) + +Note the sim's LoT VLAN is a /23 like production, so the VIP prefix differs +there -- getting that wrong produces a config that commits and then behaves +subtly wrongly, which is worse than a failure. + + ./sim-ha-config.py --role primary > r1.conf + ./sim-ha-config.py --role secondary > r2.conf +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +MIG = os.path.join(HERE, "..", "migration") + +# Reuse the DHCP/DNS generator rather than hand-writing subnets: the whole +# point is that what is proven here and what production gets share a code path. +_spec = importlib.util.spec_from_file_location( + "unifi_to_vyos", os.path.join(MIG, "unifi-to-vyos.py")) +unifi_to_vyos = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(unifi_to_vyos) + +# vlan -> (prefix, cidr). LoT is a /23 in the sim, matching production. +VLANS = { + 1: ("172.31.1", 24), + 2: ("172.31.2", 24), + 3: ("172.31.3", 24), + 9: ("172.31.9", 24), + 10: ("172.31.10", 23), + 200: ("172.31.200", 24), +} +DHCP_HA_NAME = "labsim-dhcp-pair" # must not equal either host-name + + +def group(vlan: int) -> str: + return "native" if vlan == 1 else f"vlan{vlan}" + + +def build(role: str) -> list[str]: + primary = role == "primary" + self_o, peer_o = (252, 253) if primary else (253, 252) + prio = 200 if primary else 100 + out = [f"# labsim VyOS HA -- {role}", ""] + + for vlan, (pfx, cidr) in VLANS.items(): + g = group(vlan) + iface = "bond0" if vlan == 1 else f"bond0 vif {vlan}" + out += [ + f"# VLAN {vlan}", + # The node's own address replaces the .1 it used to hold directly; + # .1 becomes the floating VIP, exactly as production will be. + f"delete interfaces bonding {iface} address", + f"set interfaces bonding {iface} address '{pfx}.{self_o}/{cidr}'", + f"set high-availability vrrp group {g} interface bond0{'' if vlan == 1 else f'.{vlan}'}", + f"set high-availability vrrp group {g} vrid {vlan}", + f"set high-availability vrrp group {g} address {pfx}.1/{cidr}", + f"set high-availability vrrp group {g} priority {prio}", + f"set high-availability vrrp group {g} hello-source-address {pfx}.{self_o}", + f"set high-availability vrrp group {g} peer-address {pfx}.{peer_o}", + f"set high-availability vrrp group {g} no-preempt", + f"set high-availability vrrp sync-group MAIN member {g}", + "", + ] + + out += [ + "# --- DHCP high-availability ---", + "# The thing under test: active-passive should mean exactly one OFFER.", + "set service dhcp-server high-availability mode active-passive", + f"set service dhcp-server high-availability status {role}", + f"set service dhcp-server high-availability name {DHCP_HA_NAME}", + f"set service dhcp-server high-availability source-address 172.31.10.{self_o}", + f"set service dhcp-server high-availability remote 172.31.10.{peer_o}", + "", + ] + + inv = json.load(open(os.path.join(MIG, "export", "inventory.json"))) + dhcp, stats = unifi_to_vyos.build(inv, "sim") + out += [l for l in dhcp if l.strip() and not l.startswith("#")] + print(f"{role}: {stats['subnets']} subnets, {stats['mappings']} mappings", + file=sys.stderr) + return out + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--role", choices=("primary", "secondary"), required=True) + args = ap.parse_args() + sys.stdout.write("\n".join(build(args.role)) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())