Compare commits

...

10 Commits

Author SHA1 Message Date
didericis-claude 606a57b7ed docs: renumber PRD 0081 -> 0083 across the reconcile chunk
test / unit (pull_request) Successful in 1m1s
lint / lint (push) Successful in 1m9s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / integration-docker (pull_request) Successful in 1m2s
test / image-input-builds (pull_request) Successful in 56s
test / coverage (pull_request) Successful in 17s
Follows the base PR's renumber (0081 was already taken; main is on 0082). Update
every `PRD 0081` reference in the gateway-attach code, tests, and ADR 0006, plus
ADR 0006's link to the PRD file. No behaviour change.

Refs #516, #519.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:15:44 +00:00
didericis-claude 343bf89f82 fix(backend): finish infra_launch rename + base cap after rebase onto main
Reconciles the chunk-1 rebase onto the current main:
- `_network_container_ips` (added on main) raised the pre-rename
  `ConsolidatedLaunchError`; renamed to `InfraLaunchError` to match the rest of
  the rename.
- main grew `backend/base.py` to 563 lines; with the gateway-attach contract
  (39 lines) it is 602, so the guardrail cap moves 600 -> 615. The reconcile
  flow still lives in `backend/gateway_attach.py`, not base.py.

Refs #516, #519.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:15:14 +00:00
didericis 40955af86b fix(backend): reconcile reads the booted gateway's CA, not a default (0081)
The gateway-attach reconcile (PRD 0081) gathered its resources via a fresh
default DockerInfraService(), so on a cold-boot bring-up of a NON-default
gateway instance — e.g. an isolated integration test's `-itest-` gateway — it
read the default `bot-bottle-orch-gateway`, which doesn't exist for that
instance, and setUpClass died with "No such container: bot-bottle-orch-gateway"
(the 0081 PR's integration-docker job).

Thread the infra service whose gateway just cold-booted from
DockerInfraService.ensure_running() into DockerBottleBackend so
_gateway_attach_resources() reads THAT gateway's CA. Ordinary construction (no
infra) still falls back to the per-host default singleton — production is
unchanged — and the resources-first / fail-hard reconcile ordering is kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:15:14 +00:00
didericis-claude 9ac5b19322 refactor(backend): base owns the gateway-attach flow, fail hard (0081 review)
Addresses review on #519 (@didericis 6143 + the fail-hard direction over the
codex skip).

Base owns the flow (6143 / ADR 0006). `attach_bottled_agents_to_gateway()` is
now a concrete method on `BottleBackend` that delegates to
`gateway_attach.reconcile_running_bottles`; backends override only three
primitives — `_gateway_attach_resources()`, `_running_bottles()`,
`_attach_bottle_to_gateway()`. The shared control flow + error policy live in
one place so a backend can't drift onto a bespoke loop or a silent skip. Keeps
the reconcile flow + `GatewayAttachResources` out of `backend/base.py` (the
size guardrail) in a dedicated `backend/gateway_attach.py`. New ADR 0006 records
the "shared behaviour in the base backend, subclasses override primitives"
theme.

Fail hard, never skip (the maintainer's direction over the codex review's
skip). Any attach failure now aborts bring-up instead of being logged and
skipped: a bottle that silently can't reach the fresh gateway (its egress just
starts failing TLS) is worse than a loud failure. Every bottle is attempted and
the failures are raised together (aggregate `InfraLaunchError`) so one bring-up
surfaces the full blast radius. Resource gathering (the CA fetch) also fails
hard. PRD 0081 goal + design updated to match (reversed from the earlier
"tolerate per-bottle failures" draft).

Bumps the base.py guardrail cap 580->600 for the new contract surface (the
delegator + 3 abstract primitives); the flow itself lives in gateway_attach.py.

Refs #516, #519.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:15:14 +00:00
didericis-claude 187d012ca8 refactor(backend): rename consolidated_launch -> infra_launch (0081 review)
Mechanical rename addressing review 6141 on #519: "consolidated launch" was
unclear about what it composes. Rename the per-backend module
`consolidated_launch.py` -> `infra_launch.py` and the error
`ConsolidatedLaunchError` -> `InfraLaunchError` across all three backends and
their callers/tests. Unify the three identical per-backend error classes into
one `InfraLaunchError` defined in `backend/base.py` (re-exported by each
`infra_launch`) so the base backend can raise and catch a single shared type —
groundwork for the base owning the gateway-attach flow.

Add a glossary entry defining **infra** = the per-host gateway + orchestrator
service pair every bottle attaches to.

No behavior change.

Refs #516, #519.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:15:14 +00:00
didericis-claude 5777824d0b feat(backend): reconcile running bottles' CA on gateway bring-up (0081)
Add `attach_bottled_agents_to_gateway()` as an `@abc.abstractmethod` on
`BottleBackend` and implement the first reconcile step across all three
backends: on a gateway cold boot, replace every already-running agent's
trusted CA with the freshly-minted gateway CA. Reconciling running bottles
is a backend responsibility (only the backend can enumerate its agents and
reach them — firecracker over SSH, docker/macOS over exec/cp), so making it
an abstract method keeps the fix cross-backend by construction.

The gateway rootfs is ephemeral, so a rebuild mints a new CA that every
running bottle distrusts (SSL verification fails — #510). This reconcile is
what distributes the fresh CA, so a routine gateway rebuild now doubles as a
free CA rotation. Per-bottle steps are best-effort: one unreachable or
malformed bottle is logged and skipped, never blocking the others.

Trigger — `Gateway.connect_to_orchestrator` now returns a cold-boot bool
(True when it actually (re)brought the gateway up, False when a healthy
current gateway was left untouched); each infra `ensure_running` gates the
reconcile on it, so it fires exactly on cold boot and never on an adopt.

Git-gate re-provision and egress-token restore fold into this same reconcile
on the same trigger in follow-up PRs (#516).

Refs #516. Addresses #510.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:15:14 +00:00
didericis-claude cf6bbc43da docs(prd): renumber 0081 -> 0083 (0081 already taken)
test / unit (pull_request) Has started running
test / coverage (pull_request) Blocked by required conditions
test / integration-docker (pull_request) Has been cancelled
prd-number-check / require-numbered-prds (pull_request) Successful in 14s
lint / lint (push) Successful in 1m4s
test / image-input-builds (pull_request) Successful in 59s
tracker-policy-pr / check-pr (pull_request) Successful in 11s
PRD number 0081 was claimed by another PRD before this one merged (main is on
0082); renumber this design to the next free slot, 0083. File + title only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:14:54 +00:00
didericis 4e26e075c8 docs(prd): reconcile via a backend-ABC method across all backends (0081 review)
Address review: target all backends, not firecracker-only. Make the reconcile
an @abc.abstractmethod `attach_bottled_agents_to_gateway()` on BottleBackend so
every backend implements it (cross-backend by construction) and the host calls
it on gateway bring-up. Drop the docker/macOS non-goal; add the open question of
retiring docker's now-redundant CA bind-mount.

Refs #516
2026-07-27 15:14:33 +00:00
didericis cca724244e feat(orchestrator): update a single egress secret in place (0081)
Add a single-secret counterpart to reprovision_from_secret's restore-all:
update ONE egress token for a running bottle without a relaunch, for
refreshing a short-lived host credential (e.g. the Codex access token)
whose launch-time snapshot has expired.

- registry_store.store_agent_secret: per-key upsert (delete+insert of the
  one row), the counterpart of store_agent_secrets' replace-all.
- OrchestratorCore.update_agent_secret: set the in-memory token AND upsert
  the re-encrypted row under the bottle's env_var_secret, leaving other
  tokens untouched.
- POST /bottles/<id>/secret (cli-only) + client.update_agent_secret.

Refs #510, #512

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:14:33 +00:00
didericis cecc1b4d60 docs(prd): reprovision gateway-dependent state on gateway bring-up (0081)
Reconcile every running bottle against a freshly-booted gateway (replace
each agent's CA, re-provision git-gate, restore egress tokens) instead of
persisting gateway state on volumes. Supersedes the abandoned CA-volume
(#511) and git-gate-volume (#513) PRs.

Refs #510, #512

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:14:33 +00:00
45 changed files with 1057 additions and 66 deletions
+41 -2
View File
@@ -21,7 +21,7 @@ from abc import ABC, abstractmethod
from contextlib import AbstractContextManager, contextmanager from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Generator, Generic, Sequence, TypeVar from typing import TYPE_CHECKING, Generator, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider from ..agent_provider import AgentProvisionPlan, get_provider
from ..egress import EgressPlan from ..egress import EgressPlan
@@ -35,6 +35,9 @@ from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
if TYPE_CHECKING:
from .gateway_attach import GatewayAttachResources
class BackendStatus(enum.IntEnum): class BackendStatus(enum.IntEnum):
"""Return codes for BottleBackend.status(). READY == 0 so callsites """Return codes for BottleBackend.status(). READY == 0 so callsites
@@ -270,6 +273,17 @@ class Bottle(ABC):
PlanT = TypeVar("PlanT", bound=BottlePlan) PlanT = TypeVar("PlanT", bound=BottlePlan)
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan) CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
# The backend-specific handle for one running bottle the reconcile attaches to
# the gateway (a run dir for firecracker, a container name for docker/macOS).
# Registry-style callers that don't care about the handle bind it to `Any`
# (`BottleBackend[Any, Any, Any]`).
AttachTargetT = TypeVar("AttachTargetT")
class InfraLaunchError(RuntimeError):
"""A per-host infra (gateway + orchestrator) launch/reconcile step could not
complete. Shared across backends so the base backend can raise + catch one
type; each backend's ``infra_launch`` module re-exports it."""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -284,7 +298,7 @@ class BottleImages:
sidecar: str | Path = "" sidecar: str | Path = ""
class BottleBackend(ABC, Generic[PlanT, CleanupT]): class BottleBackend(ABC, Generic[PlanT, CleanupT, AttachTargetT]):
"""Abstract base for selectable bottle backends. Concrete subclasses """Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls. (e.g. DockerBottleBackend) own their own prepare/launch impls.
Parameterized over the backend's concrete plan + cleanup-plan types Parameterized over the backend's concrete plan + cleanup-plan types
@@ -512,6 +526,31 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
compose ls`; firecracker cross-references its running gateway compose ls`; firecracker cross-references its running gateway
containers against per-bottle metadata.""" containers against per-bottle metadata."""
def attach_bottled_agents_to_gateway(self) -> None:
"""Reconcile every running bottle against the freshly-(re)booted gateway
on the cold-boot bring-up path (PRD 0083). The shared flow + fail-hard
policy live in `gateway_attach`; backends override only the three
primitives below (ADR 0006)."""
from .gateway_attach import reconcile_running_bottles
reconcile_running_bottles(self)
@abstractmethod
def _gateway_attach_resources(self) -> "GatewayAttachResources":
"""Gather what every bottle needs to (re)attach to the current gateway
(the CA now). Raises if the gateway isn't reachable (aborts reconcile)."""
@abstractmethod
def _running_bottles(self) -> Sequence[AttachTargetT]:
"""The live bottles as backend handles for `_attach_bottle_to_gateway`.
Raises if the live set can't be determined (fail hard, no partial set)."""
@abstractmethod
def _attach_bottle_to_gateway(
self, bottle: AttachTargetT, resources: "GatewayAttachResources",
) -> None:
"""(Re)attach one bottle to the current gateway (SSH / `exec`+`cp`).
Raises `InfraLaunchError` naming the bottle on failure."""
@classmethod @classmethod
@abstractmethod @abstractmethod
def is_available(cls) -> bool: def is_available(cls) -> bool:
+33 -2
View File
@@ -23,7 +23,7 @@ import shutil
import io import io
from contextlib import contextmanager, redirect_stderr from contextlib import contextmanager, redirect_stderr
from pathlib import Path from pathlib import Path
from typing import Generator, Sequence from typing import TYPE_CHECKING, Generator, Sequence
from ...supervisor.types import SUPERVISE_HOSTNAME, SUPERVISE_PORT from ...supervisor.types import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from ...agent_provider import AgentProvisionPlan from ...agent_provider import AgentProvisionPlan
@@ -33,6 +33,7 @@ from ...git_gate import GitGatePlan
from ...supervisor.plan import SupervisePlan from ...supervisor.plan import SupervisePlan
from ...manifest import Manifest from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from ..gateway_attach import GatewayAttachResources
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -40,12 +41,27 @@ from . import resolve_plan as _resolve_plan
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
if TYPE_CHECKING:
from .infra import DockerInfraService
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan", str]):
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND """Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
when set to `docker`; retained as a legacy/example backend.""" when set to `docker`; retained as a legacy/example backend."""
name = "docker" name = "docker"
def __init__(self, *, infra: "DockerInfraService | None" = None) -> None:
# The infra service whose gateway just cold-booted, passed in on the
# bring-up reconcile path (PRD 0083) so `_gateway_attach_resources`
# reads THAT gateway's CA rather than a fresh default-named service —
# otherwise a non-default instance (an isolated integration test's
# `-itest-` gateway) reads the default `bot-bottle-orch-gateway`, which
# doesn't exist for it (#519 review). None for ordinary construction:
# falls back to the per-host default singleton.
self._infra = infra
@classmethod @classmethod
def is_available(cls) -> bool: def is_available(cls) -> bool:
"""`docker` on PATH is sufficient; we don't probe `docker info` """`docker` on PATH is sufficient; we don't probe `docker info`
@@ -140,3 +156,18 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def enumerate_active(self) -> Sequence[ActiveAgent]: def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active() return _enumerate.enumerate_active()
def _gateway_attach_resources(self) -> GatewayAttachResources:
from .infra import DockerInfraService
infra = self._infra if self._infra is not None else DockerInfraService()
return GatewayAttachResources(ca_pem=infra.gateway().ca_cert_pem())
def _running_bottles(self) -> Sequence[str]:
from .infra_launch import running_agent_containers
return running_agent_containers()
def _attach_bottle_to_gateway(
self, bottle: str, resources: GatewayAttachResources,
) -> None:
from .infra_launch import push_ca_to_container
push_ca_to_container(bottle, resources.ca_pem)
+5 -2
View File
@@ -200,7 +200,7 @@ class DockerGateway(Gateway):
f"gateway network {self.network} failed to create: {proc.stderr.strip()}" f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
) )
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None: def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
# Bind to this orchestrator (PRD 0070): stash the URL its daemons resolve # Bind to this orchestrator (PRD 0070): stash the URL its daemons resolve
# policy against + the pre-minted `gateway` token they present, then bring # policy against + the pre-minted `gateway` token they present, then bring
# the container up. The gateway never mints, so it holds no signing key — # the container up. The gateway never mints, so it holds no signing key —
@@ -227,7 +227,7 @@ class DockerGateway(Gateway):
# just when the container is absent. # just when the container is absent.
self._ensure_network() self._ensure_network()
if self.is_running() and self._running_image_is_current(): if self.is_running() and self._running_image_is_current():
return return False
# Clear any stale (stopped OR outdated-image) container holding the # Clear any stale (stopped OR outdated-image) container holding the
# fixed name, then start fresh. `rm --force` on an absent name is a # fixed name, then start fresh. `rm --force` on an absent name is a
# tolerated no-op. # tolerated no-op.
@@ -271,6 +271,9 @@ class DockerGateway(Gateway):
# pre-connect window (they retry /resolve per request). # pre-connect window (they retry /resolve per request).
if self._control_network: if self._control_network:
self._connect_control_network() self._connect_control_network()
# (Re)created a fresh container — signal a cold boot so the caller
# reconciles running bottles against it (PRD 0083).
return True
def _connect_control_network(self) -> None: def _connect_control_network(self) -> None:
"""Ensure the `--internal` control network exists and attach the gateway """Ensure the `--internal` control network exists and attach the gateway
+8 -1
View File
@@ -142,9 +142,16 @@ class DockerInfraService(InfraService):
# mints the role-scoped `gateway` JWT here and hands it to the gateway, # mints the role-scoped `gateway` JWT here and hands it to the gateway,
# which never sees the key (#469). `connect_to_orchestrator` is # which never sees the key (#469). `connect_to_orchestrator` is
# idempotent. # idempotent.
gateway.connect_to_orchestrator( cold_booted = gateway.connect_to_orchestrator(
orchestrator.gateway_url(), orchestrator.mint_gateway_token(), orchestrator.gateway_url(), orchestrator.mint_gateway_token(),
) )
# A (re)created gateway container minted/mounted its CA afresh and lost
# every bottle's per-bottle state; reconcile the already-running bottles
# against it (PRD 0083). Skipped when a healthy current gateway was left
# untouched — no cold boot, nothing to reconcile.
if cold_booted:
from .backend import DockerBottleBackend
DockerBottleBackend(infra=self).attach_bottled_agents_to_gateway()
return orchestrator.url() return orchestrator.url()
def stop(self) -> None: def stop(self) -> None:
@@ -13,6 +13,7 @@ orchestrator-facing wiring so that sequence stays testable in isolation.
from __future__ import annotations from __future__ import annotations
import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from ... import log from ... import log
@@ -24,15 +25,13 @@ from ...gateway import GATEWAY_NETWORK
from .infra import INFRA_NAME, DockerInfraService from .infra import INFRA_NAME, DockerInfraService
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ...orchestrator.reprovision import reprovision_bottles from ...orchestrator.reprovision import reprovision_bottles
from ..base import InfraLaunchError
from ..provision_bottle import deprovision_bottle, provision_bottle from ..provision_bottle import deprovision_bottle, provision_bottle
from ..util import AGENT_CA_PATH
from .gateway_transport import DockerGatewayTransport from .gateway_transport import DockerGatewayTransport
from .gateway_net import next_free_ip from .gateway_net import next_free_ip
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True) @dataclass(frozen=True)
class LaunchContext: class LaunchContext:
"""What the agent container needs to join the shared gateway.""" """What the agent container needs to join the shared gateway."""
@@ -54,7 +53,7 @@ def _network_cidr(network: str) -> str:
]) ])
cidr = proc.stdout.strip() cidr = proc.stdout.strip()
if proc.returncode != 0 or not cidr: if proc.returncode != 0 or not cidr:
raise ConsolidatedLaunchError( raise InfraLaunchError(
f"gateway network {network} has no subnet: {proc.stderr.strip()}" f"gateway network {network} has no subnet: {proc.stderr.strip()}"
) )
return cidr return cidr
@@ -70,7 +69,7 @@ def _network_container_ips(network: str) -> list[str]:
]) ])
if proc.returncode != 0: if proc.returncode != 0:
detail = proc.stderr.strip() or f"exit {proc.returncode}" detail = proc.stderr.strip() or f"exit {proc.returncode}"
raise ConsolidatedLaunchError( raise InfraLaunchError(
f"could not inspect addresses on gateway network {network}: {detail}" f"could not inspect addresses on gateway network {network}: {detail}"
) )
ips: list[str] = [] ips: list[str] = []
@@ -79,6 +78,59 @@ def _network_container_ips(network: str) -> list[str]:
return ips return ips
def running_agent_containers(
network: str = GATEWAY_NETWORK, gateway_name: str = INFRA_NAME,
) -> list[str]:
"""Every running agent container on the gateway `network` (the gateway
itself excluded) the bottles the bring-up reconcile attaches to the fresh
gateway (PRD 0083). Raises `OSError` if `docker` can't be run (fail hard: a
reconcile that can't list its bottles must not look like "no bottles")."""
proc = run_docker([
"docker", "network", "inspect", "--format",
"{{range .Containers}}{{.Name}}\n{{end}}", network,
])
return [
name for name in (line.strip() for line in proc.stdout.splitlines())
if name and name != gateway_name
]
def push_ca_to_container(container: str, ca_pem: str) -> None:
"""(Re)attach one running agent container to the current gateway: replace
its trusted gateway CA with `ca_pem` and rebuild its trust store via
`docker cp` + `docker exec` (PRD 0083). Unconditional install there is one
gateway, so no fingerprint match is needed.
Fail hard: raises `InfraLaunchError` (naming the container) on any failure
the base reconcile aggregates and surfaces it rather than leaving the agent
silently unable to reach the gateway."""
mkdir = run_docker(
["docker", "exec", container, "mkdir", "-p",
"/usr/local/share/ca-certificates"]
)
if mkdir.returncode != 0:
raise InfraLaunchError(
f"CA push to {container} failed (mkdir): {mkdir.stderr.strip()}"
)
with tempfile.NamedTemporaryFile("w", suffix=".crt") as tmp:
tmp.write(ca_pem)
tmp.flush()
cp = run_docker(["docker", "cp", tmp.name, f"{container}:{AGENT_CA_PATH}"])
if cp.returncode != 0:
raise InfraLaunchError(
f"CA push to {container} failed (cp): {cp.stderr.strip()}"
)
ex = run_docker(
["docker", "exec", container, "sh", "-c",
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates"]
)
if ex.returncode != 0:
raise InfraLaunchError(
f"CA push to {container} failed (update-ca-certificates): "
f"{ex.stderr.strip()}"
)
def _reprovision_running_bottles( def _reprovision_running_bottles(
orchestrator_url: str, orchestrator_url: str,
network: str = GATEWAY_NETWORK, network: str = GATEWAY_NETWORK,
@@ -185,6 +237,8 @@ def deprovision_consolidated(
__all__ = [ __all__ = [
"LaunchContext", "LaunchContext",
"launch_consolidated", "launch_consolidated",
"running_agent_containers",
"push_ca_to_container",
"deprovision_consolidated", "deprovision_consolidated",
"ConsolidatedLaunchError", "InfraLaunchError",
] ]
+1 -1
View File
@@ -61,7 +61,7 @@ from .compose import (
) )
from .consolidated_compose import consolidated_agent_compose from .consolidated_compose import consolidated_agent_compose
from ...orchestrator.store.config_store import resolve_teardown_timeout from ...orchestrator.store.config_store import resolve_teardown_timeout
from .consolidated_launch import launch_consolidated, deprovision_consolidated from .infra_launch import launch_consolidated, deprovision_consolidated
from .infra import INFRA_NAME from .infra import INFRA_NAME
from .gateway import DockerGateway from .gateway import DockerGateway
from ... import resources from ... import resources
+15 -1
View File
@@ -20,6 +20,7 @@ from ...git_gate import GitGatePlan
from ...manifest import Manifest from ...manifest import Manifest
from ...supervisor.plan import SupervisePlan from ...supervisor.plan import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from ..gateway_attach import GatewayAttachResources
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -31,7 +32,7 @@ from .bottle_plan import FirecrackerBottlePlan
class FirecrackerBottleBackend( class FirecrackerBottleBackend(
BottleBackend["FirecrackerBottlePlan", "FirecrackerBottleCleanupPlan"] BottleBackend["FirecrackerBottlePlan", "FirecrackerBottleCleanupPlan", "Path"]
): ):
name = "firecracker" name = "firecracker"
@@ -119,6 +120,19 @@ class FirecrackerBottleBackend(
def enumerate_active(self) -> Sequence[ActiveAgent]: def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active() return _enumerate.enumerate_active()
def _gateway_attach_resources(self) -> GatewayAttachResources:
from .gateway import FirecrackerGateway
return GatewayAttachResources(ca_pem=FirecrackerGateway().ca_cert_pem())
def _running_bottles(self) -> Sequence[Path]:
return _cleanup.live_run_dirs()
def _attach_bottle_to_gateway(
self, bottle: Path, resources: GatewayAttachResources,
) -> None:
from .infra_launch import attach_ca_to_agent_vm
attach_ca_to_agent_vm(bottle, resources.ca_pem)
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str: def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
return plan.agent_supervise_url return plan.agent_supervise_url
+7 -2
View File
@@ -69,13 +69,17 @@ class FirecrackerGateway(Gateway):
self._orchestrator_url = "" self._orchestrator_url = ""
self._gateway_token = "" self._gateway_token = ""
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None: def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
"""Boot the gateway VM on its link resolving policy against """Boot the gateway VM on its link resolving policy against
`orchestrator_url`, then seed `gateway_token` over SSH. The orchestrator's `orchestrator_url`, then seed `gateway_token` over SSH. The orchestrator's
guest IP is parsed from the URL and passed on the cmdline (`bb_orch=`), so guest IP is parsed from the URL and passed on the cmdline (`bb_orch=`), so
the baked init stays IP-independent. Boot the orchestrator first the the baked init stays IP-independent. Boot the orchestrator first the
gateway daemons reach it at startup. The gateway never mints, so it holds gateway daemons reach it at startup. The gateway never mints, so it holds
no signing key, only this token (#469).""" no signing key, only this token (#469).
Always returns True: the firecracker gateway VM is booted fresh here
(this runs only on the infra cold-boot path), so it is always a cold
boot that minted a new CA the caller reconciles running bottles."""
self._orchestrator_url = orchestrator_url self._orchestrator_url = orchestrator_url
self._gateway_token = gateway_token self._gateway_token = gateway_token
if not self._orchestrator_url: if not self._orchestrator_url:
@@ -105,6 +109,7 @@ class FirecrackerGateway(Gateway):
"the gateway JWT to the gateway VM (its data plane will not start)", "the gateway JWT to the gateway VM (its data plane will not start)",
) )
self._vm = vm self._vm = vm
return True
def is_running(self) -> bool: def is_running(self) -> bool:
return infra_vm._pidfile_alive(infra_vm._gw_dir()) return infra_vm._pidfile_alive(infra_vm._gw_dir())
+9 -1
View File
@@ -67,9 +67,17 @@ class FirecrackerInfraService(InfraService):
# holds the signing key) mints the role-scoped `gateway` JWT for the # holds the signing key) mints the role-scoped `gateway` JWT for the
# gateway, which never sees the key (#469). # gateway, which never sees the key (#469).
orchestrator.ensure_running(startup_timeout=startup_timeout) orchestrator.ensure_running(startup_timeout=startup_timeout)
self.gateway().connect_to_orchestrator( cold_booted = self.gateway().connect_to_orchestrator(
orchestrator.gateway_url(), orchestrator.mint_gateway_token()) orchestrator.gateway_url(), orchestrator.mint_gateway_token())
infra_vm.record_booted_version(want) infra_vm.record_booted_version(want)
# The fresh gateway rootfs minted a new CA and lost every bottle's
# git-gate/token state; reconcile the already-running bottles against
# it so a gateway rebuild doesn't silently break their egress
# (PRD 0083). Cold-boot only — the adopt fast-paths above never reach
# here, so a healthy current gateway is left untouched.
if cold_booted:
from .backend import FirecrackerBottleBackend
FirecrackerBottleBackend().attach_bottled_agents_to_gateway()
return url return url
def stop(self) -> None: def stop(self) -> None:
@@ -40,7 +40,9 @@ from ...orchestrator.lifecycle import (
) )
from ...orchestrator.reprovision import reprovision_bottles from ...orchestrator.reprovision import reprovision_bottles
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..base import InfraLaunchError
from ..provision_bottle import deprovision_bottle, provision_bottle from ..provision_bottle import deprovision_bottle, provision_bottle
from ..util import AGENT_CA_PATH
from . import cleanup, util from . import cleanup, util
from .gateway import FirecrackerGateway from .gateway import FirecrackerGateway
from .infra import FirecrackerInfraService from .infra import FirecrackerInfraService
@@ -48,10 +50,6 @@ from .infra import FirecrackerInfraService
_ENV_VAR_SECRET_PATH = "/run/bot-bottle/env-var-secret" _ENV_VAR_SECRET_PATH = "/run/bot-bottle/env-var-secret"
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True) @dataclass(frozen=True)
class LaunchContext: class LaunchContext:
"""What the Firecracker launch needs from the consolidated sequence.""" """What the Firecracker launch needs from the consolidated sequence."""
@@ -83,12 +81,48 @@ def persist_env_var_secret(private_key: Path, guest_ip: str, secret: str) -> Non
input=secret, capture_output=True, text=True, check=False, input=secret, capture_output=True, text=True, check=False,
) )
if proc.returncode != 0: if proc.returncode != 0:
raise ConsolidatedLaunchError( raise InfraLaunchError(
f"failed to persist {ENV_VAR_SECRET_NAME} in agent VM: " f"failed to persist {ENV_VAR_SECRET_NAME} in agent VM: "
f"{proc.stderr.strip() or '<no stderr>'}" f"{proc.stderr.strip() or '<no stderr>'}"
) )
def attach_ca_to_agent_vm(run_dir: Path, ca_pem: str) -> None:
"""(Re)attach one running agent VM (identified by its `run_dir`) to the
current gateway: replace its trusted gateway CA with `ca_pem` and rebuild
its trust store over SSH (PRD 0083). Unconditional install there is one
gateway, so no fingerprint match is needed. Bare-pipe input keeps the cert
off argv.
Fail hard: raises `InfraLaunchError` (naming the bottle) if the run dir is
malformed or the push fails the base reconcile aggregates and surfaces it
rather than leaving the agent silently unable to reach the gateway."""
guest_ip = _guest_ip_from_config(run_dir / "config.json")
private_key = run_dir / "bottle_id_ed25519"
if not guest_ip or not private_key.is_file():
raise InfraLaunchError(
f"{run_dir.name}: cannot resolve guest IP / SSH key to attach it "
f"to the gateway"
)
install = (
"umask 022; mkdir -p /usr/local/share/ca-certificates && "
f"cat > {AGENT_CA_PATH} && chmod 644 {AGENT_CA_PATH} && "
"update-ca-certificates"
)
try:
proc = subprocess.run(
util.ssh_base_argv(private_key, guest_ip) + [install],
input=ca_pem, capture_output=True, text=True, check=False,
)
except OSError as exc:
raise InfraLaunchError(f"{run_dir.name} ({guest_ip}): CA push failed: {exc}") from exc
if proc.returncode != 0:
raise InfraLaunchError(
f"{run_dir.name} ({guest_ip}): CA push failed: "
f"{proc.stderr.strip() or '<no stderr>'}"
)
def _reprovision_running_bottles(client: OrchestratorClient) -> None: def _reprovision_running_bottles(client: OrchestratorClient) -> None:
"""Read keys from live agent VMs and restore the restarted gateway.""" """Read keys from live agent VMs and restore the restarted gateway."""
try: try:
@@ -161,7 +195,8 @@ def deprovision_consolidated(
__all__ = [ __all__ = [
"LaunchContext", "LaunchContext",
"launch_consolidated", "launch_consolidated",
"attach_ca_to_agent_vm",
"deprovision_consolidated", "deprovision_consolidated",
"ConsolidatedLaunchError", "InfraLaunchError",
"OrchestratorStartError", "OrchestratorStartError",
] ]
+1 -1
View File
@@ -51,7 +51,7 @@ from .bottle import FirecrackerBottle
from .bottle_plan import FirecrackerBottlePlan from .bottle_plan import FirecrackerBottlePlan
from ...orchestrator.store.config_store import resolve_teardown_timeout from ...orchestrator.store.config_store import resolve_teardown_timeout
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from .consolidated_launch import ( from .infra_launch import (
launch_consolidated, launch_consolidated,
persist_env_var_secret, persist_env_var_secret,
deprovision_consolidated, deprovision_consolidated,
+54
View File
@@ -0,0 +1,54 @@
"""The shared gateway-attach reconcile flow (PRD 0083).
`BottleBackend.attach_bottled_agents_to_gateway()` delegates here so every
backend reconciles running bottles against a freshly-booted gateway *the same
way* the control flow + fail-hard policy live in one place and backends
override only the primitives (see ADR 0006). Kept out of `backend/base.py` so
the backend contract module stays lean (the base.py size guardrail).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from .base import InfraLaunchError
if TYPE_CHECKING:
from .base import BottleBackend
@dataclass(frozen=True)
class GatewayAttachResources:
"""Everything a running bottle needs to (re)attach to the current gateway on
a cold boot (PRD 0083). Gathered once per reconcile and handed to every
bottle. The CA now; egress secrets and git-gate state join as the reconcile
grows (#516)."""
ca_pem: str
def reconcile_running_bottles(backend: "BottleBackend[Any, Any, Any]") -> None:
"""Reconcile every already-running bottle against the freshly-(re)booted
gateway: gather the attach resources once, then attach every live bottle.
Fail hard, never skip: a bottle that silently can't reach the fresh gateway
(its egress just starts failing TLS) is worse than a loud bring-up failure,
so any attach failure aborts bring-up. Every bottle is attempted first and
the failures are raised together, so one bring-up surfaces the full blast
radius rather than one bottle at a time."""
resources = backend._gateway_attach_resources()
failures: list[str] = []
for bottle in backend._running_bottles():
try:
backend._attach_bottle_to_gateway(bottle, resources)
except InfraLaunchError as exc:
failures.append(str(exc))
if failures:
raise InfraLaunchError(
f"could not attach {len(failures)} running bottle(s) to the "
f"freshly-booted gateway:\n " + "\n ".join(failures)
)
__all__ = ["GatewayAttachResources", "reconcile_running_bottles"]
+16 -1
View File
@@ -14,6 +14,7 @@ from ...git_gate import GitGatePlan
from ...supervisor.plan import SupervisePlan from ...supervisor.plan import SupervisePlan
from ...manifest import Manifest from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from ..gateway_attach import GatewayAttachResources
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -25,7 +26,7 @@ from .bottle_plan import MacosContainerBottlePlan
class MacosContainerBottleBackend( class MacosContainerBottleBackend(
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"] BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan", str]
): ):
"""Apple Container backend. Selected by """Apple Container backend. Selected by
`BOT_BOTTLE_BACKEND=macos-container` or `BOT_BOTTLE_BACKEND=macos-container` or
@@ -117,5 +118,19 @@ class MacosContainerBottleBackend(
def enumerate_active(self) -> Sequence[ActiveAgent]: def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active() return _enumerate.enumerate_active()
def _gateway_attach_resources(self) -> GatewayAttachResources:
from .infra import MacosInfraService
return GatewayAttachResources(ca_pem=MacosInfraService().ca_cert_pem())
def _running_bottles(self) -> Sequence[str]:
from .infra_launch import running_agent_containers
return running_agent_containers()
def _attach_bottle_to_gateway(
self, bottle: str, resources: GatewayAttachResources,
) -> None:
from .infra_launch import push_ca_to_container
push_ca_to_container(bottle, resources.ca_pem)
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str: def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
return plan.agent_supervise_url return plan.agent_supervise_url
@@ -104,11 +104,15 @@ class MacosGateway(Gateway):
container_mod.build_image( container_mod.build_image(
self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway") self.image_ref, str(self._repo_root), dockerfile="Dockerfile.gateway")
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None: def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
"""Bind the gateway to this orchestrator and (re)start it, dual-homed on """Bind the gateway to this orchestrator and (re)start it, dual-homed on
the agent + control networks, resolving policy against `orchestrator_url` the agent + control networks, resolving policy against `orchestrator_url`
(the orchestrator's control-network address — Apple has no container (the orchestrator's control-network address — Apple has no container
DNS) and presenting `gateway_token`.""" DNS) and presenting `gateway_token`.
Always returns True: this recreates the gateway container unconditionally
(a cold boot), so the caller reconciles running bottles against it
(PRD 0083)."""
self._orchestrator_url = orchestrator_url self._orchestrator_url = orchestrator_url
self._gateway_token = gateway_token self._gateway_token = gateway_token
# Fail closed on a missing policy source or token: the resolver-only data # Fail closed on a missing policy source or token: the resolver-only data
@@ -156,6 +160,7 @@ class MacosGateway(Gateway):
f"gateway container failed to start: " f"gateway container failed to start: "
f"{(result.stderr or '').strip() or '<no stderr>'}" f"{(result.stderr or '').strip() or '<no stderr>'}"
) )
return True
def is_running(self) -> bool: def is_running(self) -> bool:
return container_mod.container_is_running(self.name) return container_mod.container_is_running(self.name)
+9 -1
View File
@@ -138,7 +138,15 @@ class MacosInfraService(InfraService):
# `gateway` JWT and hands it to the gateway, which never sees the key # `gateway` JWT and hands it to the gateway, which never sees the key
# (#469). # (#469).
url = orchestrator.url() url = orchestrator.url()
gateway.connect_to_orchestrator(url, orchestrator.mint_gateway_token()) cold_booted = gateway.connect_to_orchestrator(
url, orchestrator.mint_gateway_token())
# A (re)created gateway container minted/mounted its CA afresh and lost
# every bottle's per-bottle state; reconcile the already-running bottles
# against it (PRD 0083). Skipped when a healthy current gateway was left
# untouched — no cold boot, nothing to reconcile.
if cold_booted:
from .backend import MacosContainerBottleBackend
MacosContainerBottleBackend().attach_bottled_agents_to_gateway()
return url return url
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str: def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
@@ -34,6 +34,7 @@ every real command arrives through `container exec`.
from __future__ import annotations from __future__ import annotations
import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from ...egress import EgressPlan from ...egress import EgressPlan
@@ -42,7 +43,9 @@ from ...log import info
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
from ...orchestrator.reprovision import reprovision_bottles from ...orchestrator.reprovision import reprovision_bottles
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME
from ..base import InfraLaunchError
from ..provision_bottle import deprovision_bottle, provision_bottle from ..provision_bottle import deprovision_bottle, provision_bottle
from ..util import AGENT_CA_PATH
from . import util as container_mod from . import util as container_mod
from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active
from .gateway import GATEWAY_NETWORK from .gateway import GATEWAY_NETWORK
@@ -50,10 +53,6 @@ from .gateway_transport import MacosGatewayTransport
from .infra import MacosInfraService, OrchestratorStartError from .infra import MacosInfraService, OrchestratorStartError
class ConsolidatedLaunchError(RuntimeError):
"""The consolidated register/provision sequence could not complete."""
@dataclass(frozen=True) @dataclass(frozen=True)
class GatewayEndpoint: class GatewayEndpoint:
"""What the agent `container run` needs to reach the shared gateway. """What the agent `container run` needs to reach the shared gateway.
@@ -98,6 +97,52 @@ def ensure_gateway(
return endpoint return endpoint
def running_agent_containers() -> list[str]:
"""Every running agent container's name — the bottles the bring-up reconcile
attaches to the fresh gateway (PRD 0083). Raises `EnumerationError` if the
live set can't be determined (fail hard rather than reconcile a partial
set)."""
return [f"{CONTAINER_NAME_PREFIX}{agent.slug}" for agent in enumerate_active()]
def push_ca_to_container(name: str, ca_pem: str) -> None:
"""(Re)attach one running agent container to the current gateway: replace
its trusted gateway CA with `ca_pem` and rebuild its trust store via
`container cp` + `container exec` (PRD 0083). Unconditional install there
is one gateway, so no fingerprint match is needed.
Fail hard: raises `InfraLaunchError` (naming the container) on any failure
the base reconcile aggregates and surfaces it rather than leaving the agent
silently unable to reach the gateway."""
mkdir = container_mod.run_container_argv(
["container", "exec", name, "mkdir", "-p",
"/usr/local/share/ca-certificates"]
)
if mkdir.returncode != 0:
raise InfraLaunchError(
f"CA push to {name} failed (mkdir): {(mkdir.stderr or '').strip()}"
)
with tempfile.NamedTemporaryFile("w", suffix=".crt") as tmp:
tmp.write(ca_pem)
tmp.flush()
cp = container_mod.run_container_argv(
["container", "cp", tmp.name, f"{name}:{AGENT_CA_PATH}"]
)
if cp.returncode != 0:
raise InfraLaunchError(
f"CA push to {name} failed (cp): {(cp.stderr or '').strip()}"
)
ex = container_mod.run_container_argv(
["container", "exec", name, "sh", "-c",
f"chmod 644 {AGENT_CA_PATH} && update-ca-certificates"]
)
if ex.returncode != 0:
raise InfraLaunchError(
f"CA push to {name} failed (update-ca-certificates): "
f"{(ex.stderr or '').strip()}"
)
def _reprovision_running_bottles(endpoint: GatewayEndpoint) -> None: def _reprovision_running_bottles(endpoint: GatewayEndpoint) -> None:
"""Recover keys from live Apple containers and restore gateway tokens.""" """Recover keys from live Apple containers and restore gateway tokens."""
try: try:
@@ -200,10 +245,12 @@ __all__ = [
"GatewayEndpoint", "GatewayEndpoint",
"LaunchContext", "LaunchContext",
"ensure_gateway", "ensure_gateway",
"running_agent_containers",
"push_ca_to_container",
"live_source_ips", "live_source_ips",
"register_agent", "register_agent",
"deprovision_consolidated", "deprovision_consolidated",
"ConsolidatedLaunchError", "InfraLaunchError",
"OrchestratorStartError", "OrchestratorStartError",
"GATEWAY_NETWORK", "GATEWAY_NETWORK",
] ]
+2 -2
View File
@@ -5,7 +5,7 @@ proxies egress through the one per-host gateway, replacing the per-bottle
companion container removed in #385. companion container removed in #385.
The order differs from docker's, forced by Apple Container 1.0.0 having no The order differs from docker's, forced by Apple Container 1.0.0 having no
`--ip` (see `consolidated_launch`): the agent is started *before* it is `--ip` (see `infra_launch`): the agent is started *before* it is
registered, because its DHCP-assigned address the attribution key does not registered, because its DHCP-assigned address the attribution key does not
exist until then. exist until then.
@@ -64,7 +64,7 @@ from . import nested_containers as nested_containers_mod
from .bottle_plan import MacosContainerBottlePlan from .bottle_plan import MacosContainerBottlePlan
from ...orchestrator.store.config_store import resolve_teardown_timeout from ...orchestrator.store.config_store import resolve_teardown_timeout
from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME, new_env_var_secret from ...orchestrator.store.secret_store import ENV_VAR_SECRET_NAME, new_env_var_secret
from .consolidated_launch import ( from .infra_launch import (
GatewayEndpoint, GatewayEndpoint,
ensure_gateway, ensure_gateway,
register_agent, register_agent,
+1 -1
View File
@@ -2,7 +2,7 @@
Register a bottle with the orchestrator and provision its git-gate state into Register a bottle with the orchestrator and provision its git-gate state into
the shared gateway (`provision_bottle`), and the inverse teardown the shared gateway (`provision_bottle`), and the inverse teardown
(`deprovision_bottle`). Backend-neutral each backend's consolidated_launch (`deprovision_bottle`). Backend-neutral each backend's infra_launch
drives these through its own `GatewayTransport` rather than re-implementing drives these through its own `GatewayTransport` rather than re-implementing
them. The git-gate half of the work lives in `provision_gateway`. them. The git-gate half of the work lives in `provision_gateway`.
""" """
+3 -3
View File
@@ -26,10 +26,10 @@ from .base import ActiveAgent, BackendStatus, BottleBackend
# Tests may replace _backends with a {name: fake} dict via patch.object; # Tests may replace _backends with a {name: fake} dict via patch.object;
# _get_backends() returns the current module-level value as-is when it # _get_backends() returns the current module-level value as-is when it
# is not None, so test fakes take effect without triggering real imports. # is not None, so test fakes take effect without triggering real imports.
_backends: dict[str, BottleBackend[Any, Any]] | None = None _backends: dict[str, BottleBackend[Any, Any, Any]] | None = None
def _get_backends() -> dict[str, BottleBackend[Any, Any]]: def _get_backends() -> dict[str, BottleBackend[Any, Any, Any]]:
"""Return the registry of all backend instances, loading lazily on first call.""" """Return the registry of all backend instances, loading lazily on first call."""
global _backends # pylint: disable=global-statement global _backends # pylint: disable=global-statement
if _backends is None: if _backends is None:
@@ -48,7 +48,7 @@ def get_bottle_backend(
name: str | None = None, name: str | None = None,
*, *,
prompt: bool = True, prompt: bool = True,
) -> BottleBackend[Any, Any]: ) -> BottleBackend[Any, Any, Any]:
"""Resolve the bottle backend. """Resolve the bottle backend.
`name` precedence: `name` precedence:
+8 -2
View File
@@ -122,12 +122,18 @@ class Gateway(abc.ABC):
return return
@abc.abstractmethod @abc.abstractmethod
def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> None: def connect_to_orchestrator(self, orchestrator_url: str, gateway_token: str) -> bool:
"""Bind the gateway to this orchestrator and bring it up: store the URL + """Bind the gateway to this orchestrator and bring it up: store the URL +
the pre-minted `gateway` token as instance state, then (re)start the the pre-minted `gateway` token as instance state, then (re)start the
gateway unit carrying the mitmproxy CA + that token, resolving policy gateway unit carrying the mitmproxy CA + that token, resolving policy
against `orchestrator_url`. Idempotent a healthy, current gateway on against `orchestrator_url`. Idempotent a healthy, current gateway on
the same binding is left alone; a changed binding reconciles it.""" the same binding is left alone; a changed binding reconciles it.
Returns True when the gateway was actually (re)brought up (a cold boot,
so its mitmproxy minted a fresh CA and lost its per-bottle state), False
when a healthy current gateway was left untouched. The bring-up flow
uses this as the cold-boot signal that gates the running-bottle
reconcile (`attach_bottled_agents_to_gateway`, PRD 0083)."""
@abc.abstractmethod @abc.abstractmethod
def is_running(self) -> bool: def is_running(self) -> bool:
+21
View File
@@ -50,6 +50,12 @@ class ReprovisionBody(_StrictModel):
env_var_secret: StrictStr env_var_secret: StrictStr
class SecretBody(_StrictModel):
name: StrictStr
value: StrictStr
env_var_secret: StrictStr
class ReconcileBody(_StrictModel): class ReconcileBody(_StrictModel):
live_source_ips: list[StrictStr] live_source_ips: list[StrictStr]
grace_seconds: float | None = None grace_seconds: float | None = None
@@ -259,6 +265,21 @@ def create_app(orch: OrchestratorCore, *, signing_key: str) -> FastAPI:
return {"reprovisioned": True} return {"reprovisioned": True}
raise HTTPException(404, "no stored secrets for this bottle") raise HTTPException(404, "no stored secrets for this bottle")
@app.post("/bottles/{bottle_id}/secret")
def update_secret(bottle_id: str, body: SecretBody) -> dict[str, object]:
# Update ONE egress token for a running bottle in place — the
# single-secret form of reprovision, for refreshing a short-lived host
# credential (e.g. the Codex access token) without a relaunch. cli-only
# (not in _GATEWAY_ROUTES): a bottle must never set its own tokens.
if orch.update_agent_secret(
bottle_id,
_required(body.name, "name"),
_required(body.value, "value"),
_required(body.env_var_secret, "env_var_secret"),
):
return {"updated": True}
raise HTTPException(404, "no such bottle")
@app.delete("/bottles/{bottle_id}") @app.delete("/bottles/{bottle_id}")
def teardown(bottle_id: str) -> dict[str, object]: def teardown(bottle_id: str) -> dict[str, object]:
if orch.teardown_bottle(bottle_id): if orch.teardown_bottle(bottle_id):
+21
View File
@@ -184,6 +184,27 @@ class OrchestratorClient:
) )
return True return True
def update_agent_secret(
self, bottle_id: str, name: str, value: str, env_var_secret: str,
) -> bool:
"""Update ONE egress token for a running bottle in place
(`POST /bottles/<id>/secret`) the single-secret form of
`reprovision_gateway`, for pushing a freshly-refreshed host credential
into a bottle without a relaunch. Returns True on success, False when the
orchestrator doesn't know the bottle (404)."""
status, _ = self._request(
"POST",
f"/bottles/{bottle_id}/secret",
{"name": name, "value": value, "env_var_secret": env_var_secret},
)
if status == 404:
return False
if not 200 <= status < 300:
raise OrchestratorClientError(
f"update_agent_secret {bottle_id}: HTTP {status}"
)
return True
def teardown_bottle(self, bottle_id: str) -> bool: def teardown_bottle(self, bottle_id: str) -> bool:
"""Tear a bottle down (`DELETE /bottles/<id>`). False if the """Tear a bottle down (`DELETE /bottles/<id>`). False if the
orchestrator didn't know it (404) — idempotent for cleanup paths.""" orchestrator didn't know it (404) — idempotent for cleanup paths."""
+24
View File
@@ -379,6 +379,30 @@ class OrchestratorCore:
self._tokens[bottle_id] = decrypted self._tokens[bottle_id] = decrypted
return True return True
def update_agent_secret(
self, bottle_id: str, name: str, value: str, env_var_secret: str,
) -> bool:
"""Update ONE egress token for a known bottle in place — the
single-secret form of ``reprovision_from_secret``.
Sets the in-memory token AND upserts the single re-encrypted row under
*env_var_secret* (the same key the rest of the rows are encrypted with, so
the whole set stays decryptable by a later ``reprovision_from_secret``),
leaving every other token untouched. Returns False if the bottle is
unknown.
Unlike ``reprovision_from_secret`` (which restores the values captured at
launch), this pushes a *caller-supplied* value used to refresh a
short-lived host credential (e.g. the Codex access token) into a
still-running bottle without a relaunch."""
from .store.secret_store import encrypt_value
if self.registry.get(bottle_id) is None:
return False
self._tokens.setdefault(bottle_id, {})[name] = value
self.registry.store_agent_secret(
bottle_id, name, encrypt_value(env_var_secret, value))
return True
# --- consolidated gateway ---------------------------------------------- # --- consolidated gateway ----------------------------------------------
def gateway_status(self) -> dict[str, object]: def gateway_status(self) -> dict[str, object]:
@@ -370,6 +370,30 @@ class RegistryStore(DbStore):
) )
self._chmod() self._chmod()
def store_agent_secret(
self,
bottle_id: str,
key: str,
encrypted_value: str,
secret_type: str = "injected_env_var",
) -> None:
"""Upsert ONE encrypted secret row (env-var name → ciphertext) for
*bottle_id*, leaving the bottle's other secrets untouched — the per-key
counterpart of ``store_agent_secrets``' replace-all. Delete-then-insert
because the table carries no unique constraint to `ON CONFLICT` against."""
with self._connection() as conn:
conn.execute(
"DELETE FROM bottled_agent_secrets "
"WHERE bottled_agent_id = ? AND key = ? AND type = ?",
(bottle_id, key, secret_type),
)
conn.execute(
"INSERT INTO bottled_agent_secrets "
"(bottled_agent_id, key, value, type) VALUES (?, ?, ?, ?)",
(bottle_id, key, encrypted_value, secret_type),
)
self._chmod()
def get_agent_secrets( def get_agent_secrets(
self, self,
bottle_id: str, bottle_id: str,
@@ -0,0 +1,65 @@
# ADR 0006: Shared behaviour lives in the base backend
- **Status:** Accepted
- **Date:** 2026-07-27
- **Deciders:** didericis
## Context
`BottleBackend` has three concrete implementations (Firecracker, docker,
macOS-container). Cross-backend operations can be expressed two ways:
1. **Abstract method per backend** — the base declares `@abstractmethod foo()`
and each backend writes its own `foo()` end to end.
2. **Template method** — the base owns the *control flow* of `foo()` as a
concrete method and each backend overrides small **primitives** it calls.
Form 1 gives each backend total freedom, which is exactly the problem: the three
implementations drift. The bring-up reconcile (PRD 0083) first shipped as form 1
and immediately diverged — each backend re-implemented "gather CA → list running
bottles → push to each", and one backend's copy silently skipped failures while
another's aborted, an inconsistency caught only in review (#519). The behaviour
that must be identical (the loop, the error policy, the ordering) was duplicated
in the place least likely to stay in sync.
## Decision
Prefer the template-method form for cross-backend behaviour: **encode shared
control flow and policy in the base backend; backends override primitives, not
control flow.** Lean toward *less* freedom in the concrete backends and *more*
consistency in the base.
Concretely, a cross-backend operation on `BottleBackend` should be a concrete
method that:
- owns the sequencing, iteration, and error policy (e.g. fail-hard vs.
best-effort, fail-fast vs. attempt-all-then-aggregate); and
- delegates only the irreducibly backend-specific steps to `@abstractmethod`
primitives with narrow, well-typed signatures.
The first application is `attach_bottled_agents_to_gateway()` (PRD 0083): the
base gathers resources once, attempts every running bottle, and raises an
aggregate `InfraLaunchError` if any failed; backends supply only
`_gateway_attach_resources()`, `_running_bottles()`, and
`_attach_bottle_to_gateway()`.
A backend-specific `@abstractmethod` with no shared control flow (e.g.
`_launch_impl`, `enumerate_active`) stays as form 1 — this ADR is about
operations whose *shape* should be uniform, not about banning abstract methods.
## Consequences
- The error policy, ordering, and loop for a cross-backend operation have one
source of truth; a new backend cannot forget them or diverge.
- New backends implement smaller, more focused primitives instead of re-deriving
a whole flow.
- The base carries more logic and needs generic-enough primitive signatures
(e.g. a backend-specific attach-target type parameter on `BottleBackend`).
- Genuinely backend-specific one-offs still use a plain abstract method; the
guidance is a default, not an absolute.
## Links
- PRD 0083 (`docs/prds/0083-reprovision-gateway-state-on-bringup.md`).
- Review that prompted this: PR #519.
- `bot_bottle/backend/base.py` (`BottleBackend.attach_bottled_agents_to_gateway`).
+10
View File
@@ -55,3 +55,13 @@ agent has two observable states:
Shorthand for an active (running) Bottled Agent. Used in the supervisor TUI Shorthand for an active (running) Bottled Agent. Used in the supervisor TUI
and discovery layer to mean "a bottle whose agent runtime is currently up." and discovery layer to mean "a bottle whose agent runtime is currently up."
## Infra
The per-host pair of shared services every bottle attaches to: the
**orchestrator** (control plane — holds the signing key, owns the registry DB,
mints tokens) and the **gateway** (data plane — TLS-inspecting egress proxy,
git-gate, supervise). Brought up as an idempotent singleton pair by each
backend's `InfraService` (two containers on docker/macOS, two microVMs on
Firecracker). The per-backend `infra_launch` module composes the bring-up and
bottle register/provision sequence against this pair.
@@ -0,0 +1,136 @@
# PRD 0083: Reprovision gateway-dependent state on gateway bring-up
- **Status:** Active
- **Author:** claude
- **Created:** 2026-07-26
- **Issue:** #510, #512
## Summary
When the gateway is (re)built, reconcile every already-running bottle against the
fresh gateway instead of persisting the gateway's state. On a gateway cold boot,
the gateway reconciles all live bottles in one flow: **replace** each agent's
trusted CA with the freshly-minted gateway CA, **re-provision** each bottle's
git-gate repos + creds onto the gateway, and **restore** each bottle's egress
tokens. One mechanism across all three services; the CA rotates for free on every
bring-up.
## Problem
A gateway rebuild/restart silently breaks every already-running bottle:
- **CA (#510).** The gateway's mitmproxy mints a new CA on a fresh rootfs; agents
still trust the old one, so egress fails TLS verification (`SSL certificate
verification failed`).
- **git-gate (#512).** Per-bottle bare repos (`/git/<id>`) + deploy creds
(`/git-gate/creds/<id>`) live in the gateway's ephemeral rootfs; a rebuild wipes
them and the agent 404s on fetch/push.
Both are the same root cause: per-bottle gateway-dependent state is provisioned
**once, at bottle launch**, and nothing restores it for already-running bottles
when the gateway comes back. The existing launch-time reprovision
(`reprovision_bottles`) restores **only** egress tokens, and only as a side effect
of the *next* launch.
Persisting the state (a host bind-mount / a per-VM data volume per service) was
prototyped and rejected: it differs per service (a volume for the CA, another for
git-gate, reprovision for tokens), it pins the CA static forever (no rotation),
and it adds volume surface that `docker volume prune` / a wiped cache can silently
destroy (the original #450 failure mode).
## Goals / Success Criteria
- A gateway (re)boot restores **all** running bottles' gateway-dependent state
with no manual step and no relaunch — the agent's next egress / fetch / push
just works.
- **One** reconcile flow covering CA, git-gate, and egress tokens, rather than a
different mechanism per service.
- The CA **rotates** on every gateway bring-up (no long-lived CA), distributed to
running agents by the same reconcile.
- Reconcile failures are **loud, never hidden**: if a running bottle can't be
re-attached to the fresh gateway, the bring-up fails and names the bottle
rather than leaving it silently unable to reach the gateway (a bottle whose
egress just starts failing TLS is worse than a loud failure). Every bottle is
attempted first and the failures are raised together, so one bring-up surfaces
the full blast radius. (Reversed from an earlier "tolerate per-bottle
failures" draft after #519 review.)
- **All backends** (Firecracker, docker, macOS) reconcile through the *same*
contract — an abstract method on the backend base class, so a new backend
cannot forget to implement it and none drifts onto a bespoke mechanism.
## Non-goals
- Deliberate mid-session CA rotation *without* a gateway restart — `rotate_ca`
stays for that operator action.
- Changing source-IP attribution, `/resolve`, or the plane split (#469).
## Design
**The contract — `attach_bottled_agents_to_gateway()`, a concrete flow on the
backend base.** Reconciling running bottles against the current gateway is a
backend responsibility (only the backend can enumerate its agents and reach them
— firecracker over SSH, docker/macOS over `exec`/`cp`). To keep every backend
reconciling identically, the *control flow* lives in a concrete
`attach_bottled_agents_to_gateway()` on `BottleBackend` (`backend/base.py`) and
each backend overrides only three primitives:
1. `_gateway_attach_resources()` — gather what a bottle needs to attach (the
gateway CA now; egress secrets + git-gate state as this grows).
2. `_running_bottles()` — enumerate the live bottles as backend handles.
3. `_attach_bottle_to_gateway(bottle, resources)` — push the resources into one
bottle, raising `InfraLaunchError` (naming it) on failure.
The base gathers resources once, attempts every bottle, and — fail hard, never
skip — raises an aggregate `InfraLaunchError` if any failed, so a backend can't
drift onto a bespoke loop or a silent skip. (Encoding shared behaviour in the
base backend, with subclasses overriding primitives rather than control flow, is
[ADR 0006](../decisions/0006-shared-behavior-in-base-backend.md).) The host
calls it whenever the gateway is (re)brought up. It reprovisions **all**
registered bottles' gateway-dependent state: CA, git-gate, and egress tokens.
**Trigger — the gateway bring-up path.** The host calls
`attach_bottled_agents_to_gateway()` only when the gateway was actually
(re)brought up — the cold-boot branch of the infra bring-up (e.g.
`FirecrackerInfraService.ensure_running` after it boots a fresh pair), never on an
adopt of a healthy, current gateway (state intact). So it fires exactly when the
gateway was (re)booted — including orchestrator restarts, since the pair boots
together — and there is no bare-restart path that bypasses bring-up.
**Per-backend implementation.** Each backend's `attach_bottled_agents_to_gateway`
enumerates its live bottles and, for each, reconciles the three services against
the current gateway. The firecracker implementation, once the gateway VM is up
and its CA is available:
1. Map each live bottle's guest IP → `bottle_id` from the orchestrator registry
(`list_bottles`).
2. Install the **current** shared git-gate hooks (`git_gate_render_hook` /
`git_gate_render_access_hook`) into the fresh gateway once — rendered from
code, never from a bottle's possibly-stale state dir.
3. For each live agent VM (enumerated from its run dir):
- **CA:** SSH the current gateway CA into the agent's trust store and run
`update-ca-certificates` (unconditional replace — there is one gateway, so no
fingerprint match is needed).
- **git-gate:** rebuild the bottle's upstreams from its persisted git-gate
state dir (deploy key, known_hosts, upstream URL) and re-init its bare repos
+ per-repo creds under `/git/<bottle_id>`.
- **egress token:** read the agent's `ENV_VAR_SECRET` and feed
`reprovision_bottles`, restoring the orchestrator's in-memory tokens.
4. If any per-bottle step fails, the reconcile attempts the rest and then raises
an aggregate naming every bottle that failed — bring-up fails loudly rather
than leaving a bottle silently unable to reach the gateway.
**Retire the persistence prototype.** No CA data volume, no git-gate data volume
(the abandoned PRs #511 / #513). The launch-time `_reprovision_running_bottles`
call folds into this bring-up reconcile, so egress tokens are restored on the same
cold-boot trigger (an adopt needs no restore — the orchestrator never restarted).
**CA rotation.** Because the gateway rootfs is ephemeral, every cold boot mints a
fresh CA; reconcile is what distributes it, so a routine gateway rebuild doubles
as a CA rotation with zero extra machinery.
## Open questions
- Docker's existing host-bind-mounted CA (`host_gateway_ca_dir`): once docker's
`attach_bottled_agents_to_gateway` pushes the CA to running agents on bring-up,
the bind-mount is redundant — drop it (so docker rotates like firecracker) or
keep it as belt-and-suspenders? Leaning drop, for one behaviour across backends.
@@ -21,7 +21,7 @@ import subprocess
import time import time
import unittest import unittest
from bot_bottle.backend.docker.consolidated_launch import ( from bot_bottle.backend.docker.infra_launch import (
_network_cidr, _network_cidr,
_network_container_ips, _network_container_ips,
) )
+6 -1
View File
@@ -83,8 +83,13 @@ class TestRuntimeModuleSizes(unittest.TestCase):
self.assertEqual([], violations) self.assertEqual([], violations)
def test_backend_contract_does_not_absorb_preparation_logic(self) -> None: def test_backend_contract_does_not_absorb_preparation_logic(self) -> None:
# base.py holds the backend *contract*; implementation lives elsewhere.
# The cap is a tripwire against absorbing preparation/impl logic, not a
# ban on new contract surface — bumped 580->615 for the PRD 0083
# gateway-attach contract (delegator + 3 abstract primitives; the flow
# itself lives in backend/gateway_attach.py, not here).
caps = { caps = {
ROOT / "bot_bottle" / "backend" / "base.py": 580, ROOT / "bot_bottle" / "backend" / "base.py": 615,
ROOT / "bot_bottle" / "backend" / "preparation.py": 160, ROOT / "bot_bottle" / "backend" / "preparation.py": 160,
} }
oversized = [ oversized = [
+172 -4
View File
@@ -11,9 +11,11 @@ from types import SimpleNamespace
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from bot_bottle.orchestrator.reprovision import reprovision_bottles from bot_bottle.orchestrator.reprovision import reprovision_bottles
from bot_bottle.backend.firecracker import consolidated_launch as fc from bot_bottle.backend.base import BottleBackend, InfraLaunchError
from bot_bottle.backend.macos_container import consolidated_launch as mac from bot_bottle.backend.gateway_attach import GatewayAttachResources
from bot_bottle.backend.docker import consolidated_launch as docker from bot_bottle.backend.firecracker import infra_launch as fc
from bot_bottle.backend.macos_container import infra_launch as mac
from bot_bottle.backend.docker import infra_launch as docker
from bot_bottle.orchestrator.client import OrchestratorClientError from bot_bottle.orchestrator.client import OrchestratorClientError
@@ -132,7 +134,7 @@ class TestFirecrackerReprovision(unittest.TestCase):
def test_persist_failure_is_fatal_to_launch(self) -> None: def test_persist_failure_is_fatal_to_launch(self) -> None:
with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \ with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc(1, stderr="denied")): patch.object(fc.subprocess, "run", return_value=_proc(1, stderr="denied")):
with self.assertRaisesRegex(fc.ConsolidatedLaunchError, "denied"): with self.assertRaisesRegex(fc.InfraLaunchError, "denied"):
fc.persist_env_var_secret(Path("/key"), "10.0.0.1", "secret") fc.persist_env_var_secret(Path("/key"), "10.0.0.1", "secret")
def test_reads_live_vm_key_and_reprovisions(self) -> None: def test_reads_live_vm_key_and_reprovisions(self) -> None:
@@ -159,5 +161,171 @@ class TestFirecrackerReprovision(unittest.TestCase):
restore.assert_called_once_with(client, {}) restore.assert_called_once_with(client, {})
class TestAttachFlow(unittest.TestCase):
"""PRD 0083 / #519 review: the base backend owns the reconcile flow and
fails hard gather resources, attempt every running bottle, then raise an
aggregate if any failed (never silently skip)."""
def _fake(self, **primitives: object) -> SimpleNamespace:
base: dict[str, object] = {
"_gateway_attach_resources": Mock(
return_value=GatewayAttachResources(ca_pem="PEM")),
"_running_bottles": Mock(return_value=["a", "b", "c"]),
"_attach_bottle_to_gateway": Mock(),
}
base.update(primitives)
return SimpleNamespace(**base)
def test_attaches_every_running_bottle_once(self) -> None:
fake = self._fake()
BottleBackend.attach_bottled_agents_to_gateway(fake) # type: ignore[arg-type]
self.assertEqual(3, fake._attach_bottle_to_gateway.call_count)
# Each bottle got the single gathered resource set.
res = fake._gateway_attach_resources.return_value
for call in fake._attach_bottle_to_gateway.call_args_list:
self.assertIs(res, call.args[1])
def test_attempts_all_then_raises_aggregate_on_failure(self) -> None:
def attach(bottle: str, _resources: object) -> None:
if bottle in ("b", "c"):
raise InfraLaunchError(f"{bottle} unreachable")
fake = self._fake(_attach_bottle_to_gateway=Mock(side_effect=attach))
with self.assertRaises(InfraLaunchError) as ctx:
BottleBackend.attach_bottled_agents_to_gateway(fake) # type: ignore[arg-type]
msg = str(ctx.exception)
# Every bottle was attempted (not fail-fast), and both failures surface.
self.assertEqual(3, fake._attach_bottle_to_gateway.call_count)
self.assertIn("b unreachable", msg)
self.assertIn("c unreachable", msg)
self.assertIn("2", msg)
def test_resource_gathering_failure_fails_hard_before_enumerating(self) -> None:
fake = self._fake(
_gateway_attach_resources=Mock(side_effect=RuntimeError("no CA yet")))
with self.assertRaises(RuntimeError):
BottleBackend.attach_bottled_agents_to_gateway(fake) # type: ignore[arg-type]
fake._running_bottles.assert_not_called()
class TestFirecrackerAttachPrimitive(unittest.TestCase):
"""PRD 0083: attach_ca_to_agent_vm pushes the current gateway CA into one
running agent VM over SSH, failing hard."""
def _run_dir(self, root: Path, ip: str = "10.243.0.3") -> Path:
run_dir = root / "demo"
run_dir.mkdir()
(run_dir / "bottle_id_ed25519").write_text("key")
(run_dir / "config.json").write_text(json.dumps({
"boot-source": {"boot_args": f"root=/dev/vda ip={ip}::gw:mask::eth0:off"}
}))
return run_dir
def test_pushes_ca_over_stdin_and_rebuilds_trust_store(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc()) as run:
fc.attach_ca_to_agent_vm(run_dir, "PEM-DATA")
# The cert goes over stdin (off argv), and the trust store is rebuilt.
self.assertEqual("PEM-DATA", run.call_args.kwargs["input"])
script = run.call_args.args[0][-1]
self.assertIn(fc.AGENT_CA_PATH, script)
self.assertIn("update-ca-certificates", script)
def test_malformed_run_dir_fails_hard(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
empty = Path(tmp) / "no-config"
empty.mkdir()
with self.assertRaises(fc.InfraLaunchError):
fc.attach_ca_to_agent_vm(empty, "PEM")
def test_push_failure_fails_hard(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
run_dir = self._run_dir(Path(tmp))
with patch.object(fc.util, "ssh_base_argv", return_value=["ssh", "guest"]), \
patch.object(fc.subprocess, "run", return_value=_proc(1, stderr="down")):
with self.assertRaisesRegex(fc.InfraLaunchError, "down"):
fc.attach_ca_to_agent_vm(run_dir, "PEM")
class TestDockerAttachPrimitives(unittest.TestCase):
def test_running_agent_containers_excludes_gateway(self) -> None:
# network inspect lists the gateway (excluded) + one agent + a blank line.
inspect = _proc(stdout="bot-bottle-orch-gateway\nbot-bottle-a\n\n")
with patch.object(docker, "run_docker", return_value=inspect):
names = docker.running_agent_containers(
network="net", gateway_name="bot-bottle-orch-gateway")
self.assertEqual(["bot-bottle-a"], names)
def test_running_agent_containers_fails_hard_on_docker_error(self) -> None:
with patch.object(docker, "run_docker", side_effect=FileNotFoundError("docker")):
with self.assertRaises(FileNotFoundError):
docker.running_agent_containers()
def test_push_ca_cp_then_rebuilds_trust_store(self) -> None:
with patch.object(
docker, "run_docker", side_effect=[_proc(), _proc(), _proc()],
) as run:
docker.push_ca_to_container("bot-bottle-a", "PEM")
argvs = [c.args[0] for c in run.call_args_list]
cp = next(a for a in argvs if a[:2] == ["docker", "cp"])
self.assertEqual(f"bot-bottle-a:{docker.AGENT_CA_PATH}", cp[-1])
self.assertTrue(any("update-ca-certificates" in a[-1] for a in argvs))
def test_push_ca_fails_hard(self) -> None:
with patch.object(docker, "run_docker", return_value=_proc(1, stderr="gone")):
with self.assertRaisesRegex(docker.InfraLaunchError, "gone"):
docker.push_ca_to_container("bot-bottle-a", "PEM")
def test_gateway_attach_resources_reads_the_threaded_infra_gateway(self) -> None:
# On the bring-up reconcile path the backend is threaded with the infra
# whose gateway just cold-booted, so it reads THAT gateway's CA — not a
# fresh default-named DockerInfraService that wouldn't exist for a
# non-default instance (#519 review).
from bot_bottle.backend.docker.backend import DockerBottleBackend
infra = Mock()
infra.gateway.return_value.ca_cert_pem.return_value = "ITEST-CA"
res = DockerBottleBackend(infra=infra)._gateway_attach_resources()
self.assertEqual("ITEST-CA", res.ca_pem)
infra.gateway.assert_called_once()
def test_gateway_attach_resources_defaults_to_the_host_singleton(self) -> None:
# Ordinary construction (no infra) falls back to the per-host default.
from bot_bottle.backend.docker.backend import DockerBottleBackend
with patch("bot_bottle.backend.docker.infra.DockerInfraService") as InfraCls:
InfraCls.return_value.gateway.return_value.ca_cert_pem.return_value = "DEFAULT-CA"
res = DockerBottleBackend()._gateway_attach_resources()
InfraCls.assert_called_once_with()
self.assertEqual("DEFAULT-CA", res.ca_pem)
class TestMacosAttachPrimitives(unittest.TestCase):
def test_running_agent_containers_from_enumeration(self) -> None:
with patch.object(mac, "enumerate_active",
return_value=[SimpleNamespace(slug="demo")]):
names = mac.running_agent_containers()
self.assertEqual([f"{mac.CONTAINER_NAME_PREFIX}demo"], names)
def test_running_agent_containers_fails_hard(self) -> None:
with patch.object(mac, "enumerate_active",
side_effect=mac.EnumerationError("failed")):
with self.assertRaises(mac.EnumerationError):
mac.running_agent_containers()
def test_push_ca_cp_then_rebuilds_trust_store(self) -> None:
with patch.object(mac.container_mod, "run_container_argv",
return_value=_proc()) as run:
mac.push_ca_to_container(f"{mac.CONTAINER_NAME_PREFIX}demo", "PEM")
argvs = [c.args[0] for c in run.call_args_list]
self.assertTrue(any(mac.AGENT_CA_PATH in " ".join(a) for a in argvs))
self.assertTrue(any("update-ca-certificates" in a[-1] for a in argvs))
def test_push_ca_fails_hard(self) -> None:
with patch.object(mac.container_mod, "run_container_argv",
return_value=_proc(1, stderr="no")):
with self.assertRaisesRegex(mac.InfraLaunchError, "no"):
mac.push_ca_to_container("x", "PEM")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+24
View File
@@ -29,10 +29,20 @@ class TestDockerInfraService(unittest.TestCase):
self.orch.gateway_url.return_value = f"http://{ORCHESTRATOR_NAME}:8099" self.orch.gateway_url.return_value = f"http://{ORCHESTRATOR_NAME}:8099"
self.orch.mint_gateway_token.return_value = "gw.jwt" self.orch.mint_gateway_token.return_value = "gw.jwt"
self.gw = MagicMock() self.gw = MagicMock()
# Default to a cold boot (gateway (re)created) so the reconcile branch
# runs; the actual reconcile is stubbed so these composition tests stay
# isolated from docker.
self.gw.connect_to_orchestrator.return_value = True
for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)): for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)):
p = patch.object(self.svc, name, return_value=mock) p = patch.object(self.svc, name, return_value=mock)
p.start() p.start()
self.addCleanup(p.stop) self.addCleanup(p.stop)
ap = patch(
"bot_bottle.backend.docker.backend.DockerBottleBackend"
".attach_bottled_agents_to_gateway"
)
self.attach = ap.start()
self.addCleanup(ap.stop)
def test_url_delegates_to_the_orchestrator(self) -> None: def test_url_delegates_to_the_orchestrator(self) -> None:
self.assertEqual("http://127.0.0.1:8099", self.svc.url()) self.assertEqual("http://127.0.0.1:8099", self.svc.url())
@@ -58,6 +68,20 @@ class TestDockerInfraService(unittest.TestCase):
f"http://{ORCHESTRATOR_NAME}:8099", "gw.jwt") f"http://{ORCHESTRATOR_NAME}:8099", "gw.jwt")
self.orch.mint_gateway_token.assert_called_once() self.orch.mint_gateway_token.assert_called_once()
def test_cold_boot_reconciles_running_bottles(self) -> None:
# A (re)created gateway (connect returns True) triggers the bring-up
# reconcile so running bottles re-trust the fresh gateway (PRD 0083).
self.gw.connect_to_orchestrator.return_value = True
self.svc.ensure_running()
self.attach.assert_called_once_with()
def test_no_reconcile_when_gateway_left_untouched(self) -> None:
# A healthy current gateway (connect returns False) is not a cold boot,
# so there is nothing to reconcile.
self.gw.connect_to_orchestrator.return_value = False
self.svc.ensure_running()
self.attach.assert_not_called()
def test_stop_removes_both_containers(self) -> None: def test_stop_removes_both_containers(self) -> None:
with patch(_RUN) as run: with patch(_RUN) as run:
self.svc.stop() self.svc.stop()
@@ -16,7 +16,7 @@ from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.consolidated_launch import LaunchContext from bot_bottle.backend.docker.infra_launch import LaunchContext
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.log import Die from bot_bottle.log import Die
+1 -1
View File
@@ -19,7 +19,7 @@ from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleImages, BottleSpec from bot_bottle.backend import BottleImages, BottleSpec
from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.consolidated_launch import LaunchContext from bot_bottle.backend.docker.infra_launch import LaunchContext
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
+4 -1
View File
@@ -61,7 +61,10 @@ class TestFirecrackerGatewayConnect(unittest.TestCase):
booted = infra_vm.InfraVm(guest_ip="10.243.255.3", private_key=Path("/k")) booted = infra_vm.InfraVm(guest_ip="10.243.255.3", private_key=Path("/k"))
with patch.object(infra_vm, "boot_vm", return_value=booted) as boot, \ with patch.object(infra_vm, "boot_vm", return_value=booted) as boot, \
patch.object(infra_vm, "push_secret") as push: patch.object(infra_vm, "push_secret") as push:
gw.connect_to_orchestrator(_ORCH_URL, _TOKEN) cold_booted = gw.connect_to_orchestrator(_ORCH_URL, _TOKEN)
# The VM is booted fresh here (cold-boot path only), so it always
# signals a cold boot → the caller reconciles running bottles (PRD 0083).
self.assertTrue(cold_booted)
# Booted on the gateway link with the gateway role; the orchestrator's # Booted on the gateway link with the gateway role; the orchestrator's
# guest IP (parsed off the URL) rides the cmdline as bb_orch. # guest IP (parsed off the URL) rides the cmdline as bb_orch.
kw = boot.call_args.kwargs kw = boot.call_args.kwargs
+18
View File
@@ -40,7 +40,21 @@ class TestAccessors(unittest.TestCase):
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", svc.url()) self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", svc.url())
_ATTACH = (
"bot_bottle.backend.firecracker.backend.FirecrackerBottleBackend"
".attach_bottled_agents_to_gateway"
)
class TestEnsureRunning(unittest.TestCase): class TestEnsureRunning(unittest.TestCase):
def setUp(self) -> None:
# The cold-boot branch reconciles running bottles against the fresh
# gateway (PRD 0083). Stub it so these composition tests stay isolated
# from SSH; the wiring itself is asserted in test_cold_boot_reconciles*.
ap = patch(_ATTACH)
self.attach = ap.start()
self.addCleanup(ap.stop)
def test_adopts_when_healthy_alive_and_version_matches(self): def test_adopts_when_healthy_alive_and_version_matches(self):
# Healthy control plane + live gateway + existing key + matching version # Healthy control plane + live gateway + existing key + matching version
# marker -> adopt (no stop/build/boot); returns the orchestrator URL. # marker -> adopt (no stop/build/boot); returns the orchestrator URL.
@@ -59,6 +73,8 @@ class TestEnsureRunning(unittest.TestCase):
built.assert_not_called() built.assert_not_called()
ip = infra_vm.netpool.orch_slot().guest_ip ip = infra_vm.netpool.orch_slot().guest_ip
self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", url) self.assertEqual(f"http://{ip}:{infra_vm.ORCHESTRATOR_PORT}", url)
# Adopt path — no cold boot, so no running-bottle reconcile.
self.attach.assert_not_called()
def test_reboots_both_when_version_stale(self): def test_reboots_both_when_version_stale(self):
# Healthy control plane but the running pair booted an OLDER image # Healthy control plane but the running pair booted an OLDER image
@@ -120,6 +136,8 @@ class TestEnsureRunning(unittest.TestCase):
# reach the control plane at startup). # reach the control plane at startup).
orch_cls.return_value.ensure_running.assert_called_once() orch_cls.return_value.ensure_running.assert_called_once()
gw_cls.return_value.connect_to_orchestrator.assert_called_once() gw_cls.return_value.connect_to_orchestrator.assert_called_once()
# A cold boot minted a fresh gateway CA — reconcile running bottles.
self.attach.assert_called_once_with()
if __name__ == "__main__": if __name__ == "__main__":
@@ -6,8 +6,8 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, Mock, patch
from bot_bottle.backend.docker.consolidated_launch import ( from bot_bottle.backend.docker.infra_launch import (
ConsolidatedLaunchError, InfraLaunchError,
_network_container_ips, _network_container_ips,
launch_consolidated, launch_consolidated,
deprovision_consolidated, deprovision_consolidated,
@@ -16,7 +16,7 @@ from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.orchestrator.client import RegisteredBottle from bot_bottle.orchestrator.client import RegisteredBottle
_MOD = "bot_bottle.backend.docker.consolidated_launch" _MOD = "bot_bottle.backend.docker.infra_launch"
_UTIL = "bot_bottle.backend.provision_bottle" _UTIL = "bot_bottle.backend.provision_bottle"
@@ -93,7 +93,7 @@ class TestNetworkContainerIps(unittest.TestCase):
result = Mock(returncode=1, stdout="", stderr="daemon unavailable") result = Mock(returncode=1, stdout="", stderr="daemon unavailable")
with ( with (
patch(f"{_MOD}.run_docker", return_value=result), patch(f"{_MOD}.run_docker", return_value=result),
self.assertRaisesRegex(ConsolidatedLaunchError, "daemon unavailable"), self.assertRaisesRegex(InfraLaunchError, "daemon unavailable"),
): ):
_network_container_ips("bot-bottle-gateway") _network_container_ips("bot-bottle-gateway")
@@ -16,7 +16,7 @@ from unittest.mock import ANY, patch
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle from bot_bottle.backend.macos_container.bottle import MacosContainerBottle
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
from bot_bottle.backend.macos_container.consolidated_launch import GatewayEndpoint from bot_bottle.backend.macos_container.infra_launch import GatewayEndpoint
from bot_bottle.backend.macos_container.gateway_hosts import GATEWAY_HOSTNAME from bot_bottle.backend.macos_container.gateway_hosts import GATEWAY_HOSTNAME
from bot_bottle.backend.macos_container.launch import ( from bot_bottle.backend.macos_container.launch import (
_agent_run_argv, _agent_run_argv,
+11
View File
@@ -44,6 +44,17 @@ class TestMacosGatewayConnect(unittest.TestCase):
def test_default_name(self) -> None: def test_default_name(self) -> None:
self.assertEqual(GATEWAY_NAME, self.gw.name) self.assertEqual(GATEWAY_NAME, self.gw.name)
def test_connect_signals_cold_boot(self) -> None:
# The container is recreated unconditionally, so connect always signals
# a cold boot → the caller reconciles running bottles (PRD 0083).
run = Mock(return_value=_proc())
with patch(f"{_GW}.container_mod") as mod, \
patch(f"{_GW}.host_gateway_ca_dir", return_value=Path("/ca")):
mod.dns_server.return_value = "1.1.1.1"
mod.bind_mount_spec.side_effect = _spec
mod.run_container_argv = run
self.assertTrue(self.gw.connect_to_orchestrator(_ORCH_URL, _TOKEN))
def test_refuses_without_orchestrator_url(self) -> None: def test_refuses_without_orchestrator_url(self) -> None:
with patch(f"{_GW}.container_mod") as mod: with patch(f"{_GW}.container_mod") as mod:
with self.assertRaises(GatewayError): with self.assertRaises(GatewayError):
+24
View File
@@ -24,10 +24,20 @@ class TestInfraEnsureRunning(unittest.TestCase):
self.orch.url.return_value = "http://192.168.128.2:8099" self.orch.url.return_value = "http://192.168.128.2:8099"
self.orch.mint_gateway_token.return_value = "gw.jwt" self.orch.mint_gateway_token.return_value = "gw.jwt"
self.gw = MagicMock() self.gw = MagicMock()
# Default to a cold boot (gateway (re)created) so the reconcile branch
# runs; the actual reconcile is stubbed so these composition tests stay
# isolated from the `container` CLI.
self.gw.connect_to_orchestrator.return_value = True
for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)): for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)):
p = patch.object(self.svc, name, return_value=mock) p = patch.object(self.svc, name, return_value=mock)
p.start() p.start()
self.addCleanup(p.stop) self.addCleanup(p.stop)
ap = patch(
"bot_bottle.backend.macos_container.backend"
".MacosContainerBottleBackend.attach_bottled_agents_to_gateway"
)
self.attach = ap.start()
self.addCleanup(ap.stop)
def test_composes_networks_builds_and_brings_up_orchestrator_first(self) -> None: def test_composes_networks_builds_and_brings_up_orchestrator_first(self) -> None:
with patch(f"{_INFRA}.ensure_networks") as nets: with patch(f"{_INFRA}.ensure_networks") as nets:
@@ -46,6 +56,20 @@ class TestInfraEnsureRunning(unittest.TestCase):
"http://192.168.128.2:8099", "gw.jwt") "http://192.168.128.2:8099", "gw.jwt")
self.orch.mint_gateway_token.assert_called_once() self.orch.mint_gateway_token.assert_called_once()
def test_cold_boot_reconciles_running_bottles(self) -> None:
# A (re)created gateway (connect returns True) triggers the bring-up
# reconcile so running bottles re-trust the fresh gateway (PRD 0083).
self.gw.connect_to_orchestrator.return_value = True
with patch(f"{_INFRA}.ensure_networks"):
self.svc.ensure_running()
self.attach.assert_called_once_with()
def test_no_reconcile_when_gateway_left_untouched(self) -> None:
self.gw.connect_to_orchestrator.return_value = False
with patch(f"{_INFRA}.ensure_networks"):
self.svc.ensure_running()
self.attach.assert_not_called()
def test_is_healthy_delegates_to_the_orchestrator(self) -> None: def test_is_healthy_delegates_to_the_orchestrator(self) -> None:
self.orch.is_healthy.return_value = True self.orch.is_healthy.return_value = True
self.assertTrue(self.svc.is_healthy()) self.assertTrue(self.svc.is_healthy())
@@ -6,7 +6,7 @@ import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, Mock, patch
from bot_bottle.backend.macos_container.consolidated_launch import ( from bot_bottle.backend.macos_container.infra_launch import (
GatewayEndpoint, GatewayEndpoint,
ensure_gateway, ensure_gateway,
register_agent, register_agent,
@@ -16,7 +16,7 @@ from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.orchestrator.client import RegisteredBottle from bot_bottle.orchestrator.client import RegisteredBottle
_MOD = "bot_bottle.backend.macos_container.consolidated_launch" _MOD = "bot_bottle.backend.macos_container.infra_launch"
_UTIL = "bot_bottle.backend.provision_bottle" _UTIL = "bot_bottle.backend.provision_bottle"
@@ -145,7 +145,7 @@ class TestLiveSourceIps(unittest.TestCase):
return [Mock(slug=s) for s in slugs] return [Mock(slug=s) for s in slugs]
def test_maps_slugs_to_container_addresses(self) -> None: def test_maps_slugs_to_container_addresses(self) -> None:
from bot_bottle.backend.macos_container.consolidated_launch import live_source_ips from bot_bottle.backend.macos_container.infra_launch import live_source_ips
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \ with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
patch(f"{_MOD}.container_mod.inspect_container_network_ip", patch(f"{_MOD}.container_mod.inspect_container_network_ip",
side_effect=["10.0.0.1", "10.0.0.2"]) as ip: side_effect=["10.0.0.1", "10.0.0.2"]) as ip:
@@ -156,7 +156,7 @@ class TestLiveSourceIps(unittest.TestCase):
def test_containers_without_an_address_are_skipped(self) -> None: def test_containers_without_an_address_are_skipped(self) -> None:
"""A container that hasn't been given a DHCP address yet contributes """A container that hasn't been given a DHCP address yet contributes
nothing the reap's grace window, not this list, protects it.""" nothing the reap's grace window, not this list, protects it."""
from bot_bottle.backend.macos_container.consolidated_launch import live_source_ips from bot_bottle.backend.macos_container.infra_launch import live_source_ips
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \ with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
patch(f"{_MOD}.container_mod.inspect_container_network_ip", patch(f"{_MOD}.container_mod.inspect_container_network_ip",
side_effect=["", "10.0.0.2"]): side_effect=["", "10.0.0.2"]):
@@ -165,7 +165,7 @@ class TestLiveSourceIps(unittest.TestCase):
def test_container_list_failure_raises(self) -> None: def test_container_list_failure_raises(self) -> None:
"""If container list fails, the live set is not authoritative and """If container list fails, the live set is not authoritative and
reconciliation must be skipped.""" reconciliation must be skipped."""
from bot_bottle.backend.macos_container.consolidated_launch import live_source_ips from bot_bottle.backend.macos_container.infra_launch import live_source_ips
from bot_bottle.backend.macos_container.enumerate import EnumerationError from bot_bottle.backend.macos_container.enumerate import EnumerationError
with patch(f"{_MOD}.enumerate_active", with patch(f"{_MOD}.enumerate_active",
side_effect=EnumerationError("container list failed")): side_effect=EnumerationError("container list failed")):
@@ -174,7 +174,7 @@ class TestLiveSourceIps(unittest.TestCase):
def test_per_container_inspect_failure_raises(self) -> None: def test_per_container_inspect_failure_raises(self) -> None:
"""If any individual inspect fails, the live set is not authoritative.""" """If any individual inspect fails, the live set is not authoritative."""
from bot_bottle.backend.macos_container.consolidated_launch import live_source_ips from bot_bottle.backend.macos_container.infra_launch import live_source_ips
from bot_bottle.backend.macos_container.enumerate import EnumerationError from bot_bottle.backend.macos_container.enumerate import EnumerationError
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \ with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
patch(f"{_MOD}.container_mod.inspect_container_network_ip", patch(f"{_MOD}.container_mod.inspect_container_network_ip",
+25
View File
@@ -151,6 +151,31 @@ class TestReprovisionGateway(unittest.TestCase):
self.c.reprovision_gateway("b1", "key") 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): class TestHealthAndPolicy(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.c = OrchestratorClient("http://orch:8080") self.c = OrchestratorClient("http://orch:8080")
+10 -3
View File
@@ -86,9 +86,12 @@ class TestDockerGateway(unittest.TestCase):
return _proc() return _proc()
with patch(_RUN_DOCKER, side_effect=fake): with patch(_RUN_DOCKER, side_effect=fake):
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) cold_booted = self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "run"]]) self.assertEqual([], [c for c in calls if c[:2] == ["docker", "run"]])
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "rm"]]) self.assertEqual([], [c for c in calls if c[:2] == ["docker", "rm"]])
# A healthy current gateway left untouched is not a cold boot — the
# bring-up flow skips the running-bottle reconcile (PRD 0083).
self.assertFalse(cold_booted)
def test_ensure_running_recreates_when_image_is_stale(self) -> None: def test_ensure_running_recreates_when_image_is_stale(self) -> None:
# Running, but the container was built from an OLD image → recreate so # Running, but the container was built from an OLD image → recreate so
@@ -106,9 +109,12 @@ class TestDockerGateway(unittest.TestCase):
return _proc() return _proc()
with patch(_RUN_DOCKER, side_effect=fake): with patch(_RUN_DOCKER, side_effect=fake):
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) cold_booted = self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
self.assertEqual(1, len([c for c in calls if c[:2] == ["docker", "run"]])) self.assertEqual(1, len([c for c in calls if c[:2] == ["docker", "run"]]))
self.assertTrue(any(c[:2] == ["docker", "rm"] for c in calls)) self.assertTrue(any(c[:2] == ["docker", "rm"] for c in calls))
# A recreated container minted/mounted its CA afresh — a cold boot that
# triggers the running-bottle reconcile (PRD 0083).
self.assertTrue(cold_booted)
def test_ensure_running_starts_the_singleton_when_absent(self) -> None: def test_ensure_running_starts_the_singleton_when_absent(self) -> None:
calls: list[list[str]] = [] calls: list[list[str]] = []
@@ -118,7 +124,8 @@ class TestDockerGateway(unittest.TestCase):
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc() return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
with patch(_RUN_DOCKER, side_effect=fake): with patch(_RUN_DOCKER, side_effect=fake):
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) cold_booted = self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
self.assertTrue(cold_booted) # started fresh → reconcile running bottles
runs = [c for c in calls if c[:2] == ["docker", "run"]] runs = [c for c in calls if c[:2] == ["docker", "run"]]
self.assertEqual(1, len(runs)) self.assertEqual(1, len(runs))
@@ -199,6 +199,20 @@ class TestAgentSecrets(unittest.TestCase):
got = self.store.get_agent_secrets("bottle-1") got = self.store.get_agent_secrets("bottle-1")
self.assertEqual({"K": "new", "K2": "v2"}, got) 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: def test_delete_removes_secrets(self) -> None:
self.store.store_agent_secrets("bottle-1", {"K": "v"}) self.store.store_agent_secrets("bottle-1", {"K": "v"})
self.store.delete_agent_secrets("bottle-1") 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), {"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: def test_reprovision_validates_request_and_missing_rows(self) -> None:
status, _ = dispatch( status, _ = dispatch(
self.orch, "POST", "/bottles/b1/reprovision_gateway", b"not-json", 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.assertTrue(self.orch.reprovision_from_secret(rec.bottle_id, key))
self.assertEqual({"EGRESS_TOKEN_0": "secret"}, self.orch.tokens_for(rec.bottle_id)) 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: def test_reprovision_rejects_missing_rows_and_wrong_key(self) -> None:
self.assertFalse(self.orch.reprovision_from_secret("missing", new_env_var_secret())) self.assertFalse(self.orch.reprovision_from_secret("missing", new_env_var_secret()))
key = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE" key = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"