Compare commits

..

1 Commits

Author SHA1 Message Date
didericis-codex c7c3a79028 fix(cleanup): revalidate destructive backend plans
test / image-input-builds (pull_request) Failing after 13m11s
test / unit (pull_request) Has started running
test / coverage (pull_request) Blocked by required conditions
test / integration-docker (pull_request) Blocked by required conditions
tracker-policy-pr / check-pr (pull_request) Successful in 13s
2026-07-27 04:45:59 +00:00
8 changed files with 176 additions and 43 deletions
+39 -9
View File
@@ -30,7 +30,7 @@ from pathlib import Path
from ...log import info from ...log import info
from .. import EnumerationError from .. import EnumerationError
from . import util from . import lifecycle_lock, util
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
@@ -126,12 +126,42 @@ def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
def cleanup(plan: FirecrackerBottleCleanupPlan) -> None: def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
for pid in plan.vm_pids: """Revalidate the preview under the launch lock, then remove its survivors."""
info(f"kill firecracker VM pid {pid}") with lifecycle_lock.hold():
fresh = prepare_cleanup()
approved_pids = set(plan.vm_pids).intersection(fresh.vm_pids)
approved_dirs = set(plan.run_dirs).intersection(fresh.run_dirs)
for pid in sorted(approved_pids):
_terminate_orphan(pid, _run_root())
for path in sorted(approved_dirs):
info(f"rm -rf {path}")
shutil.rmtree(path, ignore_errors=True)
def _terminate_orphan(pid: int, run_root: Path) -> None:
"""Signal exactly the process identity that still owns an orphan config."""
try:
pidfd = os.pidfd_open(pid)
except ProcessLookupError:
return
except OSError as exc:
raise EnumerationError(
f"could not pin Firecracker pid {pid} for cleanup: {exc}"
) from exc
try:
try: try:
os.kill(pid, signal.SIGTERM) raw = Path(f"/proc/{pid}/cmdline").read_bytes()
except ProcessLookupError: except FileNotFoundError:
pass return
for path in plan.run_dirs: except OSError as exc:
info(f"rm -rf {path}") raise EnumerationError(
shutil.rmtree(path, ignore_errors=True) f"could not revalidate Firecracker pid {pid}: {exc}"
) from exc
command = raw.replace(b"\0", b" ").decode(errors="replace")
run_dir = _run_dir_of(command, run_root)
if run_dir is None or run_dir.is_dir():
return
info(f"kill firecracker VM pid {pid}")
signal.pidfd_send_signal(pidfd, signal.SIGTERM)
finally:
os.close(pidfd)
+23 -19
View File
@@ -46,7 +46,7 @@ from ...log import die, info, warn
from ...supervisor.types import SUPERVISE_PORT from ...supervisor.types import SUPERVISE_PORT
from ..docker.egress import EGRESS_PORT from ..docker.egress import EGRESS_PORT
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from . import firecracker_vm, image_builder, isolation_probe, netpool, util from . import firecracker_vm, image_builder, isolation_probe, lifecycle_lock, netpool, util
from .bottle import FirecrackerBottle from .bottle import FirecrackerBottle
from .bottle_plan import FirecrackerBottlePlan from .bottle_plan import FirecrackerBottlePlan
from ...orchestrator.store.config_store import resolve_teardown_timeout from ...orchestrator.store.config_store import resolve_teardown_timeout
@@ -164,25 +164,29 @@ def launch(
) )
# Step 6: build the per-bottle rootfs + SSH key, then boot. # Step 6: build the per-bottle rootfs + SSH key, then boot.
run_dir = util.cache_dir() / "run" / plan.slug # Cleanup takes the same lock while refreshing its process snapshot.
run_dir.mkdir(parents=True, exist_ok=True) # Hold it until the VMM exists so a newly-created run dir can never be
# Remove the run dir on teardown so the per-bottle rootfs.ext4 (~1G) # mistaken for an orphan in the build-before-boot window.
# doesn't leak. Registered before vm.terminate below so it runs *after* with lifecycle_lock.hold():
# it (ExitStack is LIFO): the VM is gone before we rm its rootfs. run_dir = util.cache_dir() / "run" / plan.slug
stack.callback(lambda: shutil.rmtree(run_dir, ignore_errors=True)) run_dir.mkdir(parents=True, exist_ok=True)
rootfs = run_dir / "rootfs.ext4" # Remove the run dir on teardown so the per-bottle rootfs.ext4 (~1G)
util.build_rootfs_ext4(agent_base, rootfs) # doesn't leak. Registered before vm.terminate below so it runs
private_key, pubkey = util.generate_keypair(run_dir) # *after* it (ExitStack is LIFO).
stack.callback(lambda: shutil.rmtree(run_dir, ignore_errors=True))
rootfs = run_dir / "rootfs.ext4"
util.build_rootfs_ext4(agent_base, rootfs)
private_key, pubkey = util.generate_keypair(run_dir)
vm = firecracker_vm.boot( vm = firecracker_vm.boot(
name=plan.container_name, name=plan.container_name,
rootfs=rootfs, rootfs=rootfs,
tap=slot.iface, tap=slot.iface,
guest_ip=slot.guest_ip, guest_ip=slot.guest_ip,
host_ip=slot.host_ip, host_ip=slot.host_ip,
pubkey=pubkey, pubkey=pubkey,
run_dir=run_dir, run_dir=run_dir,
) )
stack.callback(vm.terminate) stack.callback(vm.terminate)
firecracker_vm.wait_for_ssh(vm, private_key) firecracker_vm.wait_for_ssh(vm, private_key)
persist_env_var_secret(private_key, slot.guest_ip, ctx.env_var_secret) persist_env_var_secret(private_key, slot.guest_ip, ctx.env_var_secret)
@@ -0,0 +1,30 @@
"""Serialize Firecracker run-directory creation with orphan cleanup."""
from __future__ import annotations
import fcntl
from contextlib import contextmanager
from pathlib import Path
from typing import Generator
from . import util
def _lock_path() -> Path:
return util.cache_dir() / "run.lifecycle.lock"
@contextmanager
def hold() -> Generator[None]:
"""Exclude cleanup while a launch directory lacks a visible VMM."""
path = _lock_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
__all__ = ["hold"]
@@ -4,7 +4,8 @@ from __future__ import annotations
import subprocess import subprocess
from ...log import info, warn from .. import EnumerationError
from ...log import info
from . import util as container_mod from . import util as container_mod
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
@@ -19,8 +20,8 @@ def _list_prefixed_containers() -> list[str]:
check=False, check=False,
) )
if result.returncode != 0: if result.returncode != 0:
warn(f"container list failed: {result.stderr.strip()}") detail = result.stderr.strip() or f"exit {result.returncode}"
return [] raise EnumerationError(f"container list failed: {detail}")
return sorted( return sorted(
name for name in (line.strip() for line in result.stdout.splitlines()) name for name in (line.strip() for line in result.stdout.splitlines())
if name.startswith(_PREFIX) if name.startswith(_PREFIX)
@@ -35,7 +36,8 @@ def _list_prefixed_networks() -> list[str]:
check=False, check=False,
) )
if result.returncode != 0: if result.returncode != 0:
return [] detail = result.stderr.strip() or f"exit {result.returncode}"
raise EnumerationError(f"container network list failed: {detail}")
return sorted( return sorted(
name for name in (line.strip() for line in result.stdout.splitlines()) name for name in (line.strip() for line in result.stdout.splitlines())
if name.startswith(_PREFIX) if name.startswith(_PREFIX)
+5 -1
View File
@@ -52,7 +52,11 @@ def cmd_cleanup(_argv: list[str]) -> int:
info("cleanup: skipped") info("cleanup: skipped")
return 0 return 0
for name, backend, plan in prepared: # Confirmation authorizes a fresh authoritative snapshot, not blind use of
# identities that may have changed while the operator reviewed the preview.
refreshed = [(name, backend, backend.prepare_cleanup())
for name, backend, _plan in prepared]
for name, backend, plan in refreshed:
if plan.empty: if plan.empty:
continue continue
backend.cleanup(plan) backend.cleanup(plan)
+22 -3
View File
@@ -41,8 +41,8 @@ class TestCmdCleanup(unittest.TestCase):
): ):
self.assertEqual(0, cmd.cmd_cleanup([])) self.assertEqual(0, cmd.cmd_cleanup([]))
docker.prepare_cleanup.assert_called_once() self.assertEqual(2, docker.prepare_cleanup.call_count)
fc.prepare_cleanup.assert_called_once() self.assertEqual(2, fc.prepare_cleanup.call_count)
docker.cleanup.assert_called_once_with(docker_plan) docker.cleanup.assert_called_once_with(docker_plan)
fc.cleanup.assert_called_once_with(fc_plan) fc.cleanup.assert_called_once_with(fc_plan)
@@ -68,7 +68,7 @@ class TestCmdCleanup(unittest.TestCase):
): ):
self.assertEqual(0, cmd.cmd_cleanup([])) self.assertEqual(0, cmd.cmd_cleanup([]))
docker.prepare_cleanup.assert_called_once() self.assertEqual(2, docker.prepare_cleanup.call_count)
docker.cleanup.assert_called_once_with(docker_plan) docker.cleanup.assert_called_once_with(docker_plan)
macos.prepare_cleanup.assert_not_called() macos.prepare_cleanup.assert_not_called()
@@ -135,6 +135,25 @@ class TestCmdCleanup(unittest.TestCase):
docker.cleanup.assert_called_once_with(docker_plan) docker.cleanup.assert_called_once_with(docker_plan)
fc.cleanup.assert_not_called() fc.cleanup.assert_not_called()
def test_executes_refreshed_plan_after_confirmation(self):
backend = MagicMock()
preview = MagicMock(empty=False)
refreshed = MagicMock(empty=False)
backend.prepare_cleanup.side_effect = [preview, refreshed]
with patch.object(
cmd, "known_backend_names", return_value=("firecracker",),
), patch.object(
cmd, "get_bottle_backend", return_value=backend,
), patch.object(
cmd, "has_backend", return_value=True,
), patch.object(
cmd, "_prompt_yes", return_value=True,
):
self.assertEqual(0, cmd.cmd_cleanup([]))
backend.cleanup.assert_called_once_with(refreshed)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+35 -7
View File
@@ -132,18 +132,46 @@ class TestCleanupRemoval(unittest.TestCase):
vm_pids=(101,), vm_pids=(101,),
run_dirs=("/run/dev-x",), run_dirs=("/run/dev-x",),
) )
with patch.object(fc_cleanup.os, "kill") as kill, \ with patch.object(fc_cleanup, "prepare_cleanup", return_value=plan), \
patch.object(fc_cleanup, "_run_root", return_value=Path("/run")), \
patch.object(fc_cleanup, "_terminate_orphan") as terminate, \
patch.object(fc_cleanup.shutil, "rmtree") as rmtree, \ patch.object(fc_cleanup.shutil, "rmtree") as rmtree, \
patch.object(fc_cleanup, "info"): patch.object(fc_cleanup, "info"):
fc_cleanup.cleanup(plan) fc_cleanup.cleanup(plan)
kill.assert_called_once() terminate.assert_called_once_with(101, Path("/run"))
rmtree.assert_called_once_with("/run/dev-x", ignore_errors=True) rmtree.assert_called_once_with("/run/dev-x", ignore_errors=True)
def test_cleanup_tolerates_dead_pid(self): def test_cleanup_skips_resources_no_longer_in_refreshed_plan(self):
plan = FirecrackerBottleCleanupPlan(vm_pids=(999,)) preview = FirecrackerBottleCleanupPlan(
with patch.object(fc_cleanup.os, "kill", side_effect=ProcessLookupError), \ vm_pids=(999,), run_dirs=("/run/reused",),
patch.object(fc_cleanup, "info"): )
fc_cleanup.cleanup(plan) # must not raise with patch.object(
fc_cleanup, "prepare_cleanup",
return_value=FirecrackerBottleCleanupPlan(),
), patch.object(fc_cleanup, "_terminate_orphan") as terminate, \
patch.object(fc_cleanup.shutil, "rmtree") as rmtree:
fc_cleanup.cleanup(preview)
terminate.assert_not_called()
rmtree.assert_not_called()
def test_pidfd_prevents_pid_reuse_from_signalling_unrelated_process(self):
with patch.object(fc_cleanup.os, "pidfd_open", return_value=7), \
patch.object(
fc_cleanup.Path, "read_bytes",
return_value=b"/usr/bin/python\0worker.py\0",
), patch.object(fc_cleanup.signal, "pidfd_send_signal") as send, \
patch.object(fc_cleanup.os, "close"):
fc_cleanup._terminate_orphan(101, Path("/run"))
send.assert_not_called()
def test_pidfd_signals_revalidated_orphan(self):
command = b"firecracker\0--config-file\0/run/gone/config.json\0"
with patch.object(fc_cleanup.os, "pidfd_open", return_value=7), \
patch.object(fc_cleanup.Path, "read_bytes", return_value=command), \
patch.object(fc_cleanup.signal, "pidfd_send_signal") as send, \
patch.object(fc_cleanup.os, "close"), patch.object(fc_cleanup, "info"):
fc_cleanup._terminate_orphan(101, Path("/run"))
send.assert_called_once_with(7, fc_cleanup.signal.SIGTERM)
class TestCleanupPlan(unittest.TestCase): class TestCleanupPlan(unittest.TestCase):
@@ -42,6 +42,22 @@ class TestMacosContainerCleanup(unittest.TestCase):
run.call_args_list[1].args[0], run.call_args_list[1].args[0],
) )
def test_container_enumeration_failure_aborts(self):
completed = cleanup.subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="service unavailable",
)
with patch.object(cleanup.subprocess, "run", return_value=completed), \
self.assertRaisesRegex(EnumerationError, "service unavailable"):
cleanup._list_prefixed_containers()
def test_network_enumeration_failure_aborts(self):
completed = cleanup.subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="service unavailable",
)
with patch.object(cleanup.subprocess, "run", return_value=completed), \
self.assertRaisesRegex(EnumerationError, "service unavailable"):
cleanup._list_prefixed_networks()
class TestMacosContainerEnumerate(unittest.TestCase): class TestMacosContainerEnumerate(unittest.TestCase):
"""The backend launches bottles again (PRD 0070), so enumeration is real """The backend launches bottles again (PRD 0070), so enumeration is real