refactor: unify component naming — control_plane/control_auth -> orchestrator
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / integration-docker (pull_request) Successful in 18s
test / unit (pull_request) Successful in 46s
lint / lint (push) Failing after 54s
test / integration-firecracker (pull_request) Successful in 3m21s
test / coverage (pull_request) Successful in 18s
test / publish-infra (pull_request) Has been skipped

The codebase used "control plane" both as an architectural role term AND
as an identifier alias for the orchestrator component, producing
duplicate names for one thing (control_plane_url vs orchestrator_url,
CONTROL_PLANE_PORT, host_control_plane_token, …). Going forward the
concrete component is always named for what it is — Gateway or
Orchestrator — and the plane vocabulary is reserved for prose (module
descriptions, the security argument).

Renamed (identifiers + the in-repo env/wire/file string values, all
setters/getters are in this repo so the change is atomic):

  ControlPlaneServer            -> OrchestratorServer
  control_plane_url             -> orchestrator_url
  probe_control_plane_url       -> probe_orchestrator_url
  host_control_plane_token      -> host_orchestrator_token
  CONTROL_PLANE_PORT            -> ORCHESTRATOR_PORT
  CONTROL_PLANE_TOKEN_ENV/FILE  -> ORCHESTRATOR_TOKEN_ENV/FILENAME
  BOT_BOTTLE_CONTROL_PLANE_TOKEN-> BOT_BOTTLE_ORCHESTRATOR_TOKEN
  control-plane-token (file)    -> orchestrator-token

  control_auth (module)         -> orchestrator_auth  (stays top-level;
                                   the gateway imports it and must not
                                   import the orchestrator/ package)
  CONTROL_AUTH_HEADER           -> ORCHESTRATOR_AUTH_HEADER
  x-bot-bottle-control-auth     -> x-bot-bottle-orchestrator-auth
  CONTROL_AUTH_JWT_ENV          -> ORCHESTRATOR_AUTH_JWT_ENV
  BOT_BOTTLE_CONTROL_AUTH_JWT   -> BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT
  _control_auth_headers         -> _orchestrator_auth_headers

Prose plane-terms ("control plane", "data plane") are preserved,
including the test name test_data_plane_daemons_get_jwt_not_key (it
names the security invariant). Gateway and orchestrator verified to
agree on the renamed wire header; full unit suite green (2243).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 18:17:48 -04:00
parent 4166057abc
commit ca1d341d4f
25 changed files with 146 additions and 147 deletions
+5 -5
View File
@@ -4,11 +4,11 @@ import os
import time import time
from pathlib import Path from pathlib import Path
from ...control_auth import ROLE_GATEWAY, mint from ...orchestrator_auth import ROLE_GATEWAY, mint
from .util import run_docker from .util import run_docker
from ...paths import ( from ...paths import (
CONTROL_AUTH_JWT_ENV, ORCHESTRATOR_AUTH_JWT_ENV,
host_control_plane_token, host_orchestrator_token,
host_gateway_ca_dir, host_gateway_ca_dir,
) )
from ...gateway import ( from ...gateway import (
@@ -158,8 +158,8 @@ class DockerGateway(Gateway):
# a `cli` token, so a compromised data-plane process can't drive the # a `cli` token, so a compromised data-plane process can't drive the
# operator routes (issue #469 review). Bare `--env NAME` keeps the value # operator routes (issue #469 review). Bare `--env NAME` keeps the value
# off argv / `docker inspect`; only the gateway (not the agent) is given it. # off argv / `docker inspect`; only the gateway (not the agent) is given it.
argv += ["--env", CONTROL_AUTH_JWT_ENV] argv += ["--env", ORCHESTRATOR_AUTH_JWT_ENV]
run_env[CONTROL_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_control_plane_token()) run_env[ORCHESTRATOR_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_orchestrator_token())
argv.append(self.image_ref) argv.append(self.image_ref)
proc = run_docker(argv, env=run_env) proc = run_docker(argv, env=run_env)
if proc.returncode != 0: if proc.returncode != 0:
+9 -9
View File
@@ -25,13 +25,13 @@ import urllib.request
from pathlib import Path from pathlib import Path
from ... import log from ... import log
from ...control_auth import ROLE_GATEWAY, mint from ...orchestrator_auth import ROLE_GATEWAY, mint
from .util import run_docker from .util import run_docker
from ...paths import ( from ...paths import (
CONTROL_AUTH_JWT_ENV, ORCHESTRATOR_AUTH_JWT_ENV,
CONTROL_PLANE_TOKEN_ENV, ORCHESTRATOR_TOKEN_ENV,
bot_bottle_root, bot_bottle_root,
host_control_plane_token, host_orchestrator_token,
host_gateway_ca_dir, host_gateway_ca_dir,
) )
from ...gateway import ( from ...gateway import (
@@ -176,7 +176,7 @@ class DockerInfraService:
so a later `ensure_running` can detect a real code change.""" so a later `ensure_running` can detect a real code change."""
self._ensure_network() self._ensure_network()
run_docker(["docker", "rm", "--force", self._infra_name]) run_docker(["docker", "rm", "--force", self._infra_name])
_signing_key = host_control_plane_token() _signing_key = host_orchestrator_token()
proc = run_docker([ proc = run_docker([
"docker", "run", "--detach", "docker", "run", "--detach",
"--name", self._infra_name, "--name", self._infra_name,
@@ -204,8 +204,8 @@ class DockerInfraService:
# pre-minted `gateway` JWT (data-plane daemons: present it). gateway_init # pre-minted `gateway` JWT (data-plane daemons: present it). gateway_init
# scopes each to its process, so a compromised data-plane daemon never # scopes each to its process, so a compromised data-plane daemon never
# sees the key and can't mint a `cli` token (issue #469 review). # sees the key and can't mint a `cli` token (issue #469 review).
"--env", CONTROL_PLANE_TOKEN_ENV, "--env", ORCHESTRATOR_TOKEN_ENV,
"--env", CONTROL_AUTH_JWT_ENV, "--env", ORCHESTRATOR_AUTH_JWT_ENV,
# Gateway daemons reach the orchestrator over loopback at its # Gateway daemons reach the orchestrator over loopback at its
# fixed internal port (DEFAULT_PORT), independent of self.port. # fixed internal port (DEFAULT_PORT), independent of self.port.
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}", "--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}",
@@ -214,8 +214,8 @@ class DockerInfraService:
self.image, self.image,
], env={ ], env={
**os.environ, **os.environ,
CONTROL_PLANE_TOKEN_ENV: _signing_key, ORCHESTRATOR_TOKEN_ENV: _signing_key,
CONTROL_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key), ORCHESTRATOR_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
}) })
if proc.returncode != 0: if proc.returncode != 0:
raise OrchestratorStartError( raise OrchestratorStartError(
+1 -1
View File
@@ -120,4 +120,4 @@ class FirecrackerBottleBackend(
def ensure_orchestrator(self) -> str: def ensure_orchestrator(self) -> str:
from . import infra_vm from . import infra_vm
return infra_vm.ensure_running().control_plane_url return infra_vm.ensure_running().orchestrator_url
@@ -124,7 +124,7 @@ def launch_consolidated(
provision its git-gate state into the gateway VM. Returns the context the provision its git-gate state into the gateway VM. Returns the context the
agent-VM launch needs. Raises on failure — the caller tears down.""" agent-VM launch needs. Raises on failure — the caller tears down."""
infra = infra_vm.ensure_running() infra = infra_vm.ensure_running()
url = infra.control_plane_url url = infra.orchestrator_url
client = OrchestratorClient(url) client = OrchestratorClient(url)
_reprovision_running_bottles(client) _reprovision_running_bottles(client)
+16 -16
View File
@@ -33,7 +33,7 @@ from pathlib import Path
from typing import Generator from typing import Generator
from ...log import die, info from ...log import die, info
from ...paths import CONTROL_PLANE_TOKEN_FILENAME, bot_bottle_root from ...paths import ORCHESTRATOR_TOKEN_FILENAME, bot_bottle_root
from .. import util as backend_util from .. import util as backend_util
from ..docker import util as docker_mod from ..docker import util as docker_mod
from ..docker.gateway_provision import GatewayProvisionError from ..docker.gateway_provision import GatewayProvisionError
@@ -42,7 +42,7 @@ from . import firecracker_vm, infra_artifact, netpool, util
# Where the infra VM keeps its control-plane signing key (generated on the # Where the infra VM keeps its control-plane signing key (generated on the
# persistent /dev/vdb volume mounted at BOT_BOTTLE_ROOT). The host mirrors it # persistent /dev/vdb volume mounted at BOT_BOTTLE_ROOT). The host mirrors it
# back so the CLI signs `cli` tokens the VM verifies (issue #469 review). # back so the CLI signs `cli` tokens the VM verifies (issue #469 review).
_GUEST_SIGNING_KEY_PATH = "/var/lib/bot-bottle/control-plane-token" _GUEST_SIGNING_KEY_PATH = "/var/lib/bot-bottle/orchestrator-token"
# The single infra-VM image: gateway data plane + baked control-plane source # The single infra-VM image: gateway data plane + baked control-plane source
# (Dockerfile.infra FROM the gateway image). Built from source by default; # (Dockerfile.infra FROM the gateway image). Built from source by default;
@@ -52,7 +52,7 @@ _GATEWAY_IMAGE = "bot-bottle-gateway:latest"
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest" _ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
_REPO_ROOT = Path(__file__).resolve().parents[3] _REPO_ROOT = Path(__file__).resolve().parents[3]
CONTROL_PLANE_PORT = 8099 ORCHESTRATOR_PORT = 8099
# Gateway data-plane ports (agent-facing): egress proxy, supervise MCP, # Gateway data-plane ports (agent-facing): egress proxy, supervise MCP,
# git-http. Reached by agent VMs over VM-to-VM routing (added next). # git-http. Reached by agent VMs over VM-to-VM routing (added next).
EGRESS_PORT = 9099 EGRESS_PORT = 9099
@@ -84,8 +84,8 @@ class InfraVm:
vm: firecracker_vm.VmHandle | None = None vm: firecracker_vm.VmHandle | None = None
@property @property
def control_plane_url(self) -> str: def orchestrator_url(self) -> str:
return f"http://{self.guest_ip}:{CONTROL_PLANE_PORT}" return f"http://{self.guest_ip}:{ORCHESTRATOR_PORT}"
def terminate(self) -> None: def terminate(self) -> None:
"""Stop the infra VM — via the live handle if we booted it, else the """Stop the infra VM — via the live handle if we booted it, else the
@@ -168,7 +168,7 @@ def ensure_running() -> InfraVm:
flock, so two simultaneous first launches don't both boot on the same flock, so two simultaneous first launches don't both boot on the same
rootfs/PID. The healthy fast-path takes no lock.""" rootfs/PID. The healthy fast-path takes no lock."""
slot = netpool.orch_slot() slot = netpool.orch_slot()
url = f"http://{slot.guest_ip}:{CONTROL_PLANE_PORT}" url = f"http://{slot.guest_ip}:{ORCHESTRATOR_PORT}"
key = _infra_dir() / "id_ed25519" key = _infra_dir() / "id_ed25519"
want = _expected_version() want = _expected_version()
if _adoptable(key, url, want): if _adoptable(key, url, want):
@@ -192,7 +192,7 @@ def ensure_running() -> InfraVm:
def _with_signing_key(infra: InfraVm) -> InfraVm: def _with_signing_key(infra: InfraVm) -> InfraVm:
"""Mirror the infra VM's control-plane signing key (generated on its """Mirror the infra VM's control-plane signing key (generated on its
persistent volume) into the host's control-plane-token file, so the host CLI persistent volume) into the host's orchestrator-token file, so the host CLI
signs `cli` tokens the VM verifies (issue #469 review). Best-effort: an signs `cli` tokens the VM verifies (issue #469 review). Best-effort: an
unreadable key is logged, not fatal — the VM still enforces auth, but the CLI unreadable key is logged, not fatal — the VM still enforces auth, but the CLI
may then be rejected until the key is readable. Returns `infra` for chaining.""" may then be rejected until the key is readable. Returns `infra` for chaining."""
@@ -205,7 +205,7 @@ def _with_signing_key(infra: InfraVm) -> InfraVm:
if proc.returncode != 0 or not signing_key: if proc.returncode != 0 or not signing_key:
info("infra signing key not yet readable; control-plane auth may fail") info("infra signing key not yet readable; control-plane auth may fail")
return infra return infra
path = bot_bottle_root() / CONTROL_PLANE_TOKEN_FILENAME path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f: with os.fdopen(fd, "w") as f:
@@ -458,7 +458,7 @@ def wait_for_health(
) -> None: ) -> None:
"""Poll the control plane's /health until it answers 200 or the deadline """Poll the control plane's /health until it answers 200 or the deadline
passes. Dies (with the console tail) if the VMM exits early.""" passes. Dies (with the console tail) if the VMM exits early."""
url = f"{infra.control_plane_url}/health" url = f"{infra.orchestrator_url}/health"
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
if infra.vm is not None and not infra.vm.is_alive(): if infra.vm is not None and not infra.vm.is_alive():
@@ -467,7 +467,7 @@ def wait_for_health(
try: try:
with urllib.request.urlopen(url, timeout=1.0) as resp: with urllib.request.urlopen(url, timeout=1.0) as resp:
if resp.status == 200: if resp.status == 200:
info(f"infra control plane healthy at {infra.control_plane_url}") info(f"infra control plane healthy at {infra.orchestrator_url}")
return return
except (urllib.error.URLError, TimeoutError, OSError): except (urllib.error.URLError, TimeoutError, OSError):
pass pass
@@ -525,11 +525,11 @@ cd /app
# same VM — reaching the orchestrator over 127.0.0.1, past the nft boundary that # same VM — reaching the orchestrator over 127.0.0.1, past the nft boundary that
# only fences off the separate agent VM — could drive the operator routes # only fences off the separate agent VM — could drive the operator routes
# (approve its own supervise proposals, rewrite policy, read injected tokens). # (approve its own supervise proposals, rewrite policy, read injected tokens).
CP_KEY=$(BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -c 'from bot_bottle.paths import host_control_plane_token as t; print(t())') CP_KEY=$(BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -c 'from bot_bottle.paths import host_orchestrator_token as t; print(t())')
GW_JWT=$(BB_SIGNING_KEY="$CP_KEY" python3 -c 'import os; from bot_bottle.control_auth import mint, ROLE_GATEWAY; print(mint(ROLE_GATEWAY, os.environ["BB_SIGNING_KEY"]))') GW_JWT=$(BB_SIGNING_KEY="$CP_KEY" python3 -c 'import os; from bot_bottle.orchestrator_auth import mint, ROLE_GATEWAY; print(mint(ROLE_GATEWAY, os.environ["BB_SIGNING_KEY"]))')
BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator \\ BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_ORCHESTRATOR_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator \\
--host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub & --host 0.0.0.0 --port {ORCHESTRATOR_PORT} --broker stub &
# Gateway data plane, multi-tenant: each request resolves source-IP -> # Gateway data plane, multi-tenant: each request resolves source-IP ->
# policy against the local control plane. The VM backend reaches git over # policy against the local control plane. The VM backend reaches git over
@@ -540,8 +540,8 @@ BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" pyt
# the pre-minted `gateway` JWT; gateway_init keeps the signing key out of the # the pre-minted `gateway` JWT; gateway_init keeps the signing key out of the
# data-plane daemons' env. # data-plane daemons' env.
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\ BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\ BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{ORCHESTRATOR_PORT} \\
BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT" \\ BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT="$GW_JWT" \\
python3 -m bot_bottle.gateway.bootstrap & python3 -m bot_bottle.gateway.bootstrap &
# Reap as PID 1; children are backgrounded, so `wait` blocks. # Reap as PID 1; children are backgrounded, so `wait` blocks.
@@ -102,7 +102,7 @@ class MacosContainerBottleBackend(
(`supervise`) call when no control plane is running yet. Mirrors (`supervise`) call when no control plane is running yet. Mirrors
firecracker's infra-VM bring-up.""" firecracker's infra-VM bring-up."""
from .infra import MacosInfraService from .infra import MacosInfraService
return MacosInfraService().ensure_running().control_plane_url return MacosInfraService().ensure_running().orchestrator_url
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan: def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
return _cleanup.prepare_cleanup() return _cleanup.prepare_cleanup()
@@ -90,7 +90,7 @@ def ensure_gateway(
service = service or MacosInfraService() service = service or MacosInfraService()
infra = service.ensure_running() infra = service.ensure_running()
endpoint = GatewayEndpoint( endpoint = GatewayEndpoint(
orchestrator_url=infra.control_plane_url, orchestrator_url=infra.orchestrator_url,
gateway_ip=infra.gateway_ip, gateway_ip=infra.gateway_ip,
gateway_ca_pem=service.ca_cert_pem(), gateway_ca_pem=service.ca_cert_pem(),
network=service.network, network=service.network,
+13 -13
View File
@@ -48,12 +48,12 @@ from ...orchestrator.lifecycle import (
OrchestratorStartError, OrchestratorStartError,
source_hash, source_hash,
) )
from ...control_auth import ROLE_GATEWAY, mint from ...orchestrator_auth import ROLE_GATEWAY, mint
from ...paths import ( from ...paths import (
CONTROL_AUTH_JWT_ENV, ORCHESTRATOR_AUTH_JWT_ENV,
CONTROL_PLANE_TOKEN_ENV, ORCHESTRATOR_TOKEN_ENV,
HOST_DB_FILENAME, HOST_DB_FILENAME,
host_control_plane_token, host_orchestrator_token,
host_gateway_ca_dir, host_gateway_ca_dir,
) )
from .. import util as backend_util from .. import util as backend_util
@@ -117,7 +117,7 @@ class InfraEndpoint:
"""How to reach the running infra container. The control plane and the """How to reach the running infra container. The control plane and the
gateway are the same container, so one address serves both.""" gateway are the same container, so one address serves both."""
control_plane_url: str # http://<infra ip>:8099 — host CLI + registration orchestrator_url: str # http://<infra ip>:8099 — host CLI + registration
gateway_ip: str # same container; agents' proxy / git-http / MCP target gateway_ip: str # same container; agents' proxy / git-http / MCP target
@@ -181,7 +181,7 @@ class MacosInfraService:
return None return None
url = self._resolve_url() url = self._resolve_url()
if url and self.is_healthy(url): if url and self.is_healthy(url):
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url)) return InfraEndpoint(orchestrator_url=url, gateway_ip=_ip_of(url))
return None return None
def ensure_built(self) -> None: def ensure_built(self) -> None:
@@ -247,17 +247,17 @@ class MacosInfraService:
# run process below, so neither lands on argv or in `container # run process below, so neither lands on argv or in `container
# inspect`'s command line. The agent runs in a SEPARATE container that # inspect`'s command line. The agent runs in a SEPARATE container that
# is never given these vars, which is the whole point. # is never given these vars, which is the whole point.
"--env", CONTROL_PLANE_TOKEN_ENV, "--env", ORCHESTRATOR_TOKEN_ENV,
"--env", CONTROL_AUTH_JWT_ENV, "--env", ORCHESTRATOR_AUTH_JWT_ENV,
"--entrypoint", "sh", "--entrypoint", "sh",
self.image, self.image,
"-c", _init_script(self.port), "-c", _init_script(self.port),
] ]
_signing_key = host_control_plane_token() _signing_key = host_orchestrator_token()
run_env = { run_env = {
**os.environ, **os.environ,
CONTROL_PLANE_TOKEN_ENV: _signing_key, ORCHESTRATOR_TOKEN_ENV: _signing_key,
CONTROL_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key), ORCHESTRATOR_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
} }
result = container_mod.run_container_argv(argv, env=run_env) result = container_mod.run_container_argv(argv, env=run_env)
if result.returncode != 0: if result.returncode != 0:
@@ -272,7 +272,7 @@ class MacosInfraService:
url = self._resolve_url() url = self._resolve_url()
if url and self.is_healthy(url): if url and self.is_healthy(url):
log.info("infra container healthy", context={"url": url}) log.info("infra container healthy", context={"url": url})
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url)) return InfraEndpoint(orchestrator_url=url, gateway_ip=_ip_of(url))
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
raise OrchestratorStartError( raise OrchestratorStartError(
f"infra container did not become healthy within " f"infra container did not become healthy within "
@@ -306,7 +306,7 @@ def _ip_of(url: str) -> str:
return url.split("://", 1)[-1].rsplit(":", 1)[0] return url.split("://", 1)[-1].rsplit(":", 1)[0]
def probe_control_plane_url(port: int = DEFAULT_PORT) -> str: def probe_orchestrator_url(port: int = DEFAULT_PORT) -> str:
"""The running infra container's control-plane URL, or "" if it isn't up. """The running infra container's control-plane URL, or "" if it isn't up.
Used by host-side control-plane discovery (`discover_orchestrator_url`); Used by host-side control-plane discovery (`discover_orchestrator_url`);
safe to call on any host — returns "" when the container or the `container` safe to call on any host — returns "" when the container or the `container`
+4 -4
View File
@@ -58,10 +58,10 @@ _READY_GATED_DAEMONS: tuple[str, ...] = ("git-gate", "git-http")
# The data-plane daemons instead hold the pre-minted `gateway` JWT they present. # The data-plane daemons instead hold the pre-minted `gateway` JWT they present.
# Scoping each to its process (even in the combined infra container) keeps a # Scoping each to its process (even in the combined infra container) keeps a
# compromised data-plane daemon from reading the key and minting a `cli` token # compromised data-plane daemon from reading the key and minting a `cli` token
# (issue #469 review). Values match paths.CONTROL_PLANE_TOKEN_ENV / # (issue #469 review). Values match paths.ORCHESTRATOR_TOKEN_ENV /
# CONTROL_AUTH_JWT_ENV; hardcoded here so this supervisor stays import-light. # ORCHESTRATOR_AUTH_JWT_ENV; hardcoded here so this supervisor stays import-light.
_SIGNING_KEY_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN" _SIGNING_KEY_ENV = "BOT_BOTTLE_ORCHESTRATOR_TOKEN"
_GATEWAY_JWT_ENV = "BOT_BOTTLE_CONTROL_AUTH_JWT" _GATEWAY_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
# Daemons that must be requested explicitly via BOT_BOTTLE_GATEWAY_DAEMONS # Daemons that must be requested explicitly via BOT_BOTTLE_GATEWAY_DAEMONS
# and are NOT started in the default (env-var-unset) case. The orchestrator # and are NOT started in the default (env-var-unset) case. The orchestrator
+6 -6
View File
@@ -41,16 +41,16 @@ DEFAULT_TIMEOUT_SECONDS = 2.0
# rather than imported because this module is COPYed flat into the gateway image, # rather than imported because this module is COPYed flat into the gateway image,
# free of bot-bottle imports — same rationale as IDENTITY_HEADER in egress_addon # free of bot-bottle imports — same rationale as IDENTITY_HEADER in egress_addon
# / git_http_backend. # / git_http_backend.
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth" ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
CONTROL_AUTH_JWT_ENV = "BOT_BOTTLE_CONTROL_AUTH_JWT" ORCHESTRATOR_AUTH_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
def _control_auth_headers() -> dict[str, str]: def _orchestrator_auth_headers() -> dict[str, str]:
"""The auth header to send, or {} when no token is configured (an open """The auth header to send, or {} when no token is configured (an open
control plane, e.g. Firecracker behind its nft boundary sending nothing control plane, e.g. Firecracker behind its nft boundary sending nothing
is correct there and harmlessly ignored).""" is correct there and harmlessly ignored)."""
token = os.environ.get(CONTROL_AUTH_JWT_ENV, "").strip() token = os.environ.get(ORCHESTRATOR_AUTH_JWT_ENV, "").strip()
return {CONTROL_AUTH_HEADER: token} if token else {} return {ORCHESTRATOR_AUTH_HEADER: token} if token else {}
class PolicyResolveError(RuntimeError): class PolicyResolveError(RuntimeError):
@@ -74,7 +74,7 @@ class PolicyResolver:
body = json.dumps(payload).encode() body = json.dumps(payload).encode()
req = urllib.request.Request( req = urllib.request.Request(
f"{self._base}{path}", data=body, method="POST", f"{self._base}{path}", data=body, method="POST",
headers={"Content-Type": "application/json", **_control_auth_headers()}, headers={"Content-Type": "application/json", **_orchestrator_auth_headers()},
) )
try: try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp: with urllib.request.urlopen(req, timeout=self._timeout) as resp:
+3 -3
View File
@@ -17,7 +17,7 @@ backend-neutral "consolidation core" that needs no VM packaging:
* `service` the `Orchestrator`: owns the registry, brokers the * `service` the `Orchestrator`: owns the registry, brokers the
launch lifecycle (launch/teardown), manages the launch lifecycle (launch/teardown), manages the
shared gateway, attributes. shared gateway, attributes.
* `control_plane` the HTTP control-plane RPC (launch / teardown / * `server` the HTTP control-plane RPC (launch / teardown /
list / attribute / gateway / health). list / attribute / gateway / health).
The actual backend-native launch (a real docker/firecracker broker) and The actual backend-native launch (a real docker/firecracker broker) and
@@ -40,7 +40,7 @@ from .broker import (
from .docker_broker import DockerBroker, DockerBrokerError from .docker_broker import DockerBroker, DockerBrokerError
from ..gateway import Gateway, GatewayError from ..gateway import Gateway, GatewayError
from .service import Orchestrator from .service import Orchestrator
from .server import ControlPlaneServer, dispatch, make_server from .server import OrchestratorServer, dispatch, make_server
__all__ = [ __all__ = [
"BottleRecord", "BottleRecord",
@@ -57,7 +57,7 @@ __all__ = [
"sign_request", "sign_request",
"verify_request", "verify_request",
"Orchestrator", "Orchestrator",
"ControlPlaneServer", "OrchestratorServer",
"dispatch", "dispatch",
"make_server", "make_server",
] ]
+9 -9
View File
@@ -18,9 +18,9 @@ import urllib.request
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from ..control_auth import ROLE_CLI, mint from ..orchestrator_auth import ROLE_CLI, mint
from ..paths import host_control_plane_token from ..paths import host_orchestrator_token
from .server import CONTROL_AUTH_HEADER from .server import ORCHESTRATOR_AUTH_HEADER
DEFAULT_TIMEOUT_SECONDS = 5.0 DEFAULT_TIMEOUT_SECONDS = 5.0
@@ -32,7 +32,7 @@ def _host_auth_token() -> str:
"" means 'send no auth header' correct against an open (unconfigured) "" means 'send no auth header' correct against an open (unconfigured)
control plane, and harmlessly rejected by a secured one.""" control plane, and harmlessly rejected by a secured one."""
try: try:
return mint(ROLE_CLI, host_control_plane_token()) return mint(ROLE_CLI, host_orchestrator_token())
except (OSError, ValueError): except (OSError, ValueError):
return "" return ""
@@ -83,7 +83,7 @@ class OrchestratorClient:
data = json.dumps(body).encode() if body is not None else None data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {} headers = {"Content-Type": "application/json"} if data is not None else {}
if self._auth_token: if self._auth_token:
headers[CONTROL_AUTH_HEADER] = self._auth_token headers[ORCHESTRATOR_AUTH_HEADER] = self._auth_token
req = urllib.request.Request( req = urllib.request.Request(
f"{self._base}{path}", data=data, method=method, headers=headers, f"{self._base}{path}", data=data, method=method, headers=headers,
) )
@@ -252,14 +252,14 @@ def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
candidates.append("http://127.0.0.1:8099") candidates.append("http://127.0.0.1:8099")
try: # firecracker: infra VM control plane on the orchestrator TAP try: # firecracker: infra VM control plane on the orchestrator TAP
from ..backend.firecracker import netpool from ..backend.firecracker import netpool
from ..backend.firecracker.infra_vm import CONTROL_PLANE_PORT from ..backend.firecracker.infra_vm import ORCHESTRATOR_PORT
candidates.append( candidates.append(
f"http://{netpool.orch_slot().guest_ip}:{CONTROL_PLANE_PORT}") f"http://{netpool.orch_slot().guest_ip}:{ORCHESTRATOR_PORT}")
except Exception: # noqa: BLE001 — backend optional / not firecracker except Exception: # noqa: BLE001 — backend optional / not firecracker
pass pass
try: # macOS: infra container control plane on its host-only address try: # macOS: infra container control plane on its host-only address
from ..backend.macos_container.infra import probe_control_plane_url from ..backend.macos_container.infra import probe_orchestrator_url
url = probe_control_plane_url() url = probe_orchestrator_url()
if url: if url:
candidates.append(url) candidates.append(url)
except Exception: # noqa: BLE001 — backend optional / not macOS except Exception: # noqa: BLE001 — backend optional / not macOS
+16 -16
View File
@@ -48,7 +48,7 @@ via the orchestrator. Register/deregister without a launch are internal to
`Orchestrator`, not exposed here. `Orchestrator`, not exposed here.
Routing/handling is the pure function `dispatch()` so it is unit-testable Routing/handling is the pure function `dispatch()` so it is unit-testable
without a socket; `Handler` / `ControlPlaneServer` / `make_server` are a without a socket; `Handler` / `OrchestratorServer` / `make_server` are a
thin stdlib adapter around it. Listing redacts identity tokens they are thin stdlib adapter around it. Listing redacts identity tokens they are
returned only once, to the caller that launches the bottle. returned only once, to the caller that launches the bottle.
""" """
@@ -63,8 +63,8 @@ import sys
import typing import typing
from urllib.parse import urlsplit from urllib.parse import urlsplit
from ..control_auth import ROLE_CLI, ROLES, verify from ..orchestrator_auth import ROLE_CLI, ROLES, verify
from ..paths import CONTROL_PLANE_TOKEN_ENV from ..paths import ORCHESTRATOR_TOKEN_ENV
from ..supervisor.types import TOOLS from ..supervisor.types import TOOLS
from .service import Orchestrator from .service import Orchestrator
@@ -72,13 +72,13 @@ from .service import Orchestrator
Json = dict[str, object] Json = dict[str, object]
# The request header carrying the caller's role-scoped control-plane token (a # The request header carrying the caller's role-scoped control-plane token (a
# signed JWT naming the caller's role — see control_auth). The role gates which # signed JWT naming the caller's role — see orchestrator_auth). The role gates which
# routes the caller may reach: the data plane holds a `gateway` token good only # routes the caller may reach: the data plane holds a `gateway` token good only
# for the agent-facing lookups; the host CLI holds a `cli` token for the # for the agent-facing lookups; the host CLI holds a `cli` token for the
# operator/mutating routes. An agent that can merely *reach* the port holds no # operator/mutating routes. An agent that can merely *reach* the port holds no
# token at all, and a compromised gateway holds only `gateway` — neither can # token at all, and a compromised gateway holds only `gateway` — neither can
# drive the operator routes (approve proposals, rewrite policy, read tokens). # drive the operator routes (approve proposals, rewrite policy, read tokens).
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth" ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
# The routes the data plane (role `gateway`) is allowed to reach — exactly the # The routes the data plane (role `gateway`) is allowed to reach — exactly the
# per-request lookups PolicyResolver makes. Every other authenticated route is # per-request lookups PolicyResolver makes. Every other authenticated route is
@@ -116,7 +116,7 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
`role` is the caller's verified control-plane role (`gateway` or `cli`), or `role` is the caller's verified control-plane role (`gateway` or `cli`), or
None for an unauthenticated request; an open-mode server (no signing key None for an unauthenticated request; an open-mode server (no signing key
configured see `ControlPlaneServer`) passes `cli`. Every route except configured see `OrchestratorServer`) passes `cli`. Every route except
`GET /health` requires a role: a missing role is 401, and a role that `GET /health` requires a role: a missing role is 401, and a role that
doesn't cover the route is 403 — so a `gateway` data-plane token can reach doesn't cover the route is 403 — so a `gateway` data-plane token can reach
`/resolve` + `/supervise/{propose,poll}` but not the operator routes `/resolve` + `/supervise/{propose,poll}` but not the operator routes
@@ -365,10 +365,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
crashing the connection, so one bad request can't take the control crashing the connection, so one bad request can't take the control
plane down for the caller.""" plane down for the caller."""
server = self.server server = self.server
assert isinstance(server, ControlPlaneServer) assert isinstance(server, OrchestratorServer)
length = int(self.headers.get("Content-Length") or 0) length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length > 0 else b"" body = self.rfile.read(length) if length > 0 else b""
role = server.role_for(self.headers.get(CONTROL_AUTH_HEADER, "")) role = server.role_for(self.headers.get(ORCHESTRATOR_AUTH_HEADER, ""))
try: try:
status, payload = dispatch( status, payload = dispatch(
server.orchestrator, method, self.path, body, role=role) server.orchestrator, method, self.path, body, role=role)
@@ -396,11 +396,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
self._serve("DELETE") self._serve("DELETE")
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer): class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
"""Threading HTTP server that carries the orchestrator for its handlers. """Threading HTTP server that carries the orchestrator for its handlers.
Holds the per-host control-plane *signing key* (from Holds the per-host control-plane *signing key* (from
`$BOT_BOTTLE_CONTROL_PLANE_TOKEN`, injected by the launcher into the `$BOT_BOTTLE_ORCHESTRATOR_TOKEN`, injected by the launcher into the
orchestrator process only) and verifies each request's role-scoped token orchestrator process only) and verifies each request's role-scoped token
against it. When a key is set, every route but `/health` requires a valid against it. When a key is set, every route but `/health` requires a valid
token whose role covers the route; when it is unset the server runs **open** token whose role covers the route; when it is unset the server runs **open**
@@ -413,11 +413,11 @@ class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None: def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
self.orchestrator = orchestrator self.orchestrator = orchestrator
self._signing_key = os.environ.get(CONTROL_PLANE_TOKEN_ENV, "").strip() self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip()
if not self._signing_key: if not self._signing_key:
sys.stderr.write( sys.stderr.write(
"orchestrator: WARNING — no control-plane signing key " "orchestrator: WARNING — no control-plane signing key "
f"(${CONTROL_PLANE_TOKEN_ENV}); running WITHOUT caller " f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller "
"authentication. Any client that can reach this port can drive " "authentication. Any client that can reach this port can drive "
"it. Backends that put the control plane on an agent-reachable " "it. Backends that put the control plane on an agent-reachable "
"network MUST set this.\n" "network MUST set this.\n"
@@ -438,13 +438,13 @@ class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def make_server( def make_server(
orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0 orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0
) -> ControlPlaneServer: ) -> OrchestratorServer:
"""Build (but do not start) a control-plane server. `port=0` binds an """Build (but do not start) a control-plane server. `port=0` binds an
ephemeral port read `server.server_address` for the actual one.""" ephemeral port read `server.server_address` for the actual one."""
return ControlPlaneServer((host, port), orchestrator) return OrchestratorServer((host, port), orchestrator)
__all__ = [ __all__ = [
"dispatch", "Handler", "ControlPlaneServer", "make_server", "Json", "dispatch", "Handler", "OrchestratorServer", "make_server", "Json",
"CONTROL_AUTH_HEADER", "ORCHESTRATOR_AUTH_HEADER",
] ]
+11 -12
View File
@@ -36,17 +36,16 @@ HOST_DB_FILENAME = "bot-bottle.db"
# reading route (see orchestrator/server.py); it is held only by the # reading route (see orchestrator/server.py); it is held only by the
# trusted callers (control plane, gateway, host CLI) and never handed to an # trusted callers (control plane, gateway, host CLI) and never handed to an
# agent, so an agent that can reach the control-plane port still can't drive it. # agent, so an agent that can reach the control-plane port still can't drive it.
CONTROL_PLANE_TOKEN_FILENAME = "control-plane-token" ORCHESTRATOR_TOKEN_FILENAME = "orchestrator-token"
# The env var carrying the control-plane *signing key* — held only by the # The env var carrying the orchestrator's *signing key* — held only by the
# orchestrator (to verify tokens) and the host CLI (to mint its own), never by # orchestrator (to verify tokens) and the host CLI (to mint its own), never by
# the data plane. Same value as the host token file; the name is unchanged for # the data plane. Same value as the host token file.
# backward compatibility with existing launchers. ORCHESTRATOR_TOKEN_ENV = "BOT_BOTTLE_ORCHESTRATOR_TOKEN"
CONTROL_PLANE_TOKEN_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
# The env var carrying the data plane's pre-minted `gateway`-role token (a # The env var carrying the data plane's pre-minted `gateway`-role token (a
# signed JWT the launcher mints from the signing key). The gateway presents this # signed JWT the launcher mints from the signing key). The gateway presents this
# on /resolve + /supervise/{propose,poll}; it never holds the signing key, so it # on /resolve + /supervise/{propose,poll}; it never holds the signing key, so it
# cannot forge a higher-privilege `cli` token (issue #469 review). # cannot forge a higher-privilege `cli` token (issue #469 review).
CONTROL_AUTH_JWT_ENV = "BOT_BOTTLE_CONTROL_AUTH_JWT" ORCHESTRATOR_AUTH_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
# The host directory holding the gateway's persistent mitmproxy CA. Bind-mounted # The host directory holding the gateway's persistent mitmproxy CA. Bind-mounted
# into the infra/gateway container at mitmproxy's confdir so the self-generated # into the infra/gateway container at mitmproxy's confdir so the self-generated
@@ -98,7 +97,7 @@ def host_gateway_ca_dir() -> Path:
return ca_dir return ca_dir
def host_control_plane_token() -> str: def host_orchestrator_token() -> str:
"""The per-host control-plane secret, minted (256-bit, url-safe) and """The per-host control-plane secret, minted (256-bit, url-safe) and
persisted 0600 on first use, then reused. persisted 0600 on first use, then reused.
@@ -107,7 +106,7 @@ def host_control_plane_token() -> str:
*host* artifact the file lives under the root the agent never mounts, and *host* artifact the file lives under the root the agent never mounts, and
the env var is set only on the trusted containers so reading it here is the env var is set only on the trusted containers so reading it here is
safe on the host launch path but the value never reaches a bottle.""" safe on the host launch path but the value never reaches a bottle."""
path = bot_bottle_root() / CONTROL_PLANE_TOKEN_FILENAME path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME
try: try:
existing = path.read_text().strip() existing = path.read_text().strip()
if existing: if existing:
@@ -131,13 +130,13 @@ def host_control_plane_token() -> str:
__all__ = [ __all__ = [
"HOST_DB_FILENAME", "HOST_DB_FILENAME",
"CONTROL_PLANE_TOKEN_FILENAME", "ORCHESTRATOR_TOKEN_FILENAME",
"CONTROL_PLANE_TOKEN_ENV", "ORCHESTRATOR_TOKEN_ENV",
"CONTROL_AUTH_JWT_ENV", "ORCHESTRATOR_AUTH_JWT_ENV",
"GATEWAY_CA_DIRNAME", "GATEWAY_CA_DIRNAME",
"bot_bottle_root", "bot_bottle_root",
"host_db_path", "host_db_path",
"host_db_dir", "host_db_dir",
"host_gateway_ca_dir", "host_gateway_ca_dir",
"host_control_plane_token", "host_orchestrator_token",
] ]
@@ -25,10 +25,10 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from bot_bottle.control_auth import ROLE_CLI, ROLE_GATEWAY, mint from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
from bot_bottle.orchestrator.client import OrchestratorClient from bot_bottle.orchestrator.client import OrchestratorClient
from bot_bottle.backend.docker.infra import DockerInfraService from bot_bottle.backend.docker.infra import DockerInfraService
from bot_bottle.paths import host_control_plane_token from bot_bottle.paths import host_orchestrator_token
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
# Fixed (not per-run-suffixed) so repeated runs reuse the same layer-cached # Fixed (not per-run-suffixed) so repeated runs reuse the same layer-cached
@@ -46,20 +46,20 @@ _TEST_INFRA_IMAGE = "bot-bottle-infra:itest"
"runner's /workspace — same host-bind-mount constraint as the other " "runner's /workspace — same host-bind-mount constraint as the other "
"bottle-bringup integration tests", "bottle-bringup integration tests",
) )
class TestDockerControlPlaneAuthIntegration(unittest.TestCase): class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
suffix = secrets.token_hex(4) suffix = secrets.token_hex(4)
cls._tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with cls._tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
cls.addClassCleanup(cls._tmp.cleanup) cls.addClassCleanup(cls._tmp.cleanup)
# host_control_plane_token() — both the token read below and the one # host_orchestrator_token() — both the token read below and the one
# DockerInfraService injects into the container's env — resolves its # DockerInfraService injects into the container's env — resolves its
# path via the *ambient* BOT_BOTTLE_ROOT env var, not the host_root # path via the *ambient* BOT_BOTTLE_ROOT env var, not the host_root
# kwarg passed to the constructor (that kwarg only controls the DB # kwarg passed to the constructor (that kwarg only controls the DB
# bind-mount destination). Without pointing the env var at the same # bind-mount destination). Without pointing the env var at the same
# throwaway dir, this "isolated" test would read/write the developer's # throwaway dir, this "isolated" test would read/write the developer's
# real ~/.bot-bottle/control-plane-token. # real ~/.bot-bottle/orchestrator-token.
previous_root = os.environ.get("BOT_BOTTLE_ROOT") previous_root = os.environ.get("BOT_BOTTLE_ROOT")
def _restore_root() -> None: def _restore_root() -> None:
@@ -88,7 +88,7 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
cls.svc.ensure_running() cls.svc.ensure_running()
# The control plane now verifies role-scoped signed tokens, not the raw # The control plane now verifies role-scoped signed tokens, not the raw
# key. Mint one of each role from the host signing key (issue #469 review). # key. Mint one of each role from the host signing key (issue #469 review).
signing_key = host_control_plane_token() signing_key = host_orchestrator_token()
cls.cli_token = mint(ROLE_CLI, signing_key) cls.cli_token = mint(ROLE_CLI, signing_key)
cls.gateway_token = mint(ROLE_GATEWAY, signing_key) cls.gateway_token = mint(ROLE_GATEWAY, signing_key)
+2 -2
View File
@@ -408,7 +408,7 @@ class TestEnsureOrchestrator(unittest.TestCase):
with patch( with patch(
"bot_bottle.backend.firecracker.infra_vm.ensure_running" "bot_bottle.backend.firecracker.infra_vm.ensure_running"
) as ensure_running: ) as ensure_running:
ensure_running.return_value.control_plane_url = ( ensure_running.return_value.orchestrator_url = (
"http://10.243.255.1:8099" "http://10.243.255.1:8099"
) )
url = b.ensure_orchestrator() url = b.ensure_orchestrator()
@@ -419,7 +419,7 @@ class TestEnsureOrchestrator(unittest.TestCase):
with patch( with patch(
"bot_bottle.backend.macos_container.infra.MacosInfraService" "bot_bottle.backend.macos_container.infra.MacosInfraService"
) as service_cls: ) as service_cls:
service_cls.return_value.ensure_running.return_value.control_plane_url = ( service_cls.return_value.ensure_running.return_value.orchestrator_url = (
"http://192.168.128.2:8099" "http://192.168.128.2:8099"
) )
url = b.ensure_orchestrator() url = b.ensure_orchestrator()
+8 -8
View File
@@ -31,7 +31,7 @@ class TestSigningKeySync(unittest.TestCase):
patch.object(infra_vm, "bot_bottle_root", return_value=root): patch.object(infra_vm, "bot_bottle_root", return_value=root):
out = infra_vm._with_signing_key(self._infra()) out = infra_vm._with_signing_key(self._infra())
self.assertIsInstance(out, infra_vm.InfraVm) # returned for chaining self.assertIsInstance(out, infra_vm.InfraVm) # returned for chaining
token = root / infra_vm.CONTROL_PLANE_TOKEN_FILENAME token = root / infra_vm.ORCHESTRATOR_TOKEN_FILENAME
self.assertEqual("the-signing-key", token.read_text()) self.assertEqual("the-signing-key", token.read_text())
self.assertEqual(0o600, token.stat().st_mode & 0o777) self.assertEqual(0o600, token.stat().st_mode & 0o777)
# It cat'd the guest volume path over SSH. # It cat'd the guest volume path over SSH.
@@ -44,16 +44,16 @@ class TestSigningKeySync(unittest.TestCase):
with patch.object(infra_vm.subprocess, "run", return_value=proc), \ with patch.object(infra_vm.subprocess, "run", return_value=proc), \
patch.object(infra_vm, "bot_bottle_root", return_value=root): patch.object(infra_vm, "bot_bottle_root", return_value=root):
infra_vm._with_signing_key(self._infra()) # no raise infra_vm._with_signing_key(self._infra()) # no raise
self.assertFalse((root / infra_vm.CONTROL_PLANE_TOKEN_FILENAME).exists()) self.assertFalse((root / infra_vm.ORCHESTRATOR_TOKEN_FILENAME).exists())
class TestControlPlaneUrl(unittest.TestCase): class TestOrchestratorUrl(unittest.TestCase):
def test_url_uses_guest_ip_and_port(self): def test_url_uses_guest_ip_and_port(self):
infra = infra_vm.InfraVm( infra = infra_vm.InfraVm(
vm=MagicMock(), guest_ip="10.243.255.1", private_key=Path("/k")) vm=MagicMock(), guest_ip="10.243.255.1", private_key=Path("/k"))
self.assertEqual( self.assertEqual(
f"http://10.243.255.1:{infra_vm.CONTROL_PLANE_PORT}", f"http://10.243.255.1:{infra_vm.ORCHESTRATOR_PORT}",
infra.control_plane_url, infra.orchestrator_url,
) )
@@ -81,11 +81,11 @@ class TestBuildInfraRootfs(unittest.TestCase):
# Role-scoped control-plane auth (issue #469 review): the orchestrator # Role-scoped control-plane auth (issue #469 review): the orchestrator
# gets the signing key, the gateway daemons get a pre-minted `gateway` # gets the signing key, the gateway daemons get a pre-minted `gateway`
# JWT — never open mode in the infra VM. # JWT — never open mode in the infra VM.
self.assertIn("host_control_plane_token", init) # key generated on the volume self.assertIn("host_orchestrator_token", init) # key generated on the volume
self.assertIn("mint, ROLE_GATEWAY", init) # gateway JWT minted from it self.assertIn("mint, ROLE_GATEWAY", init) # gateway JWT minted from it
self.assertIn('BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator', self.assertIn('BOT_BOTTLE_ORCHESTRATOR_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator',
init) # key -> orchestrator only init) # key -> orchestrator only
self.assertIn('BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT"', init) # JWT -> gateway daemons self.assertIn('BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT="$GW_JWT"', init) # JWT -> gateway daemons
class TestSshGatewayTransport(unittest.TestCase): class TestSshGatewayTransport(unittest.TestCase):
+7 -7
View File
@@ -64,7 +64,7 @@ class TestEnvForDaemon(unittest.TestCase):
self.assertNotIn("X", self._BASE) self.assertNotIn("X", self._BASE)
class TestControlPlaneEnvScoping(unittest.TestCase): class TestOrchestratorEnvScoping(unittest.TestCase):
"""The control-plane signing key stays with the orchestrator; the pre-minted """The control-plane signing key stays with the orchestrator; the pre-minted
`gateway` JWT goes to the data-plane daemons (issue #469 review). Scoping `gateway` JWT goes to the data-plane daemons (issue #469 review). Scoping
them per-process keeps a compromised data-plane daemon from reading the key them per-process keeps a compromised data-plane daemon from reading the key
@@ -72,20 +72,20 @@ class TestControlPlaneEnvScoping(unittest.TestCase):
_BASE = { _BASE = {
"PATH": "/usr/bin", "PATH": "/usr/bin",
"BOT_BOTTLE_CONTROL_PLANE_TOKEN": "sk-x", "BOT_BOTTLE_ORCHESTRATOR_TOKEN": "sk-x",
"BOT_BOTTLE_CONTROL_AUTH_JWT": "gw-jwt", "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT": "gw-jwt",
} }
def test_orchestrator_gets_key_not_jwt(self): def test_orchestrator_gets_key_not_jwt(self):
env = _env_for_daemon("orchestrator", self._BASE) env = _env_for_daemon("orchestrator", self._BASE)
self.assertEqual("sk-x", env["BOT_BOTTLE_CONTROL_PLANE_TOKEN"]) self.assertEqual("sk-x", env["BOT_BOTTLE_ORCHESTRATOR_TOKEN"])
self.assertNotIn("BOT_BOTTLE_CONTROL_AUTH_JWT", env) self.assertNotIn("BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT", env)
def test_data_plane_daemons_get_jwt_not_key(self): def test_data_plane_daemons_get_jwt_not_key(self):
for name in ("egress", "git-gate", "git-http", "supervise"): for name in ("egress", "git-gate", "git-http", "supervise"):
env = _env_for_daemon(name, self._BASE) env = _env_for_daemon(name, self._BASE)
self.assertNotIn("BOT_BOTTLE_CONTROL_PLANE_TOKEN", env, name) self.assertNotIn("BOT_BOTTLE_ORCHESTRATOR_TOKEN", env, name)
self.assertEqual("gw-jwt", env["BOT_BOTTLE_CONTROL_AUTH_JWT"], name) self.assertEqual("gw-jwt", env["BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"], name)
class TestSelectedDaemons(unittest.TestCase): class TestSelectedDaemons(unittest.TestCase):
+2 -2
View File
@@ -58,7 +58,7 @@ class TestEnsureGateway(unittest.TestCase):
from bot_bottle.backend.macos_container.infra import InfraEndpoint from bot_bottle.backend.macos_container.infra import InfraEndpoint
service = MagicMock() service = MagicMock()
service.ensure_running.return_value = InfraEndpoint( service.ensure_running.return_value = InfraEndpoint(
control_plane_url="http://192.168.128.2:8099", orchestrator_url="http://192.168.128.2:8099",
gateway_ip="192.168.128.2", gateway_ip="192.168.128.2",
) )
service.network = "bot-bottle-mac-gateway" service.network = "bot-bottle-mac-gateway"
@@ -72,7 +72,7 @@ class TestEnsureGateway(unittest.TestCase):
self.assertEqual("PEM", endpoint.gateway_ca_pem) self.assertEqual("PEM", endpoint.gateway_ca_pem)
self.assertEqual("bot-bottle-mac-gateway", endpoint.network) self.assertEqual("bot-bottle-mac-gateway", endpoint.network)
def test_control_plane_and_gateway_share_one_address(self) -> None: def test_orchestrator_and_gateway_share_one_address(self) -> None:
"""One infra container hosts both, so the gateway IP and the """One infra container hosts both, so the gateway IP and the
control-plane host are the same.""" control-plane host are the same."""
endpoint = self._run(self._service()) endpoint = self._run(self._service())
+5 -5
View File
@@ -10,7 +10,7 @@ from bot_bottle.backend.macos_container.infra import (
INFRA_DB_VOLUME, INFRA_DB_VOLUME,
MacosInfraService, MacosInfraService,
OrchestratorStartError, OrchestratorStartError,
probe_control_plane_url, probe_orchestrator_url,
) )
_INFRA = "bot_bottle.backend.macos_container.infra" _INFRA = "bot_bottle.backend.macos_container.infra"
@@ -110,7 +110,7 @@ class TestInfraEnsureRunning(unittest.TestCase):
mod.try_container_ipv4_on_network.return_value = "192.168.128.2" mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
endpoint = svc.ensure_running() endpoint = svc.ensure_running()
run.assert_not_called() run.assert_not_called()
self.assertEqual("http://192.168.128.2:8099", endpoint.control_plane_url) self.assertEqual("http://192.168.128.2:8099", endpoint.orchestrator_url)
self.assertEqual("192.168.128.2", endpoint.gateway_ip) self.assertEqual("192.168.128.2", endpoint.gateway_ip)
def test_changed_source_recreates(self) -> None: def test_changed_source_recreates(self) -> None:
@@ -176,16 +176,16 @@ class TestCaCertPem(unittest.TestCase):
svc.ca_cert_pem(timeout=0) svc.ca_cert_pem(timeout=0)
class TestProbeControlPlane(unittest.TestCase): class TestProbeOrchestrator(unittest.TestCase):
def test_returns_url_when_running(self) -> None: def test_returns_url_when_running(self) -> None:
with patch(f"{_INFRA}.container_mod") as mod: with patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "192.168.128.2" mod.try_container_ipv4_on_network.return_value = "192.168.128.2"
self.assertEqual("http://192.168.128.2:8099", probe_control_plane_url()) self.assertEqual("http://192.168.128.2:8099", probe_orchestrator_url())
def test_empty_when_absent(self) -> None: def test_empty_when_absent(self) -> None:
with patch(f"{_INFRA}.container_mod") as mod: with patch(f"{_INFRA}.container_mod") as mod:
mod.try_container_ipv4_on_network.return_value = "" mod.try_container_ipv4_on_network.return_value = ""
self.assertEqual("", probe_control_plane_url()) self.assertEqual("", probe_orchestrator_url())
if __name__ == "__main__": if __name__ == "__main__":
@@ -6,7 +6,7 @@ import base64
import json import json
import unittest import unittest
from bot_bottle.control_auth import ROLE_CLI, ROLE_GATEWAY, ROLES, mint, verify from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, ROLES, mint, verify
_KEY = "test-key" _KEY = "test-key"
@@ -45,14 +45,14 @@ class TestMintVerify(unittest.TestCase):
def test_validly_signed_but_wrong_alg_rejected(self) -> None: def test_validly_signed_but_wrong_alg_rejected(self) -> None:
# Alg-confusion: even a *correctly signed* token whose header claims a # Alg-confusion: even a *correctly signed* token whose header claims a
# non-HS256 alg must be rejected. # non-HS256 alg must be rejected.
from bot_bottle.control_auth import _sign # noqa: PLC0415 from bot_bottle.orchestrator_auth import _sign # noqa: PLC0415
signing_input = f"{_b64({'alg': 'none', 'typ': 'JWT'})}.{_b64({'role': 'cli'})}" signing_input = f"{_b64({'alg': 'none', 'typ': 'JWT'})}.{_b64({'role': 'cli'})}"
self.assertIsNone(verify(f"{signing_input}.{_sign(_KEY, signing_input)}", _KEY)) self.assertIsNone(verify(f"{signing_input}.{_sign(_KEY, signing_input)}", _KEY))
def test_validly_signed_but_undecodable_rejected(self) -> None: def test_validly_signed_but_undecodable_rejected(self) -> None:
# A correct signature over a header that isn't valid base64/JSON still # A correct signature over a header that isn't valid base64/JSON still
# fails closed rather than raising. # fails closed rather than raising.
from bot_bottle.control_auth import _sign # noqa: PLC0415 from bot_bottle.orchestrator_auth import _sign # noqa: PLC0415
signing_input = f"!!!not-base64!!!.{_b64({'role': 'cli'})}" signing_input = f"!!!not-base64!!!.{_b64({'role': 'cli'})}"
self.assertIsNone(verify(f"{signing_input}.{_sign(_KEY, signing_input)}", _KEY)) self.assertIsNone(verify(f"{signing_input}.{_sign(_KEY, signing_input)}", _KEY))
@@ -67,7 +67,7 @@ class TestMintVerify(unittest.TestCase):
header, _p, _s = mint(ROLE_CLI, _KEY).split(".") header, _p, _s = mint(ROLE_CLI, _KEY).split(".")
# Re-sign a token carrying an unknown role — a valid signature but a # Re-sign a token carrying an unknown role — a valid signature but a
# role the control plane doesn't recognise must still be rejected. # role the control plane doesn't recognise must still be rejected.
from bot_bottle.control_auth import _sign # noqa: PLC0415 from bot_bottle.orchestrator_auth import _sign # noqa: PLC0415
payload = _b64({"role": "root"}) payload = _b64({"role": "root"})
signing_input = f"{header}.{payload}" signing_input = f"{header}.{payload}"
forged = f"{signing_input}.{_sign(_KEY, signing_input)}" forged = f"{signing_input}.{_sign(_KEY, signing_input)}"
+3 -3
View File
@@ -7,7 +7,7 @@ import unittest
import urllib.error import urllib.error
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bot_bottle.control_auth import ROLE_CLI, verify from bot_bottle.orchestrator_auth import ROLE_CLI, verify
from bot_bottle.orchestrator.client import ( from bot_bottle.orchestrator.client import (
OrchestratorClient, OrchestratorClient,
OrchestratorClientError, OrchestratorClientError,
@@ -20,13 +20,13 @@ _URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
class TestHostAuthToken(unittest.TestCase): class TestHostAuthToken(unittest.TestCase):
def test_mints_a_cli_token_from_the_host_key(self) -> None: def test_mints_a_cli_token_from_the_host_key(self) -> None:
with patch("bot_bottle.orchestrator.client.host_control_plane_token", with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
return_value="signing-key"): return_value="signing-key"):
tok = _host_auth_token() tok = _host_auth_token()
self.assertEqual(ROLE_CLI, verify(tok, "signing-key")) self.assertEqual(ROLE_CLI, verify(tok, "signing-key"))
def test_returns_empty_when_key_unreadable(self) -> None: def test_returns_empty_when_key_unreadable(self) -> None:
with patch("bot_bottle.orchestrator.client.host_control_plane_token", with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
side_effect=OSError("no host root")): side_effect=OSError("no host root")):
self.assertEqual("", _host_auth_token()) self.assertEqual("", _host_auth_token())
+5 -5
View File
@@ -19,7 +19,7 @@ from contextlib import closing
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.control_auth import ROLE_CLI, ROLE_GATEWAY, mint from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
from bot_bottle.orchestrator.broker import StubBroker from bot_bottle.orchestrator.broker import StubBroker
from bot_bottle.orchestrator.server import dispatch, make_server from bot_bottle.orchestrator.server import dispatch, make_server
from bot_bottle.orchestrator.registry import BottleRecord, RegistryStore from bot_bottle.orchestrator.registry import BottleRecord, RegistryStore
@@ -284,7 +284,7 @@ class TestServerRoundTrip(unittest.TestCase):
self.assertEqual(reg["bottle_id"], attr["bottle_id"]) self.assertEqual(reg["bottle_id"], attr["bottle_id"])
class TestControlPlaneAuth(unittest.TestCase): class TestOrchestratorAuth(unittest.TestCase):
"""Role-scoped control-plane tokens (issue #400 / #469 review): every route """Role-scoped control-plane tokens (issue #400 / #469 review): every route
but /health needs a valid token, and the token's role gates which routes it but /health needs a valid token, and the token's role gates which routes it
reaches a `gateway` data-plane token can't drive the operator routes.""" reaches a `gateway` data-plane token can't drive the operator routes."""
@@ -349,7 +349,7 @@ class TestControlPlaneAuth(unittest.TestCase):
self.assertIsNotNone(self.orch.registry.get(rec.bottle_id)) self.assertIsNotNone(self.orch.registry.get(rec.bottle_id))
def _server_with_key(self, signing_key: str): def _server_with_key(self, signing_key: str):
with patch.dict("os.environ", {"BOT_BOTTLE_CONTROL_PLANE_TOKEN": signing_key}): with patch.dict("os.environ", {"BOT_BOTTLE_ORCHESTRATOR_TOKEN": signing_key}):
server = make_server(self.orch, "127.0.0.1", 0) server = make_server(self.orch, "127.0.0.1", 0)
self.addCleanup(server.server_close) self.addCleanup(server.server_close)
threading.Thread(target=server.serve_forever, daemon=True).start() threading.Thread(target=server.serve_forever, daemon=True).start()
@@ -360,7 +360,7 @@ class TestControlPlaneAuth(unittest.TestCase):
def _status(self, url: str, *, header: str | None = None) -> int: def _status(self, url: str, *, header: str | None = None) -> int:
req = urllib.request.Request(url) req = urllib.request.Request(url)
if header is not None: if header is not None:
req.add_header("x-bot-bottle-control-auth", header) req.add_header("x-bot-bottle-orchestrator-auth", header)
try: try:
return urllib.request.urlopen(req, timeout=5).status return urllib.request.urlopen(req, timeout=5).status
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
@@ -384,7 +384,7 @@ class TestControlPlaneAuth(unittest.TestCase):
grants full cli access, so existing round-trip behavior is unchanged.""" grants full cli access, so existing round-trip behavior is unchanged."""
with patch.dict("os.environ", {}, clear=False): with patch.dict("os.environ", {}, clear=False):
import os import os
os.environ.pop("BOT_BOTTLE_CONTROL_PLANE_TOKEN", None) os.environ.pop("BOT_BOTTLE_ORCHESTRATOR_TOKEN", None)
server = make_server(self.orch, "127.0.0.1", 0) server = make_server(self.orch, "127.0.0.1", 0)
self.addCleanup(server.server_close) self.addCleanup(server.server_close)
self.assertEqual(ROLE_CLI, server.role_for("")) self.assertEqual(ROLE_CLI, server.role_for(""))
+8 -8
View File
@@ -8,11 +8,11 @@ import urllib.error
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bot_bottle.gateway.policy_resolver import ( from bot_bottle.gateway.policy_resolver import (
CONTROL_AUTH_HEADER, ORCHESTRATOR_AUTH_HEADER,
CONTROL_AUTH_JWT_ENV, ORCHESTRATOR_AUTH_JWT_ENV,
PolicyResolveError, PolicyResolveError,
PolicyResolver, PolicyResolver,
_control_auth_headers, _orchestrator_auth_headers,
) )
_URLOPEN = "bot_bottle.gateway.policy_resolver.urllib.request.urlopen" _URLOPEN = "bot_bottle.gateway.policy_resolver.urllib.request.urlopen"
@@ -29,16 +29,16 @@ def _http_error(code: int) -> urllib.error.HTTPError:
return urllib.error.HTTPError("http://x/resolve", code, "err", {}, None) # type: ignore[arg-type] return urllib.error.HTTPError("http://x/resolve", code, "err", {}, None) # type: ignore[arg-type]
class TestControlAuthHeaders(unittest.TestCase): class TestOrchestratorAuthHeaders(unittest.TestCase):
def test_sends_the_gateway_jwt_when_configured(self) -> None: def test_sends_the_gateway_jwt_when_configured(self) -> None:
with patch.dict("os.environ", {CONTROL_AUTH_JWT_ENV: "gateway.jwt.tok"}): with patch.dict("os.environ", {ORCHESTRATOR_AUTH_JWT_ENV: "gateway.jwt.tok"}):
self.assertEqual({CONTROL_AUTH_HEADER: "gateway.jwt.tok"}, _control_auth_headers()) self.assertEqual({ORCHESTRATOR_AUTH_HEADER: "gateway.jwt.tok"}, _orchestrator_auth_headers())
def test_sends_nothing_when_unset(self) -> None: def test_sends_nothing_when_unset(self) -> None:
import os import os
with patch.dict("os.environ", {}, clear=False): with patch.dict("os.environ", {}, clear=False):
os.environ.pop(CONTROL_AUTH_JWT_ENV, None) os.environ.pop(ORCHESTRATOR_AUTH_JWT_ENV, None)
self.assertEqual({}, _control_auth_headers()) self.assertEqual({}, _orchestrator_auth_headers())
class TestPolicyResolver(unittest.TestCase): class TestPolicyResolver(unittest.TestCase):