e53104d5c1
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / integration-docker (pull_request) Successful in 15s
test / unit (pull_request) Successful in 39s
test / integration-firecracker (pull_request) Successful in 3m55s
test / coverage (pull_request) Successful in 22s
test / publish-infra (pull_request) Has been skipped
prd-number / assign-numbers (push) Failing after 21s
test / integration-docker (push) Successful in 22s
lint / lint (push) Successful in 56s
Update Quality Badges / update-badges (push) Successful in 53s
test / unit (push) Successful in 1m52s
test / integration-firecracker (push) Successful in 5m2s
test / coverage (push) Successful in 16s
test / publish-infra (push) Successful in 1m56s
Remove the empty-key opt-out codex flagged: ControlPlaneProvisioning no longer lets the orchestrator start without a signing key when a backend declares an "isolated" topology. Host separation is not the safety condition — the gateway (and any other caller) must reach the control-plane listener for /resolve, so an open orchestrator would still grant them full `cli`. The signing key is now mandatory for every backend. Drops the now-purposeless Topology/COLOCATED machinery (no backend declared a non-default topology) and the contractual test_orchestrator_key_allows_empty_when_isolated. Updates the PRD's invariant 4 and lifecycle docstring accordingly. Docs/behaviour only otherwise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
5.7 KiB
Python
135 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; it is fail-closed for every backend — the
|
|
# orchestrator never starts without its signing key.
|
|
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",
|
|
]
|