refactor(orchestrator): introduce the Orchestrator service ABC (docker impl)
Mirror the Gateway work for the control plane. Add the backend-neutral `Orchestrator` service ABC in orchestrator/lifecycle.py — build the image/rootfs, `ensure_running` (start + health-gate), `is_running`/`is_healthy`/`stop`, `url()` (host-facing) vs `gateway_url()` (what the gateway resolves against), and a concrete `mint_gateway_token()` (the orchestrator holds the signing key, so it issues the gateway's token — #469). `DockerOrchestrator` (backend/docker/orchestrator.py) is the first impl, extracted out of `DockerInfraService`: the control-plane container run, the `--internal` control network, the source-hash recreate gate, health polling, and the orchestrator constants (name/label/network/image/dockerfile/hash-label). `DockerInfraService` now composes `orchestrator()` + `gateway()` — each builds its own image via `ensure_built`, the orchestrator comes up first, then the gateway is connected with the orchestrator-minted token. Its `url`/`is_healthy` delegate to the orchestrator service. Split the container-lifecycle tests into test_docker_orchestrator; test_docker_infra now covers the composition. macOS + firecracker follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,38 @@
|
||||
"""Shared orchestrator-process constants + helpers (PRD 0070).
|
||||
"""The host-side orchestrator (control-plane) service + shared lifecycle pieces
|
||||
(PRD 0070).
|
||||
|
||||
Backend-neutral pieces the per-backend infra services build on: the
|
||||
control-plane port, the startup timeout, the start-error, and the source hash
|
||||
used to detect a code change. The per-backend infra lifecycle lives with each
|
||||
backend — docker: `backend.docker.infra.DockerInfraService`; macOS:
|
||||
`backend.macos_container.infra`; firecracker: `backend.firecracker.infra_vm`.
|
||||
`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 ..orchestrator_auth import ROLE_GATEWAY, mint
|
||||
from ..paths import host_orchestrator_token
|
||||
|
||||
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 infra container/VM did not become healthy within the timeout."""
|
||||
"""The control plane did not become healthy within the timeout."""
|
||||
|
||||
|
||||
def source_hash(repo_root: Path) -> str:
|
||||
@@ -34,9 +49,72 @@ def source_hash(repo_root: Path) -> str:
|
||||
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."""
|
||||
|
||||
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). Backend-neutral — the same host token file is the single
|
||||
source of truth across backends."""
|
||||
return mint(ROLE_GATEWAY, host_orchestrator_token())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PORT",
|
||||
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
|
||||
"DEFAULT_HEALTH_TIMEOUT_SECONDS",
|
||||
"OrchestratorStartError",
|
||||
"source_hash",
|
||||
"Orchestrator",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user