feat(prd-0081): reprovision CA, git-gate, and egress tokens on gateway bring-up
prd-number-check / require-numbered-prds (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / image-input-builds (pull_request) Successful in 41s
test / unit (pull_request) Successful in 51s
test / integration-docker (pull_request) Successful in 58s
test / coverage (pull_request) Successful in 41s
test / image-input-builds (push) Successful in 48s
test / unit (push) Successful in 56s
lint / lint (push) Successful in 1m2s
Update Quality Badges / update-badges (push) Successful in 1m4s
test / integration-docker (push) Failing after 2m53s
test / coverage (push) Has been skipped

On a Firecracker gateway cold boot, reconcile every live agent VM against
the fresh gateway: push the new mitmproxy CA into each agent's trust store,
re-provision git-gate repos/creds from the persisted upstreams snapshot,
and restore egress tokens. Per-bottle failures are logged and skipped so
one unreachable VM does not block the rest.

New modules / changes:
- backend/firecracker/reconcile.py: attach_bottled_agents_to_gateway,
  _push_ca, _reprovision_git_gate, _guest_ip_from_config
- git_gate/provision.py: write upstreams.json after key provisioning so
  the bring-up reconcile can reconstruct the upstream table without the
  manifest
- backend/firecracker/infra.py: call attach_bottled_agents_to_gateway in
  the cold-boot branch of ensure_running()
- backend/base.py: no-op default on BottleBackend
- backend/firecracker/consolidated_launch.py: remove superseded
  _reprovision_running_bottles / _guest_ip_from_config
- orchestrator: OrchestratorCore.update_agent_secret + POST
  /bottles/<id>/secret + client.update_agent_secret (single-secret
  in-place update, the reusable primitive from the 2026-07-26 hotfix)
- tests: 15 new tests in test_firecracker_reconcile.py; updated
  test_firecracker_infra.py cold-boot cases; stale FC reprovision tests
  removed from test_backend_secret_reprovision.py

Closes #516.
This commit was merged in pull request #517.
This commit is contained in:
2026-07-28 01:50:15 +00:00
parent 6f997bf118
commit dee0121e8d
69 changed files with 937 additions and 4015 deletions
@@ -2,9 +2,7 @@
from __future__ import annotations
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
@@ -107,21 +105,6 @@ class TestDockerReprovision(unittest.TestCase):
class TestFirecrackerReprovision(unittest.TestCase):
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_extracts_guest_ip_from_config(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
self.assertEqual("10.243.0.3", fc._guest_ip_from_config(run_dir / "config.json"))
self.assertEqual("", fc._guest_ip_from_config(run_dir / "missing.json"))
def test_persists_key_over_stdin_not_argv(self) -> None:
with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc()) as run:
@@ -135,29 +118,6 @@ class TestFirecrackerReprovision(unittest.TestCase):
with self.assertRaisesRegex(fc.ConsolidatedLaunchError, "denied"):
fc.persist_env_var_secret(Path("/key"), "10.0.0.1", "secret")
def test_reads_live_vm_key_and_reprovisions(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
client = Mock()
with 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(stdout="secret\n")), \
patch.object(fc, "reprovision_bottles", return_value=1) as restore, \
patch.object(fc, "info"):
fc._reprovision_running_bottles(client)
restore.assert_called_once_with(client, {"10.243.0.3": "secret"})
def test_unreadable_vm_is_skipped(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
client = Mock()
with 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)), \
patch.object(fc, "reprovision_bottles", return_value=0) as restore:
fc._reprovision_running_bottles(client)
restore.assert_called_once_with(client, {})
if __name__ == "__main__":
unittest.main()
+2 -15
View File
@@ -46,8 +46,8 @@ class TestDockerInfraService(unittest.TestCase):
url = self.svc.ensure_running()
self.assertEqual("http://127.0.0.1:8099", url)
# Both images built; the control plane comes up before the gateway.
self.orch.ensure_available.assert_called_once()
self.gw.ensure_available.assert_called_once()
self.orch.ensure_built.assert_called_once()
self.gw.ensure_built.assert_called_once()
self.orch.ensure_running.assert_called_once()
def test_ensure_running_connects_gateway_with_minted_token(self) -> None:
@@ -76,19 +76,6 @@ class TestDockerInfraService(unittest.TestCase):
self.assertEqual("registry-volume", svc.orchestrator()._root_mount_source)
self.assertEqual("ca-volume", svc.gateway()._ca_mount_source)
def test_packaged_images_disable_local_infra_dockerfiles(self) -> None:
refs = [
("registry/orchestrator@sha256:" + "a" * 64, False),
("registry/gateway@sha256:" + "b" * 64, False),
]
with patch(
"bot_bottle.backend.docker.infra.release_manifest.oci_image",
side_effect=refs,
):
svc = DockerInfraService()
self.assertIsNone(svc.orchestrator()._dockerfile)
self.assertIsNone(svc.gateway()._dockerfile)
def test_host_path_and_named_root_mount_are_mutually_exclusive(self) -> None:
with self.assertRaisesRegex(ValueError, "host_root or root_mount_source"):
DockerInfraService(
+4 -5
View File
@@ -231,12 +231,11 @@ class TestDockerOrchestrator(unittest.TestCase):
with self.assertRaises(GatewayError):
self.orch.ensure_built()
def test_ensure_built_pulls_without_dockerfile(self) -> None:
orch = DockerOrchestrator("registry/orchestrator@sha256:" + "a" * 64,
dockerfile=None)
with patch(_RUN, return_value=_proc()) as run:
def test_ensure_built_noop_without_dockerfile(self) -> None:
orch = DockerOrchestrator(dockerfile=None)
with patch(_RUN) as run:
orch.ensure_built()
run.assert_called_once_with(["docker", "pull", orch.image_ref])
run.assert_not_called()
def test_is_running_reads_docker_ps(self) -> None:
with patch(_RUN, return_value=_proc(stdout=ORCHESTRATOR_NAME)):
+1 -115
View File
@@ -7,7 +7,6 @@ the smoke-test no-op — the logic that must hold without a VM.
from __future__ import annotations
import subprocess
import tempfile
import unittest
from pathlib import Path
@@ -91,120 +90,6 @@ class TestBuildAgentRootfsDir(unittest.TestCase):
second = image_builder._rootfs_digest(self.dockerfile)
self.assertNotEqual(first, second)
def test_pinned_image_is_pulled_and_cached(self):
image = f"registry.example/agent@sha256:{'a' * 64}"
with patch.object(image_builder.util, "cache_dir", return_value=self.cache), \
patch.object(image_builder, "_pull_in_infra") as pull, \
patch.object(image_builder.util, "inject_guest_boot"):
first = image_builder.acquire_agent_image_rootfs_dir(image)
second = image_builder.acquire_agent_image_rootfs_dir(image)
pull.assert_called_once()
self.assertEqual(first, second)
def test_mutable_image_cannot_use_prebuilt_path(self):
with self.assertRaises(SystemExit):
image_builder.acquire_agent_image_rootfs_dir("agent:latest")
class TestBuildContext(unittest.TestCase):
"""The COPY-source parsing + context shipping that lets a Dockerfile pin an
input by COPYing a committed file (codex checksum list, claude/pi npm
lockfiles) through the otherwise-empty VM-side build context."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.root = Path(self._tmp.name)
self.addCleanup(self._tmp.cleanup)
codex = self.root / "bot_bottle" / "contrib" / "codex"
codex.mkdir(parents=True)
self.sums = codex / "codex-package_SHA256SUMS"
self.sums.write_text("aaaa codex-package-x86_64-unknown-linux-musl.tar.gz\n")
self.dockerfile = self.root / "Dockerfile"
self.dockerfile.write_text(
"FROM node:22-slim\n"
"COPY --chown=node:node "
"bot_bottle/contrib/codex/codex-package_SHA256SUMS /tmp/x\n"
)
def _patch_root(self):
return patch.object(
image_builder.resources, "build_root", return_value=self.root)
def test_copy_sources_parses_flags_and_continuations(self):
df = self.root / "Multi"
df.write_text(
"FROM x\n"
"COPY a/one.json \\\n a/two.json /dest/\n"
"COPY --from=builder /built /built\n" # excluded: build stage
"COPY --chown=n:n b/three /dest\n"
)
self.assertEqual(
image_builder._context_copy_sources(df),
["a/one.json", "a/two.json", "b/three"],
)
def test_context_files_resolves_existing_under_root(self):
with self._patch_root():
files = image_builder._context_files(self.dockerfile)
self.assertEqual(
[rel for rel, _ in files],
["bot_bottle/contrib/codex/codex-package_SHA256SUMS"],
)
def test_context_files_recurses_into_directory_sources(self):
nested = self.root / "assets" / "nested"
nested.mkdir(parents=True)
(self.root / "assets" / "one").write_text("one")
(nested / "two").write_text("two")
df = self.root / "Directory"
df.write_text("FROM x\nCOPY assets /opt/assets\n")
with self._patch_root():
files = image_builder._context_files(df)
self.assertEqual(
[rel for rel, _ in files],
["assets/nested/two", "assets/one"],
)
def test_context_files_drops_missing_and_traversal(self):
df = self.root / "Bad"
df.write_text("FROM x\nCOPY ../escape /d\nCOPY does/not/exist /d\n")
with self._patch_root():
self.assertEqual(image_builder._context_files(df), [])
def test_rootfs_digest_tracks_context_file_content(self):
with self._patch_root(), \
patch.object(image_builder.util, "cache_dir", return_value=self.root):
first = image_builder._rootfs_digest(self.dockerfile)
self.sums.write_text("bbbb codex-package-x86_64-unknown-linux-musl.tar.gz\n")
second = image_builder._rootfs_digest(self.dockerfile)
self.assertNotEqual(first, second)
def test_send_build_context_noop_without_copy(self):
df = self.root / "None"
df.write_text("FROM x\n")
with self._patch_root(), \
patch.object(image_builder.subprocess, "Popen") as popen:
image_builder._send_build_context(Path("/k"), "10.0.0.1", df, "/tmp/c")
popen.assert_not_called()
def test_send_build_context_streams_copied_files_to_vm(self):
completed = subprocess.CompletedProcess([], 0, stdout=b"", stderr=b"")
with self._patch_root(), \
patch.object(image_builder.subprocess, "Popen") as popen, \
patch.object(image_builder.subprocess, "run",
return_value=completed) as run:
popen.return_value.stdout = None
popen.return_value.returncode = 0
popen.return_value.wait.return_value = 0
image_builder._send_build_context(
Path("/k"), "10.0.0.1", self.dockerfile, "/tmp/c")
tar_argv = popen.call_args.args[0]
self.assertEqual(tar_argv[:3], ["tar", "-C", str(self.root)])
self.assertIn("--", tar_argv)
self.assertIn(
"bot_bottle/contrib/codex/codex-package_SHA256SUMS", tar_argv)
self.assertIn("tar -C /tmp/c/ctx -xf -", run.call_args.args[0][-1])
class TestSmokeTest(unittest.TestCase):
def test_buildah_receives_centralized_image_build_args(self):
@@ -229,6 +114,7 @@ class TestSmokeTest(unittest.TestCase):
ssh.assert_not_called()
def test_failed_smoke_dies(self):
import subprocess
result = subprocess.CompletedProcess([], 1, stdout="broken", stderr="")
with patch.object(image_builder, "_ssh", return_value=result), \
self.assertRaises(SystemExit):
+38 -7
View File
@@ -21,6 +21,7 @@ from bot_bottle.backend.firecracker.orchestrator import FirecrackerOrchestrator
# there (not at their home modules).
_ORCH_CLS = "bot_bottle.backend.firecracker.infra.FirecrackerOrchestrator"
_GW_CLS = "bot_bottle.backend.firecracker.infra.FirecrackerGateway"
_RECONCILE = "bot_bottle.backend.firecracker.infra.attach_bottled_agents_to_gateway"
class TestAccessors(unittest.TestCase):
@@ -53,10 +54,13 @@ class TestEnsureRunning(unittest.TestCase):
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_available") as built:
patch.object(infra_vm, "ensure_built") as built, \
patch(_RECONCILE) as reconcile:
url = FirecrackerInfraService().ensure_running()
stop.assert_not_called()
built.assert_not_called()
# Adopt path: no reconcile — gateway state is intact.
reconcile.assert_not_called()
ip = infra_vm.netpool.orch_slot().guest_ip
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", url)
@@ -72,8 +76,9 @@ class TestEnsureRunning(unittest.TestCase):
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=True), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_available"), \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls:
patch.object(infra_vm, "ensure_built"), \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls, \
patch(_RECONCILE) as reconcile:
orch_cls.return_value.gateway_url.return_value = "http://10.243.255.1:8099"
orch_cls.return_value.mint_gateway_token.return_value = "gw.jwt"
FirecrackerInfraService().ensure_running()
@@ -85,6 +90,8 @@ class TestEnsureRunning(unittest.TestCase):
"http://10.243.255.1:8099", "gw.jwt")
# The fresh boot records the current version for the next launcher.
self.assertEqual("v-current\n", (d / "booted-version").read_text())
# Cold boot: reconcile fires after gateway is up.
reconcile.assert_called_once()
def test_reboots_when_gateway_dead(self):
# Orchestrator healthy + marker current, but the gateway VM is gone ->
@@ -98,12 +105,14 @@ class TestEnsureRunning(unittest.TestCase):
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "_pidfile_alive", return_value=False), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_available"), \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls:
patch.object(infra_vm, "ensure_built"), \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls, \
patch(_RECONCILE) as reconcile:
FirecrackerInfraService().ensure_running()
stop.assert_called_once()
orch_cls.return_value.ensure_running.assert_called_once()
gw_cls.return_value.connect_to_orchestrator.assert_called_once()
reconcile.assert_called_once()
def test_boots_both_when_no_running_pair(self):
with tempfile.TemporaryDirectory() as td:
@@ -111,8 +120,9 @@ class TestEnsureRunning(unittest.TestCase):
patch.object(infra_vm, "expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=False), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_available") as built, \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls:
patch.object(infra_vm, "ensure_built") as built, \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls, \
patch(_RECONCILE) as reconcile:
FirecrackerInfraService().ensure_running()
stop.assert_called_once() # clear stale VMs first
built.assert_called_once()
@@ -120,6 +130,27 @@ 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()
# Cold boot: reconcile fires to restore CA, git-gate, and egress tokens.
reconcile.assert_called_once()
def test_reconcile_receives_url_and_fresh_gateway(self):
# reconcile gets the orchestrator URL and the gateway service (not the
# class). Verify the args to ensure it can reach the right endpoints.
with tempfile.TemporaryDirectory() as td:
with patch.object(infra_vm, "_infra_dir", return_value=Path(td)), \
patch.object(infra_vm, "expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=False), \
patch.object(infra_vm, "stop"), \
patch.object(infra_vm, "ensure_built"), \
patch(_ORCH_CLS) as orch_cls, patch(_GW_CLS) as gw_cls, \
patch(_RECONCILE) as reconcile:
orch_cls.return_value.url.return_value = "http://10.243.255.1:8099"
FirecrackerInfraService().ensure_running()
call_args = reconcile.call_args
# First arg: the orchestrator's host URL.
self.assertEqual("http://10.243.255.1:8099", call_args.args[0])
# Second arg: the gateway service instance (not the class).
self.assertIs(gw_cls.return_value, call_args.args[1])
if __name__ == "__main__":
+280
View File
@@ -0,0 +1,280 @@
"""Unit: bring-up reconcile — CA push, git-gate reprovision, egress tokens.
Covers attach_bottled_agents_to_gateway (reconcile.py): each per-bottle step
fires with correct args, failures on one bottle don't block others, and the
egress-token restore path works end-to-end. Does NOT spin up VMs or real SSH.
"""
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from subprocess import CalledProcessError, CompletedProcess
from unittest.mock import MagicMock, patch
_RECONCILE = "bot_bottle.backend.firecracker.reconcile"
class TestGuestIpFromConfig(unittest.TestCase):
def test_reads_ip_from_boot_args(self) -> None:
from bot_bottle.backend.firecracker.reconcile import _guest_ip_from_config
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({
"boot-source": {"boot_args": "console=ttyS0 ip=10.243.0.5:10.243.0.6:255.255.255.254"},
}, f)
name = f.name
self.assertEqual("10.243.0.5", _guest_ip_from_config(Path(name)))
def test_returns_empty_on_missing_file(self) -> None:
from bot_bottle.backend.firecracker.reconcile import _guest_ip_from_config
self.assertEqual("", _guest_ip_from_config(Path("/nonexistent/config.json")))
def test_returns_empty_on_bad_json(self) -> None:
from bot_bottle.backend.firecracker.reconcile import _guest_ip_from_config
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
f.write("not-json")
name = f.name
self.assertEqual("", _guest_ip_from_config(Path(name)))
class TestPushCa(unittest.TestCase):
def test_runs_three_ssh_commands_in_order(self) -> None:
from bot_bottle.backend.firecracker.reconcile import _push_ca
with tempfile.TemporaryDirectory() as td:
key = Path(td) / "key"
key.write_text("k")
with patch(f"{_RECONCILE}.subprocess.run") as run:
run.return_value = CompletedProcess([], 0)
_push_ca(key, "10.0.0.1", "-----BEGIN CERTIFICATE-----\n")
calls = run.call_args_list
self.assertEqual(3, len(calls))
# Each call's argv is a list; the remote command is the last element.
self.assertIn("mkdir", calls[0].args[0][-1])
self.assertIn("cat >", calls[1].args[0][-1])
self.assertIn("update-ca-certificates", calls[2].args[0][-1])
def test_passes_ca_pem_via_stdin(self) -> None:
from bot_bottle.backend.firecracker.reconcile import _push_ca
with tempfile.TemporaryDirectory() as td:
key = Path(td) / "key"
key.write_text("k")
with patch(f"{_RECONCILE}.subprocess.run") as run:
run.return_value = CompletedProcess([], 0)
_push_ca(key, "10.0.0.1", "MY-CA-PEM")
cat_call = run.call_args_list[1]
self.assertEqual("MY-CA-PEM", cat_call.kwargs.get("input"))
def test_raises_on_ssh_failure(self) -> None:
from bot_bottle.backend.firecracker.reconcile import _push_ca
with tempfile.TemporaryDirectory() as td:
key = Path(td) / "key"
key.write_text("k")
with patch(f"{_RECONCILE}.subprocess.run") as run:
run.side_effect = CalledProcessError(1, "ssh")
with self.assertRaises(CalledProcessError):
_push_ca(key, "10.0.0.1", "pem")
class TestReprovisionGitGate(unittest.TestCase):
def _make_state_dir(self, upstreams: list[dict[str, str]]) -> Path:
d = Path(tempfile.mkdtemp())
(d / "upstreams.json").write_text(json.dumps(upstreams))
(d / "git_gate_pre_receive.sh").write_text("#!/bin/sh")
(d / "git_gate_access_hook.sh").write_text("#!/bin/sh")
(d / "git_gate_entrypoint.sh").write_text("#!/bin/sh")
return d
def test_no_op_when_no_upstreams_json(self) -> None:
from bot_bottle.backend.firecracker.reconcile import _reprovision_git_gate
transport = MagicMock()
with tempfile.TemporaryDirectory() as td:
# The state dir exists but has no upstreams.json
with patch(f"{_RECONCILE}.git_gate_state_dir", return_value=Path(td)):
_reprovision_git_gate(transport, "bottle-123", "my-slug")
transport.exec.assert_not_called()
def test_calls_provision_git_gate_with_upstreams(self) -> None:
from bot_bottle.backend.firecracker.reconcile import _reprovision_git_gate
transport = MagicMock()
upstreams = [{
"name": "bot-bottle",
"upstream_url": "ssh://git@gitea.example/org/bot-bottle.git",
"upstream_host": "gitea.example",
"upstream_port": "22",
"identity_file": "/home/node/.ssh/id_ed25519",
"known_host_key": "ssh-ed25519 AAAA...",
}]
state_dir = self._make_state_dir(upstreams)
# Write the key file so identity_file falls back to state dir path.
(state_dir / "bot-bottle-key").write_bytes(b"PRIVATE")
try:
with patch(f"{_RECONCILE}.git_gate_state_dir", return_value=state_dir), \
patch(f"{_RECONCILE}.provision_git_gate") as prov:
_reprovision_git_gate(transport, "bottle-abc", "my-slug")
prov.assert_called_once()
_, bottle_id, plan = prov.call_args.args
self.assertEqual("bottle-abc", bottle_id)
self.assertEqual(1, len(plan.upstreams))
self.assertEqual("bot-bottle", plan.upstreams[0].name)
# Key in state dir takes priority over identity_file in JSON.
self.assertEqual(str(state_dir / "bot-bottle-key"), plan.upstreams[0].identity_file)
finally:
import shutil; shutil.rmtree(state_dir, ignore_errors=True)
def test_falls_back_to_manifest_identity_file_when_no_key_in_state(self) -> None:
from bot_bottle.backend.firecracker.reconcile import _reprovision_git_gate
transport = MagicMock()
upstreams = [{
"name": "repo",
"upstream_url": "ssh://git@github.com/org/repo.git",
"upstream_host": "github.com",
"upstream_port": "22",
"identity_file": "/home/node/.ssh/id_ed25519",
"known_host_key": "",
}]
state_dir = self._make_state_dir(upstreams)
# No `repo-key` in state dir — static manifest path should be used.
try:
with patch(f"{_RECONCILE}.git_gate_state_dir", return_value=state_dir), \
patch(f"{_RECONCILE}.provision_git_gate") as prov:
_reprovision_git_gate(transport, "bottle-xyz", "my-slug")
prov.assert_called_once()
_, _, plan = prov.call_args.args
self.assertEqual("/home/node/.ssh/id_ed25519", plan.upstreams[0].identity_file)
finally:
import shutil; shutil.rmtree(state_dir, ignore_errors=True)
class TestAttachBottledAgentsToGateway(unittest.TestCase):
"""Integration-level: the full reconcile loop against mocked SSH + gateway."""
def _make_run_dir(self, tmp: Path, slug: str, guest_ip: str) -> Path:
run_dir = tmp / slug
run_dir.mkdir()
key = run_dir / "bottle_id_ed25519"
key.write_text("PRIVATE")
cfg = {
"boot-source": {
"boot_args": f"console=ttyS0 ip={guest_ip}:{guest_ip}:255.255.255.254",
}
}
(run_dir / "config.json").write_text(json.dumps(cfg))
return run_dir
def _make_gateway(self, ca_pem: str = "-----BEGIN CERTIFICATE-----\n") -> MagicMock:
gw = MagicMock()
gw.ca_cert_pem.return_value = ca_pem
gw.provisioning_transport.return_value = MagicMock()
return gw
def test_skips_all_when_ca_fetch_fails(self) -> None:
from bot_bottle.backend.firecracker.gateway import FirecrackerGateway
from bot_bottle.backend.firecracker.reconcile import attach_bottled_agents_to_gateway
from bot_bottle.gateway import GatewayError
gw = MagicMock(spec=FirecrackerGateway)
gw.ca_cert_pem.side_effect = GatewayError("timeout")
with patch(f"{_RECONCILE}.cleanup.live_run_dirs", return_value=()):
attach_bottled_agents_to_gateway("http://orch:8099", gw)
# Nothing else is called when CA fetch fails.
gw.provisioning_transport.assert_not_called()
def test_ca_push_called_per_live_bottle(self) -> None:
from bot_bottle.backend.firecracker.reconcile import attach_bottled_agents_to_gateway
gw = self._make_gateway()
with tempfile.TemporaryDirectory() as td:
tmp = Path(td)
rd1 = self._make_run_dir(tmp, "agent-abc12", "10.243.0.5")
rd2 = self._make_run_dir(tmp, "agent-xyz99", "10.243.0.7")
with patch(f"{_RECONCILE}.cleanup.live_run_dirs", return_value=(rd1, rd2)), \
patch(f"{_RECONCILE}.OrchestratorClient") as client_cls, \
patch(f"{_RECONCILE}._push_ca") as push_ca, \
patch(f"{_RECONCILE}._reprovision_git_gate"), \
patch(f"{_RECONCILE}.subprocess.run",
return_value=CompletedProcess([], 1)):
client_cls.return_value.list_bottles.return_value = []
attach_bottled_agents_to_gateway("http://orch:8099", gw)
# CA pushed to both bottles.
self.assertEqual(2, push_ca.call_count)
def test_per_bottle_ca_failure_does_not_block_others(self) -> None:
from bot_bottle.backend.firecracker.reconcile import attach_bottled_agents_to_gateway
gw = self._make_gateway()
with tempfile.TemporaryDirectory() as td:
tmp = Path(td)
rd1 = self._make_run_dir(tmp, "agent-fail1", "10.243.0.5")
rd2 = self._make_run_dir(tmp, "agent-ok99", "10.243.0.7")
with patch(f"{_RECONCILE}.cleanup.live_run_dirs", return_value=(rd1, rd2)), \
patch(f"{_RECONCILE}.OrchestratorClient") as client_cls, \
patch(f"{_RECONCILE}._push_ca",
side_effect=[CalledProcessError(1, "ssh"), None]) as push_ca, \
patch(f"{_RECONCILE}._reprovision_git_gate"), \
patch(f"{_RECONCILE}.subprocess.run",
return_value=CompletedProcess([], 1)):
client_cls.return_value.list_bottles.return_value = []
# Must not raise even though the first bottle's CA push fails.
attach_bottled_agents_to_gateway("http://orch:8099", gw)
# Both bottles were attempted.
self.assertEqual(2, push_ca.call_count)
def test_egress_token_restored_for_bottles_with_secret(self) -> None:
from bot_bottle.backend.firecracker.reconcile import attach_bottled_agents_to_gateway
gw = self._make_gateway()
with tempfile.TemporaryDirectory() as td:
tmp = Path(td)
rd = self._make_run_dir(tmp, "agent-tok11", "10.243.0.5")
bottles = [{"bottle_id": "bid-001", "source_ip": "10.243.0.5"}]
with patch(f"{_RECONCILE}.cleanup.live_run_dirs", return_value=(rd,)), \
patch(f"{_RECONCILE}.OrchestratorClient") as client_cls, \
patch(f"{_RECONCILE}._push_ca"), \
patch(f"{_RECONCILE}._reprovision_git_gate"), \
patch(f"{_RECONCILE}.reprovision_bottles") as reprov, \
patch(f"{_RECONCILE}.subprocess.run",
return_value=CompletedProcess([], 0, stdout="secret-key\n")):
client_cls.return_value.list_bottles.return_value = bottles
reprov.return_value = 1
attach_bottled_agents_to_gateway("http://orch:8099", gw)
reprov.assert_called_once()
_, secrets = reprov.call_args.args
self.assertIn("10.243.0.5", secrets)
self.assertEqual("secret-key", secrets["10.243.0.5"])
def test_git_gate_reprovision_called_with_bottle_id(self) -> None:
from bot_bottle.backend.firecracker.reconcile import attach_bottled_agents_to_gateway
gw = self._make_gateway()
with tempfile.TemporaryDirectory() as td:
tmp = Path(td)
rd = self._make_run_dir(tmp, "agent-git11", "10.243.0.5")
bottles = [{"bottle_id": "bid-git", "source_ip": "10.243.0.5"}]
with patch(f"{_RECONCILE}.cleanup.live_run_dirs", return_value=(rd,)), \
patch(f"{_RECONCILE}.OrchestratorClient") as client_cls, \
patch(f"{_RECONCILE}._push_ca"), \
patch(f"{_RECONCILE}._reprovision_git_gate") as reprov_gw, \
patch(f"{_RECONCILE}.subprocess.run",
return_value=CompletedProcess([], 1)):
client_cls.return_value.list_bottles.return_value = bottles
attach_bottled_agents_to_gateway("http://orch:8099", gw)
reprov_gw.assert_called_once()
_, bottle_id_arg, slug_arg = reprov_gw.call_args.args
self.assertEqual("bid-git", bottle_id_arg)
self.assertEqual("agent-git11", slug_arg)
def test_run_dir_without_key_file_is_skipped(self) -> None:
from bot_bottle.backend.firecracker.reconcile import attach_bottled_agents_to_gateway
gw = self._make_gateway()
with tempfile.TemporaryDirectory() as td:
tmp = Path(td)
rd = self._make_run_dir(tmp, "agent-nokey", "10.243.0.5")
(rd / "bottle_id_ed25519").unlink() # remove the key
with patch(f"{_RECONCILE}.cleanup.live_run_dirs", return_value=(rd,)), \
patch(f"{_RECONCILE}.OrchestratorClient") as client_cls, \
patch(f"{_RECONCILE}._push_ca") as push_ca, \
patch(f"{_RECONCILE}._reprovision_git_gate"):
client_cls.return_value.list_bottles.return_value = []
attach_bottled_agents_to_gateway("http://orch:8099", gw)
push_ca.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -1,31 +0,0 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from scripts.generate_release_manifest import main
class TestGenerateReleaseManifest(unittest.TestCase):
def test_writes_validated_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "release.json"
rc = main([
"--source-commit", "1" * 40,
"--orchestrator-image",
"registry/orchestrator@sha256:" + "a" * 64,
"--gateway-image", "registry/gateway@sha256:" + "b" * 64,
"--agent-claude-image", "registry/claude@sha256:" + "e" * 64,
"--agent-codex-image", "registry/codex@sha256:" + "f" * 64,
"--agent-pi-image", "registry/pi@sha256:" + "0" * 64,
"--firecracker-orchestrator-version", "orch-v1",
"--firecracker-orchestrator-sha256", "c" * 64,
"--firecracker-gateway-version", "gateway-v1",
"--firecracker-gateway-sha256", "d" * 64,
"--output", str(output),
])
self.assertEqual(0, rc)
data = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual("orch-v1", data["firecracker"]["orchestrator"]["version"])
-15
View File
@@ -95,21 +95,6 @@ class TestInstallScript(unittest.TestCase):
# Tests / local installs point BOT_BOTTLE_INSTALL_SPEC at a checkout.
self.assertIn("BOT_BOTTLE_INSTALL_SPEC", self.text)
def test_published_install_selectors_are_mutually_exclusive(self):
self.assertIn("BOT_BOTTLE_CHANNEL", self.text)
self.assertIn("BOT_BOTTLE_VERSION", self.text)
self.assertIn("BOT_BOTTLE_REF", self.text)
self.assertIn("are mutually exclusive", self.text)
def test_commit_snapshot_warns_and_wheel_is_checksum_verified(self):
self.assertIn("may not have passed release qualification", self.text)
self.assertIn("downloaded wheel checksum mismatch", self.text)
self.assertIn("hashlib.sha256()", self.text)
def test_default_install_uses_production_channel(self):
self.assertIn('${BOT_BOTTLE_CHANNEL:-production}', self.text)
self.assertIn("bot-bottle-channels", self.text)
def test_requires_git_for_git_specs(self):
# A git+ / .git spec (the default) shells out to git; the script must
# gate on it rather than failing opaquely inside pipx/pip.
-98
View File
@@ -1,98 +0,0 @@
"""Unit: how the CLI tells users to re-run it under sudo.
`sudo bot-bottle …` is wrong for the users who followed the documented
install: sudo's secure_path excludes ~/.local/bin, where both pipx and
install.sh put the entry point. These lock in the absolute-path form.
"""
from __future__ import annotations
import contextlib
import io
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from bot_bottle import invocation
class TestSelfPath(unittest.TestCase):
def test_absolute_argv0_is_used_as_is(self):
with mock.patch.object(invocation.sys, "argv", ["/opt/venv/bin/bot-bottle"]):
self.assertEqual("/opt/venv/bin/bot-bottle", invocation.self_path())
def test_bare_name_is_resolved_through_path(self):
# The case that matters: invoked as `bot-bottle`, installed in a
# directory sudo would drop.
with tempfile.TemporaryDirectory() as d:
entry = Path(d, "bot-bottle")
entry.write_text("#!/bin/sh\n")
entry.chmod(0o755)
with mock.patch.object(invocation.sys, "argv", ["bot-bottle"]), \
mock.patch.dict(os.environ, {"PATH": d}):
self.assertEqual(str(entry), invocation.self_path())
def test_relative_path_is_made_absolute(self):
with tempfile.TemporaryDirectory() as d:
entry = Path(d, "bot-bottle")
entry.write_text("#!/bin/sh\n")
entry.chmod(0o755)
cwd = os.getcwd()
try:
os.chdir(d)
with mock.patch.object(invocation.sys, "argv", ["./bot-bottle"]):
self.assertTrue(os.path.isabs(invocation.self_path()))
finally:
os.chdir(cwd)
def test_unresolvable_entry_point_falls_back_to_the_name(self):
# `python -m`-style invocation, or an argv[0] that no longer exists.
# A slightly wrong hint beats a traceback raised while reporting some
# unrelated problem.
with mock.patch.object(invocation.sys, "argv", ["/nonexistent/gone"]), \
mock.patch.object(invocation.shutil, "which", return_value=None):
self.assertEqual("/nonexistent/gone", invocation.self_path())
with mock.patch.object(invocation.sys, "argv", [""]), \
mock.patch.object(invocation.shutil, "which", return_value=None):
self.assertEqual("bot-bottle", invocation.self_path())
class TestSudoCommand(unittest.TestCase):
def test_names_an_absolute_path_not_the_bare_command(self):
with mock.patch.object(invocation, "self_path",
return_value="/home/u/.local/bin/bot-bottle"):
cmd = invocation.sudo_command("backend", "setup", "--backend=firecracker")
self.assertEqual(
"sudo /home/u/.local/bin/bot-bottle backend setup --backend=firecracker",
cmd,
)
# The regression this exists to prevent.
self.assertNotIn("sudo bot-bottle", cmd)
class TestFirecrackerSetupUsesIt(unittest.TestCase):
def test_root_reinvocation_hint_names_an_absolute_path(self):
# The message that prompted all this. Drive the real code path rather
# than scanning the source, which would also match the comment
# explaining why the bare form is wrong.
from bot_bottle.backend.firecracker import setup as fc_setup
err, out = io.StringIO(), io.StringIO()
with mock.patch.object(fc_setup.os, "geteuid", return_value=501), \
mock.patch.object(fc_setup.invocation, "self_path",
return_value="/home/u/.local/bin/bot-bottle"), \
contextlib.redirect_stderr(err), contextlib.redirect_stdout(out):
fc_setup._setup_systemd()
printed = err.getvalue()
self.assertIn(
"sudo /home/u/.local/bin/bot-bottle backend setup --backend=firecracker",
printed,
)
self.assertNotIn("sudo bot-bottle", printed)
if __name__ == "__main__":
unittest.main()
+2 -14
View File
@@ -33,8 +33,8 @@ class TestInfraEnsureRunning(unittest.TestCase):
with patch(f"{_INFRA}.ensure_networks") as nets:
url = self.svc.ensure_running()
nets.assert_called_once()
self.orch.ensure_available.assert_called_once()
self.gw.ensure_available.assert_called_once()
self.orch.ensure_built.assert_called_once()
self.gw.ensure_built.assert_called_once()
self.orch.ensure_running.assert_called_once()
# Returns the host control-plane URL (the InfraService contract).
self.assertEqual("http://192.168.128.2:8099", url)
@@ -51,18 +51,6 @@ class TestInfraEnsureRunning(unittest.TestCase):
self.assertTrue(self.svc.is_healthy())
self.orch.is_healthy.assert_called_once()
def test_packaged_images_disable_local_infra_builds(self) -> None:
refs = [
("registry/orchestrator@sha256:" + "a" * 64, False),
("registry/gateway@sha256:" + "b" * 64, False),
]
with patch(
f"{_INFRA}.release_manifest.oci_image", side_effect=refs,
):
svc = MacosInfraService(repo_root=Path("/r"))
self.assertFalse(svc.orchestrator()._local_build)
self.assertFalse(svc.gateway()._local_build)
class TestCaCertPem(unittest.TestCase):
def test_delegates_to_the_gateway_service(self) -> None:
+2 -17
View File
@@ -30,16 +30,14 @@ def _spec(src: str, tgt: str, readonly: bool = False) -> str:
class TestMacosOrchestratorRun(unittest.TestCase):
def _run(self, *, local_build: bool = True) -> list[str]:
def _run(self) -> list[str]:
run = Mock(return_value=_ok())
with patch(f"{_ORCH}.container_mod") as mod, \
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.bind_mount_spec.side_effect = _spec
mod.run_container_argv = run
MacosOrchestrator(
repo_root=Path("/r"), local_build=local_build,
)._run_container("h1")
MacosOrchestrator(repo_root=Path("/r"))._run_container("h1")
return run.call_args.args[0]
def test_runs_on_the_control_network_only(self) -> None:
@@ -65,11 +63,6 @@ class TestMacosOrchestratorRun(unittest.TestCase):
def test_source_hash_is_labelled_for_recreate(self) -> None:
self.assertIn("BOT_BOTTLE_SOURCE_HASH=h1", self._run())
def test_packaged_image_does_not_overlay_host_source(self) -> None:
argv = self._run(local_build=False)
self.assertNotIn("--mount", argv)
self.assertFalse(any(arg.startswith("PYTHONPATH=") for arg in argv))
def test_start_failure_raises(self) -> None:
with patch(f"{_ORCH}.container_mod") as mod, \
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
@@ -152,14 +145,6 @@ class TestMacosOrchestratorBuildStop(unittest.TestCase):
_args, kwargs = mod.build_image.call_args
self.assertEqual("Dockerfile.orchestrator", kwargs["dockerfile"])
def test_ensure_built_pulls_packaged_image(self) -> None:
image = "registry/orchestrator@sha256:" + "a" * 64
orch = MacosOrchestrator(image, local_build=False)
with patch(f"{_ORCH}.container_mod") as mod:
orch.ensure_built()
mod.pull_image.assert_called_once_with(image)
mod.build_image.assert_not_called()
def test_stop_removes_the_container(self) -> None:
orch = MacosOrchestrator()
with patch(f"{_ORCH}.container_mod") as mod:
+25
View File
@@ -151,6 +151,31 @@ class TestReprovisionGateway(unittest.TestCase):
self.c.reprovision_gateway("b1", "key")
class TestUpdateAgentSecret(unittest.TestCase):
def setUp(self) -> None:
self.c = OrchestratorClient("http://orch:8080")
def test_success_posts_single_secret(self) -> None:
with patch(_URLOPEN, return_value=_resp(200, {"updated": True})) as opened:
self.assertTrue(self.c.update_agent_secret("b1", "A", "fresh", "key"))
request = opened.call_args.args[0]
self.assertEqual("POST", request.get_method())
self.assertTrue(request.full_url.endswith("/bottles/b1/secret"))
self.assertEqual(
{"name": "A", "value": "fresh", "env_var_secret": "key"},
json.loads(request.data),
)
def test_unknown_bottle_is_false(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(404)):
self.assertFalse(self.c.update_agent_secret("b1", "A", "v", "key"))
def test_other_status_raises(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(400)):
with self.assertRaises(OrchestratorClientError):
self.c.update_agent_secret("b1", "A", "v", "key")
class TestHealthAndPolicy(unittest.TestCase):
def setUp(self) -> None:
self.c = OrchestratorClient("http://orch:8080")
+4 -4
View File
@@ -427,11 +427,11 @@ class TestDockerGatewayBuild(unittest.TestCase):
builds = [c for c in calls if c[:2] == ["docker", "build"]]
self.assertIn("--no-cache", builds[0])
def test_ensure_built_pulls_when_no_dockerfile(self) -> None:
sc = DockerGateway("registry/gateway@sha256:" + "a" * 64, dockerfile=None)
with patch(_RUN_DOCKER, return_value=_proc()) as m:
def test_ensure_built_noop_when_no_dockerfile(self) -> None:
sc = DockerGateway("busybox", dockerfile=None)
with patch(_RUN_DOCKER) as m:
sc.ensure_built()
m.assert_called_once_with(["docker", "pull", sc.image_ref])
m.assert_not_called()
def test_ensure_built_raises_on_build_failure(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="build boom")):
@@ -199,6 +199,20 @@ class TestAgentSecrets(unittest.TestCase):
got = self.store.get_agent_secrets("bottle-1")
self.assertEqual({"K": "new", "K2": "v2"}, got)
def test_store_agent_secret_upserts_one_key_leaving_others(self) -> None:
self.store.store_agent_secrets("bottle-1", {"K": "v1", "K2": "v2"})
self.store.store_agent_secret("bottle-1", "K", "v1-new") # update existing
self.store.store_agent_secret("bottle-1", "K3", "v3") # insert new
self.assertEqual(
{"K": "v1-new", "K2": "v2", "K3": "v3"},
self.store.get_agent_secrets("bottle-1"),
)
def test_store_agent_secret_does_not_duplicate_rows(self) -> None:
self.store.store_agent_secret("bottle-1", "K", "v1")
self.store.store_agent_secret("bottle-1", "K", "v2")
self.assertEqual({"K": "v2"}, self.store.get_agent_secrets("bottle-1"))
def test_delete_removes_secrets(self) -> None:
self.store.store_agent_secrets("bottle-1", {"K": "v"})
self.store.delete_agent_secrets("bottle-1")
+41
View File
@@ -125,6 +125,47 @@ class TestDispatch(unittest.TestCase):
{"EGRESS_TOKEN_0": "upstream-secret"}, self.orch.tokens_for(bottle_id),
)
def test_update_agent_secret_in_place(self) -> None:
key = base64.urlsafe_b64encode(b"unit-test-key").rstrip(b"=").decode()
status, payload = dispatch(
self.orch, "POST", "/bottles", _body({
"source_ip": "10.243.0.21",
"tokens": {"A": "old", "B": "keep"},
"env_var_secret": key,
}),
)
self.assertEqual(201, status)
bottle_id = payload["bottle_id"]
assert isinstance(bottle_id, str)
status, response = dispatch(
self.orch, "POST", f"/bottles/{bottle_id}/secret",
_body({"name": "A", "value": "fresh", "env_var_secret": key}),
)
self.assertEqual((200, {"updated": True}), (status, response))
self.assertEqual({"A": "fresh", "B": "keep"}, self.orch.tokens_for(bottle_id))
def test_update_agent_secret_validates_and_unknown_bottle(self) -> None:
status, _ = dispatch(self.orch, "POST", "/bottles/b1/secret", b"not-json")
self.assertEqual(400, status)
status, _ = dispatch(
self.orch, "POST", "/bottles/b1/secret", _body({"name": "A"}),
)
self.assertEqual(400, status) # value + env_var_secret missing
status, _ = dispatch(
self.orch, "POST", "/bottles/ghost/secret",
_body({"name": "A", "value": "v", "env_var_secret": "k"}),
)
self.assertEqual(404, status)
def test_update_agent_secret_is_cli_only(self) -> None:
# A data-plane (`gateway`) caller must not set a bottle's tokens.
status, _ = dispatch(
self.orch, "POST", "/bottles/b1/secret",
_body({"name": "A", "value": "v", "env_var_secret": "k"}),
role=ROLE_GATEWAY,
)
self.assertEqual(403, status)
def test_reprovision_validates_request_and_missing_rows(self) -> None:
status, _ = dispatch(
self.orch, "POST", "/bottles/b1/reprovision_gateway", b"not-json",
+19
View File
@@ -103,6 +103,25 @@ class TestOrchestrator(unittest.TestCase):
self.assertTrue(self.orch.reprovision_from_secret(rec.bottle_id, key))
self.assertEqual({"EGRESS_TOKEN_0": "secret"}, self.orch.tokens_for(rec.bottle_id))
def test_update_agent_secret_refreshes_one_token_in_place(self) -> None:
key = new_env_var_secret()
rec = self.orch.launch_bottle(
"10.243.0.20", tokens={"A": "old-a", "B": "keep-b"}, env_var_secret=key,
)
self.assertTrue(self.orch.update_agent_secret(rec.bottle_id, "A", "new-a", key))
# In memory: A refreshed, B left untouched.
self.assertEqual({"A": "new-a", "B": "keep-b"}, self.orch.tokens_for(rec.bottle_id))
# At rest: the whole set stays decryptable with the SAME env_var_secret,
# so a later reprovision restores the refreshed value (not the launch one).
self.orch._tokens.clear()
self.assertTrue(self.orch.reprovision_from_secret(rec.bottle_id, key))
self.assertEqual({"A": "new-a", "B": "keep-b"}, self.orch.tokens_for(rec.bottle_id))
def test_update_agent_secret_unknown_bottle_is_false(self) -> None:
self.assertFalse(
self.orch.update_agent_secret("ghost", "A", "v", new_env_var_secret())
)
def test_reprovision_rejects_missing_rows_and_wrong_key(self) -> None:
self.assertFalse(self.orch.reprovision_from_secret("missing", new_env_var_secret()))
key = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"
-60
View File
@@ -1,60 +0,0 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from bot_bottle.release_bundle import (
build_bundle_index,
canonical_bytes,
parse_bundle_index,
)
from bot_bottle.release_manifest import ReleaseManifestError
from tests.unit.test_release_manifest import manifest
class TestReleaseBundle(unittest.TestCase):
def test_builds_commit_addressed_index(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
wheel = Path(tmp) / "bot_bottle-0.1.0-py3-none-any.whl"
wheel.write_bytes(b"wheel")
index = build_bundle_index(
manifest(),
wheel=wheel,
wheel_url=f"https://packages.example/{wheel.name}",
workflow_run="actions/123",
published_at="2026-07-27T00:00:00Z",
)
self.assertEqual("1" * 40, index["source_commit"])
self.assertEqual(64, len(index["wheel"]["sha256"]))
self.assertEqual([], index["qualifications"])
self.assertIs(index, parse_bundle_index(index))
self.assertTrue(canonical_bytes(index).endswith(b"\n"))
def test_rejects_mutable_or_mismatched_wheel_url(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
wheel = Path(tmp) / "release.whl"
wheel.write_bytes(b"wheel")
with self.assertRaisesRegex(ReleaseManifestError, "HTTPS"):
build_bundle_index(
manifest(),
wheel=wheel,
wheel_url="http://packages.example/other.whl",
workflow_run="actions/123",
published_at="now",
)
def test_rejects_index_with_changed_runtime_identity(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
wheel = Path(tmp) / "release.whl"
wheel.write_bytes(b"wheel")
index = build_bundle_index(
manifest(),
wheel=wheel,
wheel_url=f"https://packages.example/{wheel.name}",
workflow_run="actions/123",
published_at="now",
)
index["oci"]["gateway"] = "registry/gateway:latest"
with self.assertRaisesRegex(ReleaseManifestError, "digest-pinned"):
parse_bundle_index(index)
-129
View File
@@ -1,129 +0,0 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle.release_manifest import (
ReleaseManifestError,
oci_image,
packaged_manifest,
parse_manifest,
)
def manifest() -> dict[str, object]:
return {
"schema": 1,
"source_commit": "1" * 40,
"oci": {
"orchestrator": "registry/orchestrator@sha256:" + "a" * 64,
"gateway": "registry/gateway@sha256:" + "b" * 64,
"agent_claude": "registry/agent-claude@sha256:" + "e" * 64,
"agent_codex": "registry/agent-codex@sha256:" + "f" * 64,
"agent_pi": "registry/agent-pi@sha256:" + "0" * 64,
},
"firecracker": {
"orchestrator": {"version": "orch-v1", "sha256": "c" * 64},
"gateway": {"version": "gateway-v1", "sha256": "d" * 64},
},
}
class TestParseManifest(unittest.TestCase):
def test_parses_all_backend_artifacts(self) -> None:
parsed = parse_manifest(manifest())
self.assertTrue(parsed.orchestrator_image.endswith("a" * 64))
self.assertEqual("orch-v1", parsed.firecracker_orchestrator.version)
self.assertTrue(parsed.agent_images["codex"].endswith("f" * 64))
def test_rejects_mutable_oci_reference(self) -> None:
data = manifest()
assert isinstance(data["oci"], dict)
data["oci"]["orchestrator"] = "registry/orchestrator:latest"
with self.assertRaisesRegex(ReleaseManifestError, "digest-pinned"):
parse_manifest(data)
def test_rejects_malformed_firecracker_checksum(self) -> None:
data = manifest()
assert isinstance(data["firecracker"], dict)
artifact = data["firecracker"]["gateway"]
assert isinstance(artifact, dict)
artifact["sha256"] = "nope"
with self.assertRaisesRegex(ReleaseManifestError, "64 lowercase hex"):
parse_manifest(data)
def test_rejects_missing_manifest_sections(self) -> None:
cases = (
(None, "schema 1"),
({"schema": 1}, "source_commit"),
(
{"schema": 1, "source_commit": "1" * 40},
"oci must be an object",
),
)
for data, message in cases:
with self.subTest(message=message):
with self.assertRaisesRegex(ReleaseManifestError, message):
parse_manifest(data)
def test_rejects_malformed_firecracker_artifact(self) -> None:
data = manifest()
assert isinstance(data["firecracker"], dict)
data["firecracker"]["orchestrator"] = None
with self.assertRaisesRegex(ReleaseManifestError, "must be an object"):
parse_manifest(data)
data["firecracker"]["orchestrator"] = {
"version": " ",
"sha256": "c" * 64,
}
with self.assertRaisesRegex(ReleaseManifestError, "version must be non-empty"):
parse_manifest(data)
class TestImageSelection(unittest.TestCase):
def test_development_marker_selects_local_builds(self) -> None:
with patch(
"bot_bottle.release_manifest.resources.is_source_checkout",
return_value=False,
), patch(
"bot_bottle.release_manifest.manifest_path",
) as path:
path.return_value.read_text.return_value = (
'{"schema": 1, "development": true}')
self.assertIsNone(packaged_manifest())
def test_packaged_install_uses_manifest_reference_without_build(self) -> None:
parsed = parse_manifest(manifest())
with patch(
"bot_bottle.release_manifest.packaged_manifest", return_value=parsed,
), patch(
"bot_bottle.release_manifest.local_build_requested", return_value=False,
), patch.dict("os.environ", {}, clear=True):
ref, local = oci_image("orchestrator", "local:latest")
self.assertEqual(parsed.orchestrator_image, ref)
self.assertFalse(local)
def test_packaged_install_selects_agent_reference(self) -> None:
parsed = parse_manifest(manifest())
with patch(
"bot_bottle.release_manifest.packaged_manifest", return_value=parsed,
), patch(
"bot_bottle.release_manifest.local_build_requested", return_value=False,
), patch.dict("os.environ", {}, clear=True):
ref, local = oci_image("agent_codex", "local:latest")
self.assertEqual(parsed.agent_images["codex"], ref)
self.assertFalse(local)
def test_mutable_override_fails_outside_local_mode(self) -> None:
parsed = parse_manifest(manifest())
with patch(
"bot_bottle.release_manifest.packaged_manifest", return_value=parsed,
), patch(
"bot_bottle.release_manifest.local_build_requested", return_value=False,
), patch.dict(
"os.environ", {"BOT_BOTTLE_ORCHESTRATOR_IMAGE": "local:latest"},
clear=True,
):
with self.assertRaisesRegex(ReleaseManifestError, "digest-pinned"):
oci_image("orchestrator", "fallback:latest")
-163
View File
@@ -1,163 +0,0 @@
from __future__ import annotations
import tempfile
import unittest
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.release_bundle import build_bundle_index, canonical_bytes
from bot_bottle.release_manifest import ReleaseManifestError
from bot_bottle.release_publish import (
bundle_url,
remote_bytes,
remote_sha256,
request,
upload as publish_artifact,
publish_bundle,
)
from tests.unit.test_release_manifest import manifest
class TestReleasePublish(unittest.TestCase):
def setUp(self) -> None:
env = {
"BOT_BOTTLE_RELEASE_BASE": "https://packages.example",
"BOT_BOTTLE_RELEASE_OWNER": "owner",
"BOT_BOTTLE_RELEASE_TOKEN": "token",
}
self.env = patch.dict("os.environ", env, clear=True)
self.env.start()
self.addCleanup(self.env.stop)
def bundle(self, root: Path) -> tuple[dict[str, object], Path]:
wheel = root / "bot_bottle-0.1.0-py3-none-any.whl"
wheel.write_bytes(b"wheel")
index = build_bundle_index(
manifest(),
wheel=wheel,
wheel_url=bundle_url("1" * 40, wheel.name),
workflow_run="actions/123",
published_at="2026-07-27T00:00:00Z",
)
return index, wheel
@staticmethod
def wheel_sha(index: dict[str, object]) -> str:
wheel = index["wheel"]
assert isinstance(wheel, dict)
digest = wheel["sha256"]
assert isinstance(digest, str)
return digest
def test_publishes_wheel_then_index(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
index, wheel = self.bundle(Path(tmp))
with patch(
"bot_bottle.release_publish.remote_bytes", return_value=None,
), patch(
"bot_bottle.release_publish.remote_sha256", return_value=None,
), patch("bot_bottle.release_publish.upload") as upload:
result = publish_bundle(index, wheel)
self.assertTrue(result.endswith("/bundle-index.json"))
self.assertEqual(2, upload.call_count)
self.assertIs(wheel, upload.call_args_list[0].args[1])
self.assertEqual(canonical_bytes(index), upload.call_args_list[1].args[1])
def test_exact_existing_bundle_is_idempotent(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
index, wheel = self.bundle(Path(tmp))
with patch(
"bot_bottle.release_publish.remote_bytes",
return_value=canonical_bytes(index),
), patch(
"bot_bottle.release_publish.remote_sha256",
return_value=self.wheel_sha(index),
), patch("bot_bottle.release_publish.upload") as upload:
publish_bundle(index, wheel)
upload.assert_not_called()
def test_rejects_partial_publication(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
index, wheel = self.bundle(Path(tmp))
with patch(
"bot_bottle.release_publish.remote_bytes", return_value=None,
), patch(
"bot_bottle.release_publish.remote_sha256",
return_value=self.wheel_sha(index),
):
with self.assertRaisesRegex(
ReleaseManifestError, "administrative cleanup",
):
publish_bundle(index, wheel)
def test_rejects_existing_bundle_with_different_index(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
index, wheel = self.bundle(Path(tmp))
with patch(
"bot_bottle.release_publish.remote_bytes", return_value=b"other",
), patch(
"bot_bottle.release_publish.remote_sha256",
return_value=self.wheel_sha(index),
):
with self.assertRaisesRegex(
ReleaseManifestError, "already differs",
):
publish_bundle(index, wheel)
def test_request_applies_auth_and_length(self) -> None:
result = request(
"https://packages.example/file",
method="PUT",
data=b"body",
length=4,
)
self.assertEqual(result.get_method(), "PUT")
self.assertEqual(result.get_header("Authorization"), "token token")
self.assertEqual(result.get_header("Content-length"), "4")
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_remote_helpers_read_response(self, urlopen: MagicMock) -> None:
response = urlopen.return_value.__enter__.return_value
response.read.side_effect = [b"body"]
self.assertEqual(
remote_bytes("https://packages.example/file"), b"body")
response.read.side_effect = [b"body", b""]
self.assertEqual(
remote_sha256("https://packages.example/file"),
"230d8358dc8e8890b4c58deeb62912ee2"
"f20357ae92a5cc861b98e68fe31acb5",
)
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_remote_helpers_handle_not_found(self, urlopen: MagicMock) -> None:
urlopen.side_effect = urllib.error.HTTPError(
"url", 404, "missing", MagicMock(), None)
self.assertIsNone(remote_bytes("https://packages.example/file"))
self.assertIsNone(remote_sha256("https://packages.example/file"))
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_remote_helpers_report_registry_errors(
self, urlopen: MagicMock,
) -> None:
urlopen.side_effect = urllib.error.URLError("offline")
with self.assertRaisesRegex(ReleaseManifestError, "offline"):
remote_bytes("https://packages.example/file")
with self.assertRaisesRegex(ReleaseManifestError, "offline"):
remote_sha256("https://packages.example/file")
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_upload_supports_bytes_and_files(self, urlopen: MagicMock) -> None:
urlopen.return_value.__enter__.return_value = MagicMock()
publish_artifact("https://packages.example/bytes", b"body")
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "artifact"
source.write_bytes(b"artifact")
publish_artifact("https://packages.example/file", source)
self.assertEqual(urlopen.call_count, 2)
@patch("bot_bottle.release_publish.urllib.request.urlopen")
def test_upload_reports_failure(self, urlopen: MagicMock) -> None:
urlopen.side_effect = urllib.error.URLError("offline")
with self.assertRaisesRegex(ReleaseManifestError, "publishing"):
publish_artifact("https://packages.example/file", b"body")
-270
View File
@@ -1,270 +0,0 @@
"""Tests for release qualification and channel promotion."""
from __future__ import annotations
import json
import unittest
import urllib.error
from email.message import Message
from unittest import mock
from bot_bottle.release_manifest import ReleaseManifestError
from bot_bottle.release_qualification import (
build_release_pointer,
canonical_pointer,
delete,
parse_release_pointer,
publish_qualification,
release_channel,
release_version,
)
COMMIT = "a" * 40
def bundle(source_commit: str = COMMIT) -> bytes:
return json.dumps({
"schema": 1,
"source_commit": source_commit,
"wheel": {
"filename": "bot_bottle.whl",
"url": (
"https://gitea.dideric.is/api/packages/didericis/generic/"
f"bot-bottle-builds/{source_commit}/bot_bottle.whl"
),
"sha256": "b" * 64,
},
"oci": {
role: f"registry.example/{role}@sha256:{'c' * 64}"
for role in (
"orchestrator",
"gateway",
"agent_claude",
"agent_codex",
"agent_pi",
)
},
"firecracker": {
role: {"version": "v1", "sha256": "d" * 64}
for role in ("orchestrator", "gateway")
},
"provenance": {"workflow_run": "run", "published_at": "now"},
"qualifications": [],
}).encode()
class ReleaseQualificationTest(unittest.TestCase):
def test_tag_selects_channel(self) -> None:
self.assertEqual(release_channel("v1.2.3"), "production")
self.assertEqual(release_channel("v1.2.3-rc.4"), "staging")
with self.assertRaises(ReleaseManifestError):
release_channel("latest")
def test_release_versions_are_monotonic(self) -> None:
self.assertLess(
release_version("v1.2.3-rc.4"), release_version("v1.2.3"))
self.assertLess(
release_version("v1.2.3"), release_version("v2.0.0"))
with self.assertRaises(ReleaseManifestError):
release_version("v1")
def test_builds_qualified_pointer(self) -> None:
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="https://example.test/run/1",
qualified_at="2026-07-27T00:00:00Z",
)
self.assertEqual(pointer["channel"], "production")
self.assertTrue(pointer["qualified"])
self.assertTrue(canonical_pointer(pointer).endswith(b"\n"))
def test_rejects_invalid_pointer_fields(self) -> None:
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
changes = (
("schema", 2),
("qualified", False),
("channel", "staging"),
("source_commit", "short"),
("bundle_index_url", "https://wrong.example/index.json"),
("qualification", {}),
)
for key, value in changes:
with self.subTest(key=key), self.assertRaises(ReleaseManifestError):
parse_release_pointer({**pointer, key: value})
def test_rejects_invalid_build_inputs(self) -> None:
with self.assertRaises(ReleaseManifestError):
build_release_pointer(
tag="v1.2.3",
source_commit="short",
workflow_run="run",
qualified_at="now",
)
with self.assertRaises(ReleaseManifestError):
build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="",
qualified_at="now",
)
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_publishes_bundle_release_and_channel(
self, remote: mock.Mock, upload: mock.Mock,
) -> None:
remote.side_effect = [bundle(), None, None]
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
publish_qualification(pointer)
self.assertEqual(upload.call_count, 2)
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_exact_existing_publication_is_noop(
self, remote: mock.Mock, upload: mock.Mock,
) -> None:
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
wanted = canonical_pointer(pointer)
remote.side_effect = [bundle(), wanted, wanted]
publish_qualification(pointer)
upload.assert_not_called()
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.delete")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_advances_to_newer_channel(
self, remote: mock.Mock, remove: mock.Mock, upload: mock.Mock,
) -> None:
old = build_release_pointer(
tag="v1.0.0",
source_commit="b" * 40,
workflow_run="old",
qualified_at="then",
)
pointer = build_release_pointer(
tag="v2.0.0",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
remote.side_effect = [bundle(), None, canonical_pointer(old)]
publish_qualification(pointer)
remove.assert_called_once()
self.assertEqual(upload.call_count, 2)
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_rejects_non_monotonic_channel(
self, remote: mock.Mock, _upload: mock.Mock,
) -> None:
old = build_release_pointer(
tag="v2.0.0",
source_commit="b" * 40,
workflow_run="old-run",
qualified_at="old-time",
)
remote.side_effect = [bundle(), None, json.dumps(old).encode()]
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
with self.assertRaisesRegex(ReleaseManifestError, "not monotonic"):
publish_qualification(pointer)
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.delete")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_explicit_rollback_allows_older_channel(
self, remote: mock.Mock, remove: mock.Mock, _upload: mock.Mock,
) -> None:
old = build_release_pointer(
tag="v2.0.0",
source_commit="b" * 40,
workflow_run="old",
qualified_at="then",
)
pointer = build_release_pointer(
tag="v1.0.0",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
remote.side_effect = [bundle(), None, canonical_pointer(old)]
publish_qualification(pointer, allow_rollback=True)
remove.assert_called_once()
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_rejects_missing_or_malformed_bundle(self, remote: mock.Mock) -> None:
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
remote.return_value = None
with self.assertRaisesRegex(ReleaseManifestError, "does not exist"):
publish_qualification(pointer)
remote.return_value = b"not-json"
with self.assertRaisesRegex(ReleaseManifestError, "malformed"):
publish_qualification(pointer)
remote.return_value = bundle("b" * 40)
with self.assertRaisesRegex(ReleaseManifestError, "does not match"):
publish_qualification(pointer)
@mock.patch("bot_bottle.release_qualification.upload")
@mock.patch("bot_bottle.release_qualification.remote_bytes")
def test_rejects_changed_release_or_malformed_channel(
self, remote: mock.Mock, _upload: mock.Mock,
) -> None:
pointer = build_release_pointer(
tag="v1.2.3",
source_commit=COMMIT,
workflow_run="run",
qualified_at="now",
)
remote.side_effect = [bundle(), b"different"]
with self.assertRaisesRegex(ReleaseManifestError, "already differs"):
publish_qualification(pointer)
remote.side_effect = [bundle(), None, b"not-json"]
with self.assertRaisesRegex(ReleaseManifestError, "malformed"):
publish_qualification(pointer)
@mock.patch("bot_bottle.release_qualification.urllib.request.urlopen")
def test_delete_handles_success_and_not_found(self, urlopen: mock.Mock) -> None:
urlopen.return_value.__enter__.return_value = mock.Mock()
delete("https://example.test/channel")
urlopen.side_effect = urllib.error.HTTPError(
"url", 404, "missing", Message(), None)
delete("https://example.test/channel")
@mock.patch("bot_bottle.release_qualification.urllib.request.urlopen")
def test_delete_reports_registry_errors(self, urlopen: mock.Mock) -> None:
urlopen.side_effect = urllib.error.HTTPError(
"url", 500, "broken", Message(), None)
with self.assertRaisesRegex(ReleaseManifestError, "HTTP 500"):
delete("https://example.test/channel")
urlopen.side_effect = urllib.error.URLError("offline")
with self.assertRaisesRegex(ReleaseManifestError, "offline"):
delete("https://example.test/channel")
if __name__ == "__main__":
unittest.main()
-9
View File
@@ -117,15 +117,6 @@ class TestWheelInstall(unittest.TestCase):
proc = self._run(["-c", script])
self.assertIn("SELF_CONTAINED_OK", proc.stdout, msg=proc.stderr)
def test_ad_hoc_wheel_is_marked_for_local_infrastructure_builds(self):
script = (
"from bot_bottle.release_manifest import packaged_manifest\n"
"assert packaged_manifest() is None\n"
"print('DEVELOPMENT_MANIFEST_OK')\n"
)
proc = self._run(["-c", script])
self.assertIn("DEVELOPMENT_MANIFEST_OK", proc.stdout, msg=proc.stderr)
if __name__ == "__main__":
unittest.main()