a1acbc00ed04f3a44a5a433123e69510bc6afde9
18 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a1acbc00ed |
feat(orchestrator): slice 13d(i) — containerize the orchestrator (validated on docker)
Real-on-host testing surfaced two issues the unit-mocked slices couldn't: 1. The host (NixOS) firewall DROPS container->host traffic, so a host-process orchestrator is unreachable from the gateway container. Fix: run the orchestrator AS a container on the shared gateway network (PRD 0070's "virtualize the orchestrator") — the gateway reaches it by container name over docker DNS (container<->container, no firewall), and the host CLI reaches it via a published loopback port. 2. The control plane crashed the connection on a dispatch error (e.g. a broker failure) instead of returning 500. Changes: - lifecycle: OrchestratorProcess (host process) -> OrchestratorService (containers). Runs the control plane in the bundle image with the repo bind-mounted (orchestrator is stdlib-only), register-only stub broker so it needs NO docker socket (the backend launches agents; the host manages both containers). Registry DB persists via a host-root mount. ensure_running is an idempotent singleton over both containers. - gateway: BOT_BOTTLE_ORCHESTRATOR_URL is now the orchestrator's *by-name* URL on the shared network (dropped the host.docker.internal hack). - control_plane: _serve wraps dispatch — a failure returns 500, never crashes the connection. - OrchestratorProcess default broker -> stub (register-only) for docker. Validated live end-to-end: both containers up, gateway->orchestrator by name OK, register -> resolve-by-source-IP returns the bottle's policy from inside the gateway. pyright 0 errors; pylint 9.83/10; unit suite green (1760 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
389a2c10ce |
feat(orchestrator): slice 13c(vii) — stable shared gateway CA
The consolidated gateway TLS-intercepts every bottle, so all agents must trust ONE CA. Rather than host-generate + mount a CA, let the gateway's own mitmproxy generate its CA into a persistent named volume and extract the cert for agents — no host openssl, keeps gateway.py lean. - DockerGateway mounts GATEWAY_CA_VOLUME at /home/mitmproxy/.mitmproxy so the self-generated CA survives container recreation (it must not rotate, or already-launched agents would stop trusting the gateway). - ca_cert_pem(): read the CA cert (PEM) out of the running gateway (docker exec cat mitmproxy-ca-cert.pem) — the agent installs this into its trust store to accept the gateway's bumped certs. pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
c5ba33bf0a |
feat(orchestrator): slice 13c(iv) — gateway joins a shared network
The consolidated gateway ran on the default bridge; the model needs it on a dedicated user-defined network that every agent bottle also joins, reaching the gateway's egress / git-http / supervise ports by its address (no host port publishing) — and the source IP the gateway attributes by is the bottle's address on this network. - GATEWAY_NETWORK = "bot-bottle-gateway"; DockerGateway gains a `network` param. - ensure_running now ensures the network exists (idempotent create, tolerating a concurrent 'already exists') then runs with `--network`. - Docker picks the subnet; the launcher reads it back (13c source-IP allocator) to hand each bottle a pinned address. pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
72286f188d |
feat(orchestrator): slice 13c(iii) — host-side control-plane client
The launch path's counterpart to the gateway-side PolicyResolver: a trusted host-side HTTP client for the orchestrator control plane, so the CLI can register/teardown/re-policy bottles. Stdlib-only. - OrchestratorClient.register_bottle(source_ip, *, image_ref, metadata, policy) -> RegisteredBottle(bottle_id, identity_token) (POST /bottles) - teardown_bottle(id) -> bool (DELETE; 404 -> False, idempotent for cleanup) - set_policy(id, policy) -> bool (PUT; live reload) - list_bottles() / health() - Unlike the fail-closed data-plane resolver, this is the trusted caller: a non-2xx (other than the meaningful 404s) raises OrchestratorClientError so the launch path surfaces failures rather than swallowing them. pyright 0 errors; pylint 9.82/10; unit suite green (1742 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
ea0c070cfe |
feat(orchestrator): slice 13b — consolidated registration inputs (egress policy)
Bridges the per-bottle `prepare` output to the consolidated registry: `registration_inputs(plan)` turns a prepared egress plan into the backend-neutral inputs `Orchestrator.launch_bottle` takes. - egress_policy(plan): the policy blob = the exact routes YAML the per-bottle sidecar read from a file; the multi-tenant gateway's PolicyResolver now fetches it from the registry per request instead. Same egress_render_routes, so a bottle's allow-list is byte-identical moving onto the shared gateway. - RegistrationInputs bundles policy + metadata; metadata carries the human slug so the shared registry can map a minted bottle id back to a name (console / supervise display). Host-side glue (imports bot_bottle.egress) used by the launch path — not the lean orchestrator control-plane process. Not yet wired into launch (that's 13c/13d), consistent with the slice-6/10 pattern of landing the primitive before the cut-over. Key test: the policy round-trips through load_config back to the same routes + log level — the resolver-served policy reconstructs the per-bottle egress config exactly. pyright 0 errors; pylint 9.83/10; unit suite green (1718 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
24df322c31 |
feat(orchestrator): slice 13a — orchestrator process lifecycle (idempotent singleton)
First sub-slice of docker launch integration: the foundation the CLI needs to ensure exactly one orchestrator control plane + shared gateway is up before registering/launching bottles. Does NOT touch the real launch path yet — it's the lifecycle primitive the cut-over slices build on. - OrchestratorProcess.ensure_running(): idempotent singleton — returns the control-plane URL if a healthy one already answers /health, else spawns `python -m bot_bottle.orchestrator --broker docker --gateway` detached (start_new_session, output tee'd to <root>/orchestrator.log) and polls /health until healthy or timeout (OrchestratorStartError). - The control-plane port is the singleton key: a second orchestrator can't bind it, so a stray double-start fails fast rather than forking a rival. - Host-process (not container) per the PRD's dev-harness sequencing — it already has the host user's docker access to broker launches; the data-plane gateway it manages is the container. pyright 0 errors; pylint 9.83/10; unit suite green (1714 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
e0b0429cd1 |
refactor(orchestrator): rename Sidecar -> Gateway for the consolidated data plane
Retire "sidecar" for the consolidated per-host path (PRD 0070 naming decision): the orchestrator is the umbrella/control plane, and the egress/git/supervise data-plane unit it runs is the "gateway". - git mv sidecar.py -> gateway.py and the two integration + one unit test files; DockerSidecar->DockerGateway, Sidecar->Gateway, SidecarError->GatewayError, SIDECAR_*->GATEWAY_*, ensure_sidecar-> ensure_gateway, sidecar_status->gateway_status, container name bot-bottle-orch-sidecar->bot-bottle-orch-gateway. - Prose rename across broker/registry/egress/policy_resolver + PRD 0070. - Preserved: the image name bot-bottle-sidecars, the BOT_BOTTLE_SIDECAR_IMAGE env var, Dockerfile.sidecars, and PRD 0069's own stage-name cross-references (that doc still uses "sidecar"). No behavior change. Full unit suite green (1679 tests; the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
d3e08cf039 |
feat(orchestrator+egress): slice 8 — multi-tenant egress via the resolver (#352)
The egress addon now selects each request's Config by the calling bottle's
source IP, so one shared sidecar serves every bottle. Opt-in and fail-closed;
single-tenant behaviour is unchanged.
Orchestrator side (source-IP-primary attribution, per the PRD invariant):
* registry: `by_source_ip` (the single active bottle at a source IP —
network-layer attribution); `attribute` now composes it + the token.
* service: `resolve(source_ip, token="")` — with a token, strict
attribution; without, source IP alone.
* control_plane: `POST /resolve`'s identity_token is now OPTIONAL (absent
→ source-IP-only); split cleanly from the token-required `/attribute`.
* policy_resolver: `resolve` token now optional.
Egress side:
* egress_addon_core: `resolve_client_config(resolver, client_ip, token)` —
fetches + parses the client's Config, **fail-closed**: unattributed, a
resolver error, or an unparseable policy all yield deny-all (no routes).
Host-testable; `PolicyResolverLike` Protocol keeps it import-free.
* egress_addon: consolidated mode when `BOT_BOTTLE_ORCHESTRATOR_URL` is
set → `_active_config(flow)` resolves per client IP (reads + strips the
`x-bot-bottle-identity` header); `request()` uses it. Unset → the static
routes file, exactly as before. `PolicyResolver` added to the bundle.
Security note: source-IP-only resolution is safe where the IP is unspoofable
(Firecracker /31 + nft) AND the control plane is reachable only by the
trusted sidecar; the identity token, when the agent injects it, strengthens
it on weaker backends.
Scope note: the egress data plane is now multi-tenant. Remaining to be fully
live: the network topology routing every bottle's proxy to the one shared
sidecar, git-gate multitenancy, and agent-side identity-token injection.
Tests: registry by_source_ip; orchestrator resolve (with/without token);
control-plane /resolve token-optional; resolver token-optional;
resolve_client_config fail-closed matrix. All 182 egress tests still pass
(single-tenant unchanged). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
f754c575d7 |
feat(orchestrator): slice 6 — source-IP-keyed multi-tenant policy (#352)
The orchestrator side of the multi-tenant consolidated sidecar: hold each
bottle's sidecar policy and serve it by verified source IP, with live
reload. One shared sidecar can now get per-bottle config keyed on who's
calling.
* registry.py — a `policy` column (migration v3, opaque JSON the sidecar
interprets) on BottleRecord; `register(..., policy=)` stores it,
`set_policy(bottle_id, policy)` updates it live, and `attribute` returns
it (the source-IP-keyed resolution).
* service.py — `launch_bottle(..., policy=)` and `set_policy`.
* control_plane.py — `POST /bottles` accepts `policy`; `PUT
/bottles/<id>/policy` live-reloads it; `POST /resolve` returns
{bottle_id, policy} for a verified (source_ip, token) — the per-request
call the multi-tenant sidecar makes; `/attribute` stays identity-only.
Scope note: this is the control-plane / state half. The data-plane half —
the egress mitmproxy addon (and git-gate) selecting allowlist / DLP /
token-injection per client IP by calling `/resolve` — is the next slice
(route agent bottles through the shared sidecar). The orchestrator stays
policy-agnostic: it stores and serves the blob verbatim.
Tests: registry policy store/update/persist; Orchestrator launch-with-policy
+ live set_policy; control-plane resolve returns policy (403 on bad token),
PUT policy updates / 404 / 400. Verified live over HTTP (launch -> resolve
-> PUT reload -> resolve reflects). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
a092d00312 |
feat(orchestrator): slice 5 — build the consolidated sidecar bundle image (#352)
Answers "where do we build the consolidated sidecar": nowhere, until now.
* sidecar.py — `Sidecar.ensure_built()` (default no-op) + `DockerSidecar`
now defaults its image to the real bundle (`bot-bottle-sidecars`) and
`ensure_built()` builds it from `Dockerfile.sidecars` when
`docker image inspect` shows it's missing (no-op when present or when no
dockerfile is configured, e.g. a pre-pulled image). `image_exists()`
added.
* service.py — `ensure_sidecar()` now builds then runs.
* __main__.py — `--sidecar` runs the consolidated bundle (build-if-missing).
Scope note: this builds + launches the bundle *container*; making the
running instance functional across bottles needs the per-bottle,
source-IP-keyed multi-tenant config + registration/reload, and routing
agent bottles to it — the next slices (added to PRD 0070's roadmap).
Tests: unit (docker mocked) — image_exists, ensure_built builds when
missing / no-op when present / no-op without a dockerfile / raises on build
failure; ensure_sidecar builds-then-runs; integration (gated, no heavy
build) — image_exists reflects real docker state. Full suite green (only
pre-existing /bin/sleep errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
67652899eb |
refactor(orchestrator): move run_docker to a lean top-level docker_cmd (#352)
Review feedback: don't bury a parallel docker util in the orchestrator. But reusing backend.docker.util (docker_mod) isn't right either — importing it runs backend/__init__.py, which eagerly loads all three backends (docker + firecracker + macos) plus the manifest/egress/git-gate/supervise framework (~76 modules), so every orchestrator import would drag the whole backend layer in. Compromise: promote the helper to a top-level, framework-free bot_bottle/docker_cmd.py (single stdlib import), a proper shared home the orchestrator's docker components use now and backend.docker.util can adopt later. Verified `import bot_bottle.orchestrator` stays lean (12 modules, no firecracker/macos backends). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
f85cbdeebf |
feat(orchestrator): slice 4 — consolidated per-host sidecar (#352)
The core consolidation win: one persistent sidecar per host, shared by
every bottle, instead of a sidecar bundle per bottle. Safe to share
because the attribution invariant (source IP + identity token) lets the
sidecar map each request to the right bottle.
* orchestrator/sidecar.py — a backend-neutral `Sidecar` lifecycle
contract (mirrors LaunchBroker) + a `DockerSidecar` impl. 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 launches never spawn
N sidecars; `stop` is idempotent.
* orchestrator/dockerutil.py — a shared `run_docker` helper; DockerBroker
now uses it too (DRY with slice 3).
* service.py — the Orchestrator holds an optional `Sidecar`, exposes
`ensure_sidecar()` + `sidecar_status()`.
* control_plane.py — `GET /sidecar` reports it; __main__ gains
`--sidecar-image` and ensures the single sidecar on startup.
Tests: unit (docker mocked) — is_running, ensure idempotent (no-op when up,
starts when absent), failure raises, stop idempotent; Orchestrator sidecar
wiring/status; control-plane /sidecar; integration (gated) — ensure is a
real idempotent singleton (one container after two ensures), stop removes.
Full suite green (only pre-existing /bin/sleep errors); integration
verified locally against real docker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
4fb0f64249 |
feat(orchestrator): slice 3 — real Docker launch broker (#352)
The first concrete LaunchBroker, proving the orchestrator -> backend seam
on the cheapest backend (the sidecar bundle is already containers):
* orchestrator/docker_broker.py — DockerBroker runs a container on a
verified launch (`docker run --detach --name <bottle> --label ...
<image_ref>`) and removes it on teardown (`docker rm --force`,
idempotent on an already-absent container). The argv is built only from
the request's static ids/flags, so nothing free-form reaches docker;
provenance/schema verification is inherited from LaunchBroker.submit.
* __main__.py gains `--broker {stub,docker}` so the harness can drive real
containers.
Slice 3 launches a single container from image_ref (the seam); the full
agent + sidecar bundle is a later slice.
Tests: unit (docker mocked) — argv from static fields, launch/teardown call
the right commands, missing-image and docker-failure raise, teardown
idempotent on missing, forged token never touches docker; integration
(gated on a reachable daemon) — launch creates a real container, teardown
removes it. Full suite green (only pre-existing /bin/sleep errors);
integration verified locally against real docker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
9226d45041 |
feat(orchestrator): slice 2 — launch lifecycle + signed launch-broker (#352)
Second slice of PRD 0070, still a backend-neutral dev-harness:
* orchestrator/broker.py — the launch-broker contract. A LaunchRequest is
structured (static ids/flags only — bottle id, pool slot, a
content-addressed image_ref; never a path/argv) and signed as a compact
HS256 JWT so the broker verifies PROVENANCE before acting: a compromised
co-located component can't forge a launch without the shared secret.
verify_request is fail-closed (bad sig / malformed / off-schema -> raise).
Stdlib only (no runtime deps). Ships a StubBroker that records verified
requests for the harness/tests.
* orchestrator/service.py — the Orchestrator: owns the registry and brokers
the lifecycle. launch_bottle mints the bottle + sends a signed launch,
rolling the registry entry back if the launch fails (no orphans);
teardown_bottle brokers teardown then deregisters; attribute delegates.
* control_plane.py — POST /bottles now launches, DELETE tears down (both go
through the Orchestrator + broker). dispatch/server take an Orchestrator.
* __main__.py wires an ephemeral secret + StubBroker for the harness.
Tests: broker sign/verify round-trip, tamper/wrong-secret/malformed/off-schema
rejection, StubBroker fail-closed; Orchestrator launch->registry->attribute,
teardown, rollback-on-broker-failure; control-plane updated for launch/teardown.
Full suite green (only the pre-existing /bin/sleep errors); harness does
launch -> attribute -> teardown over HTTP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|
||
|
|
421d31c32f |
refactor: move bot_bottle_root/host_db_path to paths; kill the monkeypatch (#352)
Create bot_bottle/paths.py as the canonical home for the app-root path helpers (bot_bottle_root, host_db_path, HOST_DB_FILENAME) — foundational, not supervise- or db-specific. `bot_bottle_root()` now honours a BOT_BOTTLE_ROOT env override. Repoint every consumer (supervise, supervise_types, db_store, queue_store, audit_store, store_manager, bottle_state, cli/supervise, docker/cleanup, orchestrator/registry) at paths; remove the definitions (and supervise's duplicate host_db_path) and the now-dead `import sys`. Add paths.py to the sidecar bundle (Dockerfile.sidecars) for the flat-import copies. Tests: replace ~12 files' monkeypatching of supervise.bot_bottle_root (and the flat/pkg/supervise_types triple-patch dance) with a single `use_bottle_root()` helper that sets BOT_BOTTLE_ROOT — every module and flat/package copy reads the same env var, so one override covers them all. Net -97 lines. Behaviour-preserving: full unit suite unchanged (only the pre-existing /bin/sleep sidecar-init errors remain). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
b4b4e08f62 |
refactor: move host_db_path/HOST_DB_FILENAME to db_store (#352)
host_db_path is shared DB infrastructure, not supervise-specific, so its canonical home is db_store (alongside DbStore). It resolves bot_bottle_root from supervise_types lazily inside the function — no load-time cycle, and a monkey-patch of supervise_types.bot_bottle_root still propagates. supervise_types re-exports both names for the historical import path (queue_store/audit_store unchanged); the orchestrator registry now imports from db_store. Drops the now-unused `import sys` from supervise_types. Behavior-preserving: full unit suite unchanged (only the pre-existing /bin/sleep sidecar-init errors remain); monkeypatch propagation verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
b174442c60 |
feat(orchestrator): registry co-tenants the shared bot-bottle.db (#352)
Per review: use one shared bot-bottle.db for all runtime state including the registry (DbStore namespaces by schema_key), so it's one queryable file for backup/console. default_db_path() -> host_db_path(). Drop the unilateral WAL flip — WAL on the shared DB affects supervise/audit and is finicky over guest shares, so it's a deliberate future change; keep a busy_timeout for lock contention. PRD State section updated: integrity now by SOLE ownership (only the orchestrator opens bot-bottle.db; data plane + console reach state via the control-plane RPC, never a file handle) rather than ro/rw mount-splitting, which one shared file can't do. Notes the transitional caveat that the supervise sidecar currently rw-mounts bot-bottle.db. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck |
||
|
|
1702664b81 |
feat(orchestrator): slice 1 — registry + attribution + HTTP control plane (#352)
First implementation slice of PRD 0070, the backend-neutral consolidation
core as a plain-process dev-harness (no VM packaging yet):
* orchestrator/registry.py — SQLite (WAL) runtime-state store on the
existing DbStore/TableMigrations base. Live bottle registry keyed by
source IP + per-bottle identity token, with fail-closed attribution:
a request resolves to a bottle only when its source IP AND identity
token both match exactly one active record (unknown/ambiguous IP,
empty token, or token mismatch all deny). Tokens are 256-bit urandom.
* orchestrator/control_plane.py — the HTTP control plane (the universal
transport chosen in 0070): register / deregister / list / attribute /
health. Routing is a pure dispatch() so it is socket-free testable;
Handler/ControlPlaneServer/make_server are a thin stdlib adapter.
register/deregister are the live-reload path; listing redacts tokens.
* orchestrator/__main__.py — `python -m bot_bottle.orchestrator` harness.
Launch/teardown, the launch broker, and the egress/git/supervise data
plane come in later slices. 24 unit tests (attribution matrix, persistence,
dispatch, one real-socket round-trip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
|