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
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:
@@ -4,11 +4,11 @@ import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from ...control_auth import ROLE_GATEWAY, mint
|
||||
from ...orchestrator_auth import ROLE_GATEWAY, mint
|
||||
from .util import run_docker
|
||||
from ...paths import (
|
||||
CONTROL_AUTH_JWT_ENV,
|
||||
host_control_plane_token,
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
host_orchestrator_token,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ...gateway import (
|
||||
@@ -158,8 +158,8 @@ class DockerGateway(Gateway):
|
||||
# a `cli` token, so a compromised data-plane process can't drive the
|
||||
# operator routes (issue #469 review). Bare `--env NAME` keeps the value
|
||||
# off argv / `docker inspect`; only the gateway (not the agent) is given it.
|
||||
argv += ["--env", CONTROL_AUTH_JWT_ENV]
|
||||
run_env[CONTROL_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_control_plane_token())
|
||||
argv += ["--env", ORCHESTRATOR_AUTH_JWT_ENV]
|
||||
run_env[ORCHESTRATOR_AUTH_JWT_ENV] = mint(ROLE_GATEWAY, host_orchestrator_token())
|
||||
argv.append(self.image_ref)
|
||||
proc = run_docker(argv, env=run_env)
|
||||
if proc.returncode != 0:
|
||||
|
||||
@@ -25,13 +25,13 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ... import log
|
||||
from ...control_auth import ROLE_GATEWAY, mint
|
||||
from ...orchestrator_auth import ROLE_GATEWAY, mint
|
||||
from .util import run_docker
|
||||
from ...paths import (
|
||||
CONTROL_AUTH_JWT_ENV,
|
||||
CONTROL_PLANE_TOKEN_ENV,
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
ORCHESTRATOR_TOKEN_ENV,
|
||||
bot_bottle_root,
|
||||
host_control_plane_token,
|
||||
host_orchestrator_token,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ...gateway import (
|
||||
@@ -176,7 +176,7 @@ class DockerInfraService:
|
||||
so a later `ensure_running` can detect a real code change."""
|
||||
self._ensure_network()
|
||||
run_docker(["docker", "rm", "--force", self._infra_name])
|
||||
_signing_key = host_control_plane_token()
|
||||
_signing_key = host_orchestrator_token()
|
||||
proc = run_docker([
|
||||
"docker", "run", "--detach",
|
||||
"--name", self._infra_name,
|
||||
@@ -204,8 +204,8 @@ class DockerInfraService:
|
||||
# pre-minted `gateway` JWT (data-plane daemons: present it). gateway_init
|
||||
# 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).
|
||||
"--env", CONTROL_PLANE_TOKEN_ENV,
|
||||
"--env", CONTROL_AUTH_JWT_ENV,
|
||||
"--env", ORCHESTRATOR_TOKEN_ENV,
|
||||
"--env", ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
# Gateway daemons reach the orchestrator over loopback at its
|
||||
# fixed internal port (DEFAULT_PORT), independent of self.port.
|
||||
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{DEFAULT_PORT}",
|
||||
@@ -214,8 +214,8 @@ class DockerInfraService:
|
||||
self.image,
|
||||
], env={
|
||||
**os.environ,
|
||||
CONTROL_PLANE_TOKEN_ENV: _signing_key,
|
||||
CONTROL_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
|
||||
ORCHESTRATOR_TOKEN_ENV: _signing_key,
|
||||
ORCHESTRATOR_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
|
||||
})
|
||||
if proc.returncode != 0:
|
||||
raise OrchestratorStartError(
|
||||
|
||||
@@ -120,4 +120,4 @@ class FirecrackerBottleBackend(
|
||||
|
||||
def ensure_orchestrator(self) -> str:
|
||||
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
|
||||
agent-VM launch needs. Raises on failure — the caller tears down."""
|
||||
infra = infra_vm.ensure_running()
|
||||
url = infra.control_plane_url
|
||||
url = infra.orchestrator_url
|
||||
client = OrchestratorClient(url)
|
||||
_reprovision_running_bottles(client)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
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 ..docker import util as docker_mod
|
||||
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
|
||||
# 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).
|
||||
_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
|
||||
# (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"
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
CONTROL_PLANE_PORT = 8099
|
||||
ORCHESTRATOR_PORT = 8099
|
||||
# Gateway data-plane ports (agent-facing): egress proxy, supervise MCP,
|
||||
# git-http. Reached by agent VMs over VM-to-VM routing (added next).
|
||||
EGRESS_PORT = 9099
|
||||
@@ -84,8 +84,8 @@ class InfraVm:
|
||||
vm: firecracker_vm.VmHandle | None = None
|
||||
|
||||
@property
|
||||
def control_plane_url(self) -> str:
|
||||
return f"http://{self.guest_ip}:{CONTROL_PLANE_PORT}"
|
||||
def orchestrator_url(self) -> str:
|
||||
return f"http://{self.guest_ip}:{ORCHESTRATOR_PORT}"
|
||||
|
||||
def terminate(self) -> None:
|
||||
"""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
|
||||
rootfs/PID. The healthy fast-path takes no lock."""
|
||||
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"
|
||||
want = _expected_version()
|
||||
if _adoptable(key, url, want):
|
||||
@@ -192,7 +192,7 @@ def ensure_running() -> InfraVm:
|
||||
|
||||
def _with_signing_key(infra: InfraVm) -> InfraVm:
|
||||
"""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
|
||||
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."""
|
||||
@@ -205,7 +205,7 @@ def _with_signing_key(infra: InfraVm) -> InfraVm:
|
||||
if proc.returncode != 0 or not signing_key:
|
||||
info("infra signing key not yet readable; control-plane auth may fail")
|
||||
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)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
@@ -458,7 +458,7 @@ def wait_for_health(
|
||||
) -> None:
|
||||
"""Poll the control plane's /health until it answers 200 or the deadline
|
||||
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
|
||||
while time.monotonic() < deadline:
|
||||
if infra.vm is not None and not infra.vm.is_alive():
|
||||
@@ -467,7 +467,7 @@ def wait_for_health(
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1.0) as resp:
|
||||
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
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
pass
|
||||
@@ -525,11 +525,11 @@ cd /app
|
||||
# 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
|
||||
# (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())')
|
||||
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"]))')
|
||||
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.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 \\
|
||||
--host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub &
|
||||
BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_ORCHESTRATOR_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator \\
|
||||
--host 0.0.0.0 --port {ORCHESTRATOR_PORT} --broker stub &
|
||||
|
||||
# Gateway data plane, multi-tenant: each request resolves source-IP ->
|
||||
# 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
|
||||
# data-plane daemons' env.
|
||||
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
|
||||
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
|
||||
BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT" \\
|
||||
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{ORCHESTRATOR_PORT} \\
|
||||
BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT="$GW_JWT" \\
|
||||
python3 -m bot_bottle.gateway.bootstrap &
|
||||
|
||||
# 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
|
||||
firecracker's infra-VM bring-up."""
|
||||
from .infra import MacosInfraService
|
||||
return MacosInfraService().ensure_running().control_plane_url
|
||||
return MacosInfraService().ensure_running().orchestrator_url
|
||||
|
||||
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
||||
return _cleanup.prepare_cleanup()
|
||||
|
||||
@@ -90,7 +90,7 @@ def ensure_gateway(
|
||||
service = service or MacosInfraService()
|
||||
infra = service.ensure_running()
|
||||
endpoint = GatewayEndpoint(
|
||||
orchestrator_url=infra.control_plane_url,
|
||||
orchestrator_url=infra.orchestrator_url,
|
||||
gateway_ip=infra.gateway_ip,
|
||||
gateway_ca_pem=service.ca_cert_pem(),
|
||||
network=service.network,
|
||||
|
||||
@@ -48,12 +48,12 @@ from ...orchestrator.lifecycle import (
|
||||
OrchestratorStartError,
|
||||
source_hash,
|
||||
)
|
||||
from ...control_auth import ROLE_GATEWAY, mint
|
||||
from ...orchestrator_auth import ROLE_GATEWAY, mint
|
||||
from ...paths import (
|
||||
CONTROL_AUTH_JWT_ENV,
|
||||
CONTROL_PLANE_TOKEN_ENV,
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
ORCHESTRATOR_TOKEN_ENV,
|
||||
HOST_DB_FILENAME,
|
||||
host_control_plane_token,
|
||||
host_orchestrator_token,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from .. import util as backend_util
|
||||
@@ -117,7 +117,7 @@ class InfraEndpoint:
|
||||
"""How to reach the running infra container. The control plane and the
|
||||
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
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ class MacosInfraService:
|
||||
return None
|
||||
url = self._resolve_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
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
@@ -247,17 +247,17 @@ class MacosInfraService:
|
||||
# run process below, so neither lands on argv or in `container
|
||||
# inspect`'s command line. The agent runs in a SEPARATE container that
|
||||
# is never given these vars, which is the whole point.
|
||||
"--env", CONTROL_PLANE_TOKEN_ENV,
|
||||
"--env", CONTROL_AUTH_JWT_ENV,
|
||||
"--env", ORCHESTRATOR_TOKEN_ENV,
|
||||
"--env", ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
"--entrypoint", "sh",
|
||||
self.image,
|
||||
"-c", _init_script(self.port),
|
||||
]
|
||||
_signing_key = host_control_plane_token()
|
||||
_signing_key = host_orchestrator_token()
|
||||
run_env = {
|
||||
**os.environ,
|
||||
CONTROL_PLANE_TOKEN_ENV: _signing_key,
|
||||
CONTROL_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
|
||||
ORCHESTRATOR_TOKEN_ENV: _signing_key,
|
||||
ORCHESTRATOR_AUTH_JWT_ENV: mint(ROLE_GATEWAY, _signing_key),
|
||||
}
|
||||
result = container_mod.run_container_argv(argv, env=run_env)
|
||||
if result.returncode != 0:
|
||||
@@ -272,7 +272,7 @@ class MacosInfraService:
|
||||
url = self._resolve_url()
|
||||
if url and self.is_healthy(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:
|
||||
raise OrchestratorStartError(
|
||||
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]
|
||||
|
||||
|
||||
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.
|
||||
Used by host-side control-plane discovery (`discover_orchestrator_url`);
|
||||
safe to call on any host — returns "" when the container or the `container`
|
||||
|
||||
@@ -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.
|
||||
# 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
|
||||
# (issue #469 review). Values match paths.CONTROL_PLANE_TOKEN_ENV /
|
||||
# CONTROL_AUTH_JWT_ENV; hardcoded here so this supervisor stays import-light.
|
||||
_SIGNING_KEY_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
|
||||
_GATEWAY_JWT_ENV = "BOT_BOTTLE_CONTROL_AUTH_JWT"
|
||||
# (issue #469 review). Values match paths.ORCHESTRATOR_TOKEN_ENV /
|
||||
# ORCHESTRATOR_AUTH_JWT_ENV; hardcoded here so this supervisor stays import-light.
|
||||
_SIGNING_KEY_ENV = "BOT_BOTTLE_ORCHESTRATOR_TOKEN"
|
||||
_GATEWAY_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
|
||||
|
||||
# Daemons that must be requested explicitly via BOT_BOTTLE_GATEWAY_DAEMONS
|
||||
# and are NOT started in the default (env-var-unset) case. The orchestrator
|
||||
|
||||
@@ -41,16 +41,16 @@ DEFAULT_TIMEOUT_SECONDS = 2.0
|
||||
# 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
|
||||
# / git_http_backend.
|
||||
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
|
||||
CONTROL_AUTH_JWT_ENV = "BOT_BOTTLE_CONTROL_AUTH_JWT"
|
||||
ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
|
||||
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
|
||||
control plane, e.g. Firecracker behind its nft boundary — sending nothing
|
||||
is correct there and harmlessly ignored)."""
|
||||
token = os.environ.get(CONTROL_AUTH_JWT_ENV, "").strip()
|
||||
return {CONTROL_AUTH_HEADER: token} if token else {}
|
||||
token = os.environ.get(ORCHESTRATOR_AUTH_JWT_ENV, "").strip()
|
||||
return {ORCHESTRATOR_AUTH_HEADER: token} if token else {}
|
||||
|
||||
|
||||
class PolicyResolveError(RuntimeError):
|
||||
@@ -74,7 +74,7 @@ class PolicyResolver:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
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:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
|
||||
@@ -17,7 +17,7 @@ backend-neutral "consolidation core" that needs no VM packaging:
|
||||
* `service` — the `Orchestrator`: owns the registry, brokers the
|
||||
launch lifecycle (launch/teardown), manages the
|
||||
shared gateway, attributes.
|
||||
* `control_plane` — the HTTP control-plane RPC (launch / teardown /
|
||||
* `server` — the HTTP control-plane RPC (launch / teardown /
|
||||
list / attribute / gateway / health).
|
||||
|
||||
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 ..gateway import Gateway, GatewayError
|
||||
from .service import Orchestrator
|
||||
from .server import ControlPlaneServer, dispatch, make_server
|
||||
from .server import OrchestratorServer, dispatch, make_server
|
||||
|
||||
__all__ = [
|
||||
"BottleRecord",
|
||||
@@ -57,7 +57,7 @@ __all__ = [
|
||||
"sign_request",
|
||||
"verify_request",
|
||||
"Orchestrator",
|
||||
"ControlPlaneServer",
|
||||
"OrchestratorServer",
|
||||
"dispatch",
|
||||
"make_server",
|
||||
]
|
||||
|
||||
@@ -18,9 +18,9 @@ import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..control_auth import ROLE_CLI, mint
|
||||
from ..paths import host_control_plane_token
|
||||
from .server import CONTROL_AUTH_HEADER
|
||||
from ..orchestrator_auth import ROLE_CLI, mint
|
||||
from ..paths import host_orchestrator_token
|
||||
from .server import ORCHESTRATOR_AUTH_HEADER
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
@@ -32,7 +32,7 @@ def _host_auth_token() -> str:
|
||||
"" means 'send no auth header' — correct against an open (unconfigured)
|
||||
control plane, and harmlessly rejected by a secured one."""
|
||||
try:
|
||||
return mint(ROLE_CLI, host_control_plane_token())
|
||||
return mint(ROLE_CLI, host_orchestrator_token())
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
@@ -83,7 +83,7 @@ class OrchestratorClient:
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
if self._auth_token:
|
||||
headers[CONTROL_AUTH_HEADER] = self._auth_token
|
||||
headers[ORCHESTRATOR_AUTH_HEADER] = self._auth_token
|
||||
req = urllib.request.Request(
|
||||
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")
|
||||
try: # firecracker: infra VM control plane on the orchestrator TAP
|
||||
from ..backend.firecracker import netpool
|
||||
from ..backend.firecracker.infra_vm import CONTROL_PLANE_PORT
|
||||
from ..backend.firecracker.infra_vm import ORCHESTRATOR_PORT
|
||||
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
|
||||
pass
|
||||
try: # macOS: infra container control plane on its host-only address
|
||||
from ..backend.macos_container.infra import probe_control_plane_url
|
||||
url = probe_control_plane_url()
|
||||
from ..backend.macos_container.infra import probe_orchestrator_url
|
||||
url = probe_orchestrator_url()
|
||||
if url:
|
||||
candidates.append(url)
|
||||
except Exception: # noqa: BLE001 — backend optional / not macOS
|
||||
|
||||
@@ -48,7 +48,7 @@ via the orchestrator. Register/deregister without a launch are internal to
|
||||
`Orchestrator`, not exposed here.
|
||||
|
||||
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
|
||||
returned only once, to the caller that launches the bottle.
|
||||
"""
|
||||
@@ -63,8 +63,8 @@ import sys
|
||||
import typing
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from ..control_auth import ROLE_CLI, ROLES, verify
|
||||
from ..paths import CONTROL_PLANE_TOKEN_ENV
|
||||
from ..orchestrator_auth import ROLE_CLI, ROLES, verify
|
||||
from ..paths import ORCHESTRATOR_TOKEN_ENV
|
||||
from ..supervisor.types import TOOLS
|
||||
from .service import Orchestrator
|
||||
|
||||
@@ -72,13 +72,13 @@ from .service import Orchestrator
|
||||
Json = dict[str, object]
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# token at all, and a compromised gateway holds only `gateway` — neither can
|
||||
# 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
|
||||
# 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
|
||||
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
|
||||
doesn't cover the route is 403 — so a `gateway` data-plane token can reach
|
||||
`/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
|
||||
plane down for the caller."""
|
||||
server = self.server
|
||||
assert isinstance(server, ControlPlaneServer)
|
||||
assert isinstance(server, OrchestratorServer)
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
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:
|
||||
status, payload = dispatch(
|
||||
server.orchestrator, method, self.path, body, role=role)
|
||||
@@ -396,11 +396,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
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.
|
||||
|
||||
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
|
||||
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**
|
||||
@@ -413,11 +413,11 @@ class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
|
||||
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
|
||||
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:
|
||||
sys.stderr.write(
|
||||
"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 "
|
||||
"it. Backends that put the control plane on an agent-reachable "
|
||||
"network MUST set this.\n"
|
||||
@@ -438,13 +438,13 @@ class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
|
||||
def make_server(
|
||||
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
|
||||
ephemeral port — read `server.server_address` for the actual one."""
|
||||
return ControlPlaneServer((host, port), orchestrator)
|
||||
return OrchestratorServer((host, port), orchestrator)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"dispatch", "Handler", "ControlPlaneServer", "make_server", "Json",
|
||||
"CONTROL_AUTH_HEADER",
|
||||
"dispatch", "Handler", "OrchestratorServer", "make_server", "Json",
|
||||
"ORCHESTRATOR_AUTH_HEADER",
|
||||
]
|
||||
|
||||
+11
-12
@@ -36,17 +36,16 @@ HOST_DB_FILENAME = "bot-bottle.db"
|
||||
# reading route (see orchestrator/server.py); it is held only by the
|
||||
# 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.
|
||||
CONTROL_PLANE_TOKEN_FILENAME = "control-plane-token"
|
||||
# The env var carrying the control-plane *signing key* — held only by the
|
||||
ORCHESTRATOR_TOKEN_FILENAME = "orchestrator-token"
|
||||
# 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
|
||||
# the data plane. Same value as the host token file; the name is unchanged for
|
||||
# backward compatibility with existing launchers.
|
||||
CONTROL_PLANE_TOKEN_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
|
||||
# the data plane. Same value as the host token file.
|
||||
ORCHESTRATOR_TOKEN_ENV = "BOT_BOTTLE_ORCHESTRATOR_TOKEN"
|
||||
# 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
|
||||
# on /resolve + /supervise/{propose,poll}; it never holds the signing key, so it
|
||||
# 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
|
||||
# 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
|
||||
|
||||
|
||||
def host_control_plane_token() -> str:
|
||||
def host_orchestrator_token() -> str:
|
||||
"""The per-host control-plane secret, minted (256-bit, url-safe) and
|
||||
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
|
||||
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."""
|
||||
path = bot_bottle_root() / CONTROL_PLANE_TOKEN_FILENAME
|
||||
path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME
|
||||
try:
|
||||
existing = path.read_text().strip()
|
||||
if existing:
|
||||
@@ -131,13 +130,13 @@ def host_control_plane_token() -> str:
|
||||
|
||||
__all__ = [
|
||||
"HOST_DB_FILENAME",
|
||||
"CONTROL_PLANE_TOKEN_FILENAME",
|
||||
"CONTROL_PLANE_TOKEN_ENV",
|
||||
"CONTROL_AUTH_JWT_ENV",
|
||||
"ORCHESTRATOR_TOKEN_FILENAME",
|
||||
"ORCHESTRATOR_TOKEN_ENV",
|
||||
"ORCHESTRATOR_AUTH_JWT_ENV",
|
||||
"GATEWAY_CA_DIRNAME",
|
||||
"bot_bottle_root",
|
||||
"host_db_path",
|
||||
"host_db_dir",
|
||||
"host_gateway_ca_dir",
|
||||
"host_control_plane_token",
|
||||
"host_orchestrator_token",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user