fix(firecracker): version-aware infra-VM adoption + robust teardown
test / integration-docker (pull_request) Successful in 15s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / coverage (pull_request) Failing after 35s
test / unit (pull_request) Successful in 40s
test / integration-firecracker (pull_request) Failing after 1m46s
lint / lint (push) Failing after 2m33s
test / integration-docker (pull_request) Successful in 15s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / coverage (pull_request) Failing after 35s
test / unit (pull_request) Successful in 40s
test / integration-firecracker (pull_request) Failing after 1m46s
lint / lint (push) Failing after 2m33s
The infra VM is a per-host singleton that outlives short-lived launchers, so
`ensure_running` adopts it when its control plane is healthy. But it adopted
ANY healthy VM regardless of the code that built it — so after an infra-code
change the old VM kept being adopted and the new code never booted. The only
way to dislodge it was an out-of-band `kill`, which then raced whatever
launched next. On CI this meant every infra change needed a manual VM kill.
Make adoption version-aware:
* boot records the infra-artifact version it booted from in a `booted-version`
marker beside the singleton; `stop` clears it.
* `ensure_running` adopts only when the marker matches the current version
(`_adoptable`); a missing/mismatched marker falls through to stop + reboot.
So a stale VM is replaced automatically on the next launch — no manual kill,
and it's concurrency-safe (the reboot happens under the singleton flock).
Also harden teardown: the PID file drifts after crashes / out-of-band kills,
so `stop` now also reaps any orphaned firecracker still bound to the infra
config path (scoped to that path, so interactive-pool VMs are untouched) —
otherwise a survivor holds the orchestrator TAP and the fresh boot dies with
"tap … Resource busy".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
This commit is contained in:
@@ -161,20 +161,23 @@ def ensure_running() -> InfraVm:
|
|||||||
slot = netpool.orch_slot()
|
slot = netpool.orch_slot()
|
||||||
url = f"http://{slot.guest_ip}:{CONTROL_PLANE_PORT}"
|
url = f"http://{slot.guest_ip}:{CONTROL_PLANE_PORT}"
|
||||||
key = _infra_dir() / "id_ed25519"
|
key = _infra_dir() / "id_ed25519"
|
||||||
if key.exists() and _health_ok(url):
|
want = _expected_version()
|
||||||
|
if _adoptable(key, url, want):
|
||||||
info(f"adopting running infra VM at {url}")
|
info(f"adopting running infra VM at {url}")
|
||||||
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
|
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
|
||||||
|
|
||||||
with _singleton_lock():
|
with _singleton_lock():
|
||||||
# Re-check under the lock: another launcher may have booted it while
|
# Re-check under the lock: another launcher may have booted it while
|
||||||
# we waited for the lock (double-checked, so we adopt not re-boot).
|
# we waited for the lock (double-checked, so we adopt not re-boot).
|
||||||
if key.exists() and _health_ok(url):
|
if _adoptable(key, url, want):
|
||||||
info(f"adopting running infra VM at {url}")
|
info(f"adopting running infra VM at {url}")
|
||||||
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
|
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
|
||||||
stop() # clear a stale/hung VM holding the link before booting fresh
|
# Clear a stale/hung/OUTDATED VM holding the link before booting fresh.
|
||||||
|
stop()
|
||||||
ensure_built()
|
ensure_built()
|
||||||
infra = boot()
|
infra = boot()
|
||||||
wait_for_health(infra)
|
wait_for_health(infra)
|
||||||
|
_record_booted_version(want)
|
||||||
return infra
|
return infra
|
||||||
|
|
||||||
|
|
||||||
@@ -193,9 +196,15 @@ def _singleton_lock() -> Generator[None, None, None]:
|
|||||||
|
|
||||||
|
|
||||||
def stop() -> None:
|
def stop() -> None:
|
||||||
"""Stop the infra VM singleton (idempotent — absent is success)."""
|
"""Stop the infra VM singleton (idempotent — absent is success). Reaps the
|
||||||
|
recorded VMM AND any orphaned firecracker still bound to the infra config —
|
||||||
|
the PID file drifts after crashes / out-of-band kills, and a survivor would
|
||||||
|
hold the orchestrator TAP so the next boot dies with "tap … Resource busy".
|
||||||
|
Drops the version marker so a stopped VM is never treated as adoptable."""
|
||||||
_kill_pidfile()
|
_kill_pidfile()
|
||||||
|
_kill_infra_firecrackers()
|
||||||
_pid_file().unlink(missing_ok=True)
|
_pid_file().unlink(missing_ok=True)
|
||||||
|
_version_file().unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def boot() -> InfraVm:
|
def boot() -> InfraVm:
|
||||||
@@ -238,6 +247,36 @@ def _pid_file() -> Path:
|
|||||||
return _infra_dir() / "vm.pid"
|
return _infra_dir() / "vm.pid"
|
||||||
|
|
||||||
|
|
||||||
|
def _version_file() -> Path:
|
||||||
|
"""Records the infra-artifact version the *running* VM booted from, so a
|
||||||
|
later launcher can tell whether the singleton it found is the current code.
|
||||||
|
Without it, a healthy VM built from an older image gets adopted forever and
|
||||||
|
the new code never boots — every infra change would need an out-of-band
|
||||||
|
kill to dislodge the stale VM (and races whatever launched next)."""
|
||||||
|
return _infra_dir() / "booted-version"
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_version() -> str:
|
||||||
|
return infra_artifact.infra_artifact_version(_infra_init())
|
||||||
|
|
||||||
|
|
||||||
|
def _adoptable(key: Path, url: str, want: str) -> bool:
|
||||||
|
"""Adopt a running infra VM only if it booted from the CURRENT version and
|
||||||
|
its control plane is healthy. A missing/mismatched marker means a prior
|
||||||
|
launcher booted an older infra image — reboot rather than reuse stale code."""
|
||||||
|
if not key.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
booted = _version_file().read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return booted == want and _health_ok(url)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_booted_version(version: str) -> None:
|
||||||
|
_version_file().write_text(version + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
# The registry "volume": a host-side ext4 file attached to the infra VM as a
|
# The registry "volume": a host-side ext4 file attached to the infra VM as a
|
||||||
# second virtio-block device (guest /dev/vdb), mounted at the control plane's
|
# second virtio-block device (guest /dev/vdb), mounted at the control plane's
|
||||||
# DB dir. It outlives the ephemeral rootfs, so the bottle registry survives an
|
# DB dir. It outlives the ephemeral rootfs, so the bottle registry survives an
|
||||||
@@ -309,6 +348,28 @@ def _kill_pidfile() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_infra_firecrackers(proc_root: Path = Path("/proc")) -> None:
|
||||||
|
"""SIGKILL any firecracker VMM whose `--config-file` is this host's infra
|
||||||
|
config, independent of the PID file — reaps orphans it lost track of so the
|
||||||
|
orchestrator TAP is free to rebind. Scoped to the infra config path, so the
|
||||||
|
interactive pool's agent/infra VMs (other config paths) are untouched."""
|
||||||
|
cfg = str(_infra_dir() / "config.json")
|
||||||
|
for entry in proc_root.iterdir():
|
||||||
|
if not entry.name.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if (entry / "comm").read_text().strip() != "firecracker":
|
||||||
|
continue
|
||||||
|
args = (entry / "cmdline").read_bytes().split(b"\0")
|
||||||
|
except OSError:
|
||||||
|
continue # process vanished / not ours
|
||||||
|
if any(a.decode("utf-8", "replace") == cfg for a in args):
|
||||||
|
try:
|
||||||
|
os.kill(int(entry.name), signal.SIGKILL)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _health_ok(url: str) -> bool:
|
def _health_ok(url: str) -> bool:
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(f"{url}/health", timeout=1.0) as resp:
|
with urllib.request.urlopen(f"{url}/health", timeout=1.0) as resp:
|
||||||
|
|||||||
@@ -140,27 +140,58 @@ class TestWaitForHealth(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestEnsureRunningSingleton(unittest.TestCase):
|
class TestEnsureRunningSingleton(unittest.TestCase):
|
||||||
def test_adopts_when_healthy(self):
|
def test_adopts_when_healthy_and_version_matches(self):
|
||||||
# A healthy control plane + existing key -> adopt (no boot), vm=None.
|
# Healthy control plane + existing key + matching version marker
|
||||||
with patch.object(infra_vm, "_health_ok", return_value=True), \
|
# -> adopt (no boot), vm=None.
|
||||||
patch.object(infra_vm, "_infra_dir") as d, \
|
import tempfile
|
||||||
patch.object(infra_vm, "boot") as boot:
|
with tempfile.TemporaryDirectory() as td:
|
||||||
keydir = MagicMock()
|
d = Path(td)
|
||||||
(keydir / "id_ed25519").exists.return_value = True
|
(d / "id_ed25519").write_text("k")
|
||||||
d.return_value = keydir
|
(d / "booted-version").write_text("v-current\n")
|
||||||
infra = infra_vm.ensure_running()
|
with patch.object(infra_vm, "_infra_dir", return_value=d), \
|
||||||
|
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
|
||||||
|
patch.object(infra_vm, "_health_ok", return_value=True), \
|
||||||
|
patch.object(infra_vm, "boot") as boot:
|
||||||
|
infra = infra_vm.ensure_running()
|
||||||
boot.assert_not_called()
|
boot.assert_not_called()
|
||||||
self.assertIsNone(infra.vm)
|
self.assertIsNone(infra.vm)
|
||||||
|
|
||||||
|
def test_reboots_when_version_stale(self):
|
||||||
|
# Healthy control plane but the running VM booted an OLDER image
|
||||||
|
# (marker mismatch) -> reboot rather than adopt stale code.
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
d = Path(td)
|
||||||
|
(d / "id_ed25519").write_text("k")
|
||||||
|
(d / "booted-version").write_text("v-old\n")
|
||||||
|
with patch.object(infra_vm, "_infra_dir", return_value=d), \
|
||||||
|
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
|
||||||
|
patch.object(infra_vm, "_health_ok", return_value=True), \
|
||||||
|
patch.object(infra_vm, "stop") as stop, \
|
||||||
|
patch.object(infra_vm, "ensure_built"), \
|
||||||
|
patch.object(infra_vm, "wait_for_health"), \
|
||||||
|
patch.object(infra_vm, "boot") as boot:
|
||||||
|
boot.return_value = infra_vm.InfraVm(
|
||||||
|
guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
|
||||||
|
infra_vm.ensure_running()
|
||||||
|
stop.assert_called_once() # dislodge the outdated VM
|
||||||
|
boot.assert_called_once()
|
||||||
|
# The fresh boot records the current version for the next launcher.
|
||||||
|
self.assertEqual("v-current\n", (d / "booted-version").read_text())
|
||||||
|
|
||||||
def test_boots_when_unhealthy(self):
|
def test_boots_when_unhealthy(self):
|
||||||
with patch.object(infra_vm, "_health_ok", return_value=False), \
|
import tempfile
|
||||||
patch.object(infra_vm, "stop") as stop, \
|
with tempfile.TemporaryDirectory() as td:
|
||||||
patch.object(infra_vm, "ensure_built") as built, \
|
with patch.object(infra_vm, "_infra_dir", return_value=Path(td)), \
|
||||||
patch.object(infra_vm, "boot") as boot, \
|
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
|
||||||
patch.object(infra_vm, "wait_for_health") as wait:
|
patch.object(infra_vm, "_health_ok", return_value=False), \
|
||||||
boot.return_value = infra_vm.InfraVm(
|
patch.object(infra_vm, "stop") as stop, \
|
||||||
guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
|
patch.object(infra_vm, "ensure_built") as built, \
|
||||||
infra_vm.ensure_running()
|
patch.object(infra_vm, "boot") as boot, \
|
||||||
|
patch.object(infra_vm, "wait_for_health") as wait:
|
||||||
|
boot.return_value = infra_vm.InfraVm(
|
||||||
|
guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
|
||||||
|
infra_vm.ensure_running()
|
||||||
stop.assert_called_once() # clear a stale VM first
|
stop.assert_called_once() # clear a stale VM first
|
||||||
built.assert_called_once()
|
built.assert_called_once()
|
||||||
boot.assert_called_once()
|
boot.assert_called_once()
|
||||||
@@ -187,5 +218,74 @@ class TestKillPidfile(unittest.TestCase):
|
|||||||
kill.assert_not_called()
|
kill.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdoptable(unittest.TestCase):
|
||||||
|
def _dir(self, td, *, key=True, version=None):
|
||||||
|
d = Path(td)
|
||||||
|
if key:
|
||||||
|
(d / "id_ed25519").write_text("k")
|
||||||
|
if version is not None:
|
||||||
|
(d / "booted-version").write_text(version + "\n")
|
||||||
|
return d
|
||||||
|
|
||||||
|
def test_true_when_key_version_and_health(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
d = self._dir(td, version="v1")
|
||||||
|
with patch.object(infra_vm, "_infra_dir", return_value=d), \
|
||||||
|
patch.object(infra_vm, "_health_ok", return_value=True):
|
||||||
|
self.assertTrue(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
|
||||||
|
|
||||||
|
def test_false_when_key_missing(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
d = self._dir(td, key=False, version="v1")
|
||||||
|
with patch.object(infra_vm, "_infra_dir", return_value=d):
|
||||||
|
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
|
||||||
|
|
||||||
|
def test_false_when_no_version_marker(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
d = self._dir(td) # key present, no booted-version
|
||||||
|
with patch.object(infra_vm, "_infra_dir", return_value=d):
|
||||||
|
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
|
||||||
|
|
||||||
|
def test_false_when_version_mismatch(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
d = self._dir(td, version="v-old")
|
||||||
|
with patch.object(infra_vm, "_infra_dir", return_value=d), \
|
||||||
|
patch.object(infra_vm, "_health_ok", return_value=True):
|
||||||
|
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestKillInfraFirecrackers(unittest.TestCase):
|
||||||
|
def _fake_proc(self, root, pid, comm, cmdline):
|
||||||
|
p = root / str(pid)
|
||||||
|
p.mkdir()
|
||||||
|
(p / "comm").write_text(comm + "\n")
|
||||||
|
(p / "cmdline").write_bytes(b"\0".join(a.encode() for a in cmdline) + b"\0")
|
||||||
|
|
||||||
|
def test_kills_only_matching_infra_firecracker(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td, \
|
||||||
|
tempfile.TemporaryDirectory() as proc:
|
||||||
|
infra_dir = Path(td)
|
||||||
|
cfg = str(infra_dir / "config.json")
|
||||||
|
root = Path(proc)
|
||||||
|
# target: firecracker bound to the infra config -> killed
|
||||||
|
self._fake_proc(root, 111, "firecracker",
|
||||||
|
["firecracker", "--no-api", "--config-file", cfg])
|
||||||
|
# a firecracker for a different (interactive) VM -> spared
|
||||||
|
self._fake_proc(root, 222, "firecracker",
|
||||||
|
["firecracker", "--config-file", "/home/u/other.json"])
|
||||||
|
# a non-firecracker process on the same config path -> spared
|
||||||
|
self._fake_proc(root, 333, "python3", ["python3", cfg])
|
||||||
|
(root / "not-a-pid").mkdir()
|
||||||
|
with patch.object(infra_vm, "_infra_dir", return_value=infra_dir), \
|
||||||
|
patch.object(infra_vm.os, "kill") as kill:
|
||||||
|
infra_vm._kill_infra_firecrackers(proc_root=root)
|
||||||
|
kill.assert_called_once_with(111, infra_vm.signal.SIGKILL)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user