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
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:
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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"])
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user