Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32e9bb95e0 | |||
| 8e567edde4 |
@@ -1,27 +0,0 @@
|
||||
"""Lean, framework-free `docker` subprocess primitive.
|
||||
|
||||
Deliberately a top-level module with a single stdlib import so it can be
|
||||
reused from anywhere without cost. It is intentionally *not* placed in
|
||||
`backend.docker.util`: importing that module runs `backend/__init__.py`,
|
||||
which eagerly loads all three bottle backends (docker + firecracker +
|
||||
macos) plus the manifest/egress/git-gate/supervise framework — ~76 modules
|
||||
— which would drag the whole backend layer into the deliberately-lean
|
||||
orchestrator. This primitive stays free of that so both the orchestrator's
|
||||
docker components and (in time) `backend.docker.util` can share it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def run_docker(argv: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a `docker` command, capturing stdout/stderr as text. Never raises
|
||||
on a non-zero exit — callers inspect `returncode` / `stderr` so they can
|
||||
stay fail-closed or tolerate idempotent no-ops (e.g. removing an
|
||||
already-absent container)."""
|
||||
return subprocess.run(
|
||||
argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["run_docker"]
|
||||
@@ -7,57 +7,23 @@ backend-neutral "consolidation core" that needs no VM packaging:
|
||||
|
||||
* `registry` — the SQLite runtime-state store + fail-closed
|
||||
attribution (source IP + per-bottle identity token).
|
||||
* `broker` — the signed, structured launch-request contract + a
|
||||
`LaunchBroker` (stub for the harness) that verifies
|
||||
provenance before acting.
|
||||
* `sidecar` — the consolidated per-host sidecar: a `Sidecar`
|
||||
lifecycle contract (idempotent singleton) + a
|
||||
`DockerSidecar` impl. One sidecar shared by all
|
||||
bottles instead of one per bottle.
|
||||
* `service` — the `Orchestrator`: owns the registry, brokers the
|
||||
launch lifecycle (launch/teardown), manages the
|
||||
shared sidecar, attributes.
|
||||
* `control_plane` — the HTTP control-plane RPC (launch / teardown /
|
||||
list / attribute / sidecar / health).
|
||||
* `control_plane` — the HTTP control-plane RPC (register / deregister /
|
||||
list / attribute / health), with live reload.
|
||||
|
||||
The actual backend-native launch (a real docker/firecracker broker) and
|
||||
the egress/git/supervise data plane land in later slices once this core is
|
||||
proven (see the PRD sequencing: plain-process dev-harness -> docker
|
||||
orchestrator -> firecracker).
|
||||
Launch/teardown, the launch broker, and the actual egress/git/supervise
|
||||
data plane land in later slices once this core is proven (see the PRD's
|
||||
sequencing: plain-process dev-harness -> docker orchestrator -> firecracker).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .registry import BottleRecord, RegistryStore, new_identity_token
|
||||
from .broker import (
|
||||
BrokerAuthError,
|
||||
LaunchBroker,
|
||||
LaunchRequest,
|
||||
StubBroker,
|
||||
sign_request,
|
||||
verify_request,
|
||||
)
|
||||
from .docker_broker import DockerBroker, DockerBrokerError
|
||||
from .sidecar import DockerSidecar, Sidecar, SidecarError
|
||||
from .service import Orchestrator
|
||||
from .control_plane import ControlPlaneServer, dispatch, make_server
|
||||
|
||||
__all__ = [
|
||||
"BottleRecord",
|
||||
"RegistryStore",
|
||||
"new_identity_token",
|
||||
"BrokerAuthError",
|
||||
"LaunchBroker",
|
||||
"LaunchRequest",
|
||||
"StubBroker",
|
||||
"DockerBroker",
|
||||
"DockerBrokerError",
|
||||
"Sidecar",
|
||||
"DockerSidecar",
|
||||
"SidecarError",
|
||||
"sign_request",
|
||||
"verify_request",
|
||||
"Orchestrator",
|
||||
"ControlPlaneServer",
|
||||
"dispatch",
|
||||
"make_server",
|
||||
|
||||
@@ -12,16 +12,11 @@ container packaging. Wrapping this exact service in a backend-native unit
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from .. import log
|
||||
from .broker import LaunchBroker, StubBroker
|
||||
from .control_plane import make_server
|
||||
from .docker_broker import DockerBroker
|
||||
from .registry import RegistryStore, default_db_path
|
||||
from .service import Orchestrator
|
||||
from .sidecar import DockerSidecar, Sidecar
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -33,34 +28,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"--db", type=Path, default=None,
|
||||
help=f"registry DB path (default: {default_db_path()})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--broker", choices=("stub", "docker"), default="stub",
|
||||
help="launch broker: 'stub' records requests; 'docker' runs containers",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sidecar", action="store_true",
|
||||
help="run one consolidated per-host sidecar bundle (build-if-missing)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
registry = RegistryStore(args.db)
|
||||
registry.migrate()
|
||||
|
||||
# An ephemeral signing secret ties the orchestrator (signer) to its
|
||||
# broker (verifier). 'stub' records launches instead of starting
|
||||
# anything; 'docker' runs real containers (firecracker drops in later).
|
||||
secret = secrets.token_bytes(32)
|
||||
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
|
||||
sidecar: Sidecar | None = DockerSidecar() if args.sidecar else None
|
||||
orchestrator = Orchestrator(registry, broker, secret, sidecar)
|
||||
|
||||
# One persistent per-host sidecar, shared by every bottle: build the
|
||||
# bundle image if missing, then bring the singleton up (idempotent).
|
||||
if sidecar is not None:
|
||||
orchestrator.ensure_sidecar()
|
||||
log.info("consolidated sidecar ensured", context={"name": sidecar.name})
|
||||
|
||||
server = make_server(orchestrator, host=args.host, port=args.port)
|
||||
server = make_server(registry, host=args.host, port=args.port)
|
||||
bound_host, bound_port = server.server_address[0], server.server_address[1]
|
||||
log.info(
|
||||
"orchestrator control plane listening",
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Launch broker contract (PRD 0070).
|
||||
|
||||
A VM/container can't spawn its own host-network siblings, so the
|
||||
orchestrator brokers agent launches through a small privileged component.
|
||||
The request it sends is:
|
||||
|
||||
* **structured** — static flags + ids only (bottle id, pool slot, a
|
||||
content-addressed image ref), never a free-form path or argv, so a
|
||||
request can't be coerced into launching an arbitrary payload; and
|
||||
* **signed** — wrapped as a compact JWS/JWT the broker verifies before
|
||||
acting, so a *compromised co-located component* (an agent-facing
|
||||
sidecar, say) can't forge a launch. This is the concrete form of the
|
||||
"structured requests only" rule in the PRD security review.
|
||||
|
||||
Signing is **HS256** over a secret shared by the orchestrator (signer) and
|
||||
the broker (verifier) — appropriate here because both are trusted host
|
||||
components provisioned together; the secret is exactly what an
|
||||
agent-facing component does not have. (Stdlib only — the project takes no
|
||||
runtime deps; a cross-host deployment could swap in an asymmetric alg.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
_JWT_HEADER = {"alg": "HS256", "typ": "JWT"}
|
||||
_ALLOWED_OPS = ("launch", "teardown")
|
||||
|
||||
|
||||
class BrokerAuthError(Exception):
|
||||
"""A broker request failed provenance or schema verification —
|
||||
bad/absent signature, malformed token, or a payload that doesn't match
|
||||
the fixed launch-request shape. Fail-closed: the broker must not act."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaunchRequest:
|
||||
"""The structured, un-coercible launch/teardown request. Ids + flags
|
||||
only — `image_ref` is a content-addressed id, never a path/argv."""
|
||||
|
||||
op: str # one of _ALLOWED_OPS
|
||||
bottle_id: str
|
||||
source_ip: str = ""
|
||||
image_ref: str = ""
|
||||
slot: int | None = None
|
||||
|
||||
|
||||
# --- minimal JWS/JWT (HS256), stdlib only ----------------------------------
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(text: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||
|
||||
|
||||
def _mac(signing_input: str, secret: bytes) -> str:
|
||||
digest = hmac.new(secret, signing_input.encode("ascii"), hashlib.sha256).digest()
|
||||
return _b64url(digest)
|
||||
|
||||
|
||||
def sign_request(req: LaunchRequest, secret: bytes) -> str:
|
||||
"""Serialize `req` to signed-JWT form. Adds a per-signature `jti`
|
||||
(replay id) and `iat` (issued-at)."""
|
||||
claims: dict[str, object] = {
|
||||
"op": req.op,
|
||||
"bottle_id": req.bottle_id,
|
||||
"source_ip": req.source_ip,
|
||||
"image_ref": req.image_ref,
|
||||
"slot": req.slot,
|
||||
"jti": secrets.token_hex(8),
|
||||
"iat": int(time.time()),
|
||||
}
|
||||
header = _b64url(json.dumps(_JWT_HEADER, sort_keys=True, separators=(",", ":")).encode())
|
||||
payload = _b64url(json.dumps(claims, sort_keys=True, separators=(",", ":")).encode())
|
||||
signing_input = f"{header}.{payload}"
|
||||
return f"{signing_input}.{_mac(signing_input, secret)}"
|
||||
|
||||
|
||||
def verify_request(token: str, secret: bytes) -> LaunchRequest:
|
||||
"""Verify the signature and the request shape; return the parsed
|
||||
request. Raises `BrokerAuthError` on any failure (fail-closed)."""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise BrokerAuthError("malformed token")
|
||||
header_b, payload_b, sig = parts
|
||||
if not hmac.compare_digest(_mac(f"{header_b}.{payload_b}", secret), sig):
|
||||
raise BrokerAuthError("bad signature")
|
||||
try:
|
||||
header = json.loads(_b64url_decode(header_b))
|
||||
claims = json.loads(_b64url_decode(payload_b))
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
raise BrokerAuthError("malformed token payload") from e
|
||||
if not isinstance(header, dict) or header.get("alg") != "HS256":
|
||||
raise BrokerAuthError("unexpected header/alg")
|
||||
if not isinstance(claims, dict):
|
||||
raise BrokerAuthError("claims are not an object")
|
||||
|
||||
op = claims.get("op")
|
||||
bottle_id = claims.get("bottle_id")
|
||||
source_ip = claims.get("source_ip", "")
|
||||
image_ref = claims.get("image_ref", "")
|
||||
slot = claims.get("slot")
|
||||
if (
|
||||
not isinstance(op, str) or op not in _ALLOWED_OPS
|
||||
or not isinstance(bottle_id, str) or not bottle_id
|
||||
or not isinstance(source_ip, str) or not isinstance(image_ref, str)
|
||||
or not (slot is None or isinstance(slot, int))
|
||||
):
|
||||
raise BrokerAuthError("request does not match the launch-request schema")
|
||||
return LaunchRequest(
|
||||
op=op, bottle_id=bottle_id, source_ip=source_ip, image_ref=image_ref, slot=slot
|
||||
)
|
||||
|
||||
|
||||
# --- the broker itself ------------------------------------------------------
|
||||
|
||||
class LaunchBroker(abc.ABC):
|
||||
"""Verifies a signed request came from the orchestrator, then performs
|
||||
the backend-native launch/teardown. Subclasses implement `_launch` /
|
||||
`_teardown`; verification is shared and fail-closed."""
|
||||
|
||||
def __init__(self, secret: bytes) -> None:
|
||||
self._secret = secret
|
||||
|
||||
def submit(self, token: str) -> LaunchRequest:
|
||||
"""Verify `token` and perform its op. Returns the verified request;
|
||||
raises `BrokerAuthError` if provenance/shape fails."""
|
||||
req = verify_request(token, self._secret)
|
||||
if req.op == "launch":
|
||||
self._launch(req)
|
||||
else:
|
||||
self._teardown(req)
|
||||
return req
|
||||
|
||||
@abc.abstractmethod
|
||||
def _launch(self, req: LaunchRequest) -> None:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
...
|
||||
|
||||
|
||||
class StubBroker(LaunchBroker):
|
||||
"""Dev-harness broker: records verified requests without launching
|
||||
anything. Exercises the full sign -> verify -> act contract in-process."""
|
||||
|
||||
def __init__(self, secret: bytes) -> None:
|
||||
super().__init__(secret)
|
||||
self.launched: list[LaunchRequest] = []
|
||||
self.torn_down: list[LaunchRequest] = []
|
||||
|
||||
def _launch(self, req: LaunchRequest) -> None:
|
||||
self.launched.append(req)
|
||||
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
self.torn_down.append(req)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BrokerAuthError",
|
||||
"LaunchRequest",
|
||||
"LaunchBroker",
|
||||
"StubBroker",
|
||||
"sign_request",
|
||||
"verify_request",
|
||||
]
|
||||
@@ -2,26 +2,23 @@
|
||||
|
||||
The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over
|
||||
**HTTP** — the universal transport chosen in 0070 (works on every host; no
|
||||
vsock / unix-socket portability caveats):
|
||||
vsock / unix-socket portability caveats). Endpoints mutate the live
|
||||
registry, so register/deregister are the *live-reload* path — no restart:
|
||||
|
||||
GET /health -> 200 {"status": "ok"}
|
||||
GET /sidecar -> 200 {"configured", ["name", "running"]}
|
||||
GET /bottles -> 200 {"bottles": [ <redacted record>, ... ]}
|
||||
POST /bottles -> 201 {"bottle_id", "identity_token"} (launch)
|
||||
body: {"source_ip", ["image_ref"], ["metadata"]}
|
||||
DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown)
|
||||
POST /bottles -> 201 {"bottle_id", "identity_token"}
|
||||
body: {"source_ip", ["bottle_id"], ["metadata"]}
|
||||
DELETE /bottles/<bottle_id> -> 200 {"deregistered": true} | 404
|
||||
POST /attribute -> 200 {"bottle_id"} | 403
|
||||
body: {"source_ip", "identity_token"}
|
||||
|
||||
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
|
||||
tear down) the bottle in the registry AND broker the backend-native launch
|
||||
via the orchestrator. Register/deregister without a launch are internal to
|
||||
`Orchestrator`, not exposed here.
|
||||
|
||||
Routing/handling is the pure function `dispatch()` so it is unit-testable
|
||||
without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a
|
||||
thin stdlib adapter around it. Listing redacts identity tokens — they are
|
||||
returned only once, to the caller that launches the bottle.
|
||||
thin stdlib adapter around it.
|
||||
|
||||
Note the listing redacts identity tokens — they are never returned except
|
||||
once, to the caller that registers the bottle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,7 +30,7 @@ import socketserver
|
||||
import typing
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .service import Orchestrator
|
||||
from .registry import RegistryStore
|
||||
|
||||
# JSON body payload type (parsed request / rendered response).
|
||||
Json = dict[str, object]
|
||||
@@ -50,20 +47,17 @@ def _parse_json_object(body: bytes) -> Json:
|
||||
|
||||
|
||||
def dispatch( # pylint: disable=too-many-return-statements
|
||||
orch: Orchestrator, method: str, path: str, body: bytes
|
||||
registry: RegistryStore, method: str, path: str, body: bytes
|
||||
) -> tuple[int, Json]:
|
||||
"""Route one control-plane request to a (status, payload) pair. Pure —
|
||||
no I/O beyond the orchestrator — so it is fully testable without a socket."""
|
||||
no I/O beyond the registry — so it is fully testable without a socket."""
|
||||
route = urlsplit(path).path.rstrip("/") or "/"
|
||||
|
||||
if method == "GET" and route == "/health":
|
||||
return 200, {"status": "ok"}
|
||||
|
||||
if method == "GET" and route == "/sidecar":
|
||||
return 200, orch.sidecar_status()
|
||||
|
||||
if method == "GET" and route == "/bottles":
|
||||
return 200, {"bottles": [r.redacted() for r in orch.registry.all()]}
|
||||
return 200, {"bottles": [r.redacted() for r in registry.all()]}
|
||||
|
||||
if method == "POST" and route == "/bottles":
|
||||
try:
|
||||
@@ -73,19 +67,19 @@ def dispatch( # pylint: disable=too-many-return-statements
|
||||
source_ip = data.get("source_ip")
|
||||
if not isinstance(source_ip, str) or not source_ip:
|
||||
return 400, {"error": "source_ip (string) is required"}
|
||||
image_ref = data.get("image_ref")
|
||||
bottle_id = data.get("bottle_id")
|
||||
metadata = data.get("metadata")
|
||||
rec = orch.launch_bottle(
|
||||
rec = registry.register(
|
||||
source_ip,
|
||||
image_ref=image_ref if isinstance(image_ref, str) else "",
|
||||
bottle_id=bottle_id if isinstance(bottle_id, str) else None,
|
||||
metadata=metadata if isinstance(metadata, str) else "",
|
||||
)
|
||||
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
||||
|
||||
if method == "DELETE" and route.startswith("/bottles/"):
|
||||
bottle_id = route[len("/bottles/"):]
|
||||
if orch.teardown_bottle(bottle_id):
|
||||
return 200, {"torn_down": True}
|
||||
if registry.deregister(bottle_id):
|
||||
return 200, {"deregistered": True}
|
||||
return 404, {"error": "no such bottle"}
|
||||
|
||||
if method == "POST" and route == "/attribute":
|
||||
@@ -97,7 +91,7 @@ def dispatch( # pylint: disable=too-many-return-statements
|
||||
token = data.get("identity_token")
|
||||
if not isinstance(source_ip, str) or not isinstance(token, str):
|
||||
return 400, {"error": "source_ip and identity_token (strings) required"}
|
||||
rec = orch.attribute(source_ip, token)
|
||||
rec = registry.attribute(source_ip, token)
|
||||
if rec is None:
|
||||
return 403, {"error": "unattributed"}
|
||||
return 200, {"bottle_id": rec.bottle_id}
|
||||
@@ -120,7 +114,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
assert isinstance(server, ControlPlaneServer)
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length > 0 else b""
|
||||
status, payload = dispatch(server.orchestrator, method, self.path, body)
|
||||
status, payload = dispatch(server.registry, method, self.path, body)
|
||||
data = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
@@ -139,22 +133,22 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
"""Threading HTTP server that carries the orchestrator for its handlers."""
|
||||
"""Threading HTTP server that carries the registry for its handlers."""
|
||||
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
|
||||
self.orchestrator = orchestrator
|
||||
def __init__(self, address: tuple[str, int], registry: RegistryStore) -> None:
|
||||
self.registry = registry
|
||||
super().__init__(address, Handler)
|
||||
|
||||
|
||||
def make_server(
|
||||
orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0
|
||||
registry: RegistryStore, host: str = "127.0.0.1", port: int = 0
|
||||
) -> ControlPlaneServer:
|
||||
"""Build (but do not start) a control-plane server. `port=0` binds an
|
||||
ephemeral port — read `server.server_address` for the actual one."""
|
||||
return ControlPlaneServer((host, port), orchestrator)
|
||||
return ControlPlaneServer((host, port), registry)
|
||||
|
||||
|
||||
__all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"]
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Docker launch broker (PRD 0070) — the first *real* LaunchBroker.
|
||||
|
||||
On a verified launch request it starts a Docker container; on teardown it
|
||||
removes it. This proves the orchestrator -> backend seam on the cheapest
|
||||
backend (the sidecar bundle is already containers). Only the request's
|
||||
static ids/flags reach `docker`, so nothing free-form crosses the boundary.
|
||||
|
||||
Slice 3 launches a single container from the request's `image_ref`, named
|
||||
after the bottle id and labelled for cleanup. Wiring the full agent +
|
||||
sidecar bundle (networks, mounts, the consolidated sidecar) is a later
|
||||
slice — this is the seam, not the finished launcher.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from ..docker_cmd import run_docker
|
||||
from .broker import LaunchBroker, LaunchRequest
|
||||
|
||||
CONTAINER_PREFIX = "bot-bottle-orch-"
|
||||
BOTTLE_ID_LABEL = "bot-bottle-bottle-id"
|
||||
|
||||
|
||||
class DockerBrokerError(Exception):
|
||||
"""A brokered docker launch/teardown failed (non-zero `docker` exit)."""
|
||||
|
||||
|
||||
def container_name(bottle_id: str) -> str:
|
||||
"""Deterministic container name for a bottle."""
|
||||
return f"{CONTAINER_PREFIX}{bottle_id}"
|
||||
|
||||
|
||||
def run_argv(req: LaunchRequest) -> list[str]:
|
||||
"""`docker run` argv for a launch request — built only from its static
|
||||
fields, so it can't be coerced into an arbitrary command."""
|
||||
return [
|
||||
"docker", "run", "--detach",
|
||||
"--name", container_name(req.bottle_id),
|
||||
"--label", f"{BOTTLE_ID_LABEL}={req.bottle_id}",
|
||||
req.image_ref,
|
||||
]
|
||||
|
||||
|
||||
def rm_argv(req: LaunchRequest) -> list[str]:
|
||||
"""`docker rm --force` argv to tear a bottle's container down."""
|
||||
return ["docker", "rm", "--force", container_name(req.bottle_id)]
|
||||
|
||||
|
||||
class DockerBroker(LaunchBroker):
|
||||
"""A `LaunchBroker` that runs / removes a Docker container per bottle.
|
||||
Provenance + schema verification are inherited from `LaunchBroker`."""
|
||||
|
||||
def _docker(self, argv: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return run_docker(argv)
|
||||
|
||||
def _launch(self, req: LaunchRequest) -> None:
|
||||
if not req.image_ref:
|
||||
raise DockerBrokerError(f"launch request for {req.bottle_id} has no image_ref")
|
||||
proc = self._docker(run_argv(req))
|
||||
if proc.returncode != 0:
|
||||
raise DockerBrokerError(
|
||||
f"docker run failed for {req.bottle_id}: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
proc = self._docker(rm_argv(req))
|
||||
# Idempotent: an already-absent container is a successful teardown.
|
||||
if proc.returncode != 0 and "No such container" not in proc.stderr:
|
||||
raise DockerBrokerError(
|
||||
f"docker rm failed for {req.bottle_id}: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DockerBroker",
|
||||
"DockerBrokerError",
|
||||
"container_name",
|
||||
"run_argv",
|
||||
"rm_argv",
|
||||
"CONTAINER_PREFIX",
|
||||
"BOTTLE_ID_LABEL",
|
||||
]
|
||||
@@ -1,103 +0,0 @@
|
||||
"""The per-host orchestrator core (PRD 0070).
|
||||
|
||||
`Orchestrator` is the single backend-neutral object the control plane talks
|
||||
to: it owns the registry (runtime state) and brokers agent launches. It
|
||||
never branches on backend — the `LaunchBroker` abstracts the backend-native
|
||||
launch, so this same object drives docker / firecracker / apple once a real
|
||||
broker is wired in.
|
||||
|
||||
Launch lifecycle:
|
||||
|
||||
* `launch_bottle` mints the bottle (registry: source IP + identity
|
||||
token), sends a *signed, structured* launch request through the broker,
|
||||
and returns the record. If the broker rejects/fails, the registry entry
|
||||
is rolled back so a failed launch leaves no orphan.
|
||||
* `teardown_bottle` sends a signed teardown request, then deregisters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .broker import LaunchBroker, LaunchRequest, sign_request
|
||||
from .registry import BottleRecord, RegistryStore
|
||||
from .sidecar import Sidecar
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""Owns the registry + brokers launches, and manages the single
|
||||
consolidated per-host sidecar. Backend-neutral (broker and sidecar
|
||||
abstract the backend-native pieces)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: RegistryStore,
|
||||
broker: LaunchBroker,
|
||||
sign_secret: bytes,
|
||||
sidecar: Sidecar | None = None,
|
||||
) -> None:
|
||||
self.registry = registry
|
||||
self._broker = broker
|
||||
self._secret = sign_secret
|
||||
self._sidecar = sidecar
|
||||
|
||||
def launch_bottle(
|
||||
self,
|
||||
source_ip: str,
|
||||
*,
|
||||
image_ref: str = "",
|
||||
slot: int | None = None,
|
||||
metadata: str = "",
|
||||
) -> BottleRecord:
|
||||
"""Register a bottle and broker its launch. Rolls the registry entry
|
||||
back if the launch doesn't take, so a failure leaves no orphan."""
|
||||
rec = self.registry.register(source_ip, metadata=metadata)
|
||||
req = LaunchRequest(
|
||||
op="launch",
|
||||
bottle_id=rec.bottle_id,
|
||||
source_ip=source_ip,
|
||||
image_ref=image_ref,
|
||||
slot=slot,
|
||||
)
|
||||
launched = False
|
||||
try:
|
||||
self._broker.submit(sign_request(req, self._secret))
|
||||
launched = True
|
||||
finally:
|
||||
if not launched:
|
||||
self.registry.deregister(rec.bottle_id)
|
||||
return rec
|
||||
|
||||
def teardown_bottle(self, bottle_id: str) -> bool:
|
||||
"""Broker teardown then deregister. False if the bottle is unknown."""
|
||||
rec = self.registry.get(bottle_id)
|
||||
if rec is None:
|
||||
return False
|
||||
req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip)
|
||||
self._broker.submit(sign_request(req, self._secret))
|
||||
self.registry.deregister(bottle_id)
|
||||
return True
|
||||
|
||||
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
||||
"""Fail-closed attribution (delegates to the registry)."""
|
||||
return self.registry.attribute(source_ip, identity_token)
|
||||
|
||||
# --- consolidated sidecar ----------------------------------------------
|
||||
|
||||
def ensure_sidecar(self) -> None:
|
||||
"""Ensure the single per-host sidecar is built and up (idempotent).
|
||||
No-op when no sidecar is configured."""
|
||||
if self._sidecar is not None:
|
||||
self._sidecar.ensure_built()
|
||||
self._sidecar.ensure_running()
|
||||
|
||||
def sidecar_status(self) -> dict[str, object]:
|
||||
"""Report the shared sidecar for the control plane / console."""
|
||||
if self._sidecar is None:
|
||||
return {"configured": False}
|
||||
return {
|
||||
"configured": True,
|
||||
"name": self._sidecar.name,
|
||||
"running": self._sidecar.is_running(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["Orchestrator"]
|
||||
@@ -1,139 +0,0 @@
|
||||
"""The consolidated per-host sidecar (PRD 0070).
|
||||
|
||||
The core consolidation win: **one** persistent sidecar per host, shared by
|
||||
every bottle, instead of a sidecar bundle per bottle. It's safe to share
|
||||
because the attribution invariant (source IP + identity token, see
|
||||
`registry`) lets the sidecar attribute each request to the right bottle —
|
||||
so per-bottle policy lives in one long-lived process keyed on who's calling.
|
||||
|
||||
`Sidecar` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
|
||||
ensure the single instance is up, report it, tear it down. `DockerSidecar`
|
||||
is the docker implementation; a firecracker sidecar VM slots in later.
|
||||
|
||||
The defining behaviour is **idempotent singleton**: `ensure_running` starts
|
||||
the instance if absent and is a no-op if it's already up, so N bottle
|
||||
launches never spawn N sidecars.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ..docker_cmd import run_docker
|
||||
|
||||
SIDECAR_NAME = "bot-bottle-orch-sidecar"
|
||||
SIDECAR_LABEL = "bot-bottle-orch-sidecar=1"
|
||||
|
||||
# The real sidecar-bundle image + its Dockerfile. Kept as a local constant
|
||||
# rather than imported from backend.docker.sidecar_bundle, which would drag
|
||||
# the whole backend layer into the lean orchestrator (see #359); unify when
|
||||
# that lands. Env override matches the backend's BOT_BOTTLE_SIDECAR_IMAGE.
|
||||
SIDECAR_BUNDLE_IMAGE = os.environ.get("BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest")
|
||||
SIDECAR_DOCKERFILE = "Dockerfile.sidecars"
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class SidecarError(Exception):
|
||||
"""The shared sidecar failed to build/start/stop (non-zero `docker` exit)."""
|
||||
|
||||
|
||||
class Sidecar(abc.ABC):
|
||||
"""Lifecycle of the single per-host sidecar. Backend-neutral."""
|
||||
|
||||
name: str
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
"""Ensure the sidecar's image / rootfs exists, building it if needed.
|
||||
Default: nothing to build (e.g. a stub or a pre-pulled image)."""
|
||||
return
|
||||
|
||||
@abc.abstractmethod
|
||||
def ensure_running(self) -> None:
|
||||
"""Start the sidecar if it isn't already up. Idempotent: a no-op
|
||||
when it's already running (that's the whole point — one per host).
|
||||
Assumes the image exists — call `ensure_built()` first."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_running(self) -> bool:
|
||||
"""True iff the sidecar instance is currently up."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def stop(self) -> None:
|
||||
"""Remove the sidecar. Idempotent — absent is success."""
|
||||
|
||||
|
||||
class DockerSidecar(Sidecar):
|
||||
"""The consolidated sidecar as a single, fixed-name Docker container.
|
||||
|
||||
`image_ref` defaults to the real sidecar-bundle image; `ensure_built`
|
||||
builds it from `Dockerfile.sidecars` when it's missing. (Note: slice 5
|
||||
builds + launches the bundle container; wiring its per-bottle,
|
||||
source-IP-keyed config is a later slice — see PRD 0070.)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_ref: str = SIDECAR_BUNDLE_IMAGE,
|
||||
*,
|
||||
name: str = SIDECAR_NAME,
|
||||
build_context: Path | None = None,
|
||||
dockerfile: str | None = SIDECAR_DOCKERFILE,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
self._build_context = build_context or _REPO_ROOT
|
||||
self._dockerfile = dockerfile
|
||||
|
||||
def image_exists(self) -> bool:
|
||||
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
"""Build the bundle image from its Dockerfile when it's missing.
|
||||
No-op when the image is already present, or when no dockerfile is
|
||||
configured (e.g. a pre-pulled image)."""
|
||||
if self._dockerfile is None or self.image_exists():
|
||||
return
|
||||
proc = run_docker([
|
||||
"docker", "build",
|
||||
"-t", self.image_ref,
|
||||
"-f", str(self._build_context / self._dockerfile),
|
||||
str(self._build_context),
|
||||
])
|
||||
if proc.returncode != 0:
|
||||
raise SidecarError(f"sidecar image build failed: {proc.stderr.strip()}")
|
||||
|
||||
def is_running(self) -> bool:
|
||||
proc = run_docker([
|
||||
"docker", "ps",
|
||||
"--filter", f"name=^{self.name}$",
|
||||
"--filter", "status=running",
|
||||
"--format", "{{.Names}}",
|
||||
])
|
||||
return self.name in proc.stdout.split()
|
||||
|
||||
def ensure_running(self) -> None:
|
||||
if self.is_running():
|
||||
return
|
||||
# Clear any stale (stopped) container holding the fixed name, then
|
||||
# start fresh. `rm --force` on an absent name is a tolerated no-op.
|
||||
run_docker(["docker", "rm", "--force", self.name])
|
||||
proc = run_docker([
|
||||
"docker", "run", "--detach",
|
||||
"--name", self.name,
|
||||
"--label", SIDECAR_LABEL,
|
||||
self.image_ref,
|
||||
])
|
||||
if proc.returncode != 0:
|
||||
raise SidecarError(f"sidecar failed to start: {proc.stderr.strip()}")
|
||||
|
||||
def stop(self) -> None:
|
||||
proc = run_docker(["docker", "rm", "--force", self.name])
|
||||
if proc.returncode != 0 and "No such container" not in proc.stderr:
|
||||
raise SidecarError(f"sidecar failed to stop: {proc.stderr.strip()}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Sidecar", "DockerSidecar", "SidecarError",
|
||||
"SIDECAR_NAME", "SIDECAR_LABEL", "SIDECAR_BUNDLE_IMAGE",
|
||||
]
|
||||
@@ -325,18 +325,50 @@ orchestrator becomes a VM. Decouple the two risks instead:
|
||||
secret handling) is proven with fast iteration — *then* wrap that exact
|
||||
service in the VM and solve wiring separately.
|
||||
|
||||
Backend order (cheapest proof → hardest → last):
|
||||
### Slices
|
||||
|
||||
1. **Docker orchestrator** — nearly free (the sidecar bundle is already
|
||||
containers; collapse N bundles into one persistent container). Proves
|
||||
consolidation + the `BottleBackend` seam with the least moving parts.
|
||||
2. **Firecracker orchestrator** — the real work: the shim + VM-to-VM
|
||||
Built bottom-up as a stack of small PRs off this one (status: **1–5
|
||||
implemented** in #352 → #356 → #357 → #358 → #360):
|
||||
|
||||
1. **Dev-harness core** ✅ — SQLite registry (runtime state) + fail-closed
|
||||
attribution (source IP + identity token) + the HTTP control plane.
|
||||
2. **Launch lifecycle + broker contract** ✅ — `launch_bottle` /
|
||||
`teardown_bottle` + the signed, structured `LaunchBroker` request
|
||||
(HS256 JWT, static ids/flags only, provenance-verified, fail-closed),
|
||||
with a stub broker for the harness and registry rollback on failure.
|
||||
3. **Docker launch broker** ✅ — the first real broker: `docker run` / `rm`
|
||||
per bottle, built only from the request's static fields; proves the
|
||||
`BottleBackend` seam on the cheapest backend.
|
||||
|
||||
**The consolidated-sidecar arc** (the core consolidation win — one
|
||||
persistent sidecar shared by all bottles instead of one per bottle):
|
||||
|
||||
4. **Sidecar — lifecycle** ✅ — the idempotent-singleton `Sidecar` /
|
||||
`DockerSidecar` (ensure-running is a no-op when already up, stop is
|
||||
idempotent); one container per host.
|
||||
5. **Sidecar — build the real bundle image** ✅ — the orchestrator builds
|
||||
the `bot-bottle-sidecars` bundle from `Dockerfile.sidecars` when missing
|
||||
and launches *that* (not a placeholder) as the singleton.
|
||||
6. **Sidecar — source-IP-keyed multi-tenant config** — the functional
|
||||
core: the per-bottle CA / egress routes / git-gate keys / policy, today
|
||||
baked per bottle, become **multi-tenant and selected by the verified
|
||||
source IP**, with per-bottle add/remove (live reload) on launch/teardown
|
||||
through the control plane. Until this lands, one shared instance can't
|
||||
serve multiple bottles' distinct policies.
|
||||
7. **Sidecar — route agent bottles through it** — wire each agent bottle's
|
||||
egress/git to the shared sidecar (DNAT / network) instead of a per-bottle
|
||||
bundle.
|
||||
|
||||
**Remaining backends** (docker done above; the rest against the proven
|
||||
dev-harness):
|
||||
|
||||
8. **Firecracker broker + sidecar** — the real work: the shim + VM-to-VM
|
||||
routing (host forwards `bbfcN` → orchestrator TAP; the nft table grows
|
||||
forward rules where today it drops all non-DNAT egress). Built against
|
||||
the dev-harness so the app logic is already proven.
|
||||
3. **macOS (Apple container)** — last (container-to-container networking).
|
||||
forward rules where today it drops all non-DNAT egress).
|
||||
9. **macOS (Apple container)** — last (container-to-container networking).
|
||||
|
||||
Keep the sidecar **service one shared thing** throughout.
|
||||
Backends land cheapest-first (docker → firecracker → macOS); keep the
|
||||
sidecar **service one shared thing** throughout.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Integration: the Docker launch broker starts and removes a real container.
|
||||
|
||||
Gated on a reachable Docker daemon (skips cleanly otherwise). Uses a tiny
|
||||
image; the container may exit immediately — we only assert it exists after
|
||||
launch and is gone after teardown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from bot_bottle.orchestrator.broker import LaunchRequest, sign_request
|
||||
from bot_bottle.orchestrator.docker_broker import DockerBroker, container_name
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
IMAGE = "busybox"
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestDockerBrokerIntegration(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = secrets.token_bytes(16)
|
||||
self.broker = DockerBroker(self.secret)
|
||||
self.bottle_id = "itest" + secrets.token_hex(4)
|
||||
self.name = container_name(self.bottle_id)
|
||||
self.addCleanup(
|
||||
lambda: subprocess.run(
|
||||
["docker", "rm", "--force", self.name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
)
|
||||
|
||||
def _exists(self) -> bool:
|
||||
proc = subprocess.run(
|
||||
["docker", "ps", "-a", "--filter", f"name=^{self.name}$",
|
||||
"--format", "{{.Names}}"],
|
||||
stdout=subprocess.PIPE, text=True, check=False,
|
||||
)
|
||||
return self.name in proc.stdout.split()
|
||||
|
||||
def _submit(self, req: LaunchRequest) -> None:
|
||||
self.broker.submit(sign_request(req, self.secret))
|
||||
|
||||
def test_launch_creates_then_teardown_removes(self) -> None:
|
||||
self._submit(
|
||||
LaunchRequest(op="launch", bottle_id=self.bottle_id, image_ref=IMAGE)
|
||||
)
|
||||
self.assertTrue(self._exists(), "container should exist after launch")
|
||||
self._submit(LaunchRequest(op="teardown", bottle_id=self.bottle_id))
|
||||
self.assertFalse(self._exists(), "container should be gone after teardown")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Integration: the consolidated Docker sidecar is an idempotent singleton.
|
||||
|
||||
Gated on a reachable Docker daemon. Uses a unique name so it never
|
||||
collides with a real per-host sidecar or a leftover from another run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from bot_bottle.orchestrator.sidecar import DockerSidecar
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
IMAGE = "busybox"
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestDockerSidecarIntegration(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.name = "bot-bottle-orch-sidecar-itest-" + secrets.token_hex(4)
|
||||
self.sc = DockerSidecar(IMAGE, name=self.name)
|
||||
self.addCleanup(self.sc.stop)
|
||||
|
||||
def _count(self) -> int:
|
||||
proc = subprocess.run(
|
||||
["docker", "ps", "-a", "--filter", f"name=^{self.name}$",
|
||||
"--format", "{{.Names}}"],
|
||||
stdout=subprocess.PIPE, text=True, check=False,
|
||||
)
|
||||
return sum(1 for n in proc.stdout.split() if n == self.name)
|
||||
|
||||
def test_ensure_is_idempotent_singleton(self) -> None:
|
||||
self.sc.ensure_running()
|
||||
self.assertEqual(1, self._count())
|
||||
# A second ensure must not spawn a second container — one per host.
|
||||
self.sc.ensure_running()
|
||||
self.assertEqual(1, self._count())
|
||||
self.sc.stop()
|
||||
self.assertEqual(0, self._count())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Integration: DockerSidecar.image_exists reflects real docker state.
|
||||
|
||||
Gated on a reachable Docker daemon. Deliberately does NOT build the full
|
||||
sidecar bundle (a heavy, slow image build) — it exercises the `image_exists`
|
||||
primitive that `ensure_built` gates on, against a tiny pulled image and a
|
||||
name that can't exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from bot_bottle.orchestrator.sidecar import DockerSidecar
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
IMAGE = "busybox"
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
class TestDockerSidecarImageExists(unittest.TestCase):
|
||||
def test_image_exists_true_for_present_false_for_absent(self) -> None:
|
||||
# Ensure the tiny image is present (build_if_missing is disabled here
|
||||
# so this never triggers a Dockerfile.sidecars build).
|
||||
subprocess.run(
|
||||
["docker", "pull", IMAGE],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
self.assertTrue(DockerSidecar(IMAGE, dockerfile=None).image_exists())
|
||||
self.assertFalse(
|
||||
DockerSidecar("bot-bottle-nonexistent:doesnotexist", dockerfile=None).image_exists()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Unit tests for the orchestrator launch broker (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import unittest
|
||||
|
||||
from bot_bottle.orchestrator.broker import (
|
||||
BrokerAuthError,
|
||||
LaunchRequest,
|
||||
StubBroker,
|
||||
sign_request,
|
||||
verify_request,
|
||||
)
|
||||
|
||||
|
||||
class TestSignVerify(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = secrets.token_bytes(16)
|
||||
|
||||
def test_launch_round_trip(self) -> None:
|
||||
req = LaunchRequest(
|
||||
op="launch", bottle_id="b1", source_ip="10.243.0.1",
|
||||
image_ref="sha256:abc", slot=3,
|
||||
)
|
||||
self.assertEqual(req, verify_request(sign_request(req, self.secret), self.secret))
|
||||
|
||||
def test_teardown_round_trip(self) -> None:
|
||||
req = LaunchRequest(op="teardown", bottle_id="b1")
|
||||
got = verify_request(sign_request(req, self.secret), self.secret)
|
||||
self.assertEqual(("teardown", "b1"), (got.op, got.bottle_id))
|
||||
|
||||
def test_wrong_secret_rejected(self) -> None:
|
||||
token = sign_request(LaunchRequest(op="launch", bottle_id="b1"), self.secret)
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
verify_request(token, secrets.token_bytes(16))
|
||||
|
||||
def test_tampered_payload_rejected(self) -> None:
|
||||
token = sign_request(LaunchRequest(op="launch", bottle_id="b1"), self.secret)
|
||||
header, payload, sig = token.split(".")
|
||||
flipped = payload[:-1] + ("A" if payload[-1] != "A" else "B")
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
verify_request(f"{header}.{flipped}.{sig}", self.secret)
|
||||
|
||||
def test_malformed_tokens_rejected(self) -> None:
|
||||
for bad in ("", "a.b", "a.b.c.d", "not-a-token"):
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
verify_request(bad, self.secret)
|
||||
|
||||
def test_unknown_op_rejected_despite_valid_signature(self) -> None:
|
||||
# A correctly-signed token whose op isn't in the allow-list must
|
||||
# still be refused — the schema guard is independent of provenance.
|
||||
token = sign_request(LaunchRequest(op="rm-rf", bottle_id="b1"), self.secret)
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
verify_request(token, self.secret)
|
||||
|
||||
|
||||
class TestStubBroker(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = secrets.token_bytes(16)
|
||||
self.broker = StubBroker(self.secret)
|
||||
|
||||
def test_submit_launch_records_verified_request(self) -> None:
|
||||
req = LaunchRequest(op="launch", bottle_id="b1", source_ip="10.243.0.1")
|
||||
got = self.broker.submit(sign_request(req, self.secret))
|
||||
self.assertEqual(req, got)
|
||||
self.assertEqual(["b1"], [r.bottle_id for r in self.broker.launched])
|
||||
self.assertEqual([], self.broker.torn_down)
|
||||
|
||||
def test_submit_teardown_records(self) -> None:
|
||||
self.broker.submit(
|
||||
sign_request(LaunchRequest(op="teardown", bottle_id="b1"), self.secret)
|
||||
)
|
||||
self.assertEqual(["b1"], [r.bottle_id for r in self.broker.torn_down])
|
||||
|
||||
def test_submit_forged_token_is_fail_closed(self) -> None:
|
||||
forged = sign_request(
|
||||
LaunchRequest(op="launch", bottle_id="b1"), secrets.token_bytes(16)
|
||||
)
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
self.broker.submit(forged)
|
||||
self.assertEqual([], self.broker.launched) # nothing acted on
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,62 +7,53 @@ server tests), plus one real-socket round-trip to prove the handler wiring.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.orchestrator.broker import StubBroker
|
||||
from bot_bottle.orchestrator.control_plane import dispatch, make_server
|
||||
from bot_bottle.orchestrator.registry import RegistryStore
|
||||
from bot_bottle.orchestrator.service import Orchestrator
|
||||
|
||||
|
||||
def _body(obj: object) -> bytes:
|
||||
return json.dumps(obj).encode()
|
||||
|
||||
|
||||
def _orchestrator(db_path: Path) -> Orchestrator:
|
||||
store = RegistryStore(db_path)
|
||||
store.migrate()
|
||||
secret = secrets.token_bytes(16)
|
||||
return Orchestrator(store, StubBroker(secret), secret)
|
||||
|
||||
|
||||
class TestDispatch(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.orch = _orchestrator(Path(self._tmp.name) / "r.db")
|
||||
self.store = RegistryStore(Path(self._tmp.name) / "r.db")
|
||||
self.store.migrate()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_health(self) -> None:
|
||||
status, payload = dispatch(self.orch, "GET", "/health", b"")
|
||||
status, payload = dispatch(self.store, "GET", "/health", b"")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("ok", payload["status"])
|
||||
|
||||
def test_register_returns_id_and_token(self) -> None:
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
|
||||
self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
|
||||
)
|
||||
self.assertEqual(201, status)
|
||||
self.assertTrue(payload["bottle_id"])
|
||||
self.assertTrue(payload["identity_token"])
|
||||
|
||||
def test_register_requires_source_ip(self) -> None:
|
||||
status, _ = dispatch(self.orch, "POST", "/bottles", _body({}))
|
||||
status, _ = dispatch(self.store, "POST", "/bottles", _body({}))
|
||||
self.assertEqual(400, status)
|
||||
|
||||
def test_register_rejects_bad_json(self) -> None:
|
||||
status, _ = dispatch(self.orch, "POST", "/bottles", b"{not json")
|
||||
status, _ = dispatch(self.store, "POST", "/bottles", b"{not json")
|
||||
self.assertEqual(400, status)
|
||||
|
||||
def test_list_redacts_identity_token(self) -> None:
|
||||
dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
|
||||
status, payload = dispatch(self.orch, "GET", "/bottles", b"")
|
||||
dispatch(self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
|
||||
status, payload = dispatch(self.store, "GET", "/bottles", b"")
|
||||
self.assertEqual(200, status)
|
||||
bottles = payload["bottles"]
|
||||
assert isinstance(bottles, list)
|
||||
@@ -74,62 +65,58 @@ class TestDispatch(unittest.TestCase):
|
||||
|
||||
def test_attribute_ok_and_forbidden(self) -> None:
|
||||
_, reg = dispatch(
|
||||
self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.5"})
|
||||
self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.5"})
|
||||
)
|
||||
token = reg["identity_token"]
|
||||
ok_status, ok = dispatch(
|
||||
self.orch, "POST", "/attribute",
|
||||
self.store, "POST", "/attribute",
|
||||
_body({"source_ip": "10.243.0.5", "identity_token": token}),
|
||||
)
|
||||
self.assertEqual(200, ok_status)
|
||||
self.assertEqual(reg["bottle_id"], ok["bottle_id"])
|
||||
|
||||
bad_status, _ = dispatch(
|
||||
self.orch, "POST", "/attribute",
|
||||
self.store, "POST", "/attribute",
|
||||
_body({"source_ip": "10.243.0.5", "identity_token": "nope"}),
|
||||
)
|
||||
self.assertEqual(403, bad_status)
|
||||
|
||||
def test_attribute_requires_both_fields(self) -> None:
|
||||
status, _ = dispatch(
|
||||
self.orch, "POST", "/attribute", _body({"source_ip": "10.243.0.5"})
|
||||
self.store, "POST", "/attribute", _body({"source_ip": "10.243.0.5"})
|
||||
)
|
||||
self.assertEqual(400, status)
|
||||
|
||||
def test_delete(self) -> None:
|
||||
_, reg = dispatch(
|
||||
self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
|
||||
self.store, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})
|
||||
)
|
||||
bid = reg["bottle_id"]
|
||||
status, payload = dispatch(self.orch, "DELETE", f"/bottles/{bid}", b"")
|
||||
status, payload = dispatch(self.store, "DELETE", f"/bottles/{bid}", b"")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(True, payload["torn_down"])
|
||||
self.assertEqual([], self.orch.registry.all())
|
||||
self.assertEqual(True, payload["deregistered"])
|
||||
self.assertEqual([], self.store.all())
|
||||
|
||||
def test_delete_missing_404(self) -> None:
|
||||
status, _ = dispatch(self.orch, "DELETE", "/bottles/ghost", b"")
|
||||
status, _ = dispatch(self.store, "DELETE", "/bottles/ghost", b"")
|
||||
self.assertEqual(404, status)
|
||||
|
||||
def test_unknown_route_404(self) -> None:
|
||||
status, _ = dispatch(self.orch, "GET", "/nope", b"")
|
||||
status, _ = dispatch(self.store, "GET", "/nope", b"")
|
||||
self.assertEqual(404, status)
|
||||
|
||||
def test_trailing_slash_normalized(self) -> None:
|
||||
status, _ = dispatch(self.orch, "GET", "/health/", b"")
|
||||
status, _ = dispatch(self.store, "GET", "/health/", b"")
|
||||
self.assertEqual(200, status)
|
||||
|
||||
def test_sidecar_status_unconfigured(self) -> None:
|
||||
status, payload = dispatch(self.orch, "GET", "/sidecar", b"")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(False, payload["configured"])
|
||||
|
||||
|
||||
class TestServerRoundTrip(unittest.TestCase):
|
||||
def test_http_register_health_attribute(self) -> None:
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
orch = _orchestrator(Path(tmp.name) / "r.db")
|
||||
server = make_server(orch, "127.0.0.1", 0)
|
||||
store = RegistryStore(Path(tmp.name) / "r.db")
|
||||
store.migrate()
|
||||
server = make_server(store, "127.0.0.1", 0)
|
||||
self.addCleanup(server.server_close)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Unit tests for the Docker launch broker (PRD 0070). Docker is mocked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from bot_bottle.orchestrator.broker import (
|
||||
BrokerAuthError,
|
||||
LaunchRequest,
|
||||
sign_request,
|
||||
)
|
||||
from bot_bottle.orchestrator.docker_broker import (
|
||||
BOTTLE_ID_LABEL,
|
||||
DockerBroker,
|
||||
DockerBrokerError,
|
||||
container_name,
|
||||
rm_argv,
|
||||
run_argv,
|
||||
)
|
||||
|
||||
|
||||
class TestArgv(unittest.TestCase):
|
||||
def test_run_argv_uses_only_static_fields(self) -> None:
|
||||
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
|
||||
argv = run_argv(req)
|
||||
self.assertEqual(["docker", "run"], argv[:2])
|
||||
self.assertIn("--name", argv)
|
||||
self.assertIn(container_name("b1"), argv)
|
||||
self.assertIn(f"{BOTTLE_ID_LABEL}=b1", argv)
|
||||
self.assertEqual("busybox", argv[-1]) # image is the terminal arg
|
||||
|
||||
def test_rm_argv(self) -> None:
|
||||
req = LaunchRequest(op="teardown", bottle_id="b1")
|
||||
self.assertEqual(["docker", "rm", "--force", container_name("b1")], rm_argv(req))
|
||||
|
||||
def test_container_name_is_prefixed(self) -> None:
|
||||
name = container_name("b1")
|
||||
self.assertTrue(name.startswith("bot-bottle-orch-"))
|
||||
self.assertTrue(name.endswith("b1"))
|
||||
|
||||
|
||||
class TestDockerBroker(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = secrets.token_bytes(16)
|
||||
self.broker = DockerBroker(self.secret)
|
||||
|
||||
def _submit(self, req: LaunchRequest) -> None:
|
||||
self.broker.submit(sign_request(req, self.secret))
|
||||
|
||||
def test_launch_invokes_docker_run(self) -> None:
|
||||
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
|
||||
with patch.object(self.broker, "_docker", return_value=Mock(returncode=0, stderr="")) as m:
|
||||
self._submit(req)
|
||||
m.assert_called_once()
|
||||
self.assertEqual(run_argv(req), m.call_args.args[0])
|
||||
|
||||
def test_launch_without_image_raises_and_skips_docker(self) -> None:
|
||||
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="")
|
||||
with patch.object(self.broker, "_docker") as m:
|
||||
with self.assertRaises(DockerBrokerError):
|
||||
self._submit(req)
|
||||
m.assert_not_called()
|
||||
|
||||
def test_launch_docker_failure_raises(self) -> None:
|
||||
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
|
||||
with patch.object(self.broker, "_docker", return_value=Mock(returncode=1, stderr="boom")):
|
||||
with self.assertRaises(DockerBrokerError):
|
||||
self._submit(req)
|
||||
|
||||
def test_teardown_invokes_docker_rm(self) -> None:
|
||||
req = LaunchRequest(op="teardown", bottle_id="b1")
|
||||
with patch.object(self.broker, "_docker", return_value=Mock(returncode=0, stderr="")) as m:
|
||||
self._submit(req)
|
||||
self.assertEqual(rm_argv(req), m.call_args.args[0])
|
||||
|
||||
def test_teardown_is_idempotent_on_missing_container(self) -> None:
|
||||
req = LaunchRequest(op="teardown", bottle_id="b1")
|
||||
absent = Mock(returncode=1, stderr="Error: No such container: bot-bottle-orch-b1")
|
||||
with patch.object(self.broker, "_docker", return_value=absent):
|
||||
self._submit(req) # must not raise
|
||||
|
||||
def test_teardown_other_failure_raises(self) -> None:
|
||||
req = LaunchRequest(op="teardown", bottle_id="b1")
|
||||
with patch.object(self.broker, "_docker", return_value=Mock(returncode=1, stderr="daemon down")):
|
||||
with self.assertRaises(DockerBrokerError):
|
||||
self._submit(req)
|
||||
|
||||
def test_forged_token_never_touches_docker(self) -> None:
|
||||
req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox")
|
||||
forged = sign_request(req, secrets.token_bytes(16))
|
||||
with patch.object(self.broker, "_docker") as m:
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
self.broker.submit(forged)
|
||||
m.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Unit tests for the Orchestrator launch lifecycle (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBroker
|
||||
from bot_bottle.orchestrator.registry import RegistryStore
|
||||
from bot_bottle.orchestrator.service import Orchestrator
|
||||
from bot_bottle.orchestrator.sidecar import Sidecar
|
||||
|
||||
|
||||
class _FailingBroker(LaunchBroker):
|
||||
"""Verifies the token like any broker, then fails the launch — to
|
||||
exercise the orchestrator's registry rollback."""
|
||||
|
||||
def _launch(self, req: LaunchRequest) -> None:
|
||||
raise RuntimeError("launch failed")
|
||||
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeSidecar(Sidecar):
|
||||
"""In-memory sidecar for wiring tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.name = "fake-sidecar"
|
||||
self.ensured = 0
|
||||
self.built = 0
|
||||
self._running = False
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
self.built += 1
|
||||
|
||||
def ensure_running(self) -> None:
|
||||
self.ensured += 1
|
||||
self._running = True
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
|
||||
class TestOrchestrator(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.secret = secrets.token_bytes(16)
|
||||
self.store = RegistryStore(Path(self._tmp.name) / "r.db")
|
||||
self.store.migrate()
|
||||
self.broker = StubBroker(self.secret)
|
||||
self.orch = Orchestrator(self.store, self.broker, self.secret)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_launch_registers_and_brokers_signed_request(self) -> None:
|
||||
rec = self.orch.launch_bottle("10.243.0.1", image_ref="sha256:abc", slot=2)
|
||||
self.assertIsNotNone(self.store.get(rec.bottle_id))
|
||||
self.assertEqual(1, len(self.broker.launched))
|
||||
req = self.broker.launched[0]
|
||||
self.assertEqual("launch", req.op)
|
||||
self.assertEqual(rec.bottle_id, req.bottle_id)
|
||||
self.assertEqual("10.243.0.1", req.source_ip)
|
||||
self.assertEqual("sha256:abc", req.image_ref)
|
||||
self.assertEqual(2, req.slot)
|
||||
|
||||
def test_launch_then_attribute(self) -> None:
|
||||
rec = self.orch.launch_bottle("10.243.0.3")
|
||||
got = self.orch.attribute("10.243.0.3", rec.identity_token)
|
||||
assert got is not None
|
||||
self.assertEqual(rec.bottle_id, got.bottle_id)
|
||||
self.assertIsNone(self.orch.attribute("10.243.0.3", "wrong-token"))
|
||||
|
||||
def test_teardown_brokers_and_deregisters(self) -> None:
|
||||
rec = self.orch.launch_bottle("10.243.0.1")
|
||||
self.assertTrue(self.orch.teardown_bottle(rec.bottle_id))
|
||||
self.assertIsNone(self.store.get(rec.bottle_id))
|
||||
self.assertEqual(["teardown"], [r.op for r in self.broker.torn_down])
|
||||
|
||||
def test_teardown_unknown_is_false(self) -> None:
|
||||
self.assertFalse(self.orch.teardown_bottle("ghost"))
|
||||
|
||||
def test_launch_rolls_back_registry_on_broker_failure(self) -> None:
|
||||
orch = Orchestrator(self.store, _FailingBroker(self.secret), self.secret)
|
||||
with self.assertRaises(RuntimeError):
|
||||
orch.launch_bottle("10.243.0.9")
|
||||
self.assertEqual([], self.store.all()) # no orphan
|
||||
|
||||
def test_sidecar_unconfigured_by_default(self) -> None:
|
||||
self.assertEqual({"configured": False}, self.orch.sidecar_status())
|
||||
self.orch.ensure_sidecar() # no-op, must not raise
|
||||
|
||||
def test_sidecar_wired_and_ensured(self) -> None:
|
||||
sc = _FakeSidecar()
|
||||
orch = Orchestrator(self.store, self.broker, self.secret, sidecar=sc)
|
||||
self.assertEqual(
|
||||
{"configured": True, "name": "fake-sidecar", "running": False},
|
||||
orch.sidecar_status(),
|
||||
)
|
||||
orch.ensure_sidecar()
|
||||
self.assertEqual(1, sc.built) # ensure_sidecar builds first,
|
||||
self.assertEqual(1, sc.ensured) # then runs
|
||||
self.assertEqual(
|
||||
{"configured": True, "name": "fake-sidecar", "running": True},
|
||||
orch.sidecar_status(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Unit tests for the consolidated Docker sidecar (PRD 0070). Docker mocked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from bot_bottle.orchestrator.sidecar import (
|
||||
SIDECAR_NAME,
|
||||
DockerSidecar,
|
||||
SidecarError,
|
||||
)
|
||||
|
||||
_RUN_DOCKER = "bot_bottle.orchestrator.sidecar.run_docker"
|
||||
|
||||
|
||||
def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock:
|
||||
return Mock(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
class TestDockerSidecar(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.sc = DockerSidecar("bot-bottle-sidecars:latest")
|
||||
|
||||
def test_default_name(self) -> None:
|
||||
self.assertEqual(SIDECAR_NAME, self.sc.name)
|
||||
|
||||
def test_is_running_reads_docker_ps(self) -> None:
|
||||
with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name + "\n")):
|
||||
self.assertTrue(self.sc.is_running())
|
||||
with patch(_RUN_DOCKER, return_value=_proc(stdout="")):
|
||||
self.assertFalse(self.sc.is_running())
|
||||
|
||||
def test_ensure_running_is_noop_when_already_up(self) -> None:
|
||||
with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name)) as m:
|
||||
self.sc.ensure_running()
|
||||
# Only the is_running() ps probe — no rm / run.
|
||||
self.assertEqual(1, m.call_count)
|
||||
|
||||
def test_ensure_running_starts_the_singleton_when_absent(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str]) -> Mock:
|
||||
calls.append(argv)
|
||||
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
self.sc.ensure_running()
|
||||
|
||||
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||
self.assertEqual(1, len(runs))
|
||||
self.assertIn(self.sc.name, runs[0])
|
||||
self.assertIn("bot-bottle-sidecars:latest", runs[0])
|
||||
|
||||
def test_ensure_running_raises_on_docker_failure(self) -> None:
|
||||
def fake(argv: list[str]) -> Mock:
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout="")
|
||||
if argv[:2] == ["docker", "run"]:
|
||||
return _proc(returncode=1, stderr="boom")
|
||||
return _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
with self.assertRaises(SidecarError):
|
||||
self.sc.ensure_running()
|
||||
|
||||
def test_stop_is_idempotent_on_missing(self) -> None:
|
||||
absent = _proc(returncode=1, stderr="Error: No such container: x")
|
||||
with patch(_RUN_DOCKER, return_value=absent):
|
||||
self.sc.stop() # must not raise
|
||||
|
||||
def test_stop_raises_on_other_failure(self) -> None:
|
||||
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="daemon down")):
|
||||
with self.assertRaises(SidecarError):
|
||||
self.sc.stop()
|
||||
|
||||
|
||||
class TestDockerSidecarBuild(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.sc = DockerSidecar() # defaults to the real bundle image + dockerfile
|
||||
|
||||
def test_image_exists_reads_docker_inspect(self) -> None:
|
||||
with patch(_RUN_DOCKER, return_value=_proc(returncode=0)):
|
||||
self.assertTrue(self.sc.image_exists())
|
||||
with patch(_RUN_DOCKER, return_value=_proc(returncode=1)):
|
||||
self.assertFalse(self.sc.image_exists())
|
||||
|
||||
def test_ensure_built_is_noop_when_image_present(self) -> None:
|
||||
with patch(_RUN_DOCKER, return_value=_proc(returncode=0)) as m:
|
||||
self.sc.ensure_built()
|
||||
self.assertEqual(1, m.call_count) # only the image-inspect probe
|
||||
|
||||
def test_ensure_built_builds_from_dockerfile_when_missing(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str]) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:3] == ["docker", "image", "inspect"]:
|
||||
return _proc(returncode=1) # missing
|
||||
return _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
self.sc.ensure_built()
|
||||
|
||||
builds = [c for c in calls if c[:2] == ["docker", "build"]]
|
||||
self.assertEqual(1, len(builds))
|
||||
self.assertIn(self.sc.image_ref, builds[0])
|
||||
self.assertIn("-f", builds[0])
|
||||
self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0]))
|
||||
|
||||
def test_ensure_built_noop_when_no_dockerfile(self) -> None:
|
||||
sc = DockerSidecar("busybox", dockerfile=None)
|
||||
with patch(_RUN_DOCKER) as m:
|
||||
sc.ensure_built()
|
||||
m.assert_not_called()
|
||||
|
||||
def test_ensure_built_raises_on_build_failure(self) -> None:
|
||||
def fake(argv: list[str]) -> Mock:
|
||||
if argv[:3] == ["docker", "image", "inspect"]:
|
||||
return _proc(returncode=1)
|
||||
return _proc(returncode=1, stderr="build boom")
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
with self.assertRaises(SidecarError):
|
||||
self.sc.ensure_built()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user