feat(backend): reconcile running bottles' CA on gateway bring-up (0081)
Add `attach_bottled_agents_to_gateway()` as an `@abc.abstractmethod` on `BottleBackend` and implement the first reconcile step across all three backends: on a gateway cold boot, replace every already-running agent's trusted CA with the freshly-minted gateway CA. Reconciling running bottles is a backend responsibility (only the backend can enumerate its agents and reach them — firecracker over SSH, docker/macOS over exec/cp), so making it an abstract method keeps the fix cross-backend by construction. The gateway rootfs is ephemeral, so a rebuild mints a new CA that every running bottle distrusts (SSL verification fails — #510). This reconcile is what distributes the fresh CA, so a routine gateway rebuild now doubles as a free CA rotation. Per-bottle steps are best-effort: one unreachable or malformed bottle is logged and skipped, never blocking the others. Trigger — `Gateway.connect_to_orchestrator` now returns a cold-boot bool (True when it actually (re)brought the gateway up, False when a healthy current gateway was left untouched); each infra `ensure_running` gates the reconcile on it, so it fires exactly on cold boot and never on an adopt. Git-gate re-provision and egress-token restore fold into this same reconcile on the same trigger in follow-up PRs (#516). Refs #516. Addresses #510. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -159,5 +159,126 @@ class TestFirecrackerReprovision(unittest.TestCase):
|
||||
restore.assert_called_once_with(client, {})
|
||||
|
||||
|
||||
class TestFirecrackerCaReconcile(unittest.TestCase):
|
||||
"""PRD 0081: attach_bottled_agents_to_gateway pushes the current gateway CA
|
||||
into every running agent VM over SSH."""
|
||||
|
||||
def _run_dir(self, root: Path, ip: str = "10.243.0.3") -> Path:
|
||||
run_dir = root / "demo"
|
||||
run_dir.mkdir()
|
||||
(run_dir / "bottle_id_ed25519").write_text("key")
|
||||
(run_dir / "config.json").write_text(json.dumps({
|
||||
"boot-source": {"boot_args": f"root=/dev/vda ip={ip}::gw:mask::eth0:off"}
|
||||
}))
|
||||
return run_dir
|
||||
|
||||
def test_pushes_current_ca_over_stdin_and_rebuilds_trust_store(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
run_dir = self._run_dir(Path(tmp))
|
||||
gw = Mock()
|
||||
gw.ca_cert_pem.return_value = "PEM-DATA"
|
||||
with patch.object(fc, "FirecrackerGateway", return_value=gw), \
|
||||
patch.object(fc.cleanup, "live_run_dirs", return_value=(run_dir,)), \
|
||||
patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
|
||||
patch.object(fc.subprocess, "run", return_value=_proc()) as run, \
|
||||
patch.object(fc, "info"):
|
||||
fc.attach_bottled_agents_to_gateway()
|
||||
# The cert goes over stdin (off argv), and the trust store is rebuilt.
|
||||
self.assertEqual("PEM-DATA", run.call_args.kwargs["input"])
|
||||
script = run.call_args.args[0][-1]
|
||||
self.assertIn(fc.AGENT_CA_PATH, script)
|
||||
self.assertIn("update-ca-certificates", script)
|
||||
|
||||
def test_unreachable_vm_is_skipped_not_fatal(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
run_dir = self._run_dir(Path(tmp))
|
||||
gw = Mock()
|
||||
gw.ca_cert_pem.return_value = "PEM"
|
||||
with patch.object(fc, "FirecrackerGateway", return_value=gw), \
|
||||
patch.object(fc.cleanup, "live_run_dirs", return_value=(run_dir,)), \
|
||||
patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
|
||||
patch.object(fc.subprocess, "run", return_value=_proc(1, stderr="down")), \
|
||||
patch.object(fc, "info") as info:
|
||||
fc.attach_bottled_agents_to_gateway() # does not raise
|
||||
self.assertTrue(any("skipped" in c.args[0] for c in info.call_args_list))
|
||||
|
||||
def test_missing_gateway_ca_skips_the_whole_reconcile(self) -> None:
|
||||
gw = Mock()
|
||||
gw.ca_cert_pem.side_effect = fc.GatewayError("no CA yet")
|
||||
with patch.object(fc, "FirecrackerGateway", return_value=gw), \
|
||||
patch.object(fc.cleanup, "live_run_dirs") as live, \
|
||||
patch.object(fc, "info"):
|
||||
fc.attach_bottled_agents_to_gateway()
|
||||
live.assert_not_called() # bailed before enumerating agents
|
||||
|
||||
|
||||
class TestDockerCaReconcile(unittest.TestCase):
|
||||
def test_pushes_ca_to_each_agent_container_excluding_gateway(self) -> None:
|
||||
# network inspect lists the gateway (excluded) + one agent + a blank line.
|
||||
inspect = _proc(stdout="bot-bottle-orch-gateway\nbot-bottle-a\n\n")
|
||||
with patch.object(
|
||||
docker, "run_docker",
|
||||
side_effect=[inspect, _proc(), _proc(), _proc()],
|
||||
) as run, patch.object(docker.log, "info"):
|
||||
docker.attach_bottled_agents_to_gateway(
|
||||
"PEM", gateway_name="bot-bottle-orch-gateway")
|
||||
argvs = [c.args[0] for c in run.call_args_list]
|
||||
# First call enumerates; the agent then gets mkdir + cp + exec — never
|
||||
# the gateway container.
|
||||
self.assertNotIn(
|
||||
"bot-bottle-orch-gateway",
|
||||
" ".join(tok for a in argvs[1:] for tok in a),
|
||||
)
|
||||
cp = next(a for a in argvs if a[:2] == ["docker", "cp"])
|
||||
self.assertEqual(f"bot-bottle-a:{docker.AGENT_CA_PATH}", cp[-1])
|
||||
exec_argv = next(a for a in argvs if "update-ca-certificates" in a[-1])
|
||||
self.assertIn(docker.AGENT_CA_PATH, exec_argv[-1])
|
||||
|
||||
def test_one_container_failure_does_not_block_others(self) -> None:
|
||||
inspect = _proc(stdout="a\nb\n")
|
||||
# a: mkdir fails (skip); b: mkdir/cp/exec all succeed.
|
||||
with patch.object(
|
||||
docker, "run_docker",
|
||||
side_effect=[inspect, _proc(1, stderr="gone"), _proc(), _proc(), _proc()],
|
||||
), patch.object(docker.log, "info") as info:
|
||||
docker.attach_bottled_agents_to_gateway("PEM", gateway_name="gw")
|
||||
self.assertTrue(
|
||||
any("skipped for a" in str(c.args[0]) for c in info.call_args_list)
|
||||
)
|
||||
|
||||
def test_fetches_ca_from_gateway_when_not_supplied(self) -> None:
|
||||
svc = Mock()
|
||||
svc.gateway.return_value.ca_cert_pem.return_value = "FETCHED"
|
||||
with patch.object(docker, "DockerInfraService", return_value=svc), \
|
||||
patch.object(docker, "run_docker", return_value=_proc(stdout="")), \
|
||||
patch.object(docker.log, "info"):
|
||||
docker.attach_bottled_agents_to_gateway(network="net", gateway_name="gw")
|
||||
svc.gateway.return_value.ca_cert_pem.assert_called_once()
|
||||
|
||||
|
||||
class TestMacosCaReconcile(unittest.TestCase):
|
||||
def test_pushes_ca_to_each_running_agent_container(self) -> None:
|
||||
agent = SimpleNamespace(slug="demo")
|
||||
with patch.object(mac, "enumerate_active", return_value=[agent]), \
|
||||
patch.object(mac.container_mod, "run_container_argv",
|
||||
return_value=_proc()) as run, \
|
||||
patch.object(mac, "info"):
|
||||
mac.attach_bottled_agents_to_gateway("PEM")
|
||||
argvs = [c.args[0] for c in run.call_args_list]
|
||||
name = f"{mac.CONTAINER_NAME_PREFIX}demo"
|
||||
self.assertTrue(any(name in a for a in argvs))
|
||||
self.assertTrue(
|
||||
any("update-ca-certificates" in a[-1] for a in argvs if a[-2:-1] == ["-c"])
|
||||
)
|
||||
self.assertTrue(any(mac.AGENT_CA_PATH in " ".join(a) for a in argvs))
|
||||
|
||||
def test_enumeration_failure_is_best_effort(self) -> None:
|
||||
with patch.object(mac, "enumerate_active",
|
||||
side_effect=mac.EnumerationError("failed")), \
|
||||
patch.object(mac, "info") as info:
|
||||
mac.attach_bottled_agents_to_gateway("PEM") # does not raise
|
||||
self.assertIn("skipped", info.call_args.args[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -29,10 +29,20 @@ class TestDockerInfraService(unittest.TestCase):
|
||||
self.orch.gateway_url.return_value = f"http://{ORCHESTRATOR_NAME}:8099"
|
||||
self.orch.mint_gateway_token.return_value = "gw.jwt"
|
||||
self.gw = MagicMock()
|
||||
# Default to a cold boot (gateway (re)created) so the reconcile branch
|
||||
# runs; the actual reconcile is stubbed so these composition tests stay
|
||||
# isolated from docker.
|
||||
self.gw.connect_to_orchestrator.return_value = True
|
||||
for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)):
|
||||
p = patch.object(self.svc, name, return_value=mock)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
ap = patch(
|
||||
"bot_bottle.backend.docker.backend.DockerBottleBackend"
|
||||
".attach_bottled_agents_to_gateway"
|
||||
)
|
||||
self.attach = ap.start()
|
||||
self.addCleanup(ap.stop)
|
||||
|
||||
def test_url_delegates_to_the_orchestrator(self) -> None:
|
||||
self.assertEqual("http://127.0.0.1:8099", self.svc.url())
|
||||
@@ -58,6 +68,20 @@ class TestDockerInfraService(unittest.TestCase):
|
||||
f"http://{ORCHESTRATOR_NAME}:8099", "gw.jwt")
|
||||
self.orch.mint_gateway_token.assert_called_once()
|
||||
|
||||
def test_cold_boot_reconciles_running_bottles(self) -> None:
|
||||
# A (re)created gateway (connect returns True) triggers the bring-up
|
||||
# reconcile so running bottles re-trust the fresh gateway (PRD 0081).
|
||||
self.gw.connect_to_orchestrator.return_value = True
|
||||
self.svc.ensure_running()
|
||||
self.attach.assert_called_once_with()
|
||||
|
||||
def test_no_reconcile_when_gateway_left_untouched(self) -> None:
|
||||
# A healthy current gateway (connect returns False) is not a cold boot,
|
||||
# so there is nothing to reconcile.
|
||||
self.gw.connect_to_orchestrator.return_value = False
|
||||
self.svc.ensure_running()
|
||||
self.attach.assert_not_called()
|
||||
|
||||
def test_stop_removes_both_containers(self) -> None:
|
||||
with patch(_RUN) as run:
|
||||
self.svc.stop()
|
||||
|
||||
@@ -61,7 +61,10 @@ class TestFirecrackerGatewayConnect(unittest.TestCase):
|
||||
booted = infra_vm.InfraVm(guest_ip="10.243.255.3", private_key=Path("/k"))
|
||||
with patch.object(infra_vm, "boot_vm", return_value=booted) as boot, \
|
||||
patch.object(infra_vm, "push_secret") as push:
|
||||
gw.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
cold_booted = gw.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
# The VM is booted fresh here (cold-boot path only), so it always
|
||||
# signals a cold boot → the caller reconciles running bottles (PRD 0081).
|
||||
self.assertTrue(cold_booted)
|
||||
# Booted on the gateway link with the gateway role; the orchestrator's
|
||||
# guest IP (parsed off the URL) rides the cmdline as bb_orch.
|
||||
kw = boot.call_args.kwargs
|
||||
|
||||
@@ -40,7 +40,21 @@ class TestAccessors(unittest.TestCase):
|
||||
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", svc.url())
|
||||
|
||||
|
||||
_ATTACH = (
|
||||
"bot_bottle.backend.firecracker.backend.FirecrackerBottleBackend"
|
||||
".attach_bottled_agents_to_gateway"
|
||||
)
|
||||
|
||||
|
||||
class TestEnsureRunning(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# The cold-boot branch reconciles running bottles against the fresh
|
||||
# gateway (PRD 0081). Stub it so these composition tests stay isolated
|
||||
# from SSH; the wiring itself is asserted in test_cold_boot_reconciles*.
|
||||
ap = patch(_ATTACH)
|
||||
self.attach = ap.start()
|
||||
self.addCleanup(ap.stop)
|
||||
|
||||
def test_adopts_when_healthy_alive_and_version_matches(self):
|
||||
# Healthy control plane + live gateway + existing key + matching version
|
||||
# marker -> adopt (no stop/build/boot); returns the orchestrator URL.
|
||||
@@ -59,6 +73,8 @@ class TestEnsureRunning(unittest.TestCase):
|
||||
built.assert_not_called()
|
||||
ip = infra_vm.netpool.orch_slot().guest_ip
|
||||
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", url)
|
||||
# Adopt path — no cold boot, so no running-bottle reconcile.
|
||||
self.attach.assert_not_called()
|
||||
|
||||
def test_reboots_both_when_version_stale(self):
|
||||
# Healthy control plane but the running pair booted an OLDER image
|
||||
@@ -120,6 +136,8 @@ class TestEnsureRunning(unittest.TestCase):
|
||||
# reach the control plane at startup).
|
||||
orch_cls.return_value.ensure_running.assert_called_once()
|
||||
gw_cls.return_value.connect_to_orchestrator.assert_called_once()
|
||||
# A cold boot minted a fresh gateway CA — reconcile running bottles.
|
||||
self.attach.assert_called_once_with()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -44,6 +44,17 @@ class TestMacosGatewayConnect(unittest.TestCase):
|
||||
def test_default_name(self) -> None:
|
||||
self.assertEqual(GATEWAY_NAME, self.gw.name)
|
||||
|
||||
def test_connect_signals_cold_boot(self) -> None:
|
||||
# The container is recreated unconditionally, so connect always signals
|
||||
# a cold boot → the caller reconciles running bottles (PRD 0081).
|
||||
run = Mock(return_value=_proc())
|
||||
with patch(f"{_GW}.container_mod") as mod, \
|
||||
patch(f"{_GW}.host_gateway_ca_dir", return_value=Path("/ca")):
|
||||
mod.dns_server.return_value = "1.1.1.1"
|
||||
mod.bind_mount_spec.side_effect = _spec
|
||||
mod.run_container_argv = run
|
||||
self.assertTrue(self.gw.connect_to_orchestrator(_ORCH_URL, _TOKEN))
|
||||
|
||||
def test_refuses_without_orchestrator_url(self) -> None:
|
||||
with patch(f"{_GW}.container_mod") as mod:
|
||||
with self.assertRaises(GatewayError):
|
||||
|
||||
@@ -24,10 +24,20 @@ class TestInfraEnsureRunning(unittest.TestCase):
|
||||
self.orch.url.return_value = "http://192.168.128.2:8099"
|
||||
self.orch.mint_gateway_token.return_value = "gw.jwt"
|
||||
self.gw = MagicMock()
|
||||
# Default to a cold boot (gateway (re)created) so the reconcile branch
|
||||
# runs; the actual reconcile is stubbed so these composition tests stay
|
||||
# isolated from the `container` CLI.
|
||||
self.gw.connect_to_orchestrator.return_value = True
|
||||
for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)):
|
||||
p = patch.object(self.svc, name, return_value=mock)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
ap = patch(
|
||||
"bot_bottle.backend.macos_container.backend"
|
||||
".MacosContainerBottleBackend.attach_bottled_agents_to_gateway"
|
||||
)
|
||||
self.attach = ap.start()
|
||||
self.addCleanup(ap.stop)
|
||||
|
||||
def test_composes_networks_builds_and_brings_up_orchestrator_first(self) -> None:
|
||||
with patch(f"{_INFRA}.ensure_networks") as nets:
|
||||
@@ -46,6 +56,20 @@ class TestInfraEnsureRunning(unittest.TestCase):
|
||||
"http://192.168.128.2:8099", "gw.jwt")
|
||||
self.orch.mint_gateway_token.assert_called_once()
|
||||
|
||||
def test_cold_boot_reconciles_running_bottles(self) -> None:
|
||||
# A (re)created gateway (connect returns True) triggers the bring-up
|
||||
# reconcile so running bottles re-trust the fresh gateway (PRD 0081).
|
||||
self.gw.connect_to_orchestrator.return_value = True
|
||||
with patch(f"{_INFRA}.ensure_networks"):
|
||||
self.svc.ensure_running()
|
||||
self.attach.assert_called_once_with()
|
||||
|
||||
def test_no_reconcile_when_gateway_left_untouched(self) -> None:
|
||||
self.gw.connect_to_orchestrator.return_value = False
|
||||
with patch(f"{_INFRA}.ensure_networks"):
|
||||
self.svc.ensure_running()
|
||||
self.attach.assert_not_called()
|
||||
|
||||
def test_is_healthy_delegates_to_the_orchestrator(self) -> None:
|
||||
self.orch.is_healthy.return_value = True
|
||||
self.assertTrue(self.svc.is_healthy())
|
||||
|
||||
@@ -86,9 +86,12 @@ class TestDockerGateway(unittest.TestCase):
|
||||
return _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
cold_booted = self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "run"]])
|
||||
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "rm"]])
|
||||
# A healthy current gateway left untouched is not a cold boot — the
|
||||
# bring-up flow skips the running-bottle reconcile (PRD 0081).
|
||||
self.assertFalse(cold_booted)
|
||||
|
||||
def test_ensure_running_recreates_when_image_is_stale(self) -> None:
|
||||
# Running, but the container was built from an OLD image → recreate so
|
||||
@@ -106,9 +109,12 @@ class TestDockerGateway(unittest.TestCase):
|
||||
return _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
cold_booted = self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
self.assertEqual(1, len([c for c in calls if c[:2] == ["docker", "run"]]))
|
||||
self.assertTrue(any(c[:2] == ["docker", "rm"] for c in calls))
|
||||
# A recreated container minted/mounted its CA afresh — a cold boot that
|
||||
# triggers the running-bottle reconcile (PRD 0081).
|
||||
self.assertTrue(cold_booted)
|
||||
|
||||
def test_ensure_running_starts_the_singleton_when_absent(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
@@ -118,7 +124,8 @@ class TestDockerGateway(unittest.TestCase):
|
||||
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
cold_booted = self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
self.assertTrue(cold_booted) # started fresh → reconcile running bottles
|
||||
|
||||
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||
self.assertEqual(1, len(runs))
|
||||
|
||||
Reference in New Issue
Block a user