feat(firecracker): portable systemd-unit install for the network pool
Make the network pool a persistent, distro-uniform resource instead of a non-persistent per-distro shell command. systemd is the common denominator across Debian/Ubuntu/Fedora/RHEL/Arch/… (and NixOS), so `backend setup` on any systemd host now installs one bot-bottle-owned oneshot unit (bot-bottle-firecracker-netpool.service): params pinned via Environment= (so it doesn't depend on $SUDO_USER at boot), ExecStart/Stop delegating to the bundled bring-up script (single source of logic). - render_systemd_unit() in netpool.py (derived from the same constants as the shell script + nix module). - backend setup: install + enable the unit directly when run as root, else print a self-contained copy-paste block. NixOS keeps its declarative module (which produces the same-named unit); non-systemd hosts fall back to the raw imperative script. - backend teardown: symmetric — disable + remove the unit. - backend status: report the unit's active/inactive state so you can tell a persistent install from an imperative one. Net: one install path (`backend setup`), persistent, identical across distros; per-distro variance shrinks to installing nft + iproute2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -231,13 +231,47 @@ def allocate(slug: str) -> tuple[Slot, IO[str]]:
|
|||||||
|
|
||||||
# --- config renderers (shown by `./cli.py backend setup`) -----------
|
# --- config renderers (shown by `./cli.py backend setup`) -----------
|
||||||
|
|
||||||
|
# The persistent unit is the portable install: the same systemd oneshot
|
||||||
|
# on every systemd distro (Debian/Ubuntu/Fedora/RHEL/Arch/…).
|
||||||
|
SYSTEMD_UNIT = "bot-bottle-firecracker-netpool.service"
|
||||||
|
|
||||||
|
|
||||||
def render_shell_setup() -> str:
|
def render_shell_setup() -> str:
|
||||||
"""The imperative command for non-NixOS hosts."""
|
"""The imperative one-shot command — non-persistent fallback for
|
||||||
|
hosts without systemd (OpenRC/runit/manual)."""
|
||||||
env = _nondefault_env()
|
env = _nondefault_env()
|
||||||
prefix = f"{env} " if env else ""
|
prefix = f"{env} " if env else ""
|
||||||
return f"sudo {prefix}./scripts/firecracker-netpool.sh up"
|
return f"sudo {prefix}./scripts/firecracker-netpool.sh up"
|
||||||
|
|
||||||
|
|
||||||
|
def render_systemd_unit(owner: str, script_path: str) -> str:
|
||||||
|
"""A portable systemd oneshot unit for the pool — identical across
|
||||||
|
every systemd distro. ExecStart/ExecStop delegate to the bundled
|
||||||
|
shell script (the single source of bring-up logic); pool params are
|
||||||
|
pinned via Environment= so the unit matches the CLI's current
|
||||||
|
settings and doesn't depend on $SUDO_USER at boot (systemd runs it
|
||||||
|
as root with no SUDO_USER, which would otherwise own the TAPs as
|
||||||
|
root and break the rootless launch)."""
|
||||||
|
return f"""[Unit]
|
||||||
|
Description=bot-bottle Firecracker TAP pool + nft isolation table
|
||||||
|
After=network-pre.target
|
||||||
|
Wants=network-pre.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
Environment=BOT_BOTTLE_FC_POOL_SIZE={pool_size()}
|
||||||
|
Environment=BOT_BOTTLE_FC_IP_BASE={ip_base()}
|
||||||
|
Environment=BOT_BOTTLE_FC_IFACE_PREFIX={IFACE_PREFIX}
|
||||||
|
Environment=BOT_BOTTLE_FC_OWNER={owner}
|
||||||
|
ExecStart={script_path} up
|
||||||
|
ExecStop={script_path} down
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# The NixOS setup is a real, importable module (nix/firecracker-netpool.nix,
|
# The NixOS setup is a real, importable module (nix/firecracker-netpool.nix,
|
||||||
# exposed as the flake output nixosModules.firecracker-netpool) rather than a
|
# exposed as the flake output nixosModules.firecracker-netpool) rather than a
|
||||||
# generated paste — see `backend setup` output.
|
# generated paste — see `backend setup` output.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -23,10 +24,17 @@ from . import util
|
|||||||
|
|
||||||
|
|
||||||
_FC_RELEASES = "https://github.com/firecracker-microvm/firecracker/releases"
|
_FC_RELEASES = "https://github.com/firecracker-microvm/firecracker/releases"
|
||||||
|
_UNIT_PATH = Path("/etc/systemd/system") / netpool.SYSTEMD_UNIT
|
||||||
|
|
||||||
|
|
||||||
def _owner() -> str:
|
def _owner() -> str:
|
||||||
return os.environ.get("USER", "youruser")
|
# Under `sudo`, USER is root but SUDO_USER is the real invoker — the
|
||||||
|
# TAPs must be owned by them so `./cli.py start` stays rootless.
|
||||||
|
return os.environ.get("SUDO_USER") or os.environ.get("USER") or "youruser"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_systemd() -> bool:
|
||||||
|
return Path("/run/systemd/system").is_dir()
|
||||||
|
|
||||||
|
|
||||||
def _module_path() -> str:
|
def _module_path() -> str:
|
||||||
@@ -34,6 +42,11 @@ def _module_path() -> str:
|
|||||||
return str(Path(__file__).resolve().parents[3] / "nix" / "firecracker-netpool.nix")
|
return str(Path(__file__).resolve().parents[3] / "nix" / "firecracker-netpool.nix")
|
||||||
|
|
||||||
|
|
||||||
|
def _script_path() -> str:
|
||||||
|
"""Absolute path to the bundled bring-up script in this checkout."""
|
||||||
|
return str(Path(__file__).resolve().parents[3] / "scripts" / "firecracker-netpool.sh")
|
||||||
|
|
||||||
|
|
||||||
def _print_prereqs() -> None:
|
def _print_prereqs() -> None:
|
||||||
"""The firecracker binary + KVM + guest artifacts, shown before the
|
"""The firecracker binary + KVM + guest artifacts, shown before the
|
||||||
privileged network-pool step so operators see the full picture."""
|
privileged network-pool step so operators see the full picture."""
|
||||||
@@ -113,21 +126,65 @@ def setup() -> int:
|
|||||||
f" services.bot-bottle-firecracker = {{ enable = true; owner = \"{_owner()}\"; }};\n\n"
|
f" services.bot-bottle-firecracker = {{ enable = true; owner = \"{_owner()}\"; }};\n\n"
|
||||||
"Then `nixos-rebuild switch`.\n"
|
"Then `nixos-rebuild switch`.\n"
|
||||||
)
|
)
|
||||||
|
elif _has_systemd():
|
||||||
|
_setup_systemd()
|
||||||
else:
|
else:
|
||||||
sys.stderr.write("Run the one-time setup as root:\n\n")
|
|
||||||
sys.stdout.write(netpool.render_shell_setup() + "\n")
|
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"\n(On NixOS, use the declarative module instead — this host "
|
"No systemd detected. Run the one-time bring-up as root (and add "
|
||||||
"was not detected as NixOS.)\n"
|
"your own boot persistence — e.g. an OpenRC/runit service):\n\n"
|
||||||
)
|
)
|
||||||
|
sys.stdout.write(netpool.render_shell_setup() + "\n")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_systemd() -> None:
|
||||||
|
"""Install the pool as a persistent systemd unit — the portable path,
|
||||||
|
identical on every systemd distro. Performs the install directly when
|
||||||
|
run as root; otherwise prints a self-contained copy-paste block."""
|
||||||
|
unit = netpool.render_systemd_unit(_owner(), _script_path())
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Persistent install (systemd — same on every systemd distro). Needs "
|
||||||
|
f"`nft` (nftables) and `ip` (iproute2); install via your package "
|
||||||
|
f"manager if `backend status` reports them missing.\n\n"
|
||||||
|
)
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
_UNIT_PATH.write_text(unit)
|
||||||
|
subprocess.run(["systemctl", "daemon-reload"], check=False)
|
||||||
|
rc = subprocess.run(
|
||||||
|
["systemctl", "enable", "--now", netpool.SYSTEMD_UNIT], check=False,
|
||||||
|
).returncode
|
||||||
|
if rc == 0:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Installed and started {netpool.SYSTEMD_UNIT}. Verify with "
|
||||||
|
f"`./cli.py backend status --backend=firecracker`.\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Wrote {_UNIT_PATH} but `systemctl enable --now` failed — "
|
||||||
|
f"check `systemctl status {netpool.SYSTEMD_UNIT}`.\n"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
sys.stderr.write(
|
||||||
|
"Install the unit (one copy-paste; enables it on boot too):\n\n"
|
||||||
|
)
|
||||||
|
sys.stdout.write(
|
||||||
|
f"sudo tee {_UNIT_PATH} >/dev/null <<'UNIT'\n"
|
||||||
|
f"{unit}"
|
||||||
|
f"UNIT\n"
|
||||||
|
f"sudo systemctl daemon-reload\n"
|
||||||
|
f"sudo systemctl enable --now {netpool.SYSTEMD_UNIT}\n"
|
||||||
|
)
|
||||||
|
sys.stderr.write(
|
||||||
|
f"\n(Or re-run this as root to install it directly: "
|
||||||
|
f"sudo ./cli.py backend setup --backend=firecracker)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def teardown() -> int:
|
def teardown() -> int:
|
||||||
slots = netpool.all_slots()
|
slots = netpool.all_slots()
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"Undo the Firecracker network pool ({len(slots)} slots, base "
|
f"Undo the Firecracker network pool ({len(slots)} slots, base "
|
||||||
f"{netpool.ip_base()}) — a privileged, one-time operation.\n\n"
|
f"{netpool.ip_base()}) — a privileged operation.\n\n"
|
||||||
)
|
)
|
||||||
if _is_nixos():
|
if _is_nixos():
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
@@ -136,8 +193,28 @@ def teardown() -> int:
|
|||||||
"netdevs and nft table are removed declaratively.\n\n"
|
"netdevs and nft table are removed declaratively.\n\n"
|
||||||
"To tear down imperatively before a rebuild (does not persist):\n\n"
|
"To tear down imperatively before a rebuild (does not persist):\n\n"
|
||||||
)
|
)
|
||||||
else:
|
sys.stdout.write("sudo ./scripts/firecracker-netpool.sh down\n")
|
||||||
sys.stderr.write("Run the teardown as root:\n\n")
|
return 0
|
||||||
|
if _has_systemd():
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "disable", "--now", netpool.SYSTEMD_UNIT],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
_UNIT_PATH.unlink(missing_ok=True)
|
||||||
|
subprocess.run(["systemctl", "daemon-reload"], check=False)
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Stopped, disabled, and removed {netpool.SYSTEMD_UNIT}.\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sys.stderr.write("Remove the persistent unit (one copy-paste):\n\n")
|
||||||
|
sys.stdout.write(
|
||||||
|
f"sudo systemctl disable --now {netpool.SYSTEMD_UNIT}\n"
|
||||||
|
f"sudo rm -f {_UNIT_PATH}\n"
|
||||||
|
f"sudo systemctl daemon-reload\n"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
sys.stderr.write("Run the teardown as root:\n\n")
|
||||||
sys.stdout.write("sudo ./scripts/firecracker-netpool.sh down\n")
|
sys.stdout.write("sudo ./scripts/firecracker-netpool.sh down\n")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -174,6 +251,26 @@ def status() -> int:
|
|||||||
ok = False
|
ok = False
|
||||||
else:
|
else:
|
||||||
sys.stderr.write(f"range overlap: none (base {netpool.ip_base()})\n")
|
sys.stderr.write(f"range overlap: none (base {netpool.ip_base()})\n")
|
||||||
|
_report_persistence()
|
||||||
if not ok:
|
if not ok:
|
||||||
sys.stderr.write("\nRun: ./cli.py backend setup --backend=firecracker\n")
|
sys.stderr.write("\nRun: ./cli.py backend setup --backend=firecracker\n")
|
||||||
return 0 if ok else 1
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _report_persistence() -> None:
|
||||||
|
"""Report whether the pool is installed as the persistent systemd
|
||||||
|
unit (so it survives reboot) vs brought up imperatively. Advisory —
|
||||||
|
doesn't affect launch readiness."""
|
||||||
|
if not _has_systemd():
|
||||||
|
return
|
||||||
|
state = subprocess.run(
|
||||||
|
["systemctl", "is-active", netpool.SYSTEMD_UNIT],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
).stdout.strip() or "unknown"
|
||||||
|
if state == "active":
|
||||||
|
sys.stderr.write(f"persistence: {netpool.SYSTEMD_UNIT} active "
|
||||||
|
f"(survives reboot)\n")
|
||||||
|
else:
|
||||||
|
sys.stderr.write(f"persistence: {netpool.SYSTEMD_UNIT} {state} — pool "
|
||||||
|
f"is not installed as a persistent unit (install with "
|
||||||
|
f"`backend setup` so it survives reboot)\n")
|
||||||
|
|||||||
@@ -73,6 +73,21 @@ class TestNetpoolRenderers(unittest.TestCase):
|
|||||||
self.assertIn("BOT_BOTTLE_FC_POOL_SIZE=3", out)
|
self.assertIn("BOT_BOTTLE_FC_POOL_SIZE=3", out)
|
||||||
self.assertIn("firecracker-netpool.sh up", out)
|
self.assertIn("firecracker-netpool.sh up", out)
|
||||||
|
|
||||||
|
def test_systemd_unit_pins_params_and_calls_script(self):
|
||||||
|
# The portable install: one oneshot unit, params pinned via
|
||||||
|
# Environment= (must not depend on $SUDO_USER at boot), delegating
|
||||||
|
# bring-up/teardown to the bundled script.
|
||||||
|
script = "/opt/bb/scripts/firecracker-netpool.sh"
|
||||||
|
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "4"}):
|
||||||
|
unit = netpool.render_systemd_unit("alice", script)
|
||||||
|
self.assertIn("Type=oneshot", unit)
|
||||||
|
self.assertIn("BOT_BOTTLE_FC_POOL_SIZE=4", unit)
|
||||||
|
self.assertIn("BOT_BOTTLE_FC_OWNER=alice", unit)
|
||||||
|
self.assertIn(f"BOT_BOTTLE_FC_IP_BASE={netpool.ip_base()}", unit)
|
||||||
|
self.assertIn(f"ExecStart={script} up", unit)
|
||||||
|
self.assertIn(f"ExecStop={script} down", unit)
|
||||||
|
self.assertIn("WantedBy=multi-user.target", unit)
|
||||||
|
|
||||||
|
|
||||||
class TestFirecrackerStatus(unittest.TestCase):
|
class TestFirecrackerStatus(unittest.TestCase):
|
||||||
"""`status()` gates launch: ready == TAP pool present + no overlap.
|
"""`status()` gates launch: ready == TAP pool present + no overlap.
|
||||||
|
|||||||
Reference in New Issue
Block a user