Files
bot-bottle/bot_bottle/orchestrator/lifecycle.py
T
didericis-claude 45f3cefbc5 refactor(orchestrator): uniform control-plane auth provisioning per trust domain
Hoist control-plane auth provisioning out of the per-backend launchers into
one shared contract, parameterized per trust domain (#476). Every blocking
finding in PR #471 was the same integration-bug class: each launcher
re-derived, by hand, how to generate the signing key, scope it to the
orchestrator, mint the gateway JWT, and keep the host key canonical.

Introduces `trust_domain.py`:

  * `TrustDomain` — one credential boundary (host-canonical key file + role
    set + env vars). `mint`/`verify` are scoped to the domain's roles, so a
    future host-controller domain (#468) uses its own key/verifier/roles
    rather than a `host` role on the control plane's frozenset (which the
    orchestrator key could then forge).
  * `ControlPlaneProvisioning` — the single seam answering the four
    invariants: host-canonical key, split key-vs-token credential, CLI token
    valid across co-running backends, and fail-closed (no open mode) for any
    co-located topology.
  * `Topology` — the backend declares what it is; the default is co-located +
    fail-closed, so a backend need not redeclare it.

The `Orchestrator` ABC gets `control_plane_key()` (fail-closed) and routes
`mint_gateway_token()` through the contract; docker/macOS/firecracker
orchestrators, the server (verify), and the host CLI client (mint cli) all go
through the domain instead of reading the host key directly. `orchestrator_auth`
gains an optional `roles=` arg (default unchanged) so a domain scopes its own
role set; `paths.host_signing_key(filename)` generalizes host_orchestrator_token.

Adds unit coverage for the domain boundary + provisioning invariants and a PRD
capturing the durable rationale. No change to the auth primitive's HMAC, the
plane split, or the server's documented open-mode fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Closes #476
2026-07-26 01:32:53 +00:00

136 lines
5.7 KiB
Python

"""The host-side orchestrator (control-plane) service + shared lifecycle pieces
(PRD 0070).
`Orchestrator` is the backend-neutral host-side contract for the control plane,
mirroring `Gateway` for the data plane: build its image/rootfs, bring it up,
report where the CLI and the gateway reach it, mint the gateway's token, tear it
down. One concrete impl per backend (`backend/*/orchestrator.py`); the host
composes it with the `Gateway` service.
This module also holds the backend-neutral constants those impls build on: the
control-plane port, the startup timeout, the start-error, and the `source_hash`
used to detect a code change. (The in-guest control-plane *core* — the registry
+ broker object the running process wraps — is `service.OrchestratorCore`.)
"""
from __future__ import annotations
import abc
import hashlib
import urllib.error
import urllib.request
from pathlib import Path
from ..trust_domain import ControlPlaneProvisioning
DEFAULT_PORT = 8099
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
# A single /health probe's timeout (the poll loop repeats it up to the startup
# timeout). Short so a wedged control plane fails fast per attempt.
DEFAULT_HEALTH_TIMEOUT_SECONDS = 1.0
class OrchestratorStartError(RuntimeError):
"""The control plane did not become healthy within the timeout."""
def source_hash(repo_root: Path) -> str:
"""Content hash of the orchestrator's bind-mounted Python source (the
`bot_bottle` package the control-plane process imports). Changes only
when the code that would actually run changes — a backend's `ensure_running`
recreates the container on a mismatch so a code change takes effect,
but leaves a healthy up-to-date container alone to preserve in-memory
egress tokens."""
h = hashlib.sha256()
for path in sorted((repo_root / "bot_bottle").rglob("*.py")):
h.update(str(path.relative_to(repo_root)).encode())
h.update(path.read_bytes())
return h.hexdigest()
class Orchestrator(abc.ABC):
"""Provision + interact with the per-host orchestrator (control plane).
One concrete impl per backend (`backend/*/orchestrator.py`); the host
composes it with the `Gateway` service. The orchestrator is the **sole**
holder of the signing key and the **sole** opener of `bot-bottle.db` (#469),
so it — not the gateway — mints the gateway's role-scoped token.
Backend-neutral."""
# The shared control-plane auth provisioning contract (#476). Every backend
# gets its signing key + gateway token through this one seam rather than
# re-deriving the wiring; the default topology is co-located + fail-closed,
# so a backend need not redeclare it (a truly isolated control plane would
# override this with a different `Topology`).
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
def ensure_built(self) -> None:
"""Ensure the orchestrator's image / rootfs exists, building it if
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
`ensure_running`."""
return
@abc.abstractmethod
def ensure_running(
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
) -> None:
"""Start the control plane if it isn't already healthy on the current
source, then block until `/health` answers. Idempotent — a healthy,
current control plane is left running so its in-memory egress tokens
survive (#381). Raises `OrchestratorStartError` on startup timeout."""
@abc.abstractmethod
def is_running(self) -> bool:
"""True iff the orchestrator instance (container / VM) is currently up."""
@abc.abstractmethod
def stop(self) -> None:
"""Remove the orchestrator. Idempotent — absent is success."""
@abc.abstractmethod
def url(self) -> str:
"""The host-facing control-plane URL the CLI reaches the orchestrator
at (docker: a host-loopback publish; macOS/firecracker: the guest's
control-network / guest IP)."""
@abc.abstractmethod
def gateway_url(self) -> str:
"""The control-plane URL the gateway's data plane resolves policy
against. May differ from `url()` — docker reaches the orchestrator by
its container-DNS name on the control network, not the host loopback."""
def is_healthy(self, *, timeout: float = DEFAULT_HEALTH_TIMEOUT_SECONDS) -> bool:
"""True iff the control plane answers 200 on `/health` at `url()`.
Backends may override (e.g. to also check the VM process is alive)."""
try:
with urllib.request.urlopen(f"{self.url()}/health", timeout=timeout) as resp:
return resp.status == 200
except (urllib.error.URLError, TimeoutError, OSError):
return False
def mint_gateway_token(self) -> str:
"""Mint a role-scoped `gateway` JWT from the host signing key for the
gateway to present. The orchestrator holds the key; the gateway never
does (#469). Routed through the shared provisioning contract (#476), so
the same host token file is the single source of truth across backends."""
return self.provisioning.gateway_token()
def control_plane_key(self) -> str:
"""The raw signing key the control-plane *process* must receive — the ONE
place a backend obtains it (docker/macOS inject it as `key_env`;
firecracker pushes it to the guest). Fail-closed via the provisioning
contract: it raises rather than yield an empty key that would run the
server OPEN (#476)."""
return self.provisioning.orchestrator_key()
__all__ = [
"DEFAULT_PORT",
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
"DEFAULT_HEALTH_TIMEOUT_SECONDS",
"OrchestratorStartError",
"source_hash",
"Orchestrator",
"ControlPlaneProvisioning",
]