fix(supervise): get bot-bottle.db off the data plane (supervise + egress proposals over RPC) #471
@@ -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",
|
||||
]
|
||||
|
||||
+6
-6
@@ -25,10 +25,10 @@ import tempfile
|
||||
import unittest
|
||||
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.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
|
||||
|
||||
# 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 "
|
||||
"bottle-bringup integration tests",
|
||||
)
|
||||
class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
|
||||
class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
suffix = secrets.token_hex(4)
|
||||
cls._tmp = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
|
||||
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
|
||||
# path via the *ambient* BOT_BOTTLE_ROOT env var, not the host_root
|
||||
# kwarg passed to the constructor (that kwarg only controls the DB
|
||||
# bind-mount destination). Without pointing the env var at the same
|
||||
# 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")
|
||||
|
||||
def _restore_root() -> None:
|
||||
@@ -88,7 +88,7 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
|
||||
cls.svc.ensure_running()
|
||||
# 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).
|
||||
signing_key = host_control_plane_token()
|
||||
signing_key = host_orchestrator_token()
|
||||
cls.cli_token = mint(ROLE_CLI, signing_key)
|
||||
cls.gateway_token = mint(ROLE_GATEWAY, signing_key)
|
||||
|
||||
@@ -408,7 +408,7 @@ class TestEnsureOrchestrator(unittest.TestCase):
|
||||
with patch(
|
||||
"bot_bottle.backend.firecracker.infra_vm.ensure_running"
|
||||
) as ensure_running:
|
||||
ensure_running.return_value.control_plane_url = (
|
||||
ensure_running.return_value.orchestrator_url = (
|
||||
"http://10.243.255.1:8099"
|
||||
)
|
||||
url = b.ensure_orchestrator()
|
||||
@@ -419,7 +419,7 @@ class TestEnsureOrchestrator(unittest.TestCase):
|
||||
with patch(
|
||||
"bot_bottle.backend.macos_container.infra.MacosInfraService"
|
||||
) 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"
|
||||
)
|
||||
url = b.ensure_orchestrator()
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestSigningKeySync(unittest.TestCase):
|
||||
patch.object(infra_vm, "bot_bottle_root", return_value=root):
|
||||
out = infra_vm._with_signing_key(self._infra())
|
||||
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(0o600, token.stat().st_mode & 0o777)
|
||||
# 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), \
|
||||
patch.object(infra_vm, "bot_bottle_root", return_value=root):
|
||||
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):
|
||||
infra = infra_vm.InfraVm(
|
||||
vm=MagicMock(), guest_ip="10.243.255.1", private_key=Path("/k"))
|
||||
self.assertEqual(
|
||||
f"http://10.243.255.1:{infra_vm.CONTROL_PLANE_PORT}",
|
||||
infra.control_plane_url,
|
||||
f"http://10.243.255.1:{infra_vm.ORCHESTRATOR_PORT}",
|
||||
infra.orchestrator_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,11 +81,11 @@ class TestBuildInfraRootfs(unittest.TestCase):
|
||||
# Role-scoped control-plane auth (issue #469 review): the orchestrator
|
||||
# gets the signing key, the gateway daemons get a pre-minted `gateway`
|
||||
# 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('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
|
||||
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):
|
||||
|
||||
@@ -64,7 +64,7 @@ class TestEnvForDaemon(unittest.TestCase):
|
||||
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
|
||||
`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
|
||||
@@ -72,20 +72,20 @@ class TestControlPlaneEnvScoping(unittest.TestCase):
|
||||
|
||||
_BASE = {
|
||||
"PATH": "/usr/bin",
|
||||
"BOT_BOTTLE_CONTROL_PLANE_TOKEN": "sk-x",
|
||||
"BOT_BOTTLE_CONTROL_AUTH_JWT": "gw-jwt",
|
||||
"BOT_BOTTLE_ORCHESTRATOR_TOKEN": "sk-x",
|
||||
"BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT": "gw-jwt",
|
||||
}
|
||||
|
||||
def test_orchestrator_gets_key_not_jwt(self):
|
||||
env = _env_for_daemon("orchestrator", self._BASE)
|
||||
self.assertEqual("sk-x", env["BOT_BOTTLE_CONTROL_PLANE_TOKEN"])
|
||||
self.assertNotIn("BOT_BOTTLE_CONTROL_AUTH_JWT", env)
|
||||
self.assertEqual("sk-x", env["BOT_BOTTLE_ORCHESTRATOR_TOKEN"])
|
||||
self.assertNotIn("BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT", env)
|
||||
|
||||
def test_data_plane_daemons_get_jwt_not_key(self):
|
||||
for name in ("egress", "git-gate", "git-http", "supervise"):
|
||||
env = _env_for_daemon(name, self._BASE)
|
||||
self.assertNotIn("BOT_BOTTLE_CONTROL_PLANE_TOKEN", env, name)
|
||||
self.assertEqual("gw-jwt", env["BOT_BOTTLE_CONTROL_AUTH_JWT"], name)
|
||||
self.assertNotIn("BOT_BOTTLE_ORCHESTRATOR_TOKEN", env, name)
|
||||
self.assertEqual("gw-jwt", env["BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"], name)
|
||||
|
||||
|
||||
class TestSelectedDaemons(unittest.TestCase):
|
||||
|
||||
@@ -58,7 +58,7 @@ class TestEnsureGateway(unittest.TestCase):
|
||||
from bot_bottle.backend.macos_container.infra import InfraEndpoint
|
||||
service = MagicMock()
|
||||
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",
|
||||
)
|
||||
service.network = "bot-bottle-mac-gateway"
|
||||
@@ -72,7 +72,7 @@ class TestEnsureGateway(unittest.TestCase):
|
||||
self.assertEqual("PEM", endpoint.gateway_ca_pem)
|
||||
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
|
||||
control-plane host are the same."""
|
||||
endpoint = self._run(self._service())
|
||||
|
||||
@@ -10,7 +10,7 @@ from bot_bottle.backend.macos_container.infra import (
|
||||
INFRA_DB_VOLUME,
|
||||
MacosInfraService,
|
||||
OrchestratorStartError,
|
||||
probe_control_plane_url,
|
||||
probe_orchestrator_url,
|
||||
)
|
||||
|
||||
_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"
|
||||
endpoint = svc.ensure_running()
|
||||
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)
|
||||
|
||||
def test_changed_source_recreates(self) -> None:
|
||||
@@ -176,16 +176,16 @@ class TestCaCertPem(unittest.TestCase):
|
||||
svc.ca_cert_pem(timeout=0)
|
||||
|
||||
|
||||
class TestProbeControlPlane(unittest.TestCase):
|
||||
class TestProbeOrchestrator(unittest.TestCase):
|
||||
def test_returns_url_when_running(self) -> None:
|
||||
with patch(f"{_INFRA}.container_mod") as mod:
|
||||
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:
|
||||
with patch(f"{_INFRA}.container_mod") as mod:
|
||||
mod.try_container_ipv4_on_network.return_value = ""
|
||||
self.assertEqual("", probe_control_plane_url())
|
||||
self.assertEqual("", probe_orchestrator_url())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,7 +6,7 @@ import base64
|
||||
import json
|
||||
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"
|
||||
|
||||
@@ -45,14 +45,14 @@ class TestMintVerify(unittest.TestCase):
|
||||
def test_validly_signed_but_wrong_alg_rejected(self) -> None:
|
||||
# Alg-confusion: even a *correctly signed* token whose header claims a
|
||||
# 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'})}"
|
||||
self.assertIsNone(verify(f"{signing_input}.{_sign(_KEY, signing_input)}", _KEY))
|
||||
|
||||
def test_validly_signed_but_undecodable_rejected(self) -> None:
|
||||
# A correct signature over a header that isn't valid base64/JSON still
|
||||
# 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'})}"
|
||||
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(".")
|
||||
# Re-sign a token carrying an unknown role — a valid signature but a
|
||||
# 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"})
|
||||
signing_input = f"{header}.{payload}"
|
||||
forged = f"{signing_input}.{_sign(_KEY, signing_input)}"
|
||||
@@ -7,7 +7,7 @@ import unittest
|
||||
import urllib.error
|
||||
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 (
|
||||
OrchestratorClient,
|
||||
OrchestratorClientError,
|
||||
@@ -20,13 +20,13 @@ _URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
|
||||
|
||||
class TestHostAuthToken(unittest.TestCase):
|
||||
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"):
|
||||
tok = _host_auth_token()
|
||||
self.assertEqual(ROLE_CLI, verify(tok, "signing-key"))
|
||||
|
||||
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")):
|
||||
self.assertEqual("", _host_auth_token())
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from contextlib import closing
|
||||
from pathlib import Path
|
||||
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.server import dispatch, make_server
|
||||
from bot_bottle.orchestrator.registry import BottleRecord, RegistryStore
|
||||
@@ -284,7 +284,7 @@ class TestServerRoundTrip(unittest.TestCase):
|
||||
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
|
||||
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."""
|
||||
@@ -349,7 +349,7 @@ class TestControlPlaneAuth(unittest.TestCase):
|
||||
self.assertIsNotNone(self.orch.registry.get(rec.bottle_id))
|
||||
|
||||
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)
|
||||
self.addCleanup(server.server_close)
|
||||
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:
|
||||
req = urllib.request.Request(url)
|
||||
if header is not None:
|
||||
req.add_header("x-bot-bottle-control-auth", header)
|
||||
req.add_header("x-bot-bottle-orchestrator-auth", header)
|
||||
try:
|
||||
return urllib.request.urlopen(req, timeout=5).status
|
||||
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."""
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
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)
|
||||
self.addCleanup(server.server_close)
|
||||
self.assertEqual(ROLE_CLI, server.role_for(""))
|
||||
|
||||
@@ -8,11 +8,11 @@ import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.gateway.policy_resolver import (
|
||||
CONTROL_AUTH_HEADER,
|
||||
CONTROL_AUTH_JWT_ENV,
|
||||
ORCHESTRATOR_AUTH_HEADER,
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
PolicyResolveError,
|
||||
PolicyResolver,
|
||||
_control_auth_headers,
|
||||
_orchestrator_auth_headers,
|
||||
)
|
||||
|
||||
_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]
|
||||
|
||||
|
||||
class TestControlAuthHeaders(unittest.TestCase):
|
||||
class TestOrchestratorAuthHeaders(unittest.TestCase):
|
||||
def test_sends_the_gateway_jwt_when_configured(self) -> None:
|
||||
with patch.dict("os.environ", {CONTROL_AUTH_JWT_ENV: "gateway.jwt.tok"}):
|
||||
self.assertEqual({CONTROL_AUTH_HEADER: "gateway.jwt.tok"}, _control_auth_headers())
|
||||
with patch.dict("os.environ", {ORCHESTRATOR_AUTH_JWT_ENV: "gateway.jwt.tok"}):
|
||||
self.assertEqual({ORCHESTRATOR_AUTH_HEADER: "gateway.jwt.tok"}, _orchestrator_auth_headers())
|
||||
|
||||
def test_sends_nothing_when_unset(self) -> None:
|
||||
import os
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
os.environ.pop(CONTROL_AUTH_JWT_ENV, None)
|
||||
self.assertEqual({}, _control_auth_headers())
|
||||
os.environ.pop(ORCHESTRATOR_AUTH_JWT_ENV, None)
|
||||
self.assertEqual({}, _orchestrator_auth_headers())
|
||||
|
||||
|
||||
class TestPolicyResolver(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user