Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75957b75b8 | |||
| 3c0d2fb66f | |||
| 4f2ccaed29 | |||
| de80aafb1f | |||
| 4434752db4 | |||
| f9d0e15f13 | |||
| bf6b32381d |
@@ -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
|
||||||
@@ -262,6 +265,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)
|
||||||
@@ -276,7 +290,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
|
||||||
@@ -504,6 +518,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 0081). 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:
|
||||||
|
|||||||
@@ -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 0081) 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)
|
||||||
|
|||||||
@@ -157,7 +157,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 —
|
||||||
@@ -184,7 +184,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.
|
||||||
@@ -228,6 +228,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 0081).
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 0081). 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:
|
||||||
|
|||||||
+60
-6
@@ -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
|
||||||
@@ -74,6 +73,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 0081). 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 0081). 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,
|
||||||
@@ -180,6 +232,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",
|
||||||
]
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 0081). 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:
|
||||||
|
|||||||
+41
-6
@@ -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 0081). 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",
|
||||||
]
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""The shared gateway-attach reconcile flow (PRD 0081).
|
||||||
|
|
||||||
|
`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 0081). 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"]
|
||||||
@@ -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 0081)."""
|
||||||
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)
|
||||||
|
|||||||
@@ -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 0081). 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:
|
||||||
|
|||||||
+52
-5
@@ -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 0081). 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 0081). 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",
|
||||||
]
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 0081)."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -200,6 +200,35 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
return 200, {"reprovisioned": True}
|
return 200, {"reprovisioned": True}
|
||||||
return 404, {"error": "no stored secrets for this bottle"}
|
return 404, {"error": "no stored secrets for this bottle"}
|
||||||
|
|
||||||
|
if (
|
||||||
|
method == "POST"
|
||||||
|
and route.startswith("/bottles/")
|
||||||
|
and route.endswith("/secret")
|
||||||
|
):
|
||||||
|
# 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.
|
||||||
|
bottle_id = route[len("/bottles/") : -len("/secret")]
|
||||||
|
try:
|
||||||
|
data = _parse_json_object(body)
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"error": f"invalid JSON: {e}"}
|
||||||
|
name = data.get("name")
|
||||||
|
value = data.get("value")
|
||||||
|
env_var_secret = data.get("env_var_secret")
|
||||||
|
if (
|
||||||
|
not isinstance(name, str) or not name
|
||||||
|
or not isinstance(value, str) or not value
|
||||||
|
or not isinstance(env_var_secret, str) or not env_var_secret
|
||||||
|
):
|
||||||
|
return 400, {
|
||||||
|
"error": "name, value, and env_var_secret (non-empty strings) are required"
|
||||||
|
}
|
||||||
|
if orch.update_agent_secret(bottle_id, name, value, env_var_secret):
|
||||||
|
return 200, {"updated": True}
|
||||||
|
return 404, {"error": "no such bottle"}
|
||||||
|
|
||||||
if method == "DELETE" and route.startswith("/bottles/"):
|
if method == "DELETE" and route.startswith("/bottles/"):
|
||||||
bottle_id = route[len("/bottles/"):]
|
bottle_id = route[len("/bottles/"):]
|
||||||
if orch.teardown_bottle(bottle_id):
|
if orch.teardown_bottle(bottle_id):
|
||||||
|
|||||||
@@ -377,6 +377,30 @@ class OrchestratorCore:
|
|||||||
return False
|
return False
|
||||||
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]:
|
||||||
|
|||||||
@@ -366,6 +366,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 0081) 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 0081): 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 0081 (`docs/prds/0081-reprovision-gateway-state-on-bringup.md`).
|
||||||
|
- Review that prompted this: PR #519.
|
||||||
|
- `bot_bottle/backend/base.py` (`BottleBackend.attach_bottled_agents_to_gateway`).
|
||||||
@@ -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 0081: 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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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->600 for the PRD 0081
|
||||||
|
# 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": 600,
|
||||||
ROOT / "bot_bottle" / "backend" / "preparation.py": 160,
|
ROOT / "bot_bottle" / "backend" / "preparation.py": 160,
|
||||||
}
|
}
|
||||||
oversized = [
|
oversized = [
|
||||||
|
|||||||
@@ -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 0081 / #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 0081: 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()
|
||||||
|
|||||||
@@ -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 0081).
|
||||||
|
self.gw.connect_to_orchestrator.return_value = True
|
||||||
|
self.svc.ensure_running()
|
||||||
|
self.attach.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_no_reconcile_when_gateway_left_untouched(self) -> None:
|
||||||
|
# A healthy current gateway (connect returns False) is not a cold boot,
|
||||||
|
# so there is nothing to reconcile.
|
||||||
|
self.gw.connect_to_orchestrator.return_value = False
|
||||||
|
self.svc.ensure_running()
|
||||||
|
self.attach.assert_not_called()
|
||||||
|
|
||||||
def test_stop_removes_both_containers(self) -> None:
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 0081).
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 0081). Stub it so these composition tests stay isolated
|
||||||
|
# from SSH; the wiring itself is asserted in test_cold_boot_reconciles*.
|
||||||
|
ap = patch(_ATTACH)
|
||||||
|
self.attach = ap.start()
|
||||||
|
self.addCleanup(ap.stop)
|
||||||
|
|
||||||
def test_adopts_when_healthy_alive_and_version_matches(self):
|
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,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.docker.consolidated_launch import (
|
from bot_bottle.backend.docker.infra_launch import (
|
||||||
launch_consolidated,
|
launch_consolidated,
|
||||||
deprovision_consolidated,
|
deprovision_consolidated,
|
||||||
)
|
)
|
||||||
@@ -14,7 +14,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"
|
||||||
|
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 0081).
|
||||||
|
run = Mock(return_value=_proc())
|
||||||
|
with patch(f"{_GW}.container_mod") as mod, \
|
||||||
|
patch(f"{_GW}.host_gateway_ca_dir", return_value=Path("/ca")):
|
||||||
|
mod.dns_server.return_value = "1.1.1.1"
|
||||||
|
mod.bind_mount_spec.side_effect = _spec
|
||||||
|
mod.run_container_argv = run
|
||||||
|
self.assertTrue(self.gw.connect_to_orchestrator(_ORCH_URL, _TOKEN))
|
||||||
|
|
||||||
def test_refuses_without_orchestrator_url(self) -> None:
|
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,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 0081).
|
||||||
|
self.gw.connect_to_orchestrator.return_value = True
|
||||||
|
with patch(f"{_INFRA}.ensure_networks"):
|
||||||
|
self.svc.ensure_running()
|
||||||
|
self.attach.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_no_reconcile_when_gateway_left_untouched(self) -> None:
|
||||||
|
self.gw.connect_to_orchestrator.return_value = False
|
||||||
|
with patch(f"{_INFRA}.ensure_networks"):
|
||||||
|
self.svc.ensure_running()
|
||||||
|
self.attach.assert_not_called()
|
||||||
|
|
||||||
def test_is_healthy_delegates_to_the_orchestrator(self) -> None:
|
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
-6
@@ -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",
|
||||||
@@ -125,6 +125,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")
|
||||||
|
|||||||
@@ -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 0081).
|
||||||
|
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 0081).
|
||||||
|
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")
|
||||||
|
|||||||
@@ -87,6 +87,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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user