diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index e581856..2d43a97 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -64,8 +64,10 @@ COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py COPY bot_bottle/egress_addon.py /app/egress_addon.py +COPY bot_bottle/policy_resolver.py /app/policy_resolver.py COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py COPY bot_bottle/yaml_subset.py /app/yaml_subset.py +COPY bot_bottle/paths.py /app/paths.py COPY bot_bottle/migrations.py /app/migrations.py COPY bot_bottle/db_store.py /app/db_store.py COPY bot_bottle/supervise_types.py /app/supervise_types.py diff --git a/bot_bottle/audit_store.py b/bot_bottle/audit_store.py index 7f25e9e..c01d2bb 100644 --- a/bot_bottle/audit_store.py +++ b/bot_bottle/audit_store.py @@ -6,11 +6,13 @@ import sqlite3 from pathlib import Path try: - from .supervise_types import AuditEntry, host_db_path + from .supervise_types import AuditEntry + from .paths import host_db_path from .db_store import DbStore from .migrations import TableMigrations except ImportError: - from supervise_types import AuditEntry, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from supervise_types import AuditEntry # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index 7f0c824..d9da165 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -111,6 +111,11 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup plumbing needed; the alias resolves inside the bridge.""" if plan.supervise_plan is None: return "" + # Consolidated: the supervise daemon lives on the shared gateway, so + # the agent registers the gateway address (NO_PROXY bypasses egress), + # not the per-bottle `supervise` alias. + if plan.agent_supervise_url: + return plan.agent_supervise_url return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/" def prepare_cleanup(self) -> DockerBottleCleanupPlan: diff --git a/bot_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py index 7ebc917..774a3c3 100644 --- a/bot_bottle/backend/docker/bottle_plan.py +++ b/bot_bottle/backend/docker/bottle_plan.py @@ -28,11 +28,30 @@ class DockerBottlePlan(BottlePlan): # accidental log of the plan dataclass. forwarded_env: dict[str, str] = field(repr=False) use_runsc: bool + # Consolidated mode (PRD 0070): the agent reaches git-gate over HTTP at the + # shared gateway's address (`http://:9420`), set at launch. + # Empty → single-tenant defaults (the `git-gate` alias over git://). + agent_git_gate_url: str = "" + # Likewise the supervise MCP endpoint at the gateway (`http://:9100/`); + # empty → the single-tenant `supervise` alias. + agent_supervise_url: str = "" @property def container_name(self) -> str: return self.agent_provision.instance_name + @property + def git_gate_insteadof_host(self) -> str: + if self.agent_git_gate_url.startswith("http://"): + return self.agent_git_gate_url.removeprefix("http://").rstrip("/") + return super().git_gate_insteadof_host + + @property + def git_gate_insteadof_scheme(self) -> str: + if self.agent_git_gate_url.startswith("http://"): + return "http" + return super().git_gate_insteadof_scheme + @property def image(self) -> str: return self.agent_provision.image diff --git a/bot_bottle/backend/docker/cleanup.py b/bot_bottle/backend/docker/cleanup.py index 254c63f..298d5fe 100644 --- a/bot_bottle/backend/docker/cleanup.py +++ b/bot_bottle/backend/docker/cleanup.py @@ -26,7 +26,7 @@ from __future__ import annotations import shutil import subprocess -from ... import supervise as _supervise +from ...paths import bot_bottle_root from ...log import info, warn from . import util as docker_mod from .bottle_cleanup_plan import DockerBottleCleanupPlan @@ -94,7 +94,7 @@ def _list_orphan_state_dirs( ANY backend — used so this docker-side check doesn't reap a running non-docker bottle's state dir (the layout is shared across backends).""" - state_root = _supervise.bot_bottle_root() / "state" + state_root = bot_bottle_root() / "state" if not state_root.is_dir(): return [] orphans: list[str] = [] diff --git a/bot_bottle/backend/docker/consolidated_compose.py b/bot_bottle/backend/docker/consolidated_compose.py new file mode 100644 index 0000000..72ab584 --- /dev/null +++ b/bot_bottle/backend/docker/consolidated_compose.py @@ -0,0 +1,78 @@ +"""Agent-only compose for the consolidated docker backend (PRD 0070). + +The per-bottle model rendered a compose project with the agent *and* a +sidecar bundle on two per-bottle networks. In the consolidated model the +sidecars are gone — one shared gateway serves every bottle — so this renders +just the agent, attached to the **external shared gateway network** with the +pinned source IP the orchestrator allocated, and pointed at the gateway's +address for egress (and, around the proxy, for git-http / supervise). + +Pure: it takes the launch-time `LaunchContext` values (gateway address, +source IP, network) and the prepared plan, and returns a compose dict — no +docker, so it's testable in isolation. +""" + +from __future__ import annotations + +from typing import Any + +from ...egress import egress_agent_env_entries +from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH +from .bottle_plan import DockerBottlePlan +from .egress import EGRESS_PORT + + +def consolidated_agent_compose( + plan: DockerBottlePlan, + *, + gateway_ip: str, + source_ip: str, + network: str, +) -> dict[str, Any]: + """A compose spec with only the agent service, on the external gateway + network at `source_ip`, proxying egress through `gateway_ip`.""" + proxy_url = f"http://{gateway_ip}:{EGRESS_PORT}" + # git-http + supervise live on the gateway too and must NOT go through the + # egress proxy — the agent reaches them directly by the gateway address. + no_proxy = f"localhost,127.0.0.1,{gateway_ip}" + env: list[str] = [ + f"HTTPS_PROXY={proxy_url}", + f"HTTP_PROXY={proxy_url}", + f"https_proxy={proxy_url}", + f"http_proxy={proxy_url}", + f"NO_PROXY={no_proxy}", + f"no_proxy={no_proxy}", + f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}", + f"SSL_CERT_FILE={AGENT_CA_BUNDLE}", + f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}", + ] + for name, value in sorted(plan.agent_provision.guest_env.items()): + env.append(f"{name}={value}") + # Forwarded vars: bare name → inherits from the compose-up process env so + # the secret value never lands on argv or in the compose file. + for name in sorted(plan.forwarded_env.keys()): + env.append(name) + env.extend(egress_agent_env_entries(plan.egress_plan)) + + service: dict[str, Any] = { + "image": plan.image, + "container_name": plan.container_name, + "command": ["sleep", "infinity"], + # Pinned address on the shared gateway network — the orchestrator + # registered this IP, and the gateway attributes the bottle by it. + "networks": {network: {"ipv4_address": source_ip}}, + "environment": env, + } + if plan.use_runsc: + service["runtime"] = "runsc" + + return { + "name": f"bot-bottle-{plan.slug}", + "services": {"agent": service}, + # The gateway network is created + owned by the orchestrator; compose + # attaches to it (external) and must not create or destroy it. + "networks": {network: {"external": True}}, + } + + +__all__ = ["consolidated_agent_compose"] diff --git a/bot_bottle/backend/docker/consolidated_launch.py b/bot_bottle/backend/docker/consolidated_launch.py new file mode 100644 index 0000000..ab70dcc --- /dev/null +++ b/bot_bottle/backend/docker/consolidated_launch.py @@ -0,0 +1,153 @@ +"""Consolidated bottle launch sequence for the docker backend (PRD 0070). + +Composes the orchestrator primitives into the register/teardown sequence that +replaces the per-bottle sidecar bundle: + + 1. ensure the orchestrator control plane + shared gateway are up; + 2. allocate the bottle a pinned source IP on the gateway network (the + attribution key), skipping the gateway's own address + live bottles; + 3. register it (egress policy blob + slug metadata) → bottle id + identity + token; + 4. provision its git-gate repos/creds into the running gateway. + +It returns a `LaunchContext` with everything the agent container needs to +attach — network, pinned IP, the gateway's address (its proxy target), the +orchestrator URL, and the identity token. The agent `docker run` itself is +the backend's job (it owns provider provisioning); this owns the +orchestrator-facing wiring so that sequence stays testable in isolation. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ...docker_cmd import run_docker +from ...egress import EgressPlan +from ...git_gate import GitGatePlan +from ...orchestrator.client import OrchestratorClient +from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK +from ...orchestrator.lifecycle import OrchestratorService +from ...orchestrator.registration import registration_inputs +from .gateway_net import next_free_ip +from .gateway_provision import deprovision_git_gate, provision_git_gate + + +class ConsolidatedLaunchError(RuntimeError): + """The consolidated register/provision sequence could not complete.""" + + +@dataclass(frozen=True) +class LaunchContext: + """What the agent container needs to join the shared gateway.""" + + bottle_id: str + identity_token: str + source_ip: str # the agent's pinned address (attribution key) + network: str # the shared gateway network to attach to + gateway_ip: str # the gateway's address — the agent's proxy target + orchestrator_url: str + + +def _network_cidr(network: str) -> str: + """The gateway network's IPv4 subnet, or raise.""" + proc = run_docker([ + "docker", "network", "inspect", + "--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", network, + ]) + cidr = proc.stdout.strip() + if proc.returncode != 0 or not cidr: + raise ConsolidatedLaunchError( + f"gateway network {network} has no subnet: {proc.stderr.strip()}" + ) + return cidr + + +def _container_ip(name: str, network: str) -> str: + """A container's IPv4 address on `network`, or raise.""" + proc = run_docker([ + "docker", "inspect", "--format", + f'{{{{(index .NetworkSettings.Networks "{network}").IPAddress}}}}', name, + ]) + ip = proc.stdout.strip() + if proc.returncode != 0 or not ip: + raise ConsolidatedLaunchError( + f"gateway {name} has no address on {network}: {proc.stderr.strip()}" + ) + return ip + + +def _network_container_ips(network: str) -> list[str]: + """Every address currently assigned on the gateway network — the ground + truth for "in use": the gateway + orchestrator infrastructure containers + and every live agent. Read from the network so a new bottle can't collide + with anything actually attached (a registry-only view would miss the + orchestrator/gateway containers).""" + proc = run_docker([ + "docker", "network", "inspect", "--format", + "{{range .Containers}}{{.IPv4Address}} {{end}}", network, + ]) + ips: list[str] = [] + for entry in proc.stdout.split(): + # entries look like "172.20.0.2/16" — keep the address. + ips.append(entry.split("/", 1)[0]) + return ips + + +def launch_consolidated( + egress_plan: EgressPlan, + git_gate_plan: GitGatePlan, + *, + image_ref: str = "", + tokens: dict[str, str] | None = None, + service: OrchestratorService | None = None, + gateway_name: str = GATEWAY_NAME, + network: str = GATEWAY_NETWORK, +) -> LaunchContext: + """Ensure the orchestrator + gateway are up, allocate + register the + bottle, and provision its git-gate state. Returns the agent's attach + context. Raises `ConsolidatedLaunchError` (or the primitives' own errors) + if any step fails — the caller tears down on failure.""" + service = service or OrchestratorService() + url = service.ensure_running() + client = OrchestratorClient(url) + + cidr = _network_cidr(network) + gateway_ip = _container_ip(gateway_name, network) + source_ip = next_free_ip(cidr, _network_container_ips(network)) + + inputs = registration_inputs(egress_plan) + reg = client.register_bottle( + source_ip, image_ref=image_ref, policy=inputs.policy, + metadata=inputs.metadata, tokens=tokens, + ) + try: + provision_git_gate(gateway_name, reg.bottle_id, git_gate_plan) + except Exception: + # Roll the registration back so a provisioning failure leaves no orphan. + client.teardown_bottle(reg.bottle_id) + raise + return LaunchContext( + bottle_id=reg.bottle_id, + identity_token=reg.identity_token, + source_ip=source_ip, + network=network, + gateway_ip=gateway_ip, + orchestrator_url=url, + ) + + +def teardown_consolidated( + bottle_id: str, *, orchestrator_url: str, gateway_name: str = GATEWAY_NAME, +) -> None: + """Deregister the bottle and remove its git-gate state from the gateway. + Both steps are idempotent so this is safe from a cleanup trap.""" + OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id) + deprovision_git_gate(gateway_name, bottle_id) + + +__all__ = [ + "LaunchContext", + "launch_consolidated", + "teardown_consolidated", + "ConsolidatedLaunchError", +] diff --git a/bot_bottle/backend/docker/gateway_net.py b/bot_bottle/backend/docker/gateway_net.py new file mode 100644 index 0000000..fe3b73a --- /dev/null +++ b/bot_bottle/backend/docker/gateway_net.py @@ -0,0 +1,47 @@ +"""Shared-gateway source-IP allocation for the consolidated docker backend +(PRD 0070). + +In the consolidated model one gateway container serves every bottle over a +single shared docker network, and each agent bottle attaches with a pinned, +deterministic address that the gateway uses as its **attribution key**. This +allocates those addresses from the network's subnet, skipping the reserved +ones — the network address and broadcast (excluded by `hosts()`), docker's +router `.1`, and everything already in use (`taken`: the gateway container +plus every live bottle, which the caller reads from the registry). + +Pure `ipaddress` logic — the docker-specific bits (the subnet CIDR, the +gateway container's own address) are gathered by the caller and passed in, so +this stays testable without docker. +""" + +from __future__ import annotations + +import ipaddress +from collections.abc import Iterable + + +class NoFreeAddressError(RuntimeError): + """The shared gateway network's subnet is exhausted — every host address + is reserved or already assigned to a bottle.""" + + +def next_free_ip(cidr: str, taken: Iterable[str]) -> str: + """The lowest host address in `cidr` not in `taken` and not docker's + router (`.1`). `taken` must include the gateway container's own address + and every live bottle's. Raises `NoFreeAddressError` if the subnet is + full.""" + net = ipaddress.ip_network(cidr, strict=False) + reserved = {str(a) for a in taken} + # Docker assigns the network's first host (.1) to the bridge router; a + # bottle must never be handed that address. + reserved.add(str(net.network_address + 1)) + for host in net.hosts(): # hosts() already excludes network + broadcast + candidate = str(host) + if candidate not in reserved: + return candidate + raise NoFreeAddressError( + f"no free address in {cidr} ({len(reserved)} reserved/assigned)" + ) + + +__all__ = ["next_free_ip", "NoFreeAddressError"] diff --git a/bot_bottle/backend/docker/gateway_provision.py b/bot_bottle/backend/docker/gateway_provision.py new file mode 100644 index 0000000..1e1fff9 --- /dev/null +++ b/bot_bottle/backend/docker/gateway_provision.py @@ -0,0 +1,100 @@ +"""Provision one bottle's git-gate state into the running shared gateway +(PRD 0070, docker slice). + +The consolidated gateway serves every bottle's repos under `/git//` +with per-repo credentials under `/git-gate/creds//`. When a bottle +is registered the launcher must place *its* deploy keys + known_hosts into +that per-bottle creds dir and init its bare repos there — so this copies the +credential files into the live gateway container and runs the (namespaced, +init-only) provisioning script produced by `git_gate_render_provision`. + +Isolating each bottle's creds dir + repo root by id is what keeps one +bottle's push credentials out of another's repos on the shared gateway. +""" + +from __future__ import annotations + +import re + +from ...docker_cmd import run_docker +from ...git_gate import GitGatePlan, git_gate_render_provision + +# bottle ids index the gateway's per-bottle repo + creds dirs; they land in +# `docker cp`/`rm` path arguments, so validate before any path is built (a +# traversal id like "../etc" must never reach the container). Registry ids are +# token_hex — this is defense in depth at the docker boundary. +_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+") + + +class GatewayProvisionError(RuntimeError): + """A git-gate provisioning step against the running gateway failed.""" + + +def _require_safe(bottle_id: str) -> None: + if not _SAFE_BOTTLE_ID.fullmatch(bottle_id): + raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}") + + +def _creds_dir(bottle_id: str) -> str: + return f"/git-gate/creds/{bottle_id}" + + +def _exec(gateway: str, argv: list[str]) -> None: + """`docker exec` a command in the gateway, raising on non-zero exit.""" + proc = run_docker(["docker", "exec", gateway, *argv]) + if proc.returncode != 0: + raise GatewayProvisionError( + f"gateway exec {argv!r} failed: {proc.stderr.strip()}" + ) + + +def _cp_into(gateway: str, src: str, dest: str) -> None: + """`docker cp` a host file into the gateway, raising on non-zero exit.""" + proc = run_docker(["docker", "cp", src, f"{gateway}:{dest}"]) + if proc.returncode != 0: + raise GatewayProvisionError( + f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}" + ) + + +def provision_git_gate(gateway: str, bottle_id: str, plan: GitGatePlan) -> None: + """Place `bottle_id`'s git-gate credentials into the running `gateway` + container and init its bare repos under `/git//`. + + Copies each upstream's identity key (and known_hosts, when present) into + `/git-gate/creds//`, then runs the namespaced provisioning + script. No-op for a bottle with no git upstreams.""" + _require_safe(bottle_id) + if not plan.upstreams: + return + # The pre-receive + access hooks are bottle-agnostic and shared by every + # bottle's repos; install them into the gateway (idempotent — same content + # each time). The per-bottle model cp'd these into each bundle at start. + _exec(gateway, ["mkdir", "-p", "/etc/git-gate"]) + _cp_into(gateway, str(plan.hook_script), "/etc/git-gate/pre-receive") + _cp_into(gateway, str(plan.access_hook_script), "/etc/git-gate/access-hook") + creds = _creds_dir(bottle_id) + _exec(gateway, ["mkdir", "-p", creds]) + for u in plan.upstreams: + if u.identity_file: + _cp_into(gateway, u.identity_file, f"{creds}/{u.name}-key") + known_hosts = str(u.known_hosts_file) + if known_hosts and known_hosts != ".": + _cp_into(gateway, known_hosts, f"{creds}/{u.name}-known_hosts") + # Init the bare repos + per-repo credential config for this namespace. + script = git_gate_render_provision(bottle_id, plan.upstreams) + _exec(gateway, ["sh", "-c", script]) + + +def deprovision_git_gate(gateway: str, bottle_id: str) -> None: + """Remove a bottle's repos + creds from the gateway on teardown. Idempotent + — an already-absent namespace is a clean no-op (best effort; a stray dir + can't leak, since attribution is by source IP and the bottle is gone).""" + _require_safe(bottle_id) + run_docker([ + "docker", "exec", gateway, "rm", "-rf", + f"/git/{bottle_id}", _creds_dir(bottle_id), + ]) + + +__all__ = ["provision_git_gate", "deprovision_git_gate", "GatewayProvisionError"] diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index fe49903..c1a75c5 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -42,7 +42,6 @@ from ...git_gate import ( revoke_git_gate_provisioned_keys, ) from ...log import info, warn -from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle from .bottle_plan import DockerBottlePlan @@ -53,7 +52,6 @@ from ...bottle_state import ( read_committed_image, ) from .compose import ( - bottle_plan_to_compose, compose_down, compose_dump_logs, compose_file_path, @@ -62,7 +60,9 @@ from .compose import ( compose_up, write_compose_file, ) -from .egress import egress_tls_init +from .consolidated_compose import consolidated_agent_compose +from .consolidated_launch import launch_consolidated, teardown_consolidated +from ...orchestrator.gateway import DockerGateway # Where the repo root lives, for `docker build` context. Computed once. @@ -97,8 +97,7 @@ def launch( try: # Step 1: agent image. Use a committed snapshot when one exists # and is present in the local daemon; otherwise build from the - # Dockerfile. Sidecar images get built lazily by `docker compose - # up` via the renderer's `build:` directives. + # Dockerfile. (The gateway image is built by the orchestrator.) committed = read_committed_image(plan.slug) if committed and docker_mod.image_exists(committed): info(f"using committed image {committed!r}") @@ -112,82 +111,82 @@ def launch( dockerfile=plan.dockerfile_path, ) - internal_network = network_mod.network_name_for_slug(plan.slug) - egress_network = network_mod.network_egress_name_for_slug(plan.slug) - - egress_ca_host, egress_ca_cert_only = egress_tls_init( - egress_state_dir(plan.slug), - ) - + # Step 2: mint the git-gate dynamic (gitea) deploy keys, if any, before + # provisioning the bottle's repos into the shared gateway. git_gate_plan = plan.git_gate_plan if git_gate_plan.upstreams: git_gate_plan = provision_git_gate_dynamic_keys( - plan.manifest.bottle, - git_gate_plan, - git_gate_state_dir(plan.slug), - ) - git_gate_plan = dataclasses.replace( - git_gate_plan, - internal_network=internal_network, - egress_network=egress_network, + plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug), ) + + # Step 3: register on the orchestrator + provision this bottle's + # git-gate state into the shared gateway; get the agent's attach + # context (pinned source IP, gateway address, shared network). The + # per-bottle egress auth tokens are resolved from the host env now and + # handed to the orchestrator (in memory) for the gateway to inject — + # the agent never sees them. + effective_env = {**os.environ, **plan.agent_provision.provisioned_env} + token_values = egress_resolve_token_values( + plan.egress_plan.token_env_map, effective_env, + ) + ctx = launch_consolidated( + plan.egress_plan, git_gate_plan, image_ref=plan.image, tokens=token_values, + ) + stack.callback( + teardown_consolidated, ctx.bottle_id, orchestrator_url=ctx.orchestrator_url, + ) + + # Step 4: install the SHARED gateway CA into the agent (replaces the + # per-bottle CA) — read it out of the running gateway. + ca_dir = egress_state_dir(plan.slug) / "gateway-ca" + ca_dir.mkdir(parents=True, exist_ok=True) + ca_file = ca_dir / "gateway-ca.pem" + ca_file.write_text(DockerGateway(network=ctx.network).ca_cert_pem()) egress_plan = dataclasses.replace( plan.egress_plan, - internal_network=internal_network, - egress_network=egress_network, - mitmproxy_ca_host_path=egress_ca_host, - mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, + mitmproxy_ca_host_path=ca_file, + mitmproxy_ca_cert_only_host_path=ca_file, + ) + # Point the agent's git-gate insteadOf rewrites at the shared gateway's + # HTTP git endpoint (9420) instead of the dead per-bottle `git-gate` + # alias. git-http + supervise on the gateway bypass the egress proxy + # (NO_PROXY includes the gateway address). + git_gate_url = ( + f"http://{ctx.gateway_ip}:9420" if git_gate_plan.upstreams else "" + ) + supervise_url = ( + f"http://{ctx.gateway_ip}:9100/" if plan.supervise_plan is not None else "" ) - supervise_plan = plan.supervise_plan - if supervise_plan is not None: - supervise_plan = dataclasses.replace( - supervise_plan, - internal_network=internal_network, - ) plan = dataclasses.replace( plan, git_gate_plan=git_gate_plan, egress_plan=egress_plan, - supervise_plan=supervise_plan, + agent_git_gate_url=git_gate_url, + agent_supervise_url=supervise_url, ) - # Step 6: render + write the compose file. metadata.json - # was written at prepare time and already carries - # compose_project; nothing to update here. + # Step 5: render + up the agent-only compose, pinned on the shared + # gateway network and proxied through the gateway's address. state_dir = bottle_state_dir(plan.slug) - spec = bottle_plan_to_compose(plan) + spec = consolidated_agent_compose( + plan, gateway_ip=ctx.gateway_ip, source_ip=ctx.source_ip, network=ctx.network, + ) compose_file = write_compose_file(spec, compose_file_path(state_dir)) project = compose_project_name(plan.slug) - - # Step 7: compose up. Token values + the OAuth placeholder - # flow through subprocess env; the compose file holds only - # bare names for the secret-carrying entries. - effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env} - token_values = egress_resolve_token_values( - plan.egress_plan.token_env_map, effective_env, - ) - compose_env: dict[str, str] = { - **os.environ, - **plan.forwarded_env, - **token_values, - } + # Forwarded vars (OAuth token, host interpolations) flow through the + # subprocess env as bare names so values never land in the file. + compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env} info( - f"docker compose up -d (project {project}, " - f"{len(spec['services'])} services)" + f"docker compose up -d (project {project}, agent on shared " + f"gateway {ctx.gateway_ip}, ip {ctx.source_ip})" ) compose_up(project, compose_file, env=compose_env) - - # Register teardown in reverse order: log dump first, then - # `compose down`. Networks come down last via callbacks - # registered in step 2. stack.callback(compose_down, project, compose_file) stack.callback( compose_dump_logs, project, compose_file, compose_log_path(state_dir), ) - # Step 8: provision. Create the bottle first so provisioners - # can use bottle.exec / bottle.cp_in; set the prompt path - # returned by provision_prompt after the fact. + # Step 6: provision (CA install now uses the gateway CA) + yield. bottle = DockerBottle( plan.container_name, teardown, @@ -200,10 +199,6 @@ def launch( agent_workdir=plan.workspace_plan.workdir, ) bottle.prompt_path = provision(plan, bottle) - - # Step 9: yield. exec_agent continues to use `docker exec -it` - # — the agent runs `sleep infinity` per the renderer's - # service spec. yield bottle finally: teardown() diff --git a/bot_bottle/bottle_state.py b/bot_bottle/bottle_state.py index b97c89b..1ac5bb0 100644 --- a/bot_bottle/bottle_state.py +++ b/bot_bottle/bottle_state.py @@ -36,7 +36,7 @@ from dataclasses import dataclass from pathlib import Path from typing import cast -from . import supervise as _supervise +from .paths import bot_bottle_root # Directory layout: ~/.bot-bottle/state//... @@ -163,7 +163,7 @@ def read_metadata(identity: str) -> BottleMetadata | None: def bottle_state_dir(identity: str) -> Path: """Per-bottle state directory on the host. Created lazily by the write helpers; readers tolerate its absence.""" - return _supervise.bot_bottle_root() / _STATE_SUBDIR / identity + return bot_bottle_root() / _STATE_SUBDIR / identity def per_bottle_dockerfile_path(identity: str) -> Path: diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index bf0766b..ff66ba6 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from .. import supervise as _supervise +from ..paths import bot_bottle_root from ..bottle_state import read_metadata from ..backend.docker.egress_apply import ( EgressApplyError, @@ -276,7 +276,7 @@ def _write_crash_log(exc: BaseException) -> Path: ) entry = f"=== supervise crash {stamp} ===\n{body}\n" try: - log_dir = _supervise.bot_bottle_root() / "logs" + log_dir = bot_bottle_root() / "logs" log_dir.mkdir(parents=True, exist_ok=True) path = log_dir / "supervise-crash.log" with path.open("a", encoding="utf-8") as fh: diff --git a/bot_bottle/docker_cmd.py b/bot_bottle/docker_cmd.py new file mode 100644 index 0000000..b8b4961 --- /dev/null +++ b/bot_bottle/docker_cmd.py @@ -0,0 +1,27 @@ +"""Lean, framework-free `docker` subprocess primitive. + +Deliberately a top-level module with a single stdlib import so it can be +reused from anywhere without cost. It is intentionally *not* placed in +`backend.docker.util`: importing that module runs `backend/__init__.py`, +which eagerly loads all three bottle backends (docker + firecracker + +macos) plus the manifest/egress/git-gate/supervise framework — ~76 modules +— which would drag the whole backend layer into the deliberately-lean +orchestrator. This primitive stays free of that so both the orchestrator's +docker components and (in time) `backend.docker.util` can share it.""" + +from __future__ import annotations + +import subprocess + + +def run_docker(argv: list[str]) -> subprocess.CompletedProcess[str]: + """Run a `docker` command, capturing stdout/stderr as text. Never raises + on a non-zero exit — callers inspect `returncode` / `stderr` so they can + stay fail-closed or tolerate idempotent no-ops (e.g. removing an + already-absent container).""" + return subprocess.run( + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, + ) + + +__all__ = ["run_docker"] diff --git a/bot_bottle/egress_addon.py b/bot_bottle/egress_addon.py index d0cf0c7..4516d0c 100644 --- a/bot_bottle/egress_addon.py +++ b/bot_bottle/egress_addon.py @@ -1,4 +1,4 @@ -"""mitmproxy addon entrypoint for the egress sidecar (PRD 0017, PRD 0053). +"""mitmproxy addon entrypoint for the egress gateway (PRD 0017, PRD 0053). Loaded by `mitmdump -s /app/egress_addon.py` inside the egress container.""" @@ -10,6 +10,7 @@ import json import os import signal import sys +import typing from pathlib import Path from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error @@ -32,6 +33,7 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis is_git_push_request, load_config, match_route, + resolve_client_context, outbound_scan_headers, route_to_yaml_dict, scan_inbound, @@ -51,11 +53,26 @@ try: except ImportError: # pragma: no cover - host-side path from bot_bottle import supervise as _sv # type: ignore[import-not-found] +try: + from policy_resolver import PolicyResolver # type: ignore[import-not-found] +except ImportError: # pragma: no cover - host-side path + from bot_bottle.policy_resolver import PolicyResolver + DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml" INTROSPECT_HOST = "_egress.local" +# Consolidated (multi-tenant) mode: when this points at the per-host +# orchestrator's control plane, the addon resolves each client's Config by +# source IP per request instead of using a single static routes file. Unset +# → legacy per-bottle single-tenant mode (unchanged). +ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL" + +# App-layer identity token (defense-in-depth over the source-IP invariant); +# the agent injects it, the addon strips it so it never leaks upstream. +IDENTITY_HEADER = "x-bot-bottle-identity" + # Seconds the egress proxy holds a token-blocked request open waiting for the # operator's supervisor decision (PRD 0062), overridable via env. DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0 @@ -72,20 +89,40 @@ _TOKEN_ALLOW_JUSTIFICATION = ( class EgressAddon: + # Class default so addons built via __new__ (e.g. in tests) default to + # single-tenant; __init__ sets the instance attribute for real runs. + _resolver: "PolicyResolver | None" = None + def __init__(self) -> None: self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH) self.config: Config = Config(routes=()) - # Tokens the operator has approved this session (PRD 0062). In-memory - # only — a restart re-prompts. Mutated only from the asyncio loop that - # runs the addon hooks, so no lock is needed. - self.safe_tokens: set[str] = set() + # Consolidated mode: resolve per-client Config from the orchestrator. + # Absent → single-tenant (static routes file); behaviour unchanged. + orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip() + self._resolver = PolicyResolver(orch_url) if orch_url else None + # Tokens the operator has approved this session (PRD 0062), keyed by + # bottle so the shared gateway keeps each bottle's safelist separate — + # a global set would let bottle A's approved secret pass bottle B's DLP + # scan. In-memory only (a restart re-prompts); mutated only from the + # asyncio loop that runs the addon hooks, so no lock is needed. + self._safe_tokens: dict[str, set[str]] = {} self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip() self._token_allow_timeout = _token_allow_timeout_from_env(os.environ) self._reload(initial=True) self._install_sighup() - def _supervise_available(self) -> bool: - return bool(self._supervise_slug) + @staticmethod + def _supervise_available(slug: str) -> bool: + """Supervise is reachable for this request iff we resolved a bottle to + attribute its proposals to (single-tenant env slug, or a source-IP + -attributed bottle id). Empty → fail closed (no queue to write to).""" + return bool(slug) + + def _safe_tokens_for(self, slug: str) -> set[str]: + """This bottle's operator-approved DLP safelist (PRD 0062), created on + first use. Keyed by bottle so the shared gateway never leaks one + bottle's approved token into another's scan.""" + return self._safe_tokens.setdefault(slug, set()) def _reload(self, *, initial: bool = False) -> None: try: @@ -194,6 +231,28 @@ class EgressAddon: + "\n" ) + def _resolve_flow( + self, flow: http.HTTPFlow, + ) -> "tuple[Config, str, typing.Mapping[str, str]]": + """The `(Config, supervise slug, env)` to apply to this request. + Single-tenant → the static `self.config`, the env slug, and the process + env. Consolidated → the calling bottle's Config + bottle id + auth + tokens, resolved by source IP in one round-trip (fail-closed to deny-all + + empty slug if unattributed); `env` is the process env overlaid with + the bottle's tokens, so upstream-auth injection (and DLP) use *this* + bottle's credentials — exactly what the per-bottle sidecar's env did. + The identity token, if the agent injected one, is read then stripped so + it never leaks upstream.""" + if self._resolver is None: + return self.config, self._supervise_slug, os.environ + conn = flow.client_conn + client_ip = conn.peername[0] if conn and conn.peername else "" + token = flow.request.headers.get(IDENTITY_HEADER, "") + flow.request.headers.pop(IDENTITY_HEADER, None) + config, slug, tokens = resolve_client_context(self._resolver, client_ip, token) + env = {**os.environ, **tokens} if tokens else os.environ + return config, slug, env + async def request(self, flow: http.HTTPFlow) -> None: request_path, _, query = flow.request.path.partition("?") @@ -201,12 +260,14 @@ class EgressAddon: self._serve_introspection(flow, request_path) return + config, slug, env = self._resolve_flow(flow) + # DLP outbound scan BEFORE stripping auth — catches tokens the # agent tried to smuggle in any header, path, query param, or body. # Hostname is included to catch DNS-tunnelling exfiltration attempts. - route = match_route(self.config.routes, flow.request.pretty_host) + route = match_route(config.routes, flow.request.pretty_host) if route is not None: - if not await self._handle_outbound_dlp(flow, route): + if not await self._handle_outbound_dlp(flow, route, slug, env): return # The redact policy may have rewritten the request line; recompute # the path/query the git checks below rely on. @@ -224,7 +285,7 @@ class EgressAddon: if is_git_fetch_request(request_path, query): git_decision = decide_git_fetch( - self.config.routes, flow.request.pretty_host, + config.routes, flow.request.pretty_host, ) if git_decision.action == "block": self._block( @@ -235,17 +296,17 @@ class EgressAddon: return # Strip agent-set Authorization after DLP scan so smuggled tokens - # are caught above; the route may inject sidecar-owned auth below. + # are caught above; the route may inject gateway-owned auth below. flow.request.headers.pop("authorization", None) # Build headers mapping for match evaluation req_headers = {k.lower(): v for k, v in flow.request.headers.items()} decision = decide( - self.config.routes, + config.routes, flow.request.pretty_host, request_path, - os.environ, + env, request_method=flow.request.method, request_headers=req_headers, ) @@ -257,7 +318,7 @@ class EgressAddon: if decision.inject_authorization is not None: flow.request.headers["authorization"] = decision.inject_authorization - if self.config.log >= LOG_FULL: + if config.log >= LOG_FULL: self._log_request(flow) def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None: @@ -270,10 +331,13 @@ class EgressAddon: self, flow: http.HTTPFlow, route: Route, + slug: str, + env: "typing.Mapping[str, str]", ) -> bool: """Scan the outbound request and apply the route's on-match policy - (PRD 0062). Returns True if the request may be forwarded, False if a - 403 response has been written to `flow`. + (PRD 0062). `env` is the per-bottle env overlay (process env + this + bottle's tokens) used for DLP detection. Returns True if the request may + be forwarded, False if a 403 response has been written to `flow`. Loops so the supervise policy can re-scan after each approval — a second, un-approved token in the same request is still caught.""" @@ -290,8 +354,8 @@ class EgressAddon: flow.request.pretty_host, request_path, query, headers, "", ) result = scan_outbound( - route, scan_text, os.environ, - safe_tokens=self.safe_tokens, crlf_text=crlf_text, + route, scan_text, env, + safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text, ) if result is None or result.severity != "block": return True @@ -301,7 +365,7 @@ class EgressAddon: # redact scrubs every detection (tokens and structural CRLF) and # forwards; it fails closed only if a match survives the scrub. if policy == ON_MATCH_REDACT: - if self._redact_outbound(flow, route): + if self._redact_outbound(flow, route, env): if self.config.log >= LOG_BLOCKS: sys.stderr.write(json.dumps({ "event": "egress_redacted", @@ -326,32 +390,34 @@ class EgressAddon: # supervise (default): hold the request for operator approval. # Fall back to a hard 403 when supervise isn't wired for the bottle. - if not self._supervise_available(): + if not self._supervise_available(slug): self._block_dlp(flow, result) return False - approved = await self._supervise_token_block(flow, request_path, result) + approved = await self._supervise_token_block(flow, request_path, result, slug, env) if not approved: return False # _supervise_token_block wrote the 403 response # loop: the approved value is now in safe_tokens; re-scan. - def _redact_outbound(self, flow: http.HTTPFlow, route: Route) -> bool: + def _redact_outbound( + self, flow: http.HTTPFlow, route: Route, env: "typing.Mapping[str, str]", + ) -> bool: """Scrub detected tokens (and CRLF injection sequences) from the mutable - request surfaces (body, headers, path/query) and re-scan. Returns True - if the request is now clean; False if a block-severity match remains on - a surface redaction cannot rewrite (the hostname) so the caller fails - closed.""" + request surfaces (body, headers, path/query) and re-scan. `env` is the + per-bottle env overlay. Returns True if the request is now clean; False + if a block-severity match remains on a surface redaction cannot rewrite + (the hostname) so the caller fails closed.""" body = flow.request.get_text(strict=False) if body: - redacted_body = redact_tokens(body, env=os.environ) + redacted_body = redact_tokens(body, env=env) if redacted_body != body: flow.request.text = redacted_body for name, value in list(flow.request.headers.items()): if name.lower() == "host": continue # routing-critical; never a legitimate token - redacted = strip_crlf(redact_tokens(value, env=os.environ)) + redacted = strip_crlf(redact_tokens(value, env=env)) if redacted != value: flow.request.headers[name] = redacted - redacted_path = strip_crlf(redact_tokens(flow.request.path, env=os.environ)) + redacted_path = strip_crlf(redact_tokens(flow.request.path, env=env)) if redacted_path != flow.request.path: flow.request.path = redacted_path @@ -364,7 +430,7 @@ class EgressAddon: crlf_text = build_outbound_scan_text( flow.request.pretty_host, request_path, query, headers, "", ) - result = scan_outbound(route, scan_text, os.environ, crlf_text=crlf_text) + result = scan_outbound(route, scan_text, env, crlf_text=crlf_text) return result is None or result.severity != "block" async def _supervise_token_block( @@ -372,21 +438,27 @@ class EgressAddon: flow: http.HTTPFlow, request_path: str, result: ScanResult, + slug: str, + env: "typing.Mapping[str, str]", ) -> bool: """Route a token DLP block to the operator's supervisor queue and wait. - Returns True if the operator approved (the matched value is added to - `self.safe_tokens` and the caller re-scans); False if the request must - be blocked (a 403 response has been written to `flow`).""" + `slug` attributes the proposal to the calling bottle (its own queue + + safelist) — in the shared gateway this is what keeps one bottle's + approval from unblocking another's request. `env` is the per-bottle env + overlay used to redact secrets from the proposal text. Returns True if + the operator approved (the matched value is added to that bottle's + safelist and the caller re-scans); False if the request must be blocked + (a 403 response has been written to `flow`).""" host = flow.request.pretty_host payload = build_token_allow_payload( - redact_tokens(host, env=os.environ), + redact_tokens(host, env=env), flow.request.method, - redact_tokens(request_path, env=os.environ), + redact_tokens(request_path, env=env), result, ) proposal = _sv.Proposal.new( - bottle_slug=self._supervise_slug, + bottle_slug=slug, tool=_sv.TOOL_EGRESS_TOKEN_ALLOW, proposed_file=payload, justification=_TOKEN_ALLOW_JUSTIFICATION, @@ -409,13 +481,13 @@ class EgressAddon: **self._req_ctx(flow), }) + "\n") - response = await self._await_token_response(proposal.id) - _sv.archive_proposal(self._supervise_slug, proposal.id) + response = await self._await_token_response(proposal.id, slug) + _sv.archive_proposal(slug, proposal.id) if response is not None and response.status in ( _sv.STATUS_APPROVED, _sv.STATUS_MODIFIED, ): - self.safe_tokens.add(result.matched) + self._safe_tokens_for(slug).add(result.matched) if self.config.log >= LOG_BLOCKS: sys.stderr.write(json.dumps({ "event": "egress_token_allowed", @@ -438,6 +510,7 @@ class EgressAddon: async def _await_token_response( self, proposal_id: str, + slug: str, ) -> "_sv.Response | None": """Poll the DB for the operator's response without blocking the proxy event loop. Returns the Response, or None on timeout.""" @@ -445,7 +518,7 @@ class EgressAddon: deadline = loop.time() + self._token_allow_timeout while True: try: - return _sv.read_response(self._supervise_slug, proposal_id) + return _sv.read_response(slug, proposal_id) except (OSError, ValueError, KeyError): # Not written yet, or a partial/malformed write — retry until # the deadline, then fail closed. @@ -499,6 +572,9 @@ class EgressAddon: """ if flow.websocket is None: # type: ignore[union-attr] return + # WebSocket DLP runs against the static config only (single-tenant); in + # the consolidated gateway self.config has no routes, so this is inert + # until websocket routing is made source-IP-aware (a separate slice). route = match_route(self.config.routes, flow.request.pretty_host) if route is None: return @@ -509,7 +585,7 @@ class EgressAddon: # not an injection vector here — scan only for credential leakage. result = scan_outbound( route, content, os.environ, - safe_tokens=self.safe_tokens, crlf_text="", + safe_tokens=self._safe_tokens_for(self._supervise_slug), crlf_text="", ) if result is not None and result.severity == "block": sys.stderr.write(f"egress DLP: {result.reason}\n") diff --git a/bot_bottle/egress_addon_core.py b/bot_bottle/egress_addon_core.py index af4cc39..24f632c 100644 --- a/bot_bottle/egress_addon_core.py +++ b/bot_bottle/egress_addon_core.py @@ -3,7 +3,7 @@ Split out of `egress_addon.py` so the host's unit tests can exercise the parse + decision functions without depending on the `mitmproxy` package. The companion module wraps these with the -`mitmproxy.http.HTTPFlow` API and is loaded inside the sidecar +`mitmproxy.http.HTTPFlow` API and is loaded inside the gateway container. Imports: stdlib + `yaml_subset` (which is itself stdlib-only and @@ -22,7 +22,7 @@ except ImportError: # pragma: no cover - host-side path from .yaml_subset import YamlSubsetError, parse_yaml_subset # DLP detector-config parsing lives in a sibling module (also flat-bundled -# into the sidecar — see Dockerfile.sidecars). Re-exported below so existing +# into the gateway — see Dockerfile.sidecars). Re-exported below so existing # `from egress_addon_core import ON_MATCH_*` callers keep working. try: from egress_dlp_config import ( # type: ignore[import-not-found] @@ -120,7 +120,7 @@ class ScanResult: reason: str location: str = "" # where the match was found, e.g. "body", "authorization header" context: str = "" # surrounding text with the match replaced by REDACT - # Raw substring the detector matched. Used inside the sidecar to key the + # Raw substring the detector matched. Used inside the gateway to key the # supervisor-approved "safe tokens" set (PRD 0062); never logged or written # to a proposal file. Empty for structural detectors (CRLF) that carry no # safelist-able value. @@ -413,6 +413,70 @@ def load_config(text: str) -> "Config": return parse_config(payload) +class PolicyResolverLike(typing.Protocol): + """The bit of `policy_resolver.PolicyResolver` this module needs — kept a + Protocol so egress_addon_core stays free of that import.""" + + def resolve(self, source_ip: str, identity_token: str = ...) -> "str | None": + ... + + +def _config_from_policy(policy: "str | None") -> "Config": + """Parse a resolved policy blob into a Config, fail-closed: None / empty / + unparseable all become a deny-all Config (no routes → every request + blocked).""" + if not policy: + return Config(routes=()) # unattributed or empty → deny-all + try: + return load_config(policy) + except ValueError: + return Config(routes=()) # unparseable policy → deny + + +def resolve_client_config( + resolver: PolicyResolverLike, client_ip: str, identity_token: str = "" +) -> "Config": + """The calling client's egress Config, resolved from the orchestrator via + `resolver` and parsed — **fail-closed**. An unattributed client (None), a + resolver error, or an unparseable policy all yield a deny-all Config (no + routes → every request blocked). A compromised, absent, or confused + orchestrator must never *widen* a bottle's egress.""" + try: + policy = resolver.resolve(client_ip, identity_token) + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught + return Config(routes=()) # orchestrator unreachable/errored → deny + return _config_from_policy(policy) + + +class ContextResolverLike(typing.Protocol): + """The bit of `policy_resolver.PolicyResolver` `resolve_client_context` + needs — one round-trip returning policy, bottle id, and auth tokens.""" + + def resolve_policy_and_bottle_id( + self, source_ip: str, identity_token: str = ..., + ) -> "tuple[str | None, str | None, dict[str, str]]": + ... + + +def resolve_client_context( + resolver: ContextResolverLike, client_ip: str, identity_token: str = "", +) -> "tuple[Config, str, dict[str, str]]": + """The calling client's `(Config, bottle_id, tokens)` in one round-trip — + **fail-closed**. The Config follows `resolve_client_config`'s deny-all + rules; the bottle id is `""` whenever unattributed or the orchestrator + errored (caller treats as "supervise unavailable", never another bottle's + queue); `tokens` are the per-bottle upstream auth values the addon injects. + One `/resolve` keys the egress policy, the supervise queue + safelist, and + auth injection.""" + try: + policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id( + client_ip, identity_token, + ) + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught + return Config(routes=()), "", {} # orchestrator unreachable/errored → deny + return _config_from_policy(policy), (bottle_id or ""), tokens + + # --------------------------------------------------------------------------- # Match evaluation # --------------------------------------------------------------------------- @@ -613,7 +677,7 @@ def outbound_scan_headers( ) -> dict[str, str]: """Return request headers that should be included in outbound DLP. - Routes that inject sidecar-owned auth always strip the agent's + Routes that inject gateway-owned auth always strip the agent's Authorization header before forwarding. Scanning that header first creates false positives for provider clients that insist on sending their own bearer-shaped placeholder, while still not changing what @@ -664,7 +728,7 @@ def scan_outbound( crlf_text: str | None = None, ) -> ScanResult | None: # Lazy import to avoid circular deps and keep dlp_detectors optional - # at import time (the sidecar copies it flat alongside this file). + # at import time (the gateway copies it flat alongside this file). try: from dlp_detectors import ( # type: ignore[import-not-found] scan_crlf_injection, @@ -804,6 +868,10 @@ __all__ = [ "is_git_push_request", "is_git_fetch_request", "load_config", + "resolve_client_config", + "resolve_client_context", + "PolicyResolverLike", + "ContextResolverLike", "match_route", "outbound_scan_headers", "parse_config", diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index 0c63248..10ed752 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -46,6 +46,7 @@ from .git_gate_render import ( git_gate_known_hosts_line, git_gate_render_access_hook, git_gate_render_entrypoint, + git_gate_render_provision, git_gate_render_gitconfig, git_gate_render_hook, git_gate_upstreams_for_bottle, @@ -155,6 +156,7 @@ __all__ = [ "git_gate_render_gitconfig", "git_gate_known_hosts_line", "git_gate_render_entrypoint", + "git_gate_render_provision", "git_gate_render_hook", "git_gate_render_access_hook", "provision_git_gate_dynamic_keys", diff --git a/bot_bottle/git_gate_render.py b/bot_bottle/git_gate_render.py index ec2cbc8..5b2277c 100644 --- a/bot_bottle/git_gate_render.py +++ b/bot_bottle/git_gate_render.py @@ -9,6 +9,7 @@ own; `git_gate` re-exports these names for API stability.""" from __future__ import annotations +import re import shlex from dataclasses import dataclass from pathlib import Path @@ -125,22 +126,18 @@ def git_gate_known_hosts_line(host: str, port: str, key: str) -> str: return f"{target} {key}\n" -def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: - """Posix-sh entrypoint. One `init_repo` call per upstream, then - `exec git daemon`. The function reads - `/git-gate/creds/-{key,known_hosts}` (bind-mounted into - the bundle by the renderer) and wires them into each bare repo's - config; the access-hook + pre-receive hook pick those paths up - at fetch / push time.""" - lines = [ - "#!/bin/sh", - "set -eu", - "", +def _git_gate_init_repo_fn(repo_root: str, creds_dir: str) -> list[str]: + """The `init_repo` shell function, parameterized by the bare-repo root + and the per-bottle creds dir. Single source of the credential-wiring + logic, shared by the single-tenant daemon entrypoint (`/git`, + `/git-gate/creds`) and the consolidated per-bottle provisioning + (`/git/`, `/git-gate/creds/`).""" + return [ "init_repo() {", " name=$1", " upstream_url=$2", - " keyfile=/git-gate/creds/${name}-key", - " hostsfile=/git-gate/creds/${name}-known_hosts", + f" keyfile={creds_dir}/${{name}}-key", + f" hostsfile={creds_dir}/${{name}}-known_hosts", "", # `|| true`: PRD 0018 chunk 3+ bind-mounts these RO from the # host, so chmod-syscalls fail with EROFS. The files already @@ -153,14 +150,14 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: " chmod 600 \"$hostsfile\" 2>/dev/null || true", " fi", "", - " repo=/git/${name}.git", + f" repo={repo_root}/${{name}}.git", " if [ ! -d \"$repo\" ]; then", " git init --bare \"$repo\" >/dev/null", - # --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so", - # a later `git fetch origin` mirrors the upstream's full ref", - # graph (heads, tags, notes) into the bare repo at canonical", - # paths. It does NOT set remote.origin.mirror=true, so an", - # explicit `git push origin :` still pushes one ref.", + # --mirror=fetch sets remote.origin.fetch = +refs/*:refs/* so a later + # `git fetch origin` mirrors the upstream's full ref graph (heads, + # tags, notes) into the bare repo at canonical paths. It does NOT set + # remote.origin.mirror=true, so an explicit `git push origin + # :` still pushes one ref. " git -C \"$repo\" remote add --mirror=fetch origin \"$upstream_url\"", " fi", " git -C \"$repo\" config git-gate.identityFile \"$keyfile\"", @@ -170,9 +167,19 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: " git -C \"$repo\" config http.receivepack true", " install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"", "}", - "", - "mkdir -p /git", ] + + +def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: + """Posix-sh entrypoint. One `init_repo` call per upstream, then + `exec git daemon`. The function reads + `/git-gate/creds/-{key,known_hosts}` (bind-mounted into + the bundle by the renderer) and wires them into each bare repo's + config; the access-hook + pre-receive hook pick those paths up + at fetch / push time.""" + lines = ["#!/bin/sh", "set -eu", ""] + lines += _git_gate_init_repo_fn("/git", "/git-gate/creds") + lines += ["", "mkdir -p /git"] for u in upstreams: lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}") lines.extend([ @@ -190,6 +197,35 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str: return "\n".join(lines) + "\n" +# A bottle id namespaces the consolidated gateway's repo + creds dirs; it is +# embedded unquoted in the provisioning script, so restrict it to a shell- and +# path-safe alphabet (registry ids are token_hex — this is defense in depth). +_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+") + + +def git_gate_render_provision( + bottle_id: str, upstreams: tuple[GitGateUpstream, ...], +) -> str: + """Posix-sh script that provisions ONE bottle's bare repos into the + consolidated gateway (PRD 0070), under `/git//` with creds + read from `/git-gate/creds//`. Init-only — no `git daemon`, + since the shared gateway already serves every bottle; run inside the + running gateway when the bottle is registered. + + Isolating each bottle's repo root and creds dir by id is what keeps one + bottle's push credentials out of another's repos on the shared gateway.""" + if not _SAFE_BOTTLE_ID.fullmatch(bottle_id): + raise ValueError(f"git-gate: unsafe bottle id {bottle_id!r}") + repo_root = f"/git/{bottle_id}" + creds_dir = f"/git-gate/creds/{bottle_id}" + lines = ["#!/bin/sh", "set -eu", ""] + lines += _git_gate_init_repo_fn(repo_root, creds_dir) + lines += ["", f"mkdir -p {shlex.quote(repo_root)}"] + for u in upstreams: + lines.append(f"init_repo {shlex.quote(u.name)} {shlex.quote(u.upstream_url)}") + return "\n".join(lines) + "\n" + + def git_gate_render_hook() -> str: """The shared pre-receive hook: gitleaks-scan all incoming refs, then forward each accepted ref to the real upstream (`origin`) diff --git a/bot_bottle/git_http_backend.py b/bot_bottle/git_http_backend.py index 04e6169..25275ac 100644 --- a/bot_bottle/git_http_backend.py +++ b/bot_bottle/git_http_backend.py @@ -6,6 +6,15 @@ where the guest reaches the sidecar over the point-to-point TAP). The wrapper serves the same `/git/*.git` bare repos through `git http-backend`, so pre-receive and upstream forwarding remain the git-gate enforcement point. + +Consolidated (PRD 0070): when `BOT_BOTTLE_ORCHESTRATOR_URL` is set, one +shared gateway serves every bottle, and each request is served from the +calling bottle's repo namespace (`/`), attributed from +the unspoofable source IP via the orchestrator. Per-repo credentials + +hooks scope by repo directory, so isolating the *root* per bottle isolates +its creds too. Unattributed clients fail closed (404). Unset → the legacy +per-bottle single-tenant flat root, unchanged — a transitional path that +gets stripped out once every backend runs the consolidated gateway. """ from __future__ import annotations @@ -13,13 +22,86 @@ from __future__ import annotations import os import subprocess import sys +import typing from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlsplit +# policy_resolver ships flat alongside this file in the sidecar bundle +# image (see Dockerfile.sidecars); the bot_bottle.* fallback is the +# host-side / test path. Mirrors egress_addon's import shape. +try: + from policy_resolver import ( # type: ignore[import-not-found] + PolicyResolveError, + PolicyResolver, + ) +except ImportError: # pragma: no cover - host-side path + from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver + DEFAULT_PORT = 9420 +# Consolidated (multi-tenant) mode: when this points at the per-host +# orchestrator's control plane, the backend serves each request from the +# *calling* bottle's repo namespace, selected by source IP, instead of a +# single flat repo root. Unset → legacy per-bottle single-tenant mode +# (unchanged). Same env the egress addon reads, so one orchestrator setting +# flips the whole shared gateway multi-tenant. +ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL" + +# App-layer identity token (defense-in-depth over the source-IP invariant); +# the agent injects it, the backend reads it for attribution and never +# forwards it to `git http-backend`. Mirrors egress_addon.IDENTITY_HEADER +# (duplicated, not imported: egress_addon pulls in mitmproxy). +IDENTITY_HEADER = "x-bot-bottle-identity" + +# Default flat repo root (single-tenant, and the base under which +# consolidated mode nests each sandbox's namespace). +DEFAULT_REPO_ROOT = "/git" + + +class ResolverLike(typing.Protocol): + """Structural type for the resolver `resolve_sandbox_root` needs — just + `resolve_bottle_id`. A Protocol so tests can pass a fake without + importing PolicyResolver (mirrors egress_addon_core.PolicyResolverLike).""" + + def resolve_bottle_id( + self, source_ip: str, identity_token: str = ..., + ) -> "str | None": ... + + +def resolve_sandbox_root( + resolver: "ResolverLike | None", + base_root: Path, + source_ip: str, + identity_token: str = "", +) -> Path | None: + """The per-sandbox repo root to serve this request from, or None to + deny (404). + + Single-tenant (`resolver is None`): the flat `base_root`, unchanged. + NOTE: this legacy per-bottle single-tenant path is transitional — it + will be stripped out once every backend runs the consolidated gateway + (PRD 0070), leaving only the source-IP-attributed path below. + + Consolidated: `base_root/`, where the sandbox is attributed + from the source IP via the orchestrator. Fail-closed — an unattributed + client, a resolver error, or a namespace that would escape `base_root` + all deny, so one sandbox can never reach another's repos.""" + if resolver is None: + return base_root + try: + bottle_id = resolver.resolve_bottle_id(source_ip, identity_token) + except PolicyResolveError: + return None # orchestrator unreachable/errored → deny + if not bottle_id: + return None # unattributed → deny + base = base_root.resolve() + namespace = (base / bottle_id).resolve() + if base not in namespace.parents: + return None # bottle_id tried to escape the root → deny + return namespace + # Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than # imported: this module ships as a flat top-level sibling in the sidecar # bundle image (see Dockerfile.sidecars), not as part of the bot_bottle @@ -40,10 +122,25 @@ class GitHttpHandler(BaseHTTPRequestHandler): def do_POST(self) -> None: self._run_backend() + def _sandbox_root(self) -> Path | None: + """This request's per-sandbox repo root, or None to deny. Single-tenant + unless the server was started with a resolver (consolidated mode), in + which case the root is the calling sandbox's source-IP-selected + namespace. `GIT_PROJECT_ROOT` keeps git's own env-var name.""" + base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_REPO_ROOT)) + resolver = getattr(self.server, "policy_resolver", None) + token = self.headers.get(IDENTITY_HEADER, "") + return resolve_sandbox_root(resolver, base, self.client_address[0], token) + def _run_backend(self) -> None: + sandbox_root = self._sandbox_root() + if sandbox_root is None: + # Unattributed / resolver error: deny before touching any repo. + self.send_error(404) + return parsed = urlsplit(self.path) if self._is_upload_pack(parsed.path, parsed.query): - repo_dir = self._repo_dir(parsed.path) + repo_dir = self._repo_dir(sandbox_root, parsed.path) if repo_dir is None: self.send_error(404) return @@ -77,7 +174,7 @@ class GitHttpHandler(BaseHTTPRequestHandler): return env = os.environ.copy() env.update({ - "GIT_PROJECT_ROOT": os.environ.get("GIT_PROJECT_ROOT", "/git"), + "GIT_PROJECT_ROOT": str(sandbox_root), "GIT_HTTP_EXPORT_ALL": "1", "REQUEST_METHOD": self.command, "PATH_INFO": parsed.path, @@ -91,6 +188,14 @@ class GitHttpHandler(BaseHTTPRequestHandler): "SERVER_PORT": str(self.server.server_port), # type: ignore "SERVER_PROTOCOL": self.request_version, }) + # Consolidated mode: attribute the gitleaks-allow supervise proposal + # (written by receive-pack's pre-receive hook, a child of the CGI we + # spawn below) to the calling bottle. The namespaced root is + # `/`, so its final component is the bottle id — the + # same per-bottle key egress uses. Single-tenant leaves the hook's + # container-stamped SUPERVISE_BOTTLE_SLUG untouched. + if getattr(self.server, "policy_resolver", None) is not None: + env["SUPERVISE_BOTTLE_SLUG"] = sandbox_root.name for header, variable in ( ("accept", "HTTP_ACCEPT"), ("content-encoding", "HTTP_CONTENT_ENCODING"), @@ -123,8 +228,8 @@ class GitHttpHandler(BaseHTTPRequestHandler): ) self._write_cgi_response(proc.stdout) - def _repo_dir(self, path: str) -> Path | None: - root = Path(os.environ.get("GIT_PROJECT_ROOT", "/git")).resolve() + def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None: + root = sandbox_root.resolve() relative = path.lstrip("/").split(".git", 1)[0] + ".git" candidate = (root / relative).resolve() if root not in (candidate, *candidate.parents): @@ -181,7 +286,13 @@ class GitHttpHandler(BaseHTTPRequestHandler): def main() -> int: port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT))) server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler) - sys.stdout.write(f"git-http listening on 0.0.0.0:{port}\n") + orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip() + # Consolidated mode: resolve each request's sandbox namespace by source + # IP. Absent → single-tenant (flat repo root); behaviour unchanged. + resolver = PolicyResolver(orch_url) if orch_url else None + server.policy_resolver = resolver # type: ignore[attr-defined] + mode = "multi-tenant" if orch_url else "single-tenant" + sys.stdout.write(f"git-http listening on 0.0.0.0:{port} ({mode})\n") sys.stdout.flush() server.serve_forever() return 0 diff --git a/bot_bottle/orchestrator/__init__.py b/bot_bottle/orchestrator/__init__.py new file mode 100644 index 0000000..47ff282 --- /dev/null +++ b/bot_bottle/orchestrator/__init__.py @@ -0,0 +1,64 @@ +"""Per-host orchestrator service (PRD 0070). + +A single persistent per-host service that will run the gateway functions +(egress / git-gate / supervise), coordinate with the console, and broker +agent launches. This package is being built bottom-up, starting with the +backend-neutral "consolidation core" that needs no VM packaging: + + * `registry` — the SQLite runtime-state store + fail-closed + attribution (source IP + per-bottle identity token). + * `broker` — the signed, structured launch-request contract + a + `LaunchBroker` (stub for the harness) that verifies + provenance before acting. + * `gateway` — the consolidated per-host gateway: a `Gateway` + lifecycle contract (idempotent singleton) + a + `DockerGateway` impl. One gateway shared by all + bottles instead of one per bottle. + * `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 / + list / attribute / gateway / health). + +The actual backend-native launch (a real docker/firecracker broker) and +the egress/git/supervise data plane land in later slices once this core is +proven (see the PRD sequencing: plain-process dev-harness -> docker +orchestrator -> firecracker). +""" + +from __future__ import annotations + +from .registry import BottleRecord, RegistryStore, new_identity_token +from .broker import ( + BrokerAuthError, + LaunchBroker, + LaunchRequest, + StubBroker, + sign_request, + verify_request, +) +from .docker_broker import DockerBroker, DockerBrokerError +from .gateway import DockerGateway, Gateway, GatewayError +from .service import Orchestrator +from .control_plane import ControlPlaneServer, dispatch, make_server + +__all__ = [ + "BottleRecord", + "RegistryStore", + "new_identity_token", + "BrokerAuthError", + "LaunchBroker", + "LaunchRequest", + "StubBroker", + "DockerBroker", + "DockerBrokerError", + "Gateway", + "DockerGateway", + "GatewayError", + "sign_request", + "verify_request", + "Orchestrator", + "ControlPlaneServer", + "dispatch", + "make_server", +] diff --git a/bot_bottle/orchestrator/__main__.py b/bot_bottle/orchestrator/__main__.py new file mode 100644 index 0000000..9a8d15d --- /dev/null +++ b/bot_bottle/orchestrator/__main__.py @@ -0,0 +1,85 @@ +"""Run the orchestrator control plane as a plain process (PRD 0070 dev-harness). + + python -m bot_bottle.orchestrator [--host H] [--port P] [--db PATH] + +The PRD sequences the orchestrator as a plain-process dev-harness first, so +the consolidation core (registry + attribution + HTTP control plane + live +reload) can be exercised with fast iteration, decoupled from any VM / +container packaging. Wrapping this exact service in a backend-native unit +(docker container, then Firecracker VM) comes later. +""" + +from __future__ import annotations + +import argparse +import secrets +from pathlib import Path + +from .. import log +from .broker import LaunchBroker, StubBroker +from .control_plane import make_server +from .docker_broker import DockerBroker +from .registry import RegistryStore, default_db_path +from .service import Orchestrator +from .gateway import DockerGateway, Gateway + + +def main(argv: list[str] | None = None) -> int: + """Parse args, migrate the registry, and serve the control plane.""" + parser = argparse.ArgumentParser(prog="bot_bottle.orchestrator") + parser.add_argument("--host", default="127.0.0.1", help="bind address") + parser.add_argument("--port", type=int, default=8080, help="bind port (0 = ephemeral)") + parser.add_argument( + "--db", type=Path, default=None, + help=f"registry DB path (default: {default_db_path()})", + ) + parser.add_argument( + "--broker", choices=("stub", "docker"), default="stub", + help="launch broker: 'stub' records requests; 'docker' runs containers", + ) + parser.add_argument( + "--gateway", action="store_true", + help="run one consolidated per-host sidecar bundle (build-if-missing)", + ) + args = parser.parse_args(argv) + + registry = RegistryStore(args.db) + registry.migrate() + + # An ephemeral signing secret ties the orchestrator (signer) to its + # broker (verifier). 'stub' records launches instead of starting + # anything; 'docker' runs real containers (firecracker drops in later). + secret = secrets.token_bytes(32) + broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret) + # Standalone `--gateway` (not the consolidated flow, where the host + # lifecycle runs the gateway). The gateway resolves against this same + # process; the URL is only reachable when they share a docker network. + gateway: Gateway | None = ( + DockerGateway(orchestrator_url=f"http://{args.host}:{args.port}") + if args.gateway else None + ) + orchestrator = Orchestrator(registry, broker, secret, gateway) + + # One persistent per-host gateway, shared by every bottle: build the + # bundle image if missing, then bring the singleton up (idempotent). + if gateway is not None: + orchestrator.ensure_gateway() + log.info("consolidated gateway ensured", context={"name": gateway.name}) + + server = make_server(orchestrator, host=args.host, port=args.port) + bound_host, bound_port = server.server_address[0], server.server_address[1] + log.info( + "orchestrator control plane listening", + context={"host": bound_host, "port": bound_port, "db": str(registry.db_path)}, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + log.info("orchestrator shutting down") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bot_bottle/orchestrator/broker.py b/bot_bottle/orchestrator/broker.py new file mode 100644 index 0000000..937cbc2 --- /dev/null +++ b/bot_bottle/orchestrator/broker.py @@ -0,0 +1,176 @@ +"""Launch broker contract (PRD 0070). + +A VM/container can't spawn its own host-network siblings, so the +orchestrator brokers agent launches through a small privileged component. +The request it sends is: + + * **structured** — static flags + ids only (bottle id, pool slot, a + content-addressed image ref), never a free-form path or argv, so a + request can't be coerced into launching an arbitrary payload; and + * **signed** — wrapped as a compact JWS/JWT the broker verifies before + acting, so a *compromised co-located component* (an agent-facing + gateway, say) can't forge a launch. This is the concrete form of the + "structured requests only" rule in the PRD security review. + +Signing is **HS256** over a secret shared by the orchestrator (signer) and +the broker (verifier) — appropriate here because both are trusted host +components provisioned together; the secret is exactly what an +agent-facing component does not have. (Stdlib only — the project takes no +runtime deps; a cross-host deployment could swap in an asymmetric alg.) +""" + +from __future__ import annotations + +import abc +import base64 +import hashlib +import hmac +import json +import secrets +import time +from dataclasses import dataclass + +_JWT_HEADER = {"alg": "HS256", "typ": "JWT"} +_ALLOWED_OPS = ("launch", "teardown") + + +class BrokerAuthError(Exception): + """A broker request failed provenance or schema verification — + bad/absent signature, malformed token, or a payload that doesn't match + the fixed launch-request shape. Fail-closed: the broker must not act.""" + + +@dataclass(frozen=True) +class LaunchRequest: + """The structured, un-coercible launch/teardown request. Ids + flags + only — `image_ref` is a content-addressed id, never a path/argv.""" + + op: str # one of _ALLOWED_OPS + bottle_id: str + source_ip: str = "" + image_ref: str = "" + slot: int | None = None + + +# --- minimal JWS/JWT (HS256), stdlib only ---------------------------------- + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def _mac(signing_input: str, secret: bytes) -> str: + digest = hmac.new(secret, signing_input.encode("ascii"), hashlib.sha256).digest() + return _b64url(digest) + + +def sign_request(req: LaunchRequest, secret: bytes) -> str: + """Serialize `req` to signed-JWT form. Adds a per-signature `jti` + (replay id) and `iat` (issued-at).""" + claims: dict[str, object] = { + "op": req.op, + "bottle_id": req.bottle_id, + "source_ip": req.source_ip, + "image_ref": req.image_ref, + "slot": req.slot, + "jti": secrets.token_hex(8), + "iat": int(time.time()), + } + header = _b64url(json.dumps(_JWT_HEADER, sort_keys=True, separators=(",", ":")).encode()) + payload = _b64url(json.dumps(claims, sort_keys=True, separators=(",", ":")).encode()) + signing_input = f"{header}.{payload}" + return f"{signing_input}.{_mac(signing_input, secret)}" + + +def verify_request(token: str, secret: bytes) -> LaunchRequest: + """Verify the signature and the request shape; return the parsed + request. Raises `BrokerAuthError` on any failure (fail-closed).""" + parts = token.split(".") + if len(parts) != 3: + raise BrokerAuthError("malformed token") + header_b, payload_b, sig = parts + if not hmac.compare_digest(_mac(f"{header_b}.{payload_b}", secret), sig): + raise BrokerAuthError("bad signature") + try: + header = json.loads(_b64url_decode(header_b)) + claims = json.loads(_b64url_decode(payload_b)) + except (ValueError, json.JSONDecodeError) as e: + raise BrokerAuthError("malformed token payload") from e + if not isinstance(header, dict) or header.get("alg") != "HS256": + raise BrokerAuthError("unexpected header/alg") + if not isinstance(claims, dict): + raise BrokerAuthError("claims are not an object") + + op = claims.get("op") + bottle_id = claims.get("bottle_id") + source_ip = claims.get("source_ip", "") + image_ref = claims.get("image_ref", "") + slot = claims.get("slot") + if ( + not isinstance(op, str) or op not in _ALLOWED_OPS + or not isinstance(bottle_id, str) or not bottle_id + or not isinstance(source_ip, str) or not isinstance(image_ref, str) + or not (slot is None or isinstance(slot, int)) + ): + raise BrokerAuthError("request does not match the launch-request schema") + return LaunchRequest( + op=op, bottle_id=bottle_id, source_ip=source_ip, image_ref=image_ref, slot=slot + ) + + +# --- the broker itself ------------------------------------------------------ + +class LaunchBroker(abc.ABC): + """Verifies a signed request came from the orchestrator, then performs + the backend-native launch/teardown. Subclasses implement `_launch` / + `_teardown`; verification is shared and fail-closed.""" + + def __init__(self, secret: bytes) -> None: + self._secret = secret + + def submit(self, token: str) -> LaunchRequest: + """Verify `token` and perform its op. Returns the verified request; + raises `BrokerAuthError` if provenance/shape fails.""" + req = verify_request(token, self._secret) + if req.op == "launch": + self._launch(req) + else: + self._teardown(req) + return req + + @abc.abstractmethod + def _launch(self, req: LaunchRequest) -> None: + ... + + @abc.abstractmethod + def _teardown(self, req: LaunchRequest) -> None: + ... + + +class StubBroker(LaunchBroker): + """Dev-harness broker: records verified requests without launching + anything. Exercises the full sign -> verify -> act contract in-process.""" + + def __init__(self, secret: bytes) -> None: + super().__init__(secret) + self.launched: list[LaunchRequest] = [] + self.torn_down: list[LaunchRequest] = [] + + def _launch(self, req: LaunchRequest) -> None: + self.launched.append(req) + + def _teardown(self, req: LaunchRequest) -> None: + self.torn_down.append(req) + + +__all__ = [ + "BrokerAuthError", + "LaunchRequest", + "LaunchBroker", + "StubBroker", + "sign_request", + "verify_request", +] diff --git a/bot_bottle/orchestrator/client.py b/bot_bottle/orchestrator/client.py new file mode 100644 index 0000000..f6bdc71 --- /dev/null +++ b/bot_bottle/orchestrator/client.py @@ -0,0 +1,144 @@ +"""Host-side control-plane client (PRD 0070). + +The launch path talks to the orchestrator over its HTTP control plane to +register, re-policy, and tear down bottles — the counterpart to the +gateway-side `PolicyResolver` (which only reads `/resolve`). Where +`PolicyResolver` is fail-closed and lives in the untrusted data plane, this +is the trusted control-plane caller: a non-success response is an error the +launch path must surface, not silently swallow. + +Stdlib-only, so the CLI can drive the orchestrator without any dependency. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass + +DEFAULT_TIMEOUT_SECONDS = 5.0 + + +class OrchestratorClientError(RuntimeError): + """A control-plane call failed (unreachable, or an unexpected status).""" + + +@dataclass(frozen=True) +class RegisteredBottle: + """What `POST /bottles` returns: the minted bottle id and the per-bottle + identity token the agent presents for app-layer attribution.""" + + bottle_id: str + identity_token: str + + +class OrchestratorClient: + """Trusted host-side client for the orchestrator control plane.""" + + def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None: + self._base = base_url.rstrip("/") + self._timeout = timeout + + def _request( + self, method: str, path: str, body: dict[str, object] | None = None, + ) -> tuple[int, dict[str, object]]: + """Send one request; return `(status, payload)`. Raises + `OrchestratorClientError` only when the orchestrator can't be reached + or returns malformed data — HTTP *status* codes are returned so + callers can treat 404 as a meaningful "no such bottle".""" + data = json.dumps(body).encode() if body is not None else None + headers = {"Content-Type": "application/json"} if data is not None else {} + req = urllib.request.Request( + f"{self._base}{path}", data=data, method=method, headers=headers, + ) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + raw = resp.read() + payload = json.loads(raw) if raw else {} + return resp.status, payload if isinstance(payload, dict) else {} + except urllib.error.HTTPError as e: + # A structured error response still carries a usable status. + try: + payload = json.loads(e.read() or b"{}") + except (ValueError, OSError): + payload = {} + return e.code, payload if isinstance(payload, dict) else {} + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: + raise OrchestratorClientError(f"{method} {path}: {e}") from e + + def _ok(self, method: str, path: str, body: dict[str, object] | None = None) -> dict[str, object]: + """`_request` that requires a 2xx, raising otherwise.""" + status, payload = self._request(method, path, body) + if not 200 <= status < 300: + detail = payload.get("error", "") + raise OrchestratorClientError(f"{method} {path}: HTTP {status} {detail}".rstrip()) + return payload + + def health(self) -> bool: + """True iff the control plane answers `GET /health` with 200.""" + try: + status, _ = self._request("GET", "/health") + except OrchestratorClientError: + return False + return status == 200 + + def register_bottle( + self, + source_ip: str, + *, + image_ref: str = "", + metadata: str = "", + policy: str = "", + tokens: dict[str, str] | None = None, + ) -> RegisteredBottle: + """Register a bottle and broker its launch (`POST /bottles`). `tokens` + are the per-bottle egress auth values (env_name -> value) the + orchestrator holds in memory for the gateway to inject. Returns the + minted id + identity token.""" + payload = self._ok("POST", "/bottles", { + "source_ip": source_ip, + "image_ref": image_ref, + "metadata": metadata, + "policy": policy, + "tokens": tokens or {}, + }) + bottle_id = payload.get("bottle_id") + token = payload.get("identity_token") + if not isinstance(bottle_id, str) or not isinstance(token, str): + raise OrchestratorClientError("register: response missing bottle_id/identity_token") + return RegisteredBottle(bottle_id=bottle_id, identity_token=token) + + def teardown_bottle(self, bottle_id: str) -> bool: + """Tear a bottle down (`DELETE /bottles/`). False if the + orchestrator didn't know it (404) — idempotent for cleanup paths.""" + status, _ = self._request("DELETE", f"/bottles/{bottle_id}") + if status == 404: + return False + if not 200 <= status < 300: + raise OrchestratorClientError(f"teardown {bottle_id}: HTTP {status}") + return True + + def set_policy(self, bottle_id: str, policy: str) -> bool: + """Live-reload a bottle's policy (`PUT /bottles//policy`). False on + 404 (unknown bottle).""" + status, _ = self._request("PUT", f"/bottles/{bottle_id}/policy", {"policy": policy}) + if status == 404: + return False + if not 200 <= status < 300: + raise OrchestratorClientError(f"set_policy {bottle_id}: HTTP {status}") + return True + + def list_bottles(self) -> list[dict[str, object]]: + """Every registered bottle's redacted record (`GET /bottles`).""" + payload = self._ok("GET", "/bottles") + bottles = payload.get("bottles") + return bottles if isinstance(bottles, list) else [] + + +__all__ = [ + "OrchestratorClient", + "OrchestratorClientError", + "RegisteredBottle", + "DEFAULT_TIMEOUT_SECONDS", +] diff --git a/bot_bottle/orchestrator/control_plane.py b/bot_bottle/orchestrator/control_plane.py new file mode 100644 index 0000000..2e5dec1 --- /dev/null +++ b/bot_bottle/orchestrator/control_plane.py @@ -0,0 +1,219 @@ +"""Orchestrator HTTP control plane (PRD 0070). + +The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over +**HTTP** — the universal transport chosen in 0070 (works on every host; no +vsock / unix-socket portability caveats): + + GET /health -> 200 {"status": "ok"} + GET /gateway -> 200 {"configured", ["name","running"]} + GET /bottles -> 200 {"bottles": [ , ...]} + POST /bottles -> 201 {"bottle_id","identity_token"} (launch) + body: {"source_ip", ["image_ref"], + ["metadata"], ["policy"]} + PUT /bottles//policy -> 200 {"updated": true} | 404 (live reload) + body: {"policy"} + DELETE /bottles/ -> 200 {"torn_down": true} | 404 (teardown) + POST /attribute -> 200 {"bottle_id"} | 403 + POST /resolve -> 200 {"bottle_id","policy"} | 403 + body: {"source_ip","identity_token"} + +`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or +tear down) the bottle in the registry AND broker the backend-native launch +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 +thin stdlib adapter around it. Listing redacts identity tokens — they are +returned only once, to the caller that launches the bottle. +""" + +from __future__ import annotations + +import http.server +import json +import os +import socketserver +import sys +import typing +from urllib.parse import urlsplit + +from .service import Orchestrator + +# JSON body payload type (parsed request / rendered response). +Json = dict[str, object] + + +def _parse_json_object(body: bytes) -> Json: + """Parse a JSON object body. Raises ValueError for non-objects / bad JSON.""" + if not body: + return {} + obj = json.loads(body) # raises json.JSONDecodeError (a ValueError) + if not isinstance(obj, dict): + raise ValueError("request body must be a JSON object") + return obj + + +def dispatch( # pylint: disable=too-many-return-statements,too-many-branches + orch: Orchestrator, method: str, path: str, body: bytes +) -> tuple[int, Json]: + """Route one control-plane request to a (status, payload) pair. Pure — + no I/O beyond the orchestrator — so it is fully testable without a socket.""" + route = urlsplit(path).path.rstrip("/") or "/" + + if method == "GET" and route == "/health": + return 200, {"status": "ok"} + + if method == "GET" and route == "/gateway": + return 200, orch.gateway_status() + + if method == "GET" and route == "/bottles": + return 200, {"bottles": [r.redacted() for r in orch.registry.all()]} + + if method == "POST" and route == "/bottles": + try: + data = _parse_json_object(body) + except ValueError as e: + return 400, {"error": f"invalid JSON: {e}"} + source_ip = data.get("source_ip") + if not isinstance(source_ip, str) or not source_ip: + return 400, {"error": "source_ip (string) is required"} + image_ref = data.get("image_ref") + metadata = data.get("metadata") + policy = data.get("policy") + raw_tokens = data.get("tokens") + tokens = { + k: v for k, v in raw_tokens.items() if isinstance(k, str) and isinstance(v, str) + } if isinstance(raw_tokens, dict) else {} + rec = orch.launch_bottle( + source_ip, + image_ref=image_ref if isinstance(image_ref, str) else "", + metadata=metadata if isinstance(metadata, str) else "", + policy=policy if isinstance(policy, str) else "", + tokens=tokens, + ) + return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token} + + if method == "PUT" and route.startswith("/bottles/") and route.endswith("/policy"): + bottle_id = route[len("/bottles/"):-len("/policy")] + try: + data = _parse_json_object(body) + except ValueError as e: + return 400, {"error": f"invalid JSON: {e}"} + policy = data.get("policy") + if not isinstance(policy, str): + return 400, {"error": "policy (string) is required"} + if orch.set_policy(bottle_id, policy): + return 200, {"updated": True} + return 404, {"error": "no such bottle"} + + if method == "DELETE" and route.startswith("/bottles/"): + bottle_id = route[len("/bottles/"):] + if orch.teardown_bottle(bottle_id): + return 200, {"torn_down": True} + return 404, {"error": "no such bottle"} + + if method == "POST" and route == "/attribute": + try: + data = _parse_json_object(body) + except ValueError as e: + return 400, {"error": f"invalid JSON: {e}"} + source_ip = data.get("source_ip") + token = data.get("identity_token") + if not isinstance(source_ip, str) or not isinstance(token, str): + return 400, {"error": "source_ip and identity_token (strings) required"} + rec = orch.attribute(source_ip, token) + if rec is None: + return 403, {"error": "unattributed"} + return 200, {"bottle_id": rec.bottle_id} + + if method == "POST" and route == "/resolve": + # The per-request lookup the multi-tenant gateway makes: returns the + # bottle's policy. identity_token is OPTIONAL — absent means resolve + # by source IP alone (network-layer attribution). + try: + data = _parse_json_object(body) + except ValueError as e: + return 400, {"error": f"invalid JSON: {e}"} + source_ip = data.get("source_ip") + token = data.get("identity_token") + if not isinstance(source_ip, str) or not source_ip: + return 400, {"error": "source_ip (string) is required"} + rec = orch.resolve(source_ip, token if isinstance(token, str) else "") + if rec is None: + return 403, {"error": "unattributed"} + # tokens are the in-memory per-bottle egress auth values the gateway + # injects; served here, never persisted. + return 200, { + "bottle_id": rec.bottle_id, + "policy": rec.policy, + "tokens": orch.tokens_for(rec.bottle_id), + } + + return 404, {"error": "not found"} + + +class Handler(http.server.BaseHTTPRequestHandler): + """Thin stdlib adapter: read the body, call `dispatch`, write JSON.""" + + # Quiet by default (the orchestrator has its own logging); opt back into + # stdlib access logging with BOT_BOTTLE_ORCHESTRATOR_DEBUG. + def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002 + if os.environ.get("BOT_BOTTLE_ORCHESTRATOR_DEBUG"): + super().log_message(format, *args) + + def _serve(self, method: str) -> None: + """Read the request body, dispatch it, and write the JSON reply. A + dispatch failure (e.g. a broker error) returns a 500 rather than + crashing the connection, so one bad request can't take the control + plane down for the caller.""" + server = self.server + assert isinstance(server, ControlPlaneServer) + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length > 0 else b"" + try: + status, payload = dispatch(server.orchestrator, method, self.path, body) + except Exception as e: # noqa: BLE001 — the control plane must stay up + sys.stderr.write(f"orchestrator: {method} {self.path} failed: {e!r}\n") + sys.stderr.flush() + status, payload = 500, {"error": f"internal error: {e}"} + data = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self) -> None: + self._serve("GET") + + def do_POST(self) -> None: + self._serve("POST") + + def do_PUT(self) -> None: + self._serve("PUT") + + def do_DELETE(self) -> None: + self._serve("DELETE") + + +class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + """Threading HTTP server that carries the orchestrator for its handlers.""" + + daemon_threads = True + allow_reuse_address = True + + def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None: + self.orchestrator = orchestrator + super().__init__(address, Handler) + + +def make_server( + orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0 +) -> ControlPlaneServer: + """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) + + +__all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"] diff --git a/bot_bottle/orchestrator/docker_broker.py b/bot_bottle/orchestrator/docker_broker.py new file mode 100644 index 0000000..9caf3a6 --- /dev/null +++ b/bot_bottle/orchestrator/docker_broker.py @@ -0,0 +1,83 @@ +"""Docker launch broker (PRD 0070) — the first *real* LaunchBroker. + +On a verified launch request it starts a Docker container; on teardown it +removes it. This proves the orchestrator -> backend seam on the cheapest +backend (the sidecar bundle is already containers). Only the request's +static ids/flags reach `docker`, so nothing free-form crosses the boundary. + +Slice 3 launches a single container from the request's `image_ref`, named +after the bottle id and labelled for cleanup. Wiring the full agent + +sidecar bundle (networks, mounts, the consolidated sidecar) is a later +slice — this is the seam, not the finished launcher. +""" + +from __future__ import annotations + +import subprocess + +from ..docker_cmd import run_docker +from .broker import LaunchBroker, LaunchRequest + +CONTAINER_PREFIX = "bot-bottle-orch-" +BOTTLE_ID_LABEL = "bot-bottle-bottle-id" + + +class DockerBrokerError(Exception): + """A brokered docker launch/teardown failed (non-zero `docker` exit).""" + + +def container_name(bottle_id: str) -> str: + """Deterministic container name for a bottle.""" + return f"{CONTAINER_PREFIX}{bottle_id}" + + +def run_argv(req: LaunchRequest) -> list[str]: + """`docker run` argv for a launch request — built only from its static + fields, so it can't be coerced into an arbitrary command.""" + return [ + "docker", "run", "--detach", + "--name", container_name(req.bottle_id), + "--label", f"{BOTTLE_ID_LABEL}={req.bottle_id}", + req.image_ref, + ] + + +def rm_argv(req: LaunchRequest) -> list[str]: + """`docker rm --force` argv to tear a bottle's container down.""" + return ["docker", "rm", "--force", container_name(req.bottle_id)] + + +class DockerBroker(LaunchBroker): + """A `LaunchBroker` that runs / removes a Docker container per bottle. + Provenance + schema verification are inherited from `LaunchBroker`.""" + + def _docker(self, argv: list[str]) -> subprocess.CompletedProcess[str]: + return run_docker(argv) + + def _launch(self, req: LaunchRequest) -> None: + if not req.image_ref: + raise DockerBrokerError(f"launch request for {req.bottle_id} has no image_ref") + proc = self._docker(run_argv(req)) + if proc.returncode != 0: + raise DockerBrokerError( + f"docker run failed for {req.bottle_id}: {proc.stderr.strip()}" + ) + + def _teardown(self, req: LaunchRequest) -> None: + proc = self._docker(rm_argv(req)) + # Idempotent: an already-absent container is a successful teardown. + if proc.returncode != 0 and "No such container" not in proc.stderr: + raise DockerBrokerError( + f"docker rm failed for {req.bottle_id}: {proc.stderr.strip()}" + ) + + +__all__ = [ + "DockerBroker", + "DockerBrokerError", + "container_name", + "run_argv", + "rm_argv", + "CONTAINER_PREFIX", + "BOTTLE_ID_LABEL", +] diff --git a/bot_bottle/orchestrator/gateway.py b/bot_bottle/orchestrator/gateway.py new file mode 100644 index 0000000..17dccd1 --- /dev/null +++ b/bot_bottle/orchestrator/gateway.py @@ -0,0 +1,228 @@ +"""The consolidated per-host gateway (PRD 0070). + +The core consolidation win: **one** persistent gateway per host, shared by +every bottle, instead of a sidecar bundle per bottle. It's safe to share +because the attribution invariant (source IP + identity token, see +`registry`) lets the gateway attribute each request to the right bottle — +so per-bottle policy lives in one long-lived process keyed on who's calling. + +`Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`): +ensure the single instance is up, report it, tear it down. `DockerGateway` +is the docker implementation; a firecracker gateway VM slots in later. + +The defining behaviour is **idempotent singleton**: `ensure_running` starts +the instance if absent and is a no-op if it's already up, so N bottle +launches never spawn N gateways. +""" + +from __future__ import annotations + +import abc +import os +import time +from pathlib import Path + +from ..docker_cmd import run_docker + +# The gateway's mitmproxy writes its CA a beat after the container starts, so +# reads poll for it rather than assuming it's there on a fresh launch. +_CA_POLL_SECONDS = 0.5 +DEFAULT_CA_TIMEOUT_SECONDS = 30.0 + +GATEWAY_NAME = "bot-bottle-orch-gateway" +GATEWAY_LABEL = "bot-bottle-orch-gateway=1" +# The single user-defined network the gateway and every agent bottle share. +# Agents attach here with a pinned IP and reach the gateway's egress / +# git-http / supervise ports by its address — no host port publishing, and +# the source IP the gateway attributes by is the address on this network. +GATEWAY_NETWORK = "bot-bottle-gateway" + +# mitmproxy's CA dir in the bundle. A persistent named volume here keeps the +# gateway's self-generated CA STABLE across container recreation — every agent +# installs this one CA to trust the shared gateway's TLS interception, so it +# must not rotate when the gateway restarts. +MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy" +GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy" +GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem" + +# The real sidecar-bundle image + its Dockerfile. Kept as a local constant +# rather than imported from backend.docker.sidecar_bundle, which would drag +# the whole backend layer into the lean orchestrator (see #359); unify when +# that lands. Env override matches the backend's BOT_BOTTLE_SIDECAR_IMAGE. +GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest") +GATEWAY_DOCKERFILE = "Dockerfile.sidecars" +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +class GatewayError(Exception): + """The shared gateway failed to build/start/stop (non-zero `docker` exit).""" + + +class Gateway(abc.ABC): + """Lifecycle of the single per-host gateway. Backend-neutral.""" + + name: str + + def ensure_built(self) -> None: + """Ensure the gateway's image / rootfs exists, building it if needed. + Default: nothing to build (e.g. a stub or a pre-pulled image).""" + return + + @abc.abstractmethod + def ensure_running(self) -> None: + """Start the gateway if it isn't already up. Idempotent: a no-op + when it's already running (that's the whole point — one per host). + Assumes the image exists — call `ensure_built()` first.""" + + @abc.abstractmethod + def is_running(self) -> bool: + """True iff the gateway instance is currently up.""" + + @abc.abstractmethod + def stop(self) -> None: + """Remove the gateway. Idempotent — absent is success.""" + + +class DockerGateway(Gateway): + """The consolidated gateway as a single, fixed-name Docker container. + + `image_ref` defaults to the real sidecar-bundle image; `ensure_built` + builds it from `Dockerfile.sidecars` when it's missing. (Note: slice 5 + builds + launches the bundle container; wiring its per-bottle, + source-IP-keyed config is a later slice — see PRD 0070.)""" + + def __init__( + self, + image_ref: str = GATEWAY_IMAGE, + *, + name: str = GATEWAY_NAME, + network: str = GATEWAY_NETWORK, + orchestrator_url: str = "", + build_context: Path | None = None, + dockerfile: str | None = GATEWAY_DOCKERFILE, + ) -> None: + self.image_ref = image_ref + self.name = name + self.network = network + # The control-plane URL the gateway's data plane resolves per bottle + # against — reached by container name over docker DNS on the shared + # network (container↔container, no host firewall). Empty → single-tenant. + self._orchestrator_url = orchestrator_url + self._build_context = build_context or _REPO_ROOT + self._dockerfile = dockerfile + + def image_exists(self) -> bool: + return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0 + + def ensure_built(self) -> None: + """Build the bundle image from its Dockerfile, **cache-aware** — cheap + (a cache check) when nothing changed, a real rebuild when the flat + sources (egress addon / git-http / policy_resolver / supervise) moved. + + This deliberately builds every time rather than build-if-missing: the + per-bottle model kept the image fresh via compose's `build:` on up, and + a stale image silently runs the OLD single-tenant daemons. No-op only + when no dockerfile is configured (a pre-pulled image). BOT_BOTTLE_NO_CACHE + forces a full rebuild (parity with `start --no-cache`).""" + if self._dockerfile is None: + return + argv = ["docker", "build", "-t", self.image_ref, + "-f", str(self._build_context / self._dockerfile), + str(self._build_context)] + if os.environ.get("BOT_BOTTLE_NO_CACHE"): + argv.insert(2, "--no-cache") + proc = run_docker(argv) + if proc.returncode != 0: + raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}") + + def is_running(self) -> bool: + proc = run_docker([ + "docker", "ps", + "--filter", f"name=^{self.name}$", + "--filter", "status=running", + "--format", "{{.Names}}", + ]) + return self.name in proc.stdout.split() + + def _running_image_is_current(self) -> bool: + """True iff the running gateway was created from the *current* + `image_ref`. When `ensure_built` rebuilds the image (a source change), + the running container is still the OLD image running the OLD flat + daemons — so this is how a rebuild actually takes effect: a mismatch + means recreate.""" + running = run_docker(["docker", "inspect", "--format", "{{.Image}}", self.name]) + current = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", self.image_ref]) + if running.returncode != 0 or current.returncode != 0: + return True # can't compare → don't churn a working container + return running.stdout.strip() == current.stdout.strip() + + def _ensure_network(self) -> None: + """Create the shared gateway network if it doesn't exist. Idempotent — + a concurrent create loses harmlessly (the loser sees 'already exists'). + Docker picks the subnet; the launcher reads it back to allocate IPs.""" + if run_docker(["docker", "network", "inspect", self.network]).returncode == 0: + return + proc = run_docker(["docker", "network", "create", self.network]) + if proc.returncode != 0 and "already exists" not in proc.stderr: + raise GatewayError( + f"gateway network {self.network} failed to create: {proc.stderr.strip()}" + ) + + def ensure_running(self) -> None: + # Recreate when the running container's image is stale (a rebuild), + # so source changes to the gateway's flat daemons take effect — not + # just when the container is absent. + if self.is_running() and self._running_image_is_current(): + return + self._ensure_network() + # Clear any stale (stopped OR outdated-image) container holding the + # fixed name, then start fresh. `rm --force` on an absent name is a + # tolerated no-op. + run_docker(["docker", "rm", "--force", self.name]) + argv = [ + "docker", "run", "--detach", + "--name", self.name, + "--label", GATEWAY_LABEL, + "--network", self.network, + # Persist the self-generated CA so it survives restarts (agents + # trust it) — see GATEWAY_CA_VOLUME. + "--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}", + ] + if self._orchestrator_url: + # Makes the gateway's egress / git / supervise daemons multi-tenant: + # each request resolves source-IP -> policy against the control plane. + argv += ["--env", f"BOT_BOTTLE_ORCHESTRATOR_URL={self._orchestrator_url}"] + argv.append(self.image_ref) + proc = run_docker(argv) + if proc.returncode != 0: + raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}") + + def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str: + """The gateway's CA certificate (PEM) that agents install to trust its + TLS interception. mitmproxy generates it a moment after the container + starts, so this **polls** for it (up to `timeout`) rather than assuming + it's already there on a fresh gateway — raising only if it never + appears.""" + deadline = time.monotonic() + timeout + while True: + proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT]) + if proc.returncode == 0 and proc.stdout.strip(): + return proc.stdout + if time.monotonic() >= deadline: + raise GatewayError( + f"gateway CA cert not available after {timeout:g}s: " + f"{proc.stderr.strip() or 'empty'}" + ) + time.sleep(_CA_POLL_SECONDS) + + def stop(self) -> None: + proc = run_docker(["docker", "rm", "--force", self.name]) + if proc.returncode != 0 and "No such container" not in proc.stderr: + raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}") + + +__all__ = [ + "Gateway", "DockerGateway", "GatewayError", + "GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK", + "GATEWAY_CA_VOLUME", "GATEWAY_CA_CERT", +] diff --git a/bot_bottle/orchestrator/lifecycle.py b/bot_bottle/orchestrator/lifecycle.py new file mode 100644 index 0000000..3fa7153 --- /dev/null +++ b/bot_bottle/orchestrator/lifecycle.py @@ -0,0 +1,165 @@ +"""Orchestrator + gateway lifecycle (PRD 0070, docker slice). + +Runs the orchestrator control plane **as a container** on the shared gateway +network, alongside the gateway container. This is the PRD's "virtualize the +orchestrator": container↔container between the gateway and the orchestrator +avoids the host firewall (which drops container→host traffic), and the gateway +reaches the control plane by container name over docker DNS. The host CLI +reaches it via a published loopback port. + +The orchestrator runs with the **register-only broker** — the *backend* +launches agent containers (compose), so the orchestrator needs no docker +socket. That keeps this control-plane container unprivileged; the host manages +both containers. `ensure_running` is an idempotent singleton (fixed container +names + the published port). +""" + +from __future__ import annotations + +import time +import urllib.error +import urllib.request +from pathlib import Path + +from .. import log +from ..docker_cmd import run_docker +from ..paths import bot_bottle_root +from .gateway import GATEWAY_IMAGE, GATEWAY_NETWORK, DockerGateway + +DEFAULT_PORT = 8099 +ORCHESTRATOR_NAME = "bot-bottle-orchestrator" +ORCHESTRATOR_LABEL = "bot-bottle-orchestrator=1" + +# The repo root is bind-mounted into the control-plane container so +# `python -m bot_bottle.orchestrator` resolves the package (the orchestrator +# is stdlib-only, so the bundle image's python is enough). +_REPO_ROOT = Path(__file__).resolve().parents[2] +_APP_DIR = "/app" +_ROOT_IN_CONTAINER = "/bot-bottle-root" + +_HEALTH_POLL_SECONDS = 0.25 +DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0 +_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0 + + +class OrchestratorStartError(RuntimeError): + """The orchestrator container did not become healthy within the timeout.""" + + +class OrchestratorService: + """Manages the orchestrator control-plane container + the shared gateway. + Callers only need `ensure_running()` + `url`.""" + + def __init__( + self, + *, + port: int = DEFAULT_PORT, + network: str = GATEWAY_NETWORK, + image: str = GATEWAY_IMAGE, + repo_root: Path = _REPO_ROOT, + host_root: Path | None = None, + ) -> None: + self.port = port + self.network = network + self.image = image + self._repo_root = repo_root + self._host_root = host_root or bot_bottle_root() + + @property + def url(self) -> str: + """Host-side control-plane URL (published loopback port).""" + return f"http://127.0.0.1:{self.port}" + + @property + def internal_url(self) -> str: + """Control-plane URL as the gateway container reaches it — by name over + docker DNS on the shared network. This is the gateway's + BOT_BOTTLE_ORCHESTRATOR_URL.""" + return f"http://{ORCHESTRATOR_NAME}:{self.port}" + + def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool: + try: + with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp: + return resp.status == 200 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + def _container_running(self, name: str) -> bool: + proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"]) + return name in proc.stdout.split() + + def _run_orchestrator_container(self) -> None: + """Start the control-plane container (idempotent: clears a stale + fixed-name container first). Register-only broker → no docker socket.""" + run_docker(["docker", "rm", "--force", ORCHESTRATOR_NAME]) + proc = run_docker([ + "docker", "run", "--detach", + "--name", ORCHESTRATOR_NAME, + "--label", ORCHESTRATOR_LABEL, + "--network", self.network, + # Host CLI reaches the control plane here; bound to loopback so it + # is not exposed on the host's external interfaces. + "--publish", f"127.0.0.1:{self.port}:{self.port}", + "--volume", f"{self._repo_root}:{_APP_DIR}:ro", + "--workdir", _APP_DIR, + # Persist the registry DB on the host (sole-owner: only the + # orchestrator opens bot-bottle.db). + "--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}", + "--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}", + "--entrypoint", "python3", + self.image, + "-m", "bot_bottle.orchestrator", + "--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub", + ]) + if proc.returncode != 0: + raise OrchestratorStartError( + f"orchestrator container failed to start: {proc.stderr.strip()}" + ) + + def _gateway(self) -> DockerGateway: + return DockerGateway(self.image, network=self.network, orchestrator_url=self.internal_url) + + def ensure_running( + self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS, + ) -> str: + """Ensure the control plane + shared gateway are up; return the host + control-plane URL. Idempotent — a healthy control plane and a running + gateway are left untouched. Raises `OrchestratorStartError` on + timeout.""" + gateway = self._gateway() + gateway.ensure_built() # rebuild the bundle image on a source change + gateway.ensure_running() # creates the shared network + (re)starts gateway + + # Always (re)create the orchestrator container. It runs the repo's code + # bind-mounted, but the Python process loaded that code at startup and + # won't reload — so reusing a healthy-but-stale container would keep + # running OLD control-plane code (e.g. dropping the tokens field). Cheap + # (~seconds); the registry DB persists and the current launch + # re-registers its own in-memory state. (The dedicated orchestrator + # image follow-up replaces this with image-staleness detection.) + log.info("starting orchestrator container", context={"name": ORCHESTRATOR_NAME}) + self._run_orchestrator_container() + + deadline = time.monotonic() + startup_timeout + while time.monotonic() < deadline: + if self.is_healthy(): + log.info("orchestrator healthy", context={"url": self.url}) + return self.url + time.sleep(_HEALTH_POLL_SECONDS) + raise OrchestratorStartError( + f"orchestrator at {self.url} did not become healthy within {startup_timeout:g}s" + ) + + def stop(self) -> None: + """Remove the orchestrator + gateway containers (idempotent).""" + run_docker(["docker", "rm", "--force", ORCHESTRATOR_NAME]) + self._gateway().stop() + + +__all__ = [ + "OrchestratorService", + "OrchestratorStartError", + "ORCHESTRATOR_NAME", + "DEFAULT_PORT", + "DEFAULT_STARTUP_TIMEOUT_SECONDS", +] diff --git a/bot_bottle/orchestrator/registration.py b/bot_bottle/orchestrator/registration.py new file mode 100644 index 0000000..6773a52 --- /dev/null +++ b/bot_bottle/orchestrator/registration.py @@ -0,0 +1,57 @@ +"""Consolidated registration inputs (PRD 0070, docker slice). + +Bridges the existing per-bottle `prepare` output to the consolidated +registry: turns a prepared bottle's egress plan into the backend-neutral +inputs `Orchestrator.launch_bottle` takes — the egress **policy** blob and +launch **metadata**. + +The policy blob is the exact routes YAML the per-bottle egress sidecar used +to read from a file; in the consolidated model the multi-tenant gateway's +`PolicyResolver` fetches it from the registry per request (keyed by source +IP) instead. Same render, so consolidated and single-tenant egress apply +byte-identical policy — a bottle's allow-list doesn't change when it moves +onto the shared gateway. + +Host-side glue (imports `bot_bottle.egress`), used by the launch path — not +by the lean orchestrator control-plane process itself. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from ..egress import EgressPlan, egress_render_routes + + +@dataclass(frozen=True) +class RegistrationInputs: + """What `Orchestrator.launch_bottle` needs to register a bottle, derived + from its prepared plan. `policy` is served verbatim by the gateway's + `/resolve`; `metadata` is opaque forward-compat state — it carries the + human slug so the console / supervise can show a name, not just the + minted bottle id.""" + + policy: str + metadata: str + + +def egress_policy(plan: EgressPlan) -> str: + """The bottle's egress policy blob: the routes YAML the gateway serves + and the addon parses with `load_config`. Identical to the per-bottle + `routes.yaml` render, so the consolidated path applies the same + allow-list.""" + return egress_render_routes(plan.routes, log=plan.log) + + +def registration_inputs(plan: EgressPlan) -> RegistrationInputs: + """Assemble the orchestrator registration inputs from a prepared egress + plan. `metadata` records the slug so the shared registry can map a minted + bottle id back to its human name.""" + return RegistrationInputs( + policy=egress_policy(plan), + metadata=json.dumps({"slug": plan.slug}), + ) + + +__all__ = ["RegistrationInputs", "egress_policy", "registration_inputs"] diff --git a/bot_bottle/orchestrator/registry.py b/bot_bottle/orchestrator/registry.py new file mode 100644 index 0000000..dc200a3 --- /dev/null +++ b/bot_bottle/orchestrator/registry.py @@ -0,0 +1,265 @@ +"""Orchestrator bottle registry — the per-host runtime-state store (PRD 0070). + +SQLite-backed registry mapping each live bottle to its source IP and +per-bottle identity token, plus the **fail-closed attribution** the data +plane relies on: a request is attributed to a bottle only when its source +IP *and* its identity token both match a single active record. + +The registry co-tenants the shared host `bot-bottle.db` (the `DbStore` +framework namespaces each store by `schema_key`), so all bot-bottle +runtime state lives in one queryable file — one place to back up, inspect, +and integrate a console against. + +This is the "runtime state" tier of PRD 0070 (leases / approvals / +registry) — deliberately NOT config (which stays declarative under +`~/.bot-bottle/`) and NOT the build-time constants (a flat file). It is the +source of truth the orchestrator sweeps on restart to re-adopt running +bottles, so it lives in a durable store rather than process memory. + +Attribution combines two independent signals, and needs both: + + * source IP — the network-layer invariant (on Firecracker the `/31` TAP + + nft make it unspoofable by construction; weaker on other backends). + * identity token — an application-layer per-bottle secret, defence in + depth that doesn't lean on network anti-spoof. A bottle can only ever + prove it is *itself* (it can't learn another bottle's token), so a + hostile agent gains nothing by presenting it. +""" + +from __future__ import annotations + +import hmac +import secrets +import sqlite3 +import time +from dataclasses import dataclass +from pathlib import Path + +from ..db_store import DbStore +from ..migrations import TableMigrations +from ..paths import host_db_path + +# 256 bits of urandom, URL-safe — unguessable per-bottle identity token. +IDENTITY_TOKEN_BYTES = 32 + + +def new_identity_token() -> str: + """A fresh per-bottle identity token (PRD 0070 attribution defence).""" + return secrets.token_urlsafe(IDENTITY_TOKEN_BYTES) + + +def default_db_path() -> Path: + """The shared host state DB (`bot-bottle.db`) — all bot-bottle runtime + state in one file, co-tenanted via schema_key. Host-resident so it + survives orchestrator restarts (re-adoption sweeps it).""" + return host_db_path() + + +@dataclass(frozen=True) +class BottleRecord: + """One live bottle in the registry.""" + + bottle_id: str + source_ip: str + identity_token: str + state: str = "active" + created_at: float = 0.0 + # Opaque JSON blob for forward-compat (pool slot, ...) — the registry + # doesn't interpret it, so new fields need no migration. + metadata: str = "" + # The bottle's gateway policy (opaque JSON — egress allowlist / routes / + # git config). The registry stores and serves it verbatim, keyed by + # source IP; the multi-tenant gateway interprets it. + policy: str = "" + + def redacted(self) -> dict[str, object]: + """Public view for listing — never exposes the identity token.""" + return { + "bottle_id": self.bottle_id, + "source_ip": self.source_ip, + "state": self.state, + "created_at": self.created_at, + "metadata": self.metadata, + "policy": self.policy, + } + + +_MIGRATIONS = TableMigrations( + "orchestrator_registry", + [ + # v1 — the live bottle registry. + """ + CREATE TABLE IF NOT EXISTS orchestrator_bottles ( + bottle_id TEXT PRIMARY KEY, + source_ip TEXT NOT NULL, + identity_token TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'active', + created_at REAL NOT NULL, + metadata TEXT NOT NULL DEFAULT '' + ) + """, + # source_ip is the data-plane attribution key — index it, and make + # the "one active bottle per source IP" check cheap. + "CREATE INDEX IF NOT EXISTS idx_orchestrator_bottles_source_ip " + "ON orchestrator_bottles (source_ip)", + # v3 — per-bottle policy (opaque JSON the gateway interprets): the + # egress allowlist / routes / git config selected by source IP. The + # multi-tenant gateway resolves it per request via `attribute`. + "ALTER TABLE orchestrator_bottles ADD COLUMN policy TEXT NOT NULL DEFAULT ''", + ], +) + + +def _row_to_record(row: sqlite3.Row) -> BottleRecord: + """Build a BottleRecord from a registry row.""" + return BottleRecord( + bottle_id=row["bottle_id"], + source_ip=row["source_ip"], + identity_token=row["identity_token"], + state=row["state"], + created_at=row["created_at"], + metadata=row["metadata"], + policy=row["policy"], + ) + + +class RegistryStore(DbStore): + """SQLite-backed registry of live bottles + fail-closed attribution.""" + + def __init__(self, db_path: Path | None = None) -> None: + super().__init__(db_path or default_db_path(), _MIGRATIONS) + + def _connect(self) -> sqlite3.Connection: + """Open a connection with a busy timeout for the shared DB.""" + conn = super()._connect() + # The registry co-tenants the shared bot-bottle.db, so a busy_timeout + # rides out brief lock contention with the other stores. WAL for the + # shared DB is a deliberate future change (it affects supervise/audit + # and is finicky over guest shares) — not flipped here. + conn.execute("PRAGMA busy_timeout=5000") + return conn + + def register( + self, + source_ip: str, + *, + bottle_id: str | None = None, + identity_token: str | None = None, + metadata: str = "", + policy: str = "", + ) -> BottleRecord: + """Register (or replace) a bottle. Mints a bottle_id / identity token + when not supplied. Live — this is the control-plane reload path. + + Supersedes any *other* active bottle at the same source IP first: + `by_source_ip` fail-closes on ambiguity (>1 active row → None), so a + leftover row at a reused IP — from an untorn-down bottle, crash + recovery, or a persisted DB — would otherwise *brick* the new bottle's + attribution. The new registration is authoritative for its IP; the + stale rows go. INSERT OR REPLACE handles a same-`bottle_id` reload in + place (its own row is exempt from the sweep).""" + rec = BottleRecord( + bottle_id=bottle_id or secrets.token_hex(8), + source_ip=source_ip, + identity_token=identity_token or new_identity_token(), + state="active", + created_at=time.time(), + metadata=metadata, + policy=policy, + ) + with self._connect() as conn: + conn.execute( + "DELETE FROM orchestrator_bottles " + "WHERE source_ip = ? AND state = 'active' AND bottle_id != ?", + (rec.source_ip, rec.bottle_id), + ) + conn.execute( + "INSERT OR REPLACE INTO orchestrator_bottles " + "(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + rec.bottle_id, + rec.source_ip, + rec.identity_token, + rec.state, + rec.created_at, + rec.metadata, + rec.policy, + ), + ) + self._chmod() + return rec + + def set_policy(self, bottle_id: str, policy: str) -> bool: + """Update a bottle's policy in place (live reload). Returns True if + the bottle exists.""" + with self._connect() as conn: + cur = conn.execute( + "UPDATE orchestrator_bottles SET policy = ? WHERE bottle_id = ?", + (policy, bottle_id), + ) + self._chmod() + return cur.rowcount > 0 + + def deregister(self, bottle_id: str) -> bool: + """Remove a bottle. Returns True if a row was deleted.""" + with self._connect() as conn: + cur = conn.execute( + "DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,) + ) + return cur.rowcount > 0 + + def get(self, bottle_id: str) -> BottleRecord | None: + """Return the bottle by id, or None if absent.""" + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,) + ).fetchone() + return _row_to_record(row) if row else None + + def all(self) -> list[BottleRecord]: + """Every registered bottle, oldest first.""" + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM orchestrator_bottles ORDER BY created_at" + ).fetchall() + return [_row_to_record(r) for r in rows] + + def by_source_ip(self, source_ip: str) -> BottleRecord | None: + """Network-layer attribution: the single active bottle at this source + IP, or None if unknown or ambiguous (more than one — a + misconfiguration). Safe as the *sole* attributor only where the + source IP is unspoofable (Firecracker `/31` + nft) and the control + plane is reachable only by the trusted gateway; pair with the + identity token (`attribute`) elsewhere.""" + with self._connect() as conn: + rows = conn.execute( + "SELECT * FROM orchestrator_bottles " + "WHERE source_ip = ? AND state = 'active'", + (source_ip,), + ).fetchall() + if len(rows) != 1: + return None + return _row_to_record(rows[0]) + + def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None: + """Fail-closed attribution: `by_source_ip` AND a matching identity + token (constant-time). Either signal alone is insufficient here — an + unknown/ambiguous IP, an empty token, or a token mismatch all deny.""" + if not identity_token: + return None + rec = self.by_source_ip(source_ip) + if rec is None: + return None + if not hmac.compare_digest(rec.identity_token, identity_token): + return None + return rec + + +__all__ = [ + "BottleRecord", + "RegistryStore", + "new_identity_token", + "default_db_path", + "IDENTITY_TOKEN_BYTES", +] diff --git a/bot_bottle/orchestrator/service.py b/bot_bottle/orchestrator/service.py new file mode 100644 index 0000000..ecd1e86 --- /dev/null +++ b/bot_bottle/orchestrator/service.py @@ -0,0 +1,138 @@ +"""The per-host orchestrator core (PRD 0070). + +`Orchestrator` is the single backend-neutral object the control plane talks +to: it owns the registry (runtime state) and brokers agent launches. It +never branches on backend — the `LaunchBroker` abstracts the backend-native +launch, so this same object drives docker / firecracker / apple once a real +broker is wired in. + +Launch lifecycle: + + * `launch_bottle` mints the bottle (registry: source IP + identity + token), sends a *signed, structured* launch request through the broker, + and returns the record. If the broker rejects/fails, the registry entry + is rolled back so a failed launch leaves no orphan. + * `teardown_bottle` sends a signed teardown request, then deregisters. +""" + +from __future__ import annotations + +from .broker import LaunchBroker, LaunchRequest, sign_request +from .registry import BottleRecord, RegistryStore +from .gateway import Gateway + + +class Orchestrator: + """Owns the registry + brokers launches, and manages the single + consolidated per-host gateway. Backend-neutral (broker and gateway + abstract the backend-native pieces).""" + + def __init__( + self, + registry: RegistryStore, + broker: LaunchBroker, + sign_secret: bytes, + gateway: Gateway | None = None, + ) -> None: + self.registry = registry + self._broker = broker + self._secret = sign_secret + self._gateway = gateway + # Per-bottle egress auth tokens (env_name -> value), keyed by bottle_id. + # Held **in memory only** — never written to the registry DB — so the + # gateway can inject each bottle's upstream credential without secrets + # at rest. Lost on restart (re-launch re-registers them); the future + # SecretProvider (#355) replaces this with per-request minting. + self._tokens: dict[str, dict[str, str]] = {} + + def launch_bottle( + self, + source_ip: str, + *, + image_ref: str = "", + slot: int | None = None, + metadata: str = "", + policy: str = "", + tokens: dict[str, str] | None = None, + ) -> BottleRecord: + """Register a bottle (with its gateway policy + in-memory egress auth + tokens) and broker its launch. Rolls the registry entry back if the + launch doesn't take, so a failure leaves no orphan.""" + rec = self.registry.register(source_ip, metadata=metadata, policy=policy) + if tokens: + self._tokens[rec.bottle_id] = dict(tokens) + req = LaunchRequest( + op="launch", + bottle_id=rec.bottle_id, + source_ip=source_ip, + image_ref=image_ref, + slot=slot, + ) + launched = False + try: + self._broker.submit(sign_request(req, self._secret)) + launched = True + finally: + if not launched: + self.registry.deregister(rec.bottle_id) + self._tokens.pop(rec.bottle_id, None) + return rec + + def teardown_bottle(self, bottle_id: str) -> bool: + """Broker teardown then deregister. False if the bottle is unknown.""" + rec = self.registry.get(bottle_id) + if rec is None: + return False + req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip) + self._broker.submit(sign_request(req, self._secret)) + self.registry.deregister(bottle_id) + self._tokens.pop(bottle_id, None) + return True + + def tokens_for(self, bottle_id: str) -> dict[str, str]: + """The bottle's in-memory egress auth tokens (env_name -> value), or + empty. The gateway injects these per request; they are never + persisted.""" + return dict(self._tokens.get(bottle_id, {})) + + def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None: + """Fail-closed attribution (delegates to the registry).""" + return self.registry.attribute(source_ip, identity_token) + + def resolve(self, source_ip: str, identity_token: str = "") -> BottleRecord | None: + """Resolve the bottle behind a request — the source-IP-keyed lookup + the multi-tenant gateway makes per request; the returned record + carries its `policy`. With a token, full attribution (source IP + + token); without, network-layer attribution by source IP alone + (valid where the IP is unspoofable and the control plane is + gateway-only).""" + if identity_token: + return self.registry.attribute(source_ip, identity_token) + return self.registry.by_source_ip(source_ip) + + def set_policy(self, bottle_id: str, policy: str) -> bool: + """Update a bottle's gateway policy in place (live reload). False if + the bottle is unknown.""" + return self.registry.set_policy(bottle_id, policy) + + # --- consolidated gateway ---------------------------------------------- + + def ensure_gateway(self) -> None: + """Ensure the single per-host gateway is built and up (idempotent). + No-op when no gateway is configured.""" + if self._gateway is not None: + self._gateway.ensure_built() + self._gateway.ensure_running() + + def gateway_status(self) -> dict[str, object]: + """Report the shared gateway for the control plane / console.""" + if self._gateway is None: + return {"configured": False} + return { + "configured": True, + "name": self._gateway.name, + "running": self._gateway.is_running(), + } + + +__all__ = ["Orchestrator"] diff --git a/bot_bottle/paths.py b/bot_bottle/paths.py new file mode 100644 index 0000000..8ac64ad --- /dev/null +++ b/bot_bottle/paths.py @@ -0,0 +1,43 @@ +"""Foundational filesystem paths for bot-bottle. + +`bot_bottle_root()` is the app data root — state, queue, audit logs, +git-gate keys, and the shared DB all live under it. It defaults to +`~/.bot-bottle` and is overridable with the **`BOT_BOTTLE_ROOT`** env var. + +The env override is the single knob for redirecting the root: the test +suite points it at a throwaway dir instead of monkey-patching the function +(every module and every flat/package copy reads the same env var, so one +override covers them all), and operators can relocate the root if needed. + +This module has no bot-bottle imports, so it is safe to import from any +layer (and to COPY flat into the sidecar bundle). +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# The single shared host state DB. All bot-bottle SQLite stores (supervise +# queue, audit, the orchestrator registry) co-tenant this one file — the +# TableMigrations schema_key namespaces each store's tables. +HOST_DB_FILENAME = "bot-bottle.db" + + +def bot_bottle_root() -> Path: + """The app data root — `$BOT_BOTTLE_ROOT` if set, else `~/.bot-bottle`.""" + override = os.environ.get("BOT_BOTTLE_ROOT") + return Path(override) if override else Path.home() / ".bot-bottle" + + +def host_db_path() -> Path: + """Path to the shared host state DB, `/db/bot-bottle.db`. + + Kept in its own `db/` subdirectory (not directly under the root) so a + backend that can only bind-mount *directories* can share this one file + with a sidecar without exposing the root's other contents (git-gate + keys, per-bottle state, ...).""" + return bot_bottle_root() / "db" / HOST_DB_FILENAME + + +__all__ = ["HOST_DB_FILENAME", "bot_bottle_root", "host_db_path"] diff --git a/bot_bottle/policy_resolver.py b/bot_bottle/policy_resolver.py new file mode 100644 index 0000000..7449ee5 --- /dev/null +++ b/bot_bottle/policy_resolver.py @@ -0,0 +1,128 @@ +"""Gateway-side per-client policy resolver (PRD 0070). + +The consolidated gateway serves every bottle from one process, so for each +request it must apply the *calling* bottle's policy, selected by the source +IP the attribution invariant makes unspoofable. This resolves that policy +from the orchestrator's control plane (`POST /resolve`), keyed on the +`(source_ip, identity_token)` pair. + +**Always fresh — no cache.** The resolver is called rarely enough that a +round-trip doesn't matter, and correctness matters more than speed: every +resolve reflects the orchestrator's *current* view, so a revocation, a +policy change, or a bottle teardown the orchestrator knows about is honored +immediately rather than lingering for a cache TTL. (If this ever becomes a +hot path, add caching with orchestrator-driven invalidation — not a blind +TTL.) + +**Fail-closed:** an unattributed client (the orchestrator answers `403`) +resolves to `None`, and the caller (the egress addon, git-gate) must then +deny — exactly as an unknown bottle should be treated. Orchestrator +*errors* (unreachable / unexpected status) raise, so the caller can fail +closed too rather than silently serving stale or empty policy. + +The resolved value is the policy blob the orchestrator stores verbatim; the +consumer parses it (e.g. the egress addon's `load_config`). This module is +stdlib-only and free of bot-bottle imports so it can be COPYed flat into +the sidecar bundle. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request + +DEFAULT_TIMEOUT_SECONDS = 2.0 + + +class PolicyResolveError(RuntimeError): + """The orchestrator was unreachable or returned an unexpected status — + distinct from a clean `403` (unattributed), which returns None.""" + + +class PolicyResolver: + """Resolves each client's policy from the orchestrator, fresh per call.""" + + def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None: + self._base = base_url.rstrip("/") + self._timeout = timeout + + def _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None: + """The orchestrator's `/resolve` payload for this client, or None if + unattributed (a clean `403`). Raises `PolicyResolveError` on an + unreachable / unexpected-status / malformed response so every caller + can fail closed. Shared by `resolve` and `resolve_bottle_id`.""" + body = json.dumps( + {"source_ip": source_ip, "identity_token": identity_token} + ).encode() + req = urllib.request.Request( + f"{self._base}/resolve", data=body, method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + payload = json.loads(resp.read()) + except urllib.error.HTTPError as e: + if e.code == 403: + return None # unattributed → fail closed (caller denies) + raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: + raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e + return payload if isinstance(payload, dict) else {} + + def resolve(self, source_ip: str, identity_token: str = "") -> str | None: + """The calling bottle's policy blob, or None if unattributed. Always + fetches from the orchestrator so revocations / changes / teardowns + are honored immediately. + + `identity_token` is optional *transitionally* — omitting it resolves + by source IP alone (the network-layer attribution, sound by + construction on Firecracker's `/31`+nft, weaker elsewhere). The + consolidated end state makes the token mandatory so the app-layer + defense is always enforced, not silently degraded; that flips at the + `/resolve` boundary once identity-token *delivery* lands (a PRD 0070 + open question — we can't require what the agent can't yet present). + Raises `PolicyResolveError` if the orchestrator can't be reached.""" + payload = self._post_resolve(source_ip, identity_token) + if payload is None: + return None + policy = payload.get("policy") + return policy if isinstance(policy, str) else "" + + def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None: + """The calling bottle's id, or None if unattributed. This is the + source-IP-keyed identity the git-gate uses to select the bottle's + repo namespace (there is no per-bottle policy blob to parse — the + bottle *is* the namespace). Same fail-closed contract as `resolve`.""" + payload = self._post_resolve(source_ip, identity_token) + if payload is None: + return None + bottle_id = payload.get("bottle_id") + return bottle_id if isinstance(bottle_id, str) and bottle_id else None + + def resolve_policy_and_bottle_id( + self, source_ip: str, identity_token: str = "", + ) -> tuple[str | None, str | None, dict[str, str]]: + """The policy blob, bottle id, **and per-bottle egress auth tokens** in + a single `/resolve` — so the egress addon gets everything it needs + (policy for routing, bottle id for the supervise queue/safelist, tokens + to inject upstream auth) in one round-trip. Returns `(None, None, {})` + when unattributed (a clean `403`). Raises `PolicyResolveError` if the + orchestrator can't be reached, so the caller still fails closed.""" + payload = self._post_resolve(source_ip, identity_token) + if payload is None: + return None, None, {} + policy = payload.get("policy") + bottle_id = payload.get("bottle_id") + raw = payload.get("tokens") + tokens = { + k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str) + } if isinstance(raw, dict) else {} + return ( + policy if isinstance(policy, str) else "", + bottle_id if isinstance(bottle_id, str) and bottle_id else None, + tokens, + ) + + +__all__ = ["PolicyResolver", "PolicyResolveError"] diff --git a/bot_bottle/queue_store.py b/bot_bottle/queue_store.py index 0fe3295..cd6b188 100644 --- a/bot_bottle/queue_store.py +++ b/bot_bottle/queue_store.py @@ -7,11 +7,13 @@ import sqlite3 from pathlib import Path try: - from .supervise_types import Proposal, Response, host_db_path + from .supervise_types import Proposal, Response + from .paths import host_db_path from .db_store import DbStore from .migrations import TableMigrations except ImportError: - from supervise_types import Proposal, Response, host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from supervise_types import Proposal, Response # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module diff --git a/bot_bottle/store_manager.py b/bot_bottle/store_manager.py index 70b1f46..bb1c494 100644 --- a/bot_bottle/store_manager.py +++ b/bot_bottle/store_manager.py @@ -23,13 +23,10 @@ class StoreManager: def __init__(self, db_path: Path | None = None) -> None: if db_path is None: - # Lazy import to avoid a circular dependency: supervise imports - # StoreManager at module level; StoreManager must not import - # supervise at module level in return. try: - from .supervise import host_db_path + from .paths import host_db_path except ImportError: - from supervise import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module + from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module db_path = host_db_path() self.db_path = db_path diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index cfa6a51..b086ad4 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -41,7 +41,6 @@ try: from .supervise_types import ( ACTION_OPERATOR_EDIT, AuditEntry, - HOST_DB_FILENAME, Proposal, Response, STATUSES, @@ -54,13 +53,11 @@ try: TOOL_EGRESS_TOKEN_ALLOW, TOOL_GITLEAKS_ALLOW, TOOL_LIST_EGRESS_ROUTES, - bot_bottle_root, ) except ImportError: from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module ACTION_OPERATOR_EDIT, AuditEntry, - HOST_DB_FILENAME, Proposal, Response, STATUSES, @@ -73,10 +70,15 @@ except ImportError: TOOL_EGRESS_TOKEN_ALLOW, TOOL_GITLEAKS_ALLOW, TOOL_LIST_EGRESS_ROUTES, - bot_bottle_root, ) +try: + from .paths import bot_bottle_root +except ImportError: # flat imports inside the sidecar bundle + from paths import bot_bottle_root # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module + + SUPERVISE_HOSTNAME = "supervise" SUPERVISE_PORT = 9100 @@ -106,16 +108,6 @@ def audit_dir() -> Path: return bot_bottle_root() / "audit" -def host_db_path() -> Path: - # Calls bot_bottle_root() through this module's globals so that patches - # on supervise.bot_bottle_root propagate to callers going through - # supervise.host_db_path(). - # - # Kept in its own "db" subdirectory (see supervise_types.host_db_path - # for why) — must stay in sync with that copy. - return bot_bottle_root() / "db" / HOST_DB_FILENAME - - def audit_log_path(component: str, slug: str) -> Path: return audit_dir() / f"{component}-{slug}.log" @@ -297,8 +289,6 @@ __all__ = [ "archive_proposal", "audit_dir", "audit_log_path", - "bot_bottle_root", - "host_db_path", "list_pending_proposals", "list_all_pending_proposals", "read_audit_entries", diff --git a/bot_bottle/supervise_server.py b/bot_bottle/supervise_server.py index 089e106..e6c570d 100644 --- a/bot_bottle/supervise_server.py +++ b/bot_bottle/supervise_server.py @@ -15,6 +15,12 @@ The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at container creation by the backend's start step). SUPERVISE_DB_PATH points at the bind-mounted host database. +Consolidated (PRD 0070): when BOT_BOTTLE_ORCHESTRATOR_URL is set, one +shared server fronts every bottle and attributes each proposal to the +calling bottle by source IP (resolved from the orchestrator) instead of a +fixed slug — an unattributed source fails closed. Unset → the legacy +per-bottle single-tenant server, unchanged. + Speaks MCP over HTTP+JSON-RPC. Methods handled: * `initialize` — handshake; returns server info + caps. @@ -40,16 +46,18 @@ import time import typing import urllib.error import urllib.request -from dataclasses import dataclass +from dataclasses import dataclass, replace try: # Same-directory imports inside the bundle container; these files are # COPYed flat under /app by Dockerfile.sidecars. from egress_addon_core import LOG_OFF, load_config + from policy_resolver import PolicyResolveError, PolicyResolver import supervise as _sv except ModuleNotFoundError: # Package imports for host-side tests and tooling. from .egress_addon_core import LOG_OFF, load_config + from .policy_resolver import PolicyResolveError, PolicyResolver from . import supervise as _sv @@ -73,6 +81,12 @@ DEFAULT_RESPONSE_TIMEOUT_SECONDS = 30.0 MIN_RESPONSE_POLL_INTERVAL_SECONDS = 0.05 EGRESS_LIST_TIMEOUT_SECONDS = 5.0 +# Consolidated (multi-tenant) mode: when set, one shared supervise server +# fronts every bottle and attributes each proposal to the calling bottle by +# source IP (resolved from the orchestrator), instead of a single +# SUPERVISE_BOTTLE_SLUG env. Unset → legacy per-bottle single-tenant. +ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL" + @dataclass(frozen=True) class JsonRpcRequest: @@ -511,9 +525,30 @@ class MCPHandler(http.server.BaseHTTPRequestHandler): if method == "tools/list": return handle_tools_list(req.params) if method == "tools/call": - return handle_tools_call(req.params, config) + # Attribute the proposal to the calling bottle. Single-tenant → the + # env slug on `config`; consolidated → the source-IP-resolved + # bottle id, so one shared server queues each bottle's proposal + # under its own slug. + return handle_tools_call(req.params, self._attributed_config(config)) raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}") + def _attributed_config(self, config: ServerConfig) -> ServerConfig: + """The ServerConfig with `bottle_slug` bound to *this request's* bottle. + Single-tenant (no resolver): unchanged. Consolidated: the bottle id + attributed from the source IP — **fail-closed**, an unattributed or + unreachable source raises so no proposal is queued under the wrong (or + empty) slug.""" + resolver = getattr(self.server, "policy_resolver", None) + if resolver is None: + return config + try: + bottle_id = resolver.resolve_bottle_id(self.client_address[0]) + except PolicyResolveError as e: + raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e + if not bottle_id: + raise _RpcInternalError("request source is not attributed to a bottle") + return replace(config, bottle_slug=bottle_id) + def _write_jsonrpc(self, body: bytes) -> None: self.send_response(200) self.send_header("Content-Type", "application/json") @@ -537,6 +572,9 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): allow_reuse_address = True daemon_threads = True config: ServerConfig = ServerConfig(bottle_slug="") + # None → single-tenant (proposals use config.bottle_slug); set → consolidated + # (each proposal attributed to the source-IP-resolved bottle). + policy_resolver: "PolicyResolver | None" = None # --- Entry point ----------------------------------------------------------- @@ -548,15 +586,17 @@ def serve( port: int = _sv.SUPERVISE_PORT, bind: str = "0.0.0.0", response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS, + resolver: "PolicyResolver | None" = None, ) -> typing.NoReturn: server = MCPServer((bind, port), MCPHandler) server.config = ServerConfig( bottle_slug=bottle_slug, response_timeout_seconds=response_timeout_seconds, ) + server.policy_resolver = resolver + mode = "multi-tenant" if resolver else f"slug={bottle_slug!r}" sys.stderr.write( - f"supervise listening on {bind}:{port}; " - f"slug={bottle_slug!r}; " + f"supervise listening on {bind}:{port}; {mode}; " f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type] ) sys.stderr.flush() @@ -571,8 +611,12 @@ def serve( def main(argv: list[str]) -> int: del argv # config is env-only, no CLI flags + orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip() + resolver = PolicyResolver(orch_url) if orch_url else None bottle_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "") - if not bottle_slug: + # Consolidated mode resolves the slug per request, so the env slug is + # optional there; single-tenant still requires it. + if not bottle_slug and resolver is None: sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n") return 2 port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT))) @@ -587,6 +631,7 @@ def main(argv: list[str]) -> int: port=port, bind=bind, response_timeout_seconds=response_timeout_seconds, + resolver=resolver, ) return 0 # serve() does not return diff --git a/bot_bottle/supervise_types.py b/bot_bottle/supervise_types.py index 80af0ae..d62343d 100644 --- a/bot_bottle/supervise_types.py +++ b/bot_bottle/supervise_types.py @@ -1,26 +1,19 @@ -"""Shared types and path helpers for supervise stores (PRD 0013). +"""Shared types for supervise stores (PRD 0013). Extracted from supervise.py so queue_store and audit_store can import -Proposal, Response, AuditEntry, and host_db_path without creating a -circular import (supervise imports from queue_store/audit_store and -vice-versa). +Proposal, Response, and AuditEntry without creating a circular import +(supervise imports from queue_store/audit_store and vice-versa). -Patching bot_bottle_root on this module propagates through host_db_path -because host_db_path looks up bot_bottle_root via sys.modules[__name__] -at call time rather than capturing it at import time. +The path helpers (`bot_bottle_root`, `host_db_path`, `HOST_DB_FILENAME`) +live in `paths` — import them from there. """ from __future__ import annotations import dataclasses -import sys import uuid from dataclasses import dataclass from datetime import datetime, timezone -from pathlib import Path - - -HOST_DB_FILENAME = "bot-bottle.db" TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_ALLOW = "egress-allow" @@ -43,23 +36,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED) ACTION_OPERATOR_EDIT = "operator-edit" -def bot_bottle_root() -> Path: - return Path.home() / ".bot-bottle" - - -def host_db_path() -> Path: - # Look up bot_bottle_root through this module's live namespace so that - # monkey-patches on supervise_types.bot_bottle_root take effect. - # - # Lives in its own "db" subdirectory, not directly under - # bot_bottle_root(), so backends that can only bind-mount - # directories (macos_container's `container run --mount`, which - # rejects file sources with "is not a directory") can share this - # one file with a sidecar without also exposing bot_bottle_root()'s - # other contents (git-gate keys, per-bottle state, etc.). - return sys.modules[__name__].bot_bottle_root() / "db" / HOST_DB_FILENAME - - def _require_str(raw: dict[str, object], key: str) -> str: value = raw.get(key) if not isinstance(value, str): @@ -171,7 +147,6 @@ class AuditEntry: __all__ = [ "ACTION_OPERATOR_EDIT", "AuditEntry", - "HOST_DB_FILENAME", "Proposal", "Response", "STATUSES", @@ -184,6 +159,4 @@ __all__ = [ "TOOL_EGRESS_TOKEN_ALLOW", "TOOL_GITLEAKS_ALLOW", "TOOL_LIST_EGRESS_ROUTES", - "bot_bottle_root", - "host_db_path", ] diff --git a/docs/prds/0069-firecracker-native-docker-free.md b/docs/prds/0069-firecracker-native-docker-free.md index 0a24167..c771c6c 100644 --- a/docs/prds/0069-firecracker-native-docker-free.md +++ b/docs/prds/0069-firecracker-native-docker-free.md @@ -1,10 +1,16 @@ # PRD 0069: Firecracker-native, Docker-free backend -- **Status:** Draft +- **Status:** Draft (partially superseded) - **Author:** Claude - **Created:** 2026-07-12 - **Issue:** #348 +> **Superseded in part by [PRD 0070](0070-per-host-orchestrator.md) (#351):** +> the sidecar-consolidation framing here (Stage 1, per-host sidecar; Stage 4, +> sidecar-as-VM) is taken over by 0070's per-host orchestrator. This PRD still +> owns the docker-free **image-building** work — Stage 2 (nix-built fixed +> images, a dependency of 0070) and Stage 3 (in-VM Dockerfile builder). + ## Summary Make the Firecracker backend depend on **firecracker + KVM only**, removing diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md new file mode 100644 index 0000000..8bb0a87 --- /dev/null +++ b/docs/prds/0070-per-host-orchestrator.md @@ -0,0 +1,401 @@ +# PRD 0070: Per-host orchestrator service + +- **Status:** Draft +- **Author:** Claude +- **Created:** 2026-07-12 +- **Issue:** #351 +- **Supersedes:** the Stage-1 / Stage-4 sidecar-consolidation framing of + PRD 0069 (#348). Depends on 0069's nix-built fixed images (Stage 2) for + bootstrapping; 0069 still owns the docker-free image-building work. + +## Summary + +Replace the **per-bottle gateway bundle** with a single **persistent, +per-host orchestrator**: one long-lived service that runs the gateway +functions (egress / git-gate / supervise), coordinates with the console, +and brokers agent launches and teardown. It is **virtualized from the +start** using each backend's native isolation primitive — a Firecracker +microVM on the Firecracker backend, an Apple container on macOS, a Docker +container on the legacy backend — and is fronted by a single +**backend-agnostic contract**. Per-backend variation lives on +`BottleBackend`, not in the orchestrator. + +## Motivation + +Today each bottle spins up its own gateway bundle (egress mitmproxy + +git-gate + supervise). That costs: + +- **Resources.** N bottles → N heavy bundles booting and idling. +- **Operational churn.** Per-launch container/VM lifecycle for the + gateways, a control path baked at launch and torn down at exit. +- **A blurry contract.** "How a bottle talks to its gateway" is + re-implemented per backend instead of being one agreed interface. + +A per-host orchestrator collapses the first two and forces the third to +be made explicit. It's also the component that will own per-host runtime +**state** (slot leases, the approval queue, the bottle registry) — today +that's ad-hoc `fcntl`-locked files. + +## Security review (read this first) + +Consolidation is a real change to the trust model. The goal is to **not +significantly weaken** the posture; some properties strengthen, some +weaken, and the weakened ones must be mitigated by design, not hand-waved. + +### What gets stronger + +- **Build/host isolation of untrusted inputs** (with 0069 Stage 3): user + Dockerfiles build in a disposable VM instead of on the host. +- **One audited privileged surface.** Today the launcher runs as the full + host user and needs the Docker socket (root-equivalent). The orchestrator + model replaces that with a **thin launch broker** (below) — a small, + structured, auditable privileged core instead of a fat socket. +- **Attribution is enforced, not assumed.** Making source-IP identity a + first-class contract invariant (below) means each backend must *prove* + it, rather than the gateway implicitly trusting network position. + +### What gets weaker, and the mitigation + +1. **Secret concentration.** Per-bottle gateways isolate secrets at the + process boundary — each holds only its bottle's tokens/keys. A host + orchestrator concentrates **every bottle's** egress tokens, git deploy + keys, and the console credential in one long-lived process. A single + attribution bug leaks bottle A's token into bottle B's request — a class + of bug that *cannot exist* per-bottle. + - *Mitigation:* lean on the enforced source-IP invariant for + attribution; keep the most secret-dense, least-shareable service + (**git-gate**, per-repo deploy keys, no natural source-IP scoping) + **per-bottle** unless there's a compelling reason; scope each secret + to the bottle in the state DB so a lookup can't return the wrong + bottle's secret by construction (key every secret access by the + verified source identity, never by ambient state). + +2. **Shared fate.** Orchestrator down = no new launches, and running + agents lose egress / git / supervise. Compromise = the whole host's + fleet, plus launch authority, plus the console token. + - *Mitigation:* the orchestrator is itself confined (its own VM/container + with its own fail-closed egress); make it **restartable without killing + running agent VMs** (agents keep running; they briefly lose gateway + connectivity until it's back); persist state to a host volume so a + restart re-adopts live bottles rather than losing them. + +3. **The launch broker is the new privileged core.** We don't eliminate + host privilege — we shrink and relocate it. If the broker accepts + arbitrary paths/commands, the orchestrator VM can escape through it. + - *Mitigation:* the broker takes **structured requests only** — "launch + bottle from *this* content-addressed, nix-built rootfs on TAP slot + *k*", never "run this argv". It validates against a fixed image set, + not caller-supplied paths. It is small enough to audit line-by-line. + +4. **The egress proxy now parses every bottle's traffic in one process.** + Higher blast radius for a mitmproxy/TLS-bump bug. + - *Mitigation:* this is the argument for virtualizing the orchestrator + from the start (Stage B, not a host daemon) — the code that TLS-bumps + and parses agent traffic and holds every token runs **inside its own + confined VM**, not as a host process. If egress sharing's blast radius + feels too high, egress can stay per-bottle while supervise (near-zero + secrets) goes host-level first. + +### The attribution invariant + +Source-IP attribution is what makes a shared orchestrator safe: one +process serves every bottle and tells them apart by source address. The +*mechanism* is identical everywhere (read source IP → look up bottle); the +**guarantee that the address can't be forged is a per-backend +responsibility** and part of the contract: + +> **Invariant:** a packet's source address, as seen by the orchestrator, +> *provably* identifies the originating bottle. + +- **Firecracker** — enforced by the `/31` point-to-point TAP + the + `bot_bottle_fc` nft table (strongest; already built). +- **Docker** — the per-bottle `--internal` network + anti-spoof; weaker, + must be made explicit. +- **Apple** — the host-only network. + +If a backend can't honor the invariant, source-IP consolidation is not +safe there and that backend keeps per-bottle gateways. The invariant is a +hard precondition, not an aspiration. + +**Defense-in-depth — a per-bottle identity token.** On top of the +network-layer invariant, inject a per-bottle secret token into every +request the agent makes to the orchestrator (the agent already egresses +through the gateway proxy, so this is cheap to add). It gives an +**application-layer** proof of identity independent of the network layer: + +- On **Firecracker** the `/31` + nft already make source IP unspoofable + *by construction*, so the token is belt-and-suspenders there — but cheap + insurance against a misconfigured invariant. +- On **weaker backends** (Docker) it is load-bearing, providing attribution + that doesn't lean on network anti-spoof. + +Requirements: the token is **per-bottle, unguessable, and +non-cross-leakable** — a bottle can only ever prove it is *itself* (it +can't learn another bottle's token, given bottle isolation), so a hostile +agent gains nothing by presenting it. The orchestrator provisions the +token at launch and the gateway requires it to attribute + authorize. +Note this hardens *attribution*, not *secret exposure*: it's app-layer, so +a compromised orchestrator still sees every token (that's the +concentration problem, addressed separately under Secret handling). + +### Secret handling — a FUTURE pattern (not v1) + +> **Status: future / not required for the initial orchestrator.** The +> initial cut can inject secrets as today; this section records the +> direction so v1 doesn't paint itself into a corner. Tracked as its own +> work in **#355** (generic `SecretProvider`). + +The residual weakness after all of the above is **long-lived credential +concentration** — the data-plane proxies must hold every bottle's upstream +tokens because the agent must never see them. You can't policy-gate the +component whose job is to *use* all the secrets, so a full RCE of a proxy +drains its authorized set regardless. Two moves bound this without +pretending to prevent it: + +1. **Vault as a separate trust domain.** The long-lived *roots* live in a + distinct process (ideally its own VM) that the byte-parsing data plane + never shares memory with. The proxies request secrets from it; the + crown jewels are not in the process an agent-facing parser can pop. +2. **Derive short-lived, scoped creds where the upstream allows it.** The + vault holds the root and mints expiring, narrowly-scoped credentials + per bottle-start (or per request) — GitHub App installation tokens, + OAuth/STS token exchange, forge deploy tokens. A compromise then leaks + short-lived material, not permanent keys. For upstreams stuck on static + keys the vault passes the value through (only the at-rest / audit / + revocation benefits apply, not lifetime reduction) — this residue is + accepted and documented, not solved. + +Even a plain per-request fetch (no derivation) still buys **at-rest** +reduction (process memory holds only in-flight secrets), a **detection / +rate-limit / revocation chokepoint**, and clean **cross-component +scoping** (an egress RCE can't request git-gate's creds). It does *not* +prevent abuse of currently-authorized access during the compromise window. + +**Mechanism (see #355):** generalize the existing `DeployKeyProvisioner` +(PRD 0048) into a user-extensible **`SecretProvider`** droppable into the +manifest anywhere a raw token value is accepted, discovered from +`~/.bot-bottle/contrib//secret_provider.py` exactly as user +`AgentProvider`s are — because maintaining every forge/cloud/OAuth +provider in-tree is untenable. The orchestrator's vault mints via these +providers; but the abstraction is shippable independently and today's +per-bottle gateways can use it too. + +## Design + +### The contract (backend-agnostic) + +Three surfaces; only one is per-backend. + +1. **Control plane (CLI / console → orchestrator)** — an RPC: + `launch_bottle`, `teardown_bottle`, `register_policy`, + `deregister_bottle`, `supervise_queue`. Fully backend-agnostic. Both the + local `cli.py` and the remote console funnel through it, so policy is + uniform and `cli.py` becomes a thin client rather than a parallel + launcher. **Transport: HTTP** — the most universal/reliable choice on + every host (no vsock/unix-socket portability caveats); a local unix + socket is a fine optimization, but HTTP is the wire contract. +2. **Data plane (agent → orchestrator)** — the egress / git / supervise + endpoints. Already agnostic today (agents dial `http://gateway:9099`); + only the *address* and *how packets get there* are per-backend. +3. **Launch / wire (orchestrator → backend)** — the irreducibly + backend-specific part; lives on `BottleBackend`. + +### One `Orchestrator`, no subclass tree + +The orchestrator is a **single concrete class** holding all the +backend-neutral logic — egress addon, git-gate, supervise, source-IP +attribution, live-reload control plane, console client. It never branches +on backend; it *composes* a `BottleBackend`. That composition is what makes +the contract agnostic: there is nothing backend-specific left in the +orchestrator to leak. + +Rejected alternative: an `Orchestrator` ABC with per-backend +implementations. The interesting logic (proxies, attribution, control +plane) is backend-neutral, so three subclasses would triplicate the hard +part; and a second hierarchy paralleling `BottleBackend` reintroduces the +same hand-maintained lockstep coupling we just removed from the netpool +constants (PR #350). Composition over a parallel tree. + +### `BottleBackend` absorbs the per-backend variation + +A small, cohesive surface — reused for launching agent bottles *and* the +orchestrator's own unit (the orchestrator is just another native unit): + +``` +launch_unit(spec) -> Handle # agent bottle OR the orchestrator itself + # (fc microVM / apple ctr / docker ctr) +wire(unit, endpoint) -> None # DNAT+forward (fc) | attach shared net (docker/apple) +endpoint_of(unit) -> Endpoint # address resolution +health(unit) -> Status +``` + +Plus the **launch broker** — the answer to "a VM/container can't spawn its +own host-network siblings." The orchestrator can't directly open host +`/dev/kvm` + a host TAP fd (Firecracker), and a container can't spawn +siblings without a root-equivalent socket (Docker). So every backend +exposes a broker the orchestrator calls to launch an agent: + +- **Firecracker** — a thin, structured host shim (see security #3). This + replaces today's implicit "launcher runs as host user." +- **Docker** — the socket today (fat, root-equivalent — the thing 0069's + Stage 3 removes); a narrower broker later. +- **Apple** — the `container` CLI/daemon. + +**Broker request schema:** human-readable JSON of **static flags + ids +only** (never free-form paths/argv — that's what keeps it un-coercible +into arbitrary launches), wrapped as a **signed JWT** so the broker +verifies *provenance*: the request came from the real orchestrator, not a +forged one from a compromised co-located component. The orchestrator signs; +the broker verifies with the orchestrator's public key (provisioned at +broker install). This is the concrete form of security #3's "structured +requests only." Same JSON-with-ids + JWT shape for all +orchestrator↔broker/gateway communication; cases that don't fit get +handled as they arise, with this as the default. + +If `BottleBackend` bloats, the pressure valve is composition one level +down: vend a `backend.network()` / `Wiring` collaborator rather than +piling methods on — the same discipline, recursed. + +### State: one SQLite DB, owned by the orchestrator + +The orchestrator is the natural owner of per-host **runtime state**: + +- pool **slot leases** (which bottle holds slot *i*) — replaces today's + `fcntl`-locked files with SQLite transactions; +- the **supervise approval queue** + remembered approvals; +- the **live bottle registry** (source IP → bottle → policy/secrets refs), + the lookup table the attribution invariant reads. + +This is deliberately **not** a "single source of truth for all config." +Config splits into three tiers with different homes: + +| Tier | Example | Home | +|---|---|---| +| Build-time constants | pool size, IP base, nft table | flat `.env` (PR #350) — must be readable by Nix eval + root bash, zero runtime | +| User-authored config | bottle manifests, egress routes, secret refs | declarative files under `~/.bot-bottle/` — trust boundary at `$HOME`, git-trackable, "unknown keys die at load" | +| Runtime state | slot leases, approvals, registry | one shared **`bot-bottle.db`**, solely owned by the orchestrator | + +SQLite is right for the runtime tier (mutable, concurrent, queried) and +wrong for the other two (Nix can't read it at eval time; it fights the +declarative manifest trust model). Keep the tiers separate. + +**One shared `bot-bottle.db` for all runtime state** (decided in review). +The registry co-tenants the existing host `bot-bottle.db` — the `DbStore` +framework already namespaces each store by `schema_key`, so slot +leases / approvals / registry share one file. One place to query, back up, +and integrate a console against. + +- **Host-resident, for durability.** Re-adoption sweeps the registry after + an orchestrator restart, so state *must* outlive the orchestrator + instance. The file lives on the host (`bot_bottle_root()/db/bot-bottle.db`); + the orchestrator unit reaches it, it doesn't carry it. +- **Integrity by sole ownership, not mount permissions.** Agents can't + touch the DB directly wherever it lives (network-isolated in their + bottles). The risk is a *compromised agent-facing data-plane service* + (egress/git-gate, which parse hostile bytes) writing the registry and + forging attribution. Because it's now one shared file, coarse `ro`/`rw` + mount-splitting no longer isolates the registry — so the rule is stronger + and simpler: **only the orchestrator (control plane) opens `bot-bottle.db`; + the data plane and the console reach state through the control-plane RPC, + never a direct file handle.** No agent-facing component gets the file, so + none can forge attribution. (This supersedes the earlier `ro`-mount idea.) + - *Transitional caveat:* today the per-bottle **supervise gateway + rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the + pattern the orchestrator removes (supervise consolidates into the + orchestrator; gateway writes become RPC calls). Until that lands, don't + put the attribution registry behind a data-plane-writable mount. + +Implementation note for the VM slices: SQLite **WAL** over a guest share +(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking), +which is a second reason the DB wants a **host-side owner** the orchestrator +reaches over the RPC rather than a shared mount into the VM. WAL on the +shared DB is therefore a deliberate, tested future change — not enabled ad +hoc. `sqlite3` itself is stdlib, so "the host needs SQLite" is a non-cost. + +## Sequencing + +Jump straight to the **virtualized** end state (not a host-daemon stepping +stone): a host daemon's agent→`localhost` transport is throwaway once the +orchestrator becomes a VM. Decouple the two risks instead: + +- **Consolidation risk** (one process, all secrets, attribution, reload) + and **packaging/transport risk** (VM-to-VM wiring, the shim) are + independent. Develop the orchestrator **service as a plain process + dev-harness** first, so the consolidation logic (attribution, reload, + secret handling) is proven with fast iteration — *then* wrap that exact + service in the VM and solve wiring separately. + +Backend order (cheapest proof → hardest → last): + +1. **Docker orchestrator** — nearly free (the gateway bundle is already + containers; collapse N bundles into one persistent container). Proves + consolidation + the `BottleBackend` seam with the least moving parts. +2. **Firecracker orchestrator** — the real work: the shim + VM-to-VM + routing (host forwards `bbfcN` → orchestrator TAP; the nft table grows + forward rules where today it drops all non-DNAT egress). Built against + the dev-harness so the app logic is already proven. +3. **macOS (Apple container)** — last (container-to-container networking). + +Keep the gateway **service one shared thing** throughout. + +## Non-goals + +- Removing OCI/Dockerfile support for agent images (0069's concern). +- A single database for *all* config (see the three-tier table). +- Changing the per-bottle isolation of agent workloads — only the gateway + is consolidated; agents stay one-VM/container-each. + +## Relationship to other work + +- **PRD 0069 (#348):** 0070 subsumes its Stage 1 (per-host sidecar) and + Stage 4 (sidecar-as-VM). 0069 retains Stage 2 (nix-built fixed images — + a **dependency** here: the orchestrator and agent base must be + nix-built so the broker launches from a fixed image set and bootstrapping + has no chicken-and-egg) and Stage 3 (in-VM Dockerfile builder). +- **Minimal CI runner (paused):** the Firecracker broker + no host Docker + is what lets a dedicated `gitea` runner user drop the root-equivalent + `docker` group — it only needs broker-socket access + `kvm`/pool group + membership. This work unblocks it. +- **Generic `SecretProvider` (#355):** the future secret-handling + mechanism (see "Secret handling") — generalizes PRD 0048's + `DeployKeyProvisioner` into a user-extensible provider that mints + short-lived creds. Shippable independently; the orchestrator's vault + mints through it. +- **PR #350 (netpool single-source):** the same "one source per fact, + composition over parallel hierarchies" discipline the contract follows. + +## Decisions (review 2026-07-13) + +- **Egress sharing:** **consolidate egress** (worth it). Treat it as a + minimal, hardened attack surface for a malicious agent rather than + keeping it per-bottle; pair it with the identity token above and the + short-lived-vault-token mitigations (Secret handling / #355). +- **Control-plane transport:** **HTTP** — most universal/reliable on every + host; unix socket is an optional local optimization (see the contract). +- **Broker request schema:** **signed-JWT JSON, static flags + ids only** + (see the launch broker) — provenance + un-coercible by construction. +- **State re-adoption:** the restart procedure is: + 1. **Singleton:** a new orchestrator launch requires no pre-existing + orchestrator; any found (healthy or not) is fully shut down first. + 2. Re-adoption **waits for the new orchestrator to be healthy**. + 3. Once healthy, it discovers all agents needing adoption via **both the + SQLite registry and live process/VM inspection** *before serving any + other request*. The two-source sweep is what closes the *in-flight + launch* race — a launch that started (VM booting / slot claimed) but + hadn't committed to SQLite when the old orchestrator died would be + invisible to a SQLite-only sweep; process inspection catches it. + Launches should also write an **intent record ahead of committing + resources** so the sweep can reconcile intent vs. actual. + +## Open questions + +- **VM-to-VM routing:** per-backend, and in the design it *is* + `BottleBackend.wire()` (DNAT+forward for fc, shared-net for + docker/apple), not orchestrator logic. **Not a blocker** — resolvable at + implementation time (may require a bit of host modification, as the pool + setup already does on NixOS); an earlier "gateways in VMs" spike showed + it's feasible. +- **Live-reload protocol** for per-bottle policy over the HTTP control + plane (add/remove routes/keys/proposals without a restart). +- **Identity-token delivery:** exactly how the per-bottle token is placed + where the agent can present it but not swap in another bottle's. diff --git a/tests/integration/test_multitenant_isolation.py b/tests/integration/test_multitenant_isolation.py new file mode 100644 index 0000000..7ed3031 --- /dev/null +++ b/tests/integration/test_multitenant_isolation.py @@ -0,0 +1,161 @@ +"""Integration: two bottles share one gateway; source-IP attribution keeps +their egress tokens *and* allowlists isolated (PRD 0070 multi-tenancy). + +The whole premise of the consolidation is one shared gateway for every +bottle, isolated only by the unspoofable source IP. A single-bottle test +can't catch cross-bottle token bleed or an allowlist leak — this brings up +the real orchestrator + gateway and drives two bottles through the one +gateway to prove each gets exactly its own token and its own routes. + +Gated on a reachable Docker daemon and builds the bundle image, so it runs +with the other docker integration tests, not in unit CI. Uses the real +fixed gateway/orchestrator names (that's what it's testing), so it can't run +concurrently with a live per-host gateway; it points the orchestrator at a +throwaway BOT_BOTTLE_ROOT for a clean registry and tears everything down. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +from bot_bottle.backend.docker.consolidated_launch import ( + _network_cidr, + _network_container_ips, +) +from bot_bottle.backend.docker.egress import EGRESS_PORT +from bot_bottle.backend.docker.gateway_net import next_free_ip +from bot_bottle.orchestrator.client import OrchestratorClient +from bot_bottle.orchestrator.gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK +from bot_bottle.orchestrator.lifecycle import OrchestratorService +from tests._docker import skip_unless_docker + +# One upstream reachable under two names; reflects the Authorization header so +# the probe can see exactly what the gateway injected (or didn't). +_ECHO_NAME = "bot-bottle-mtitest-echo" +_ECHO_ALIASES = ("echo-shared", "echo-bonly") +_ECHO_SRC = ( + "from http.server import BaseHTTPRequestHandler, HTTPServer\n" + "class H(BaseHTTPRequestHandler):\n" + " def do_GET(s):\n" + " a=s.headers.get('Authorization','NONE'); s.send_response(200); s.end_headers()\n" + " s.wfile.write(('AUTH='+a).encode())\n" + " def log_message(s,*a): pass\n" + "HTTPServer(('0.0.0.0',80),H).serve_forever()\n" +) + +# A: echo-shared only, authed. B: echo-shared authed + echo-bonly open. +_POLICY_A = ( + 'routes:\n' + ' - host: echo-shared\n' + ' auth_scheme: "Bearer"\n' + ' token_env: "EGRESS_TOKEN_0"\n' +) +_POLICY_B = _POLICY_A + ' - host: echo-bonly\n' +_TOKEN_A = "sk-AAA-alice" +_TOKEN_B = "sk-BBB-bob" + +# Client probe: open http:/// through the gateway proxy from a container +# pinned to the bottle's source IP, printing " ". Uses only the +# bundle image's python3 — no dependency on the agent image. +_PROBE_SRC = ( + "import sys,urllib.request,urllib.error\n" + "op=urllib.request.build_opener(urllib.request.ProxyHandler({'http':sys.argv[1]}))\n" + "try:\n" + " r=op.open('http://'+sys.argv[2]+'/',timeout=8)\n" + " sys.stdout.write(str(r.status)+' '+r.read().decode())\n" + "except urllib.error.HTTPError as e:\n" + " sys.stdout.write(str(e.code)+' '+e.read().decode())\n" +) + + +@skip_unless_docker() +@unittest.skipIf( + os.environ.get("GITEA_ACTIONS") == "true", + "skipped under act_runner: the orchestrator container bind-mounts the repo " + "path into a container on the socket-shared host daemon, which can't see the " + "runner's /workspace — same host-bind-mount constraint as the other " + "bottle-bringup integration tests", +) +class TestMultitenantIsolation(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + # Throwaway root → a clean registry DB, independent of the host's. + self.svc = OrchestratorService(host_root=Path(self._tmp.name)) + self.addCleanup(self._teardown_docker) + # ensure_running builds the bundle image (slow on a cold cache) and + # brings up the shared network + gateway + orchestrator. + url = self.svc.ensure_running(startup_timeout=300) + self.client = OrchestratorClient(url) + self.gw_ip = self._container_ip(GATEWAY_NAME) + self._run_echo() + + def _teardown_docker(self) -> None: + subprocess.run(["docker", "rm", "--force", _ECHO_NAME], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + self.svc.stop() + subprocess.run(["docker", "network", "rm", GATEWAY_NETWORK], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + # The orchestrator container wrote the registry DB as root into the + # throwaway root; chown it back so the (non-root) tempdir cleanup can + # remove it. + subprocess.run( + ["docker", "run", "--rm", "-v", f"{self._tmp.name}:/r", + "--entrypoint", "chown", GATEWAY_IMAGE, "-R", + f"{os.getuid()}:{os.getgid()}", "/r"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + + @staticmethod + def _container_ip(name: str) -> str: + proc = subprocess.run( + ["docker", "inspect", "-f", + '{{(index .NetworkSettings.Networks "' + GATEWAY_NETWORK + '").IPAddress}}', name], + stdout=subprocess.PIPE, text=True, check=True, + ) + return proc.stdout.strip() + + def _run_echo(self) -> None: + argv = ["docker", "run", "--detach", "--name", _ECHO_NAME, + "--network", GATEWAY_NETWORK] + for alias in _ECHO_ALIASES: + argv += ["--network-alias", alias] + argv += ["--entrypoint", "python3", GATEWAY_IMAGE, "-c", _ECHO_SRC] + proc = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, check=False) + self.assertEqual(0, proc.returncode, f"echo upstream failed: {proc.stderr}") + + def _free_ip(self, extra_taken: list[str]) -> str: + taken = _network_container_ips(GATEWAY_NETWORK) + extra_taken + return next_free_ip(_network_cidr(GATEWAY_NETWORK), taken) + + def _probe(self, source_ip: str, host: str) -> str: + proc = subprocess.run( + ["docker", "run", "--rm", "--network", GATEWAY_NETWORK, "--ip", source_ip, + "--entrypoint", "python3", GATEWAY_IMAGE, "-c", _PROBE_SRC, + f"http://{self.gw_ip}:{EGRESS_PORT}", host], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, timeout=90, + ) + return proc.stdout.strip() + + def test_two_bottles_share_gateway_with_isolated_tokens_and_allowlists(self) -> None: + ip_a = self._free_ip([]) + ip_b = self._free_ip([ip_a]) + self.client.register_bottle(ip_a, policy=_POLICY_A, tokens={"EGRESS_TOKEN_0": _TOKEN_A}) + self.client.register_bottle(ip_b, policy=_POLICY_B, tokens={"EGRESS_TOKEN_0": _TOKEN_B}) + + # Each bottle gets its OWN token injected on the shared route — no bleed. + self.assertEqual(f"200 AUTH=Bearer {_TOKEN_A}", self._probe(ip_a, "echo-shared")) + self.assertEqual(f"200 AUTH=Bearer {_TOKEN_B}", self._probe(ip_b, "echo-shared")) + + # Allowlist is per-bottle: echo-bonly is only in B's policy. + self.assertTrue(self._probe(ip_a, "echo-bonly").startswith("403"), # fail-closed for A + "A reached a host outside its allowlist") + self.assertEqual("200 AUTH=NONE", self._probe(ip_b, "echo-bonly")) # allowed, unauthed for B + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_orchestrator_docker_broker.py b/tests/integration/test_orchestrator_docker_broker.py new file mode 100644 index 0000000..8c4da42 --- /dev/null +++ b/tests/integration/test_orchestrator_docker_broker.py @@ -0,0 +1,56 @@ +"""Integration: the Docker launch broker starts and removes a real container. + +Gated on a reachable Docker daemon (skips cleanly otherwise). Uses a tiny +image; the container may exit immediately — we only assert it exists after +launch and is gone after teardown. +""" + +from __future__ import annotations + +import secrets +import subprocess +import unittest + +from bot_bottle.orchestrator.broker import LaunchRequest, sign_request +from bot_bottle.orchestrator.docker_broker import DockerBroker, container_name +from tests._docker import skip_unless_docker + +IMAGE = "busybox" + + +@skip_unless_docker() +class TestDockerBrokerIntegration(unittest.TestCase): + def setUp(self) -> None: + self.secret = secrets.token_bytes(16) + self.broker = DockerBroker(self.secret) + self.bottle_id = "itest" + secrets.token_hex(4) + self.name = container_name(self.bottle_id) + self.addCleanup( + lambda: subprocess.run( + ["docker", "rm", "--force", self.name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ) + ) + + def _exists(self) -> bool: + proc = subprocess.run( + ["docker", "ps", "-a", "--filter", f"name=^{self.name}$", + "--format", "{{.Names}}"], + stdout=subprocess.PIPE, text=True, check=False, + ) + return self.name in proc.stdout.split() + + def _submit(self, req: LaunchRequest) -> None: + self.broker.submit(sign_request(req, self.secret)) + + def test_launch_creates_then_teardown_removes(self) -> None: + self._submit( + LaunchRequest(op="launch", bottle_id=self.bottle_id, image_ref=IMAGE) + ) + self.assertTrue(self._exists(), "container should exist after launch") + self._submit(LaunchRequest(op="teardown", bottle_id=self.bottle_id)) + self.assertFalse(self._exists(), "container should be gone after teardown") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_orchestrator_docker_gateway.py b/tests/integration/test_orchestrator_docker_gateway.py new file mode 100644 index 0000000..324dfd9 --- /dev/null +++ b/tests/integration/test_orchestrator_docker_gateway.py @@ -0,0 +1,45 @@ +"""Integration: the consolidated Docker gateway is an idempotent singleton. + +Gated on a reachable Docker daemon. Uses a unique name so it never +collides with a real per-host gateway or a leftover from another run. +""" + +from __future__ import annotations + +import secrets +import subprocess +import unittest + +from bot_bottle.orchestrator.gateway import DockerGateway +from tests._docker import skip_unless_docker + +IMAGE = "busybox" + + +@skip_unless_docker() +class TestDockerGatewayIntegration(unittest.TestCase): + def setUp(self) -> None: + self.name = "bot-bottle-orch-gateway-itest-" + secrets.token_hex(4) + self.sc = DockerGateway(IMAGE, name=self.name) + self.addCleanup(self.sc.stop) + + def _count(self) -> int: + proc = subprocess.run( + ["docker", "ps", "-a", "--filter", f"name=^{self.name}$", + "--format", "{{.Names}}"], + stdout=subprocess.PIPE, text=True, check=False, + ) + return sum(1 for n in proc.stdout.split() if n == self.name) + + def test_ensure_is_idempotent_singleton(self) -> None: + self.sc.ensure_running() + self.assertEqual(1, self._count()) + # A second ensure must not spawn a second container — one per host. + self.sc.ensure_running() + self.assertEqual(1, self._count()) + self.sc.stop() + self.assertEqual(0, self._count()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_orchestrator_docker_gateway_build.py b/tests/integration/test_orchestrator_docker_gateway_build.py new file mode 100644 index 0000000..66f7254 --- /dev/null +++ b/tests/integration/test_orchestrator_docker_gateway_build.py @@ -0,0 +1,36 @@ +"""Integration: DockerGateway.image_exists reflects real docker state. + +Gated on a reachable Docker daemon. Deliberately does NOT build the full +sidecar bundle (a heavy, slow image build) — it exercises the `image_exists` +primitive that `ensure_built` gates on, against a tiny pulled image and a +name that can't exist. +""" + +from __future__ import annotations + +import subprocess +import unittest + +from bot_bottle.orchestrator.gateway import DockerGateway +from tests._docker import skip_unless_docker + +IMAGE = "busybox" + + +@skip_unless_docker() +class TestDockerGatewayImageExists(unittest.TestCase): + def test_image_exists_true_for_present_false_for_absent(self) -> None: + # Ensure the tiny image is present (build_if_missing is disabled here + # so this never triggers a Dockerfile.sidecars build). + subprocess.run( + ["docker", "pull", IMAGE], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ) + self.assertTrue(DockerGateway(IMAGE, dockerfile=None).image_exists()) + self.assertFalse( + DockerGateway("bot-bottle-nonexistent:doesnotexist", dockerfile=None).image_exists() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index da5784b..be349dc 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -2,7 +2,7 @@ Isolates ``HOME`` to a throwaway directory for the entire unit suite so no test ever reads or writes the real ``~/.bot-bottle`` (state, queue, -and audit dirs all derive from ``supervise.bot_bottle_root()`` → +and audit dirs all derive from ``paths.bot_bottle_root()`` → ``Path.home()``). Without this, a test that takes a ``flock`` on the real audit log can **block indefinitely** when a live bottle's supervise sidecar holds that lock — observed as a hung ``coverage run`` at 0% CPU — @@ -10,8 +10,12 @@ and unisolated tests otherwise pollute the developer's home dir. Individual tests that need their own ``HOME`` still override ``os.environ['HOME']`` and restore it; they now restore to this isolated -dir rather than the real one, so isolation holds either way. Tests that -patch ``supervise.bot_bottle_root`` directly are unaffected. +dir rather than the real one, so isolation holds either way. + +Tests that need their own app-data root call ``use_bottle_root`` (below), +which sets ``BOT_BOTTLE_ROOT`` — the supported override read by +``paths.bot_bottle_root`` — instead of monkey-patching. One env setting +covers every module and every flat/package copy of the helper. """ from __future__ import annotations @@ -20,6 +24,9 @@ import atexit import os import shutil import tempfile +from collections.abc import Callable +from pathlib import Path +from unittest import mock _real_home = os.environ.get("HOME") _tmp_home = tempfile.mkdtemp(prefix="bot-bottle-unit-home.") @@ -35,3 +42,15 @@ def _restore_home() -> None: atexit.register(_restore_home) + + +def use_bottle_root(root: Path) -> Callable[[], None]: + """Redirect ``paths.bot_bottle_root()`` to ``root`` by setting + ``BOT_BOTTLE_ROOT`` — the supported override. Returns a callable that + undoes it (store it as the test's restore hook, or pass to addCleanup). + + Replaces monkey-patching ``paths.bot_bottle_root``; one env var covers + every module and the flat/package copies alike (they all read it).""" + patcher = mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(root)}) + patcher.start() + return patcher.stop diff --git a/tests/unit/test_backend_freezer.py b/tests/unit/test_backend_freezer.py index 40241b3..3cb7d54 100644 --- a/tests/unit/test_backend_freezer.py +++ b/tests/unit/test_backend_freezer.py @@ -7,7 +7,8 @@ import unittest from pathlib import Path from unittest.mock import patch -from bot_bottle import supervise, bottle_state +from bot_bottle import bottle_state +from tests.unit import use_bottle_root from bot_bottle.backend import ActiveAgent from bot_bottle.backend.freeze import get_freezer from bot_bottle.backend.docker.freezer import DockerFreezer @@ -18,13 +19,7 @@ from bot_bottle.backend.firecracker.freezer import FirecrackerFreezer class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_backend_prepare.py b/tests/unit/test_backend_prepare.py index 6423ccd..6013229 100644 --- a/tests/unit/test_backend_prepare.py +++ b/tests/unit/test_backend_prepare.py @@ -13,7 +13,7 @@ from pathlib import Path from unittest.mock import patch from bot_bottle import bottle_state -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.backend import BottleSpec from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.resolve_common import mint_slug @@ -55,11 +55,10 @@ class _FakeStateMixin: def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.") self.root = Path(self.tmp.name) / ".bot-bottle" - self.original_root = supervise.bot_bottle_root - supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + self._restore = use_bottle_root(self.root) def tearDown(self) -> None: - supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + self._restore() self.tmp.cleanup() diff --git a/tests/unit/test_backend_workspace.py b/tests/unit/test_backend_workspace.py index f57fa02..a7463f3 100644 --- a/tests/unit/test_backend_workspace.py +++ b/tests/unit/test_backend_workspace.py @@ -13,7 +13,7 @@ from pathlib import Path from unittest.mock import MagicMock, call, patch from bot_bottle import bottle_state -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.firecracker import FirecrackerBottleBackend @@ -55,11 +55,10 @@ class _FakeStateMixin: self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.") self.tmp = Path(self.tmp_dir.name) self.root = self.tmp / ".bot-bottle" - self.original_root = supervise.bot_bottle_root - supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + self._restore = use_bottle_root(self.root) def tearDown(self) -> None: - supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + self._restore() self.tmp_dir.cleanup() diff --git a/tests/unit/test_bottle_state.py b/tests/unit/test_bottle_state.py index c9abd92..df7a200 100644 --- a/tests/unit/test_bottle_state.py +++ b/tests/unit/test_bottle_state.py @@ -6,7 +6,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.bottle_state import ( BottleMetadata, @@ -18,13 +18,7 @@ from bot_bottle.bottle_state import ( class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="bottle-state-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_cli_commit.py b/tests/unit/test_cli_commit.py index 2dd2708..6a7e7ff 100644 --- a/tests/unit/test_cli_commit.py +++ b/tests/unit/test_cli_commit.py @@ -8,7 +8,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch from bot_bottle.cli.commit import cmd_commit -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.freeze import CommitCancelled @@ -16,13 +16,7 @@ from bot_bottle.backend.freeze import CommitCancelled class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): self._restore() diff --git a/tests/unit/test_cli_start_settle.py b/tests/unit/test_cli_start_settle.py index 9a94ba5..4b1c405 100644 --- a/tests/unit/test_cli_start_settle.py +++ b/tests/unit/test_cli_start_settle.py @@ -8,7 +8,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.cli import start as start_mod @@ -16,15 +16,10 @@ from bot_bottle.cli import start as start_mod class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="cli-start-settle.") - self._original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self): - supervise.bot_bottle_root = self._original # type: ignore[assignment] + self._restore() self._tmp.cleanup() diff --git a/tests/unit/test_consolidated_compose.py b/tests/unit/test_consolidated_compose.py new file mode 100644 index 0000000..f463d33 --- /dev/null +++ b/tests/unit/test_consolidated_compose.py @@ -0,0 +1,51 @@ +"""Unit: agent-only consolidated compose render (PRD 0070).""" + +from __future__ import annotations + +import unittest + +from bot_bottle.backend.docker.consolidated_compose import consolidated_agent_compose +from tests.unit.test_compose import _plan + +_GW = "172.18.0.2" +_IP = "172.18.0.5" +_NET = "bot-bottle-gateway" + + +class TestConsolidatedAgentCompose(unittest.TestCase): + def _spec(self, *, runsc: bool = False): + plan = _plan(with_egress=True, supervise=True, with_git=True) + if runsc: + plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore[arg-type] + return consolidated_agent_compose(plan, gateway_ip=_GW, source_ip=_IP, network=_NET) + + def test_only_agent_service_no_sidecars(self) -> None: + # The whole point of consolidation: no per-bottle sidecar bundle. + self.assertEqual(["agent"], list(self._spec()["services"])) + + def test_agent_pinned_on_external_gateway_network(self) -> None: + spec = self._spec() + self.assertEqual({"external": True}, spec["networks"][_NET]) + agent_net = spec["services"]["agent"]["networks"][_NET] + self.assertEqual(_IP, agent_net["ipv4_address"]) + + def test_proxy_and_ca_point_at_gateway(self) -> None: + env = self._spec()["services"]["agent"]["environment"] + self.assertIn(f"HTTPS_PROXY=http://{_GW}:9099", env) + # git-http + supervise on the gateway must bypass the egress proxy. + self.assertTrue(any(e.startswith("NO_PROXY=") and _GW in e for e in env)) + + def test_no_sidecar_dependency(self) -> None: + self.assertNotIn("depends_on", self._spec()["services"]["agent"]) + + def test_runsc_runtime_when_enabled(self) -> None: + self.assertEqual("runsc", self._spec(runsc=True)["services"]["agent"]["runtime"]) + + def test_forwarded_env_stays_bare_names(self) -> None: + env = self._spec()["services"]["agent"]["environment"] + # forwarded secrets are bare names (value inherited from process env). + self.assertIn("CLAUDE_CODE_OAUTH_TOKEN", env) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_consolidated_launch.py b/tests/unit/test_consolidated_launch.py new file mode 100644 index 0000000..ecd8bbd --- /dev/null +++ b/tests/unit/test_consolidated_launch.py @@ -0,0 +1,95 @@ +"""Unit: consolidated launch sequence — compose the orchestrator primitives (PRD 0070).""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +from bot_bottle.backend.docker.consolidated_launch import ( + launch_consolidated, + teardown_consolidated, +) +from bot_bottle.egress import EgressPlan, EgressRoute +from bot_bottle.git_gate import GitGatePlan +from bot_bottle.orchestrator.client import RegisteredBottle + +_MOD = "bot_bottle.backend.docker.consolidated_launch" + + +def _egress_plan() -> EgressPlan: + return EgressPlan( + slug="demo", routes_path=Path("/x"), routes=(EgressRoute(host="api.example.com"),), + token_env_map={}, + ) + + +def _git_plan() -> GitGatePlan: + return GitGatePlan( + slug="demo", entrypoint_script=Path(), hook_script=Path(), + access_hook_script=Path(), upstreams=(), + ) + + +def _client(*, bottles: list[dict[str, object]] | None = None) -> Mock: + c = Mock() + c.list_bottles.return_value = bottles or [] + c.register_bottle.return_value = RegisteredBottle("b1", "tok") + return c + + +class TestLaunchConsolidated(unittest.TestCase): + def _run( + self, client: Mock, provision: Mock | None = None, + *, on_network: tuple[str, ...] = ("172.18.0.2",), + ): + service = MagicMock() + service.ensure_running.return_value = "http://orch:8080" + with patch(f"{_MOD}._network_cidr", return_value="172.18.0.0/16"), \ + patch(f"{_MOD}._container_ip", return_value="172.18.0.2"), \ + patch(f"{_MOD}._network_container_ips", return_value=list(on_network)), \ + patch(f"{_MOD}.OrchestratorClient", return_value=client), \ + patch(f"{_MOD}.provision_git_gate", provision or Mock()): + return launch_consolidated(_egress_plan(), _git_plan(), service=service) + + def test_allocates_ip_registers_and_provisions(self) -> None: + client = _client() + provision = Mock() + ctx = self._run(client, provision) + # .1 is the router, .2 is the gateway → first bottle gets .3. + self.assertEqual("172.18.0.3", ctx.source_ip) + self.assertEqual("172.18.0.2", ctx.gateway_ip) + self.assertEqual("b1", ctx.bottle_id) + self.assertEqual("tok", ctx.identity_token) + self.assertEqual("http://orch:8080", ctx.orchestrator_url) + # Registered with the source IP + the egress policy blob. + kwargs = client.register_bottle.call_args + self.assertEqual("172.18.0.3", kwargs.args[0]) + self.assertIn("api.example.com", kwargs.kwargs["policy"]) + provision.assert_called_once() + + def test_skips_all_addresses_on_the_network(self) -> None: + # Gateway .2 + orchestrator .3 already attached -> agent gets .4. + ctx = self._run(_client(), on_network=("172.18.0.2", "172.18.0.3")) + self.assertEqual("172.18.0.4", ctx.source_ip) + + def test_provision_failure_rolls_back_registration(self) -> None: + client = _client() + provision = Mock(side_effect=RuntimeError("provision boom")) + with self.assertRaises(RuntimeError): + self._run(client, provision) + client.teardown_bottle.assert_called_once_with("b1") # no orphan left + + +class TestTeardownConsolidated(unittest.TestCase): + def test_deregisters_and_deprovisions(self) -> None: + client = Mock() + with patch(f"{_MOD}.OrchestratorClient", return_value=client), \ + patch(f"{_MOD}.deprovision_git_gate") as deprov: + teardown_consolidated("b1", orchestrator_url="http://orch:8080") + client.teardown_bottle.assert_called_once_with("b1") + deprov.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_docker_cleanup.py b/tests/unit/test_docker_cleanup.py index aeab400..730f20f 100644 --- a/tests/unit/test_docker_cleanup.py +++ b/tests/unit/test_docker_cleanup.py @@ -15,7 +15,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs @@ -23,13 +23,7 @@ from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs class _FakeHomeMixin: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="docker-cleanup-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self) -> None: self._restore() diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index b6aca74..c774c28 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -23,7 +23,7 @@ import tempfile import unittest from pathlib import Path -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle import bottle_state from bot_bottle.backend.docker import enumerate as _enumerate @@ -75,13 +75,7 @@ class TestParseServicesByProject(unittest.TestCase): class _FakeHomeMixin: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="enum-active.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore_home = lambda: setattr(supervise, "bot_bottle_root", original) + self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") def _teardown_fake_home(self) -> None: self._restore_home() diff --git a/tests/unit/test_docker_launch_committed_image.py b/tests/unit/test_docker_launch_committed_image.py index 1152e63..d0919b6 100644 --- a/tests/unit/test_docker_launch_committed_image.py +++ b/tests/unit/test_docker_launch_committed_image.py @@ -1,4 +1,4 @@ -"""Unit: Docker launch step uses committed image when available.""" +"""Unit: Docker launch step uses committed image when available (consolidated).""" from __future__ import annotations @@ -6,6 +6,7 @@ import contextlib import io import tempfile import unittest +from collections.abc import Callable, Generator from pathlib import Path from typing import Any from unittest import mock @@ -14,9 +15,11 @@ from bot_bottle.agent_provider import AgentProvisionPlan from bot_bottle.backend import BottleSpec from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan +from bot_bottle.backend.docker.consolidated_launch import LaunchContext from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import ManifestIndex +from tests.unit import use_bottle_root _SLUG = "dev-abc12" @@ -28,163 +31,111 @@ _IDX = ManifestIndex.from_json_obj({ "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) +_CTX = LaunchContext( + bottle_id="b1", identity_token="t", source_ip="172.20.0.4", + network="bot-bottle-gateway", gateway_ip="172.20.0.2", + orchestrator_url="http://orch:8099", +) + def _plan(tmp: str) -> DockerBottlePlan: stage = Path(tmp) spec = BottleSpec( - manifest=_IDX, - agent_name="demo", - copy_cwd=False, - user_cwd=tmp, - identity=_SLUG, + manifest=_IDX, agent_name="demo", copy_cwd=False, user_cwd=tmp, identity=_SLUG, ) return DockerBottlePlan( spec=spec, manifest=_IDX.load_for_agent("demo"), stage_dir=stage, git_gate_plan=GitGatePlan( - slug=_SLUG, - entrypoint_script=stage / "entrypoint.sh", - hook_script=stage / "hook.sh", - access_hook_script=stage / "access-hook.sh", - upstreams=(), + slug=_SLUG, entrypoint_script=stage / "e.sh", hook_script=stage / "h.sh", + access_hook_script=stage / "a.sh", upstreams=(), ), egress_plan=EgressPlan( - slug=_SLUG, - routes_path=stage / "egress.yaml", - routes=(), - token_env_map={}, + slug=_SLUG, routes_path=stage / "egress.yaml", routes=(), token_env_map={}, ), supervise_plan=None, agent_provision=AgentProvisionPlan( - template="claude", - command="claude", - prompt_mode="append_file", - image=_DEFAULT_IMAGE, - dockerfile="", - guest_home="/home/node", - instance_name=f"bot-bottle-{_SLUG}", - prompt_file=stage / "prompt.txt", + template="claude", command="claude", prompt_mode="append_file", + image=_DEFAULT_IMAGE, dockerfile="", guest_home="/home/node", + instance_name=f"bot-bottle-{_SLUG}", prompt_file=stage / "prompt.txt", guest_env={}, ), - slug=_SLUG, - forwarded_env={}, - use_runsc=False, + slug=_SLUG, forwarded_env={}, use_runsc=False, ) class TestLaunchCommittedImage(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.") + self.addCleanup(lambda: __import__("shutil").rmtree(self._tmp, ignore_errors=True)) + # The launch writes the gateway CA under the bottle root — sandbox it. + self.addCleanup(use_bottle_root(Path(self._tmp))) - def tearDown(self) -> None: - import shutil - shutil.rmtree(self._tmp, ignore_errors=True) - - def _run_launch( + @contextlib.contextmanager + def _patched( self, - plan: DockerBottlePlan, *, - committed_tag: str | None = None, - image_present: bool = True, - ) -> list[str]: - """Drive launch() through its full sequence with the committed-image - behaviour controlled by the arguments. Returns the images that were - passed to `build_image` (empty list if it was never called).""" + committed_tag: str | None, + image_present: bool, + compose: Callable[..., dict[str, Any]], + ) -> Generator[list[str], None, None]: built: list[str] = [] + gw = mock.Mock() + gw.ca_cert_pem.return_value = "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n" - def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None: + def _build(image: str, ctx: str, *, dockerfile: str = "") -> None: del ctx, dockerfile built.append(image) - with mock.patch.object( - launch_mod, "read_committed_image", return_value=committed_tag, - ), mock.patch.object( - launch_mod.docker_mod, "image_exists", return_value=image_present, - ), mock.patch.object( - launch_mod.docker_mod, "build_image", side_effect=fake_build, - ), mock.patch.object( - launch_mod, "egress_tls_init", - return_value=(Path("/egress_ca"), Path("/egress_cert")), - ), mock.patch.object( - launch_mod.network_mod, "network_name_for_slug", - return_value="bb-internal", - ), mock.patch.object( - launch_mod.network_mod, "network_egress_name_for_slug", - return_value="bb-egress", - ), mock.patch.object( - launch_mod, "bottle_plan_to_compose", - return_value={"services": {"agent": {}}}, - ), mock.patch.object( - launch_mod, "write_compose_file", - return_value=Path("/tmp/compose.yml"), - ), mock.patch.object(launch_mod, "compose_up"), \ - mock.patch.object(launch_mod, "compose_dump_logs"), \ - mock.patch.object(launch_mod, "compose_down"), \ - contextlib.redirect_stderr(io.StringIO()): - provision = mock.Mock(return_value=None) - with launch_mod.launch(plan, provision=provision): - pass + with mock.patch.object(launch_mod, "read_committed_image", return_value=committed_tag), \ + mock.patch.object(launch_mod.docker_mod, "image_exists", return_value=image_present), \ + mock.patch.object(launch_mod.docker_mod, "build_image", side_effect=_build), \ + mock.patch.object(launch_mod, "launch_consolidated", return_value=_CTX), \ + mock.patch.object(launch_mod, "teardown_consolidated"), \ + mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \ + mock.patch.object(launch_mod, "consolidated_agent_compose", side_effect=compose), \ + mock.patch.object(launch_mod, "write_compose_file", return_value=Path("/tmp/c.yml")), \ + mock.patch.object(launch_mod, "compose_up"), \ + mock.patch.object(launch_mod, "compose_dump_logs"), \ + mock.patch.object(launch_mod, "compose_down"), \ + contextlib.redirect_stderr(io.StringIO()): + yield built + def _run_launch( + self, plan: DockerBottlePlan, *, + committed_tag: str | None = None, image_present: bool = True, + ) -> list[str]: + def compose(_p: DockerBottlePlan, **_kw: Any) -> dict[str, Any]: + return {"services": {"agent": {}}} + with self._patched( + committed_tag=committed_tag, image_present=image_present, compose=compose, + ) as built: + with launch_mod.launch(plan, provision=mock.Mock(return_value=None)): + pass return built def test_skips_build_when_committed_image_present(self) -> None: - plan = _plan(self._tmp) - built = self._run_launch(plan, committed_tag=_COMMITTED_TAG, image_present=True) - self.assertEqual([], built, "build_image should not be called when committed image exists") + built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=True) + self.assertEqual([], built) # committed image reused, no build def test_uses_committed_image_in_compose_spec(self) -> None: - """The compose spec renderer receives the committed image tag via - plan.image — captured here by checking what bottle_plan_to_compose - was called with.""" - plan = _plan(self._tmp) - captured_plans: list[DockerBottlePlan] = [] + captured: list[DockerBottlePlan] = [] - def fake_compose(p: DockerBottlePlan) -> dict[str, Any]: - captured_plans.append(p) + def compose(p: DockerBottlePlan, **_kw: Any) -> dict[str, Any]: + captured.append(p) return {"services": {"agent": {}}} - with mock.patch.object( - launch_mod, "read_committed_image", return_value=_COMMITTED_TAG, - ), mock.patch.object( - launch_mod.docker_mod, "image_exists", return_value=True, - ), mock.patch.object( - launch_mod.docker_mod, "build_image", - ), mock.patch.object( - launch_mod, "egress_tls_init", - return_value=(Path("/egress_ca"), Path("/egress_cert")), - ), mock.patch.object( - launch_mod.network_mod, "network_name_for_slug", - return_value="bb-internal", - ), mock.patch.object( - launch_mod.network_mod, "network_egress_name_for_slug", - return_value="bb-egress", - ), mock.patch.object( - launch_mod, "bottle_plan_to_compose", side_effect=fake_compose, - ), mock.patch.object( - launch_mod, "write_compose_file", - return_value=Path("/tmp/compose.yml"), - ), mock.patch.object(launch_mod, "compose_up"), \ - mock.patch.object(launch_mod, "compose_dump_logs"), \ - mock.patch.object(launch_mod, "compose_down"), \ - contextlib.redirect_stderr(io.StringIO()): - provision = mock.Mock(return_value=None) - with launch_mod.launch(plan, provision=provision): + with self._patched(committed_tag=_COMMITTED_TAG, image_present=True, compose=compose): + with launch_mod.launch(_plan(self._tmp), provision=mock.Mock(return_value=None)): pass - - self.assertEqual(1, len(captured_plans)) - self.assertEqual(_COMMITTED_TAG, captured_plans[0].image) + self.assertEqual(_COMMITTED_TAG, captured[0].image) def test_falls_back_to_build_when_no_committed_image(self) -> None: - plan = _plan(self._tmp) - built = self._run_launch(plan, committed_tag=None) - self.assertEqual([_DEFAULT_IMAGE], built) + self.assertEqual([_DEFAULT_IMAGE], self._run_launch(_plan(self._tmp), committed_tag=None)) def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None: - plan = _plan(self._tmp) - built = self._run_launch( - plan, committed_tag=_COMMITTED_TAG, image_present=False, - ) + built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=False) self.assertEqual([_DEFAULT_IMAGE], built) diff --git a/tests/unit/test_docker_launch_teardown.py b/tests/unit/test_docker_launch_teardown.py index 983bbc6..c942f73 100644 --- a/tests/unit/test_docker_launch_teardown.py +++ b/tests/unit/test_docker_launch_teardown.py @@ -19,9 +19,11 @@ from bot_bottle.agent_provider import AgentProvisionPlan from bot_bottle.backend import BottleSpec from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan +from bot_bottle.backend.docker.consolidated_launch import LaunchContext from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import ManifestIndex +from tests.unit import use_bottle_root _INDEX = ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, @@ -77,41 +79,36 @@ def _plan(tmp: str) -> DockerBottlePlan: class TestTeardownWarning(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.mkdtemp(prefix="docker-launch-teardown-test.") - - def tearDown(self) -> None: - import shutil - shutil.rmtree(self._tmp, ignore_errors=True) + self.addCleanup(lambda: __import__("shutil").rmtree(self._tmp, ignore_errors=True)) + self.addCleanup(use_bottle_root(Path(self._tmp))) # sandbox the gateway CA write def test_teardown_failure_emits_warning_with_container_and_operation(self): plan = _plan(self._tmp) buf = io.StringIO() + gw = mock.Mock() + gw.ca_cert_pem.return_value = "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n" + ctx = LaunchContext( + bottle_id="b1", identity_token="t", source_ip="172.20.0.4", + network="bot-bottle-gateway", gateway_ip="172.20.0.2", + orchestrator_url="http://orch:8099", + ) with mock.patch.object(launch_mod.docker_mod, "build_image"), \ + mock.patch.object(launch_mod, "launch_consolidated", return_value=ctx), \ + mock.patch.object(launch_mod, "teardown_consolidated"), \ + mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \ mock.patch.object( - launch_mod, "egress_tls_init", - return_value=(Path("/egress_ca"), Path("/egress_cert")), - ), \ - mock.patch.object( - launch_mod.network_mod, "network_name_for_slug", - return_value="bb-internal-test", - ), \ - mock.patch.object( - launch_mod.network_mod, "network_egress_name_for_slug", - return_value="bb-egress-test", - ), \ - mock.patch.object( - launch_mod, "bottle_plan_to_compose", + launch_mod, "consolidated_agent_compose", return_value={"services": {"agent": {}}}, ), \ mock.patch.object( - launch_mod, "write_compose_file", - return_value=Path("/tmp/compose.yml"), + launch_mod, "write_compose_file", return_value=Path("/tmp/compose.yml"), ), \ mock.patch.object(launch_mod, "compose_up"), \ mock.patch.object(launch_mod, "compose_dump_logs"), \ mock.patch.object( launch_mod, "compose_down", - side_effect=RuntimeError("network remove failed"), + side_effect=RuntimeError("compose down failed"), ), \ contextlib.redirect_stderr(buf): provision = mock.Mock(return_value=None) diff --git a/tests/unit/test_egress_addon_log_redaction.py b/tests/unit/test_egress_addon_log_redaction.py index dbff136..6a969c5 100644 --- a/tests/unit/test_egress_addon_log_redaction.py +++ b/tests/unit/test_egress_addon_log_redaction.py @@ -46,7 +46,7 @@ def _addon() -> EgressAddon: """Return a bare EgressAddon with LOG_FULL config and no routes file.""" a: EgressAddon = EgressAddon.__new__(EgressAddon) a.config = Config(routes=(), log=LOG_FULL) - a.safe_tokens = set() + a._safe_tokens = {} a._supervise_slug = "" a._token_allow_timeout = 300.0 return a diff --git a/tests/unit/test_egress_addon_request_flow.py b/tests/unit/test_egress_addon_request_flow.py index ed8d8b2..aa46628 100644 --- a/tests/unit/test_egress_addon_request_flow.py +++ b/tests/unit/test_egress_addon_request_flow.py @@ -211,7 +211,7 @@ def _addon(config: Config) -> EgressAddon: """Bare EgressAddon with a supplied config and no supervise wiring.""" a: EgressAddon = EgressAddon.__new__(EgressAddon) a.config = config - a.safe_tokens = set() + a._safe_tokens = {} a._supervise_slug = "" a._token_allow_timeout = 300.0 a.routes_path = "/nonexistent/routes.yaml" @@ -222,6 +222,32 @@ def _run_request(addon: EgressAddon, flow: _Flow) -> None: asyncio.run(addon.request(flow)) # type: ignore[arg-type] +def _with_client_ip(flow: _Flow, ip: str) -> _Flow: + """Attach a mitmproxy-style client_conn so consolidated resolution can read + the source IP (`flow.client_conn.peername[0]`).""" + flow.client_conn = types.SimpleNamespace(peername=(ip, 54321)) # type: ignore[attr-defined] + return flow + + +class _CtxResolver: + """Fake orchestrator resolver: maps source IP -> bottle id, and grants the + same allow-list to any attributed bottle (unattributed -> deny).""" + + def __init__( + self, ip_to_bottle: dict[str, str], tokens: dict[str, str] | None = None, + ) -> None: + self._map = ip_to_bottle + self._tokens = tokens or {} + + def resolve_policy_and_bottle_id( + self, source_ip: str, identity_token: str = "", + ) -> tuple[str | None, str | None, dict[str, str]]: + del identity_token + bottle_id = self._map.get(source_ip) + policy = "routes:\n - host: api.example.com\n" if bottle_id else None + return policy, bottle_id, (self._tokens if bottle_id else {}) + + # --------------------------------------------------------------------------- # Introspection endpoint # --------------------------------------------------------------------------- @@ -418,7 +444,8 @@ class TestSuperviseBranch(unittest.TestCase): with patch.object(_ea_mod, "_sv", _fake_sv("approved")): _run_request(addon, flow) self.assertIsNone(flow.response) # forwarded after approval - self.assertIn(_OPENAI_KEY, addon.safe_tokens) + # Approval lands in the calling bottle's safelist (keyed by slug). + self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("test-bottle")) def test_operator_rejection_blocks(self) -> None: addon = self._supervised_addon() @@ -735,5 +762,102 @@ class TestLogFullRequest(unittest.TestCase): self.assertTrue(any(e.get("event") == "egress_request" for e in logged)) +class TestSuperviseMultiTenant(unittest.TestCase): + """Consolidated gateway: supervise proposals + the DLP safelist are keyed + per bottle, resolved by source IP (PRD 0070).""" + + def _consolidated_addon(self) -> EgressAddon: + # Static config is empty; the resolver supplies each bottle's config. + addon = _addon(Config(routes=())) + addon._resolver = cast(Any, _CtxResolver({"10.0.0.1": "bottle-a", "10.0.0.2": "bottle-b"})) + addon._token_allow_timeout = 0.05 + return addon + + def test_approval_is_scoped_to_the_calling_bottle(self) -> None: + addon = self._consolidated_addon() + # bottle-a (10.0.0.1) sends the token; the operator approves. + flow = _with_client_ip( + _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")), + "10.0.0.1", + ) + with patch.object(_ea_mod, "_sv", _fake_sv("approved")): + _run_request(addon, flow) + self.assertIsNone(flow.response) # forwarded after approval + # The approval lands ONLY in bottle-a's safelist — never bottle-b's. + # A global set here would be the cross-tenant leak this slice closes. + self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-a")) + self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-b")) + + def test_proposal_is_attributed_to_the_source_ip_bottle(self) -> None: + addon = self._consolidated_addon() + seen: list[str] = [] + fake = _fake_sv("approved") + + def _capture(**kw: Any) -> Any: + seen.append(kw["bottle_slug"]) + return types.SimpleNamespace(id="p") + + fake.Proposal = types.SimpleNamespace(new=_capture) + flow = _with_client_ip( + _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")), + "10.0.0.2", + ) + with patch.object(_ea_mod, "_sv", fake): + _run_request(addon, flow) + self.assertEqual(["bottle-b"], seen) # proposal keyed by the resolved bottle + + def test_auth_token_injected_from_resolved_tokens(self) -> None: + # The bottle's upstream token comes from /resolve (in-memory on the + # orchestrator), NOT the gateway's env — the request is forwarded with + # Authorization injected. This is the token-injection cut-over fix. + policy = ( + 'routes:\n - host: api.example.com\n' + ' auth_scheme: "Bearer"\n token_env: "EGRESS_TOKEN_0"\n' + ) + + class _AuthResolver: + def resolve_policy_and_bottle_id(self, ip: str, identity_token: str = ""): + del ip, identity_token + return policy, "bottle-a", {"EGRESS_TOKEN_0": "sk-secret"} + + addon = _addon(Config(routes=())) + addon._resolver = cast(Any, _AuthResolver()) + flow = _with_client_ip(_Flow(_Request(host="api.example.com", method="GET")), "10.0.0.1") + _run_request(addon, flow) + self.assertIsNone(flow.response) # forwarded, not blocked + self.assertEqual("Bearer sk-secret", flow.request.headers.get("authorization")) + + def test_authed_route_without_token_is_blocked(self) -> None: + # No token resolved for the bottle → the authed route can't inject, so + # the request is blocked (the error the cut-over originally produced). + policy = ( + 'routes:\n - host: api.example.com\n' + ' auth_scheme: "Bearer"\n token_env: "EGRESS_TOKEN_0"\n' + ) + + class _NoTokenResolver: + def resolve_policy_and_bottle_id(self, ip: str, identity_token: str = ""): + del ip, identity_token + return policy, "bottle-a", {} # no tokens + + addon = _addon(Config(routes=())) + addon._resolver = cast(Any, _NoTokenResolver()) + flow = _with_client_ip(_Flow(_Request(host="api.example.com", method="GET")), "10.0.0.1") + _run_request(addon, flow) + self.assertIsNotNone(flow.response) # blocked — token unset + + def test_unattributed_source_ip_cannot_supervise(self) -> None: + addon = self._consolidated_addon() + # 10.9.9.9 is not in the resolver map -> deny-all config, empty slug. + flow = _with_client_ip( + _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")), + "10.9.9.9", + ) + with patch.object(_ea_mod, "_sv", _fake_sv("approved")): + _run_request(addon, flow) + self.assertIsNotNone(flow.response) # blocked (no route, no supervise) + self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index de51c57..12d3b1a 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -8,7 +8,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.backend.egress_apply import EgressApplyError from bot_bottle.backend.docker.egress_apply import applicator @@ -67,14 +67,8 @@ class TestValidateRoutesContent(unittest.TestCase): class TestApplyRoutesChange(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="egress-apply-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self.addCleanup(lambda: setattr(supervise, "bot_bottle_root", original)) self.addCleanup(self._tmp.cleanup) + self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle")) def test_writes_live_routes_and_signals_reload(self): calls: list[list[str]] = [] diff --git a/tests/unit/test_egress_multitenant.py b/tests/unit/test_egress_multitenant.py new file mode 100644 index 0000000..e35ec95 --- /dev/null +++ b/tests/unit/test_egress_multitenant.py @@ -0,0 +1,110 @@ +"""Unit: fail-closed per-client egress resolution — config + context (PRD 0070).""" + +from __future__ import annotations + +import unittest + +from bot_bottle.egress_addon_core import resolve_client_config, resolve_client_context +from bot_bottle.policy_resolver import PolicyResolveError + + +class _FakeResolver: + def __init__(self, result: str | None = None, raises: bool = False) -> None: + self._result = result + self._raises = raises + self.calls: list[tuple[str, str]] = [] + + def resolve(self, source_ip: str, identity_token: str = "") -> str | None: + self.calls.append((source_ip, identity_token)) + if self._raises: + raise PolicyResolveError("orchestrator down") + return self._result + + +class TestResolveClientConfig(unittest.TestCase): + def test_valid_policy_is_parsed(self) -> None: + cfg = resolve_client_config( + _FakeResolver(result="routes:\n - host: example.com\n"), "10.243.0.1" + ) + self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes)) + + def test_unattributed_none_denies_all(self) -> None: + cfg = resolve_client_config(_FakeResolver(result=None), "10.243.0.9") + self.assertEqual((), cfg.routes) # no routes → default-deny + + def test_empty_policy_denies_all(self) -> None: + self.assertEqual((), resolve_client_config(_FakeResolver(result=""), "10.243.0.1").routes) + + def test_resolver_error_denies_all(self) -> None: + # Orchestrator unreachable/errored must never widen egress. + self.assertEqual((), resolve_client_config(_FakeResolver(raises=True), "10.243.0.1").routes) + + def test_unparseable_policy_denies_all(self) -> None: + cfg = resolve_client_config(_FakeResolver(result="routes: notalist\n"), "10.243.0.1") + self.assertEqual((), cfg.routes) + + def test_forwards_source_ip_and_token(self) -> None: + r = _FakeResolver(result=None) + resolve_client_config(r, "10.243.0.1", "tok") + self.assertEqual(("10.243.0.1", "tok"), r.calls[0]) + + +class _FakeContextResolver: + def __init__( + self, policy: str | None = None, bottle_id: str | None = None, + raises: bool = False, tokens: dict[str, str] | None = None, + ) -> None: + self._policy = policy + self._bottle_id = bottle_id + self._raises = raises + self._tokens = tokens or {} + + def resolve_policy_and_bottle_id( + self, source_ip: str, identity_token: str = "", + ) -> tuple[str | None, str | None, dict[str, str]]: + if self._raises: + raise PolicyResolveError("orchestrator down") + return self._policy, self._bottle_id, self._tokens + + +class TestResolveClientContext(unittest.TestCase): + def test_returns_config_bottle_id_and_tokens(self) -> None: + cfg, slug, tokens = resolve_client_context( + _FakeContextResolver( + policy="routes:\n - host: example.com\n", bottle_id="b1", + tokens={"EGRESS_TOKEN_0": "sekret"}, + ), + "10.243.0.1", + ) + self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes)) + self.assertEqual("b1", slug) + self.assertEqual({"EGRESS_TOKEN_0": "sekret"}, tokens) # for auth injection + + def test_unattributed_denies_and_empty_slug(self) -> None: + cfg, slug, tokens = resolve_client_context( + _FakeContextResolver(policy=None, bottle_id=None), "10.243.0.9", + ) + self.assertEqual((), cfg.routes) + self.assertEqual("", slug) # no bottle → supervise unavailable + self.assertEqual({}, tokens) + + def test_resolver_error_denies_empty_slug_no_tokens(self) -> None: + cfg, slug, tokens = resolve_client_context( + _FakeContextResolver(raises=True), "10.243.0.1", + ) + self.assertEqual((), cfg.routes) + self.assertEqual("", slug) + self.assertEqual({}, tokens) + + def test_unparseable_policy_denies_but_keeps_slug(self) -> None: + # A bad policy denies egress, but the bottle is still attributed (its + # supervise queue is keyed by the id, independent of route parsing). + cfg, slug, _tokens = resolve_client_context( + _FakeContextResolver(policy="routes: notalist\n", bottle_id="b2"), "10.243.0.1", + ) + self.assertEqual((), cfg.routes) + self.assertEqual("b2", slug) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_gateway_net.py b/tests/unit/test_gateway_net.py new file mode 100644 index 0000000..3ff957a --- /dev/null +++ b/tests/unit/test_gateway_net.py @@ -0,0 +1,42 @@ +"""Unit: shared-gateway source-IP allocation (PRD 0070).""" + +from __future__ import annotations + +import unittest + +from bot_bottle.backend.docker.gateway_net import NoFreeAddressError, next_free_ip + + +class TestNextFreeIp(unittest.TestCase): + def test_skips_router_and_returns_first_host(self) -> None: + # .1 is docker's router; the first assignable address is .2. + self.assertEqual("172.18.0.2", next_free_ip("172.18.0.0/16", [])) + + def test_skips_taken_addresses(self) -> None: + # Gateway container holds .2, a live bottle holds .3 -> next is .4. + self.assertEqual( + "172.18.0.4", next_free_ip("172.18.0.0/16", ["172.18.0.2", "172.18.0.3"]), + ) + + def test_taken_order_does_not_matter(self) -> None: + self.assertEqual( + "172.18.0.2", next_free_ip("172.18.0.0/16", ["172.18.0.3", "172.18.0.5"]), + ) + + def test_allocation_is_deterministic(self) -> None: + taken = ["172.18.0.2"] + self.assertEqual(next_free_ip("172.18.0.0/16", taken), + next_free_ip("172.18.0.0/16", taken)) + + def test_raises_when_subnet_exhausted(self) -> None: + # /30: hosts are .1 (router, reserved) and .2; taking .2 leaves none. + with self.assertRaises(NoFreeAddressError): + next_free_ip("10.9.9.0/30", ["10.9.9.2"]) + + def test_accepts_host_bits_set_cidr(self) -> None: + # A container's inspected address arrives as e.g. 172.18.0.2/16. + self.assertEqual("172.18.0.2", next_free_ip("172.18.0.5/16", [])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_gateway_provision.py b/tests/unit/test_gateway_provision.py new file mode 100644 index 0000000..b28c3a0 --- /dev/null +++ b/tests/unit/test_gateway_provision.py @@ -0,0 +1,113 @@ +"""Unit: git-gate provisioning into the running shared gateway (PRD 0070).""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +from bot_bottle.backend.docker.gateway_provision import ( + GatewayProvisionError, + deprovision_git_gate, + provision_git_gate, +) +from bot_bottle.git_gate import GitGatePlan, GitGateUpstream + +_RUN = "bot_bottle.backend.docker.gateway_provision.run_docker" + + +def _proc(returncode: int = 0, stderr: str = "") -> Mock: + return Mock(returncode=returncode, stderr=stderr) + + +def _recorder(calls: list[list[str]]): + """A run_docker side_effect that records argv and returns success.""" + def fake(argv: list[str]) -> Mock: + calls.append(argv) + return _proc() + return fake + + +def _plan(*upstreams: GitGateUpstream) -> GitGatePlan: + return GitGatePlan( + slug="demo", + entrypoint_script=Path("/stage/entrypoint.sh"), + hook_script=Path("/stage/pre-receive"), + access_hook_script=Path("/stage/access-hook"), + upstreams=tuple(upstreams), + ) + + +def _up(name: str, *, key: str = "/host/keys/id", known_hosts: str = "") -> GitGateUpstream: + return GitGateUpstream( + name=name, + upstream_url=f"ssh://git@github.com/x/{name}.git", + upstream_host="github.com", + upstream_port="22", + identity_file=key, + known_host_key="", + known_hosts_file=Path(known_hosts) if known_hosts else Path(), + ) + + +class TestProvisionGitGate(unittest.TestCase): + def test_copies_creds_and_runs_namespaced_init(self) -> None: + calls: list[list[str]] = [] + with patch(_RUN, side_effect=_recorder(calls)): + provision_git_gate("gw", "bottle1", _plan(_up("foo", known_hosts="/host/kh"))) + + cps = [c for c in calls if c[:2] == ["docker", "cp"]] + self.assertIn(["docker", "cp", "/host/keys/id", "gw:/git-gate/creds/bottle1/foo-key"], cps) + self.assertIn( + ["docker", "cp", "/host/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps, + ) + # The shared (bottle-agnostic) hooks are installed into the gateway. + self.assertIn(["docker", "cp", "/stage/pre-receive", "gw:/etc/git-gate/pre-receive"], cps) + # The init script runs in the gateway, namespaced under the bottle id. + exec_scripts = [c for c in calls if c[:3] == ["docker", "exec", "gw"] and c[3] == "sh"] + self.assertEqual(1, len(exec_scripts)) + self.assertIn("repo=/git/bottle1/${name}.git", exec_scripts[0][-1]) + + def test_omits_known_hosts_copy_when_absent(self) -> None: + calls: list[list[str]] = [] + with patch(_RUN, side_effect=_recorder(calls)): + provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts + creds_cps = [c for c in calls if c[:2] == ["docker", "cp"] and "/git-gate/creds/" in c[3]] + self.assertEqual(1, len(creds_cps)) # only the key, not known_hosts + self.assertTrue(creds_cps[0][3].endswith("/foo-key")) + + def test_no_upstreams_is_noop(self) -> None: + with patch(_RUN) as m: + provision_git_gate("gw", "b1", _plan()) + m.assert_not_called() + + def test_raises_on_docker_failure(self) -> None: + with patch(_RUN, return_value=_proc(returncode=1, stderr="boom")): + with self.assertRaises(GatewayProvisionError): + provision_git_gate("gw", "b1", _plan(_up("foo"))) + + def test_rejects_unsafe_bottle_id_before_any_docker(self) -> None: + with patch(_RUN) as m: + with self.assertRaises(GatewayProvisionError): + provision_git_gate("gw", "../etc", _plan(_up("foo"))) + m.assert_not_called() # rejected before a single docker call + + +class TestDeprovision(unittest.TestCase): + def test_removes_repo_and_creds(self) -> None: + with patch(_RUN, return_value=_proc()) as m: + deprovision_git_gate("gw", "b1") + argv = m.call_args.args[0] + self.assertEqual(["docker", "exec", "gw", "rm", "-rf"], argv[:5]) + self.assertIn("/git/b1", argv) + self.assertIn("/git-gate/creds/b1", argv) + + def test_rejects_unsafe_bottle_id(self) -> None: + with patch(_RUN) as m: + with self.assertRaises(GatewayProvisionError): + deprovision_git_gate("gw", "a/b") + m.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_git_gate_provision_render.py b/tests/unit/test_git_gate_provision_render.py new file mode 100644 index 0000000..3fc1a07 --- /dev/null +++ b/tests/unit/test_git_gate_provision_render.py @@ -0,0 +1,67 @@ +"""Unit: consolidated per-bottle git-gate provisioning render (PRD 0070).""" + +from __future__ import annotations + +import unittest + +from bot_bottle.git_gate_render import ( + GitGateUpstream, + git_gate_render_entrypoint, + git_gate_render_provision, +) + + +def _ups(*names: str) -> tuple[GitGateUpstream, ...]: + return tuple( + GitGateUpstream( + name=n, + upstream_url=f"ssh://git@github.com/x/{n}.git", + upstream_host="github.com", + upstream_port="22", + identity_file="", + known_host_key="", + ) + for n in names + ) + + +class TestProvisionRender(unittest.TestCase): + def test_namespaces_repos_and_creds_by_bottle_id(self) -> None: + script = git_gate_render_provision("bottleab12", _ups("foo")) + self.assertIn("repo=/git/bottleab12/${name}.git", script) + self.assertIn("keyfile=/git-gate/creds/bottleab12/${name}-key", script) + self.assertIn("mkdir -p /git/bottleab12", script) + + def test_one_init_repo_call_per_upstream(self) -> None: + script = git_gate_render_provision("b1", _ups("foo", "bar")) + calls = [l for l in script.splitlines() if l.startswith("init_repo ")] + self.assertEqual(2, len(calls)) + + def test_provision_does_not_start_the_daemon(self) -> None: + # The shared gateway already serves; provisioning is init-only. + self.assertNotIn("git daemon", git_gate_render_provision("b1", _ups("foo"))) + + def test_installs_pre_receive_hook(self) -> None: + script = git_gate_render_provision("b1", _ups("foo")) + self.assertIn("install -m 755 /etc/git-gate/pre-receive", script) + + def test_rejects_unsafe_bottle_id(self) -> None: + for bad in ("../etc", "a/b", "a b", "a;rm", ""): + with self.assertRaises(ValueError): + git_gate_render_provision(bad, _ups("foo")) + + +class TestEntrypointUnchanged(unittest.TestCase): + """The shared `_git_gate_init_repo_fn` refactor must not alter the + single-tenant daemon entrypoint's output.""" + + def test_entrypoint_still_single_tenant_flat(self) -> None: + script = git_gate_render_entrypoint(_ups("foo")) + self.assertIn("repo=/git/${name}.git", script) # flat, not namespaced + self.assertIn("keyfile=/git-gate/creds/${name}-key", script) + self.assertIn("--base-path=/git", script) + self.assertIn("exec git daemon", script) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_git_http_backend.py b/tests/unit/test_git_http_backend.py index c605eb0..95672d8 100644 --- a/tests/unit/test_git_http_backend.py +++ b/tests/unit/test_git_http_backend.py @@ -13,6 +13,17 @@ from bot_bottle.git_gate import GIT_GATE_TIMEOUT_SECS from bot_bottle.git_http_backend import GitHttpHandler, MAX_BODY_BYTES +class _FixedResolver: + """Maps every source IP to one bottle id (consolidated-mode stub).""" + + def __init__(self, bottle_id: str) -> None: + self._bottle_id = bottle_id + + def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str: + del source_ip, identity_token + return self._bottle_id + + class TestGitHttpBackend(unittest.TestCase): def test_real_git_push_reaches_bare_repo(self): from http.server import ThreadingHTTPServer @@ -94,6 +105,62 @@ class TestGitHttpBackend(unittest.TestCase): ).strip() self.assertEqual(head, cloned) + def test_consolidated_push_stamps_bottle_slug_for_the_hook(self): + # In consolidated mode the backend attributes the push by source IP and + # stamps SUPERVISE_BOTTLE_SLUG= into the CGI env, so the + # gitleaks-allow pre-receive hook queues its proposal under the right + # bottle. The hook here just records what it received. + from http.server import ThreadingHTTPServer + + bottle_id = "bottleab12" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bare = root / bottle_id / "repo.git" # namespaced per bottle (slice 10) + bare.parent.mkdir(parents=True) + subprocess.run(["git", "init", "--bare", str(bare)], + check=True, capture_output=True, text=True) + subprocess.run( + ["git", "-C", str(bare), "config", "http.receivepack", "true"], + check=True, + ) + capture = root / "slug-capture" + hook = bare / "hooks" / "pre-receive" + hook.write_text( + f"#!/bin/sh\nprintf '%s' \"${{SUPERVISE_BOTTLE_SLUG:-UNSET}}\" > " + f"{capture}\ncat >/dev/null\nexit 0\n" + ) + hook.chmod(0o755) + + old_root = os.environ.get("GIT_PROJECT_ROOT") + os.environ["GIT_PROJECT_ROOT"] = str(root) # base; backend nests per bottle + self.addCleanup(self._restore_env, old_root) + + server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler) + server.policy_resolver = _FixedResolver(bottle_id) # type: ignore[attr-defined] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + + work = root / "work" + work.mkdir() + subprocess.run(["git", "init"], cwd=work, check=True, + capture_output=True, text=True) + subprocess.run(["git", "config", "user.name", "test"], cwd=work, check=True) + subprocess.run(["git", "config", "user.email", "t@example.invalid"], + cwd=work, check=True) + (work / "README.md").write_text("test\n") + subprocess.run(["git", "add", "README.md"], cwd=work, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=work, + check=True, capture_output=True, text=True) + + url = f"http://127.0.0.1:{server.server_port}/repo.git" + subprocess.run( + ["git", "push", url, "HEAD:refs/heads/main"], + cwd=work, check=True, capture_output=True, text=True, timeout=5, + ) + self.assertEqual(bottle_id, capture.read_text()) + def test_post_forwards_git_cgi_headers(self): from http.server import ThreadingHTTPServer diff --git a/tests/unit/test_git_http_multitenant.py b/tests/unit/test_git_http_multitenant.py new file mode 100644 index 0000000..a49fe22 --- /dev/null +++ b/tests/unit/test_git_http_multitenant.py @@ -0,0 +1,65 @@ +"""Unit: resolve_sandbox_root — source-IP-keyed git-gate repo namespace (PRD 0070). + +The consolidated git-http backend serves every bottle from one process and +selects each request's repo root by the calling bottle's source IP. This is +the fail-closed selection logic, tested without a live server. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from bot_bottle.git_http_backend import resolve_sandbox_root +from bot_bottle.policy_resolver import PolicyResolveError + +_BASE = Path("/git") + + +class _FakeResolver: + def __init__(self, bottle_id: str | None = None, raises: bool = False) -> None: + self._bottle_id = bottle_id + self._raises = raises + self.calls: list[tuple[str, str]] = [] + + def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None: + self.calls.append((source_ip, identity_token)) + if self._raises: + raise PolicyResolveError("orchestrator down") + return self._bottle_id + + +class TestResolveRepoRoot(unittest.TestCase): + def test_single_tenant_passthrough(self) -> None: + # No resolver → the flat base root, unchanged (legacy per-bottle mode). + self.assertEqual(_BASE, resolve_sandbox_root(None, _BASE, "10.243.0.1")) + + def test_attributed_bottle_gets_namespaced_root(self) -> None: + root = resolve_sandbox_root(_FakeResolver(bottle_id="ab12cd34"), _BASE, "10.243.0.1") + self.assertEqual(Path("/git/ab12cd34"), root) + + def test_unattributed_denies(self) -> None: + self.assertIsNone(resolve_sandbox_root(_FakeResolver(bottle_id=None), _BASE, "10.243.0.9")) + + def test_empty_bottle_id_denies(self) -> None: + self.assertIsNone(resolve_sandbox_root(_FakeResolver(bottle_id=""), _BASE, "10.243.0.1")) + + def test_resolver_error_denies(self) -> None: + # Orchestrator unreachable/errored must never serve repos. + self.assertIsNone(resolve_sandbox_root(_FakeResolver(raises=True), _BASE, "10.243.0.1")) + + def test_namespace_escape_denies(self) -> None: + # A bottle_id that would climb out of the root is rejected (defense in + # depth — registry ids are token_hex, but never trust the namespace). + self.assertIsNone( + resolve_sandbox_root(_FakeResolver(bottle_id="../etc"), _BASE, "10.243.0.1") + ) + + def test_forwards_source_ip_and_token(self) -> None: + r = _FakeResolver(bottle_id="b1") + resolve_sandbox_root(r, _BASE, "10.243.0.1", "tok") + self.assertEqual(("10.243.0.1", "tok"), r.calls[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_broker.py b/tests/unit/test_orchestrator_broker.py new file mode 100644 index 0000000..e7588b0 --- /dev/null +++ b/tests/unit/test_orchestrator_broker.py @@ -0,0 +1,86 @@ +"""Unit tests for the orchestrator launch broker (PRD 0070).""" + +from __future__ import annotations + +import secrets +import unittest + +from bot_bottle.orchestrator.broker import ( + BrokerAuthError, + LaunchRequest, + StubBroker, + sign_request, + verify_request, +) + + +class TestSignVerify(unittest.TestCase): + def setUp(self) -> None: + self.secret = secrets.token_bytes(16) + + def test_launch_round_trip(self) -> None: + req = LaunchRequest( + op="launch", bottle_id="b1", source_ip="10.243.0.1", + image_ref="sha256:abc", slot=3, + ) + self.assertEqual(req, verify_request(sign_request(req, self.secret), self.secret)) + + def test_teardown_round_trip(self) -> None: + req = LaunchRequest(op="teardown", bottle_id="b1") + got = verify_request(sign_request(req, self.secret), self.secret) + self.assertEqual(("teardown", "b1"), (got.op, got.bottle_id)) + + def test_wrong_secret_rejected(self) -> None: + token = sign_request(LaunchRequest(op="launch", bottle_id="b1"), self.secret) + with self.assertRaises(BrokerAuthError): + verify_request(token, secrets.token_bytes(16)) + + def test_tampered_payload_rejected(self) -> None: + token = sign_request(LaunchRequest(op="launch", bottle_id="b1"), self.secret) + header, payload, sig = token.split(".") + flipped = payload[:-1] + ("A" if payload[-1] != "A" else "B") + with self.assertRaises(BrokerAuthError): + verify_request(f"{header}.{flipped}.{sig}", self.secret) + + def test_malformed_tokens_rejected(self) -> None: + for bad in ("", "a.b", "a.b.c.d", "not-a-token"): + with self.assertRaises(BrokerAuthError): + verify_request(bad, self.secret) + + def test_unknown_op_rejected_despite_valid_signature(self) -> None: + # A correctly-signed token whose op isn't in the allow-list must + # still be refused — the schema guard is independent of provenance. + token = sign_request(LaunchRequest(op="rm-rf", bottle_id="b1"), self.secret) + with self.assertRaises(BrokerAuthError): + verify_request(token, self.secret) + + +class TestStubBroker(unittest.TestCase): + def setUp(self) -> None: + self.secret = secrets.token_bytes(16) + self.broker = StubBroker(self.secret) + + def test_submit_launch_records_verified_request(self) -> None: + req = LaunchRequest(op="launch", bottle_id="b1", source_ip="10.243.0.1") + got = self.broker.submit(sign_request(req, self.secret)) + self.assertEqual(req, got) + self.assertEqual(["b1"], [r.bottle_id for r in self.broker.launched]) + self.assertEqual([], self.broker.torn_down) + + def test_submit_teardown_records(self) -> None: + self.broker.submit( + sign_request(LaunchRequest(op="teardown", bottle_id="b1"), self.secret) + ) + self.assertEqual(["b1"], [r.bottle_id for r in self.broker.torn_down]) + + def test_submit_forged_token_is_fail_closed(self) -> None: + forged = sign_request( + LaunchRequest(op="launch", bottle_id="b1"), secrets.token_bytes(16) + ) + with self.assertRaises(BrokerAuthError): + self.broker.submit(forged) + self.assertEqual([], self.broker.launched) # nothing acted on + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_client.py b/tests/unit/test_orchestrator_client.py new file mode 100644 index 0000000..848ee6f --- /dev/null +++ b/tests/unit/test_orchestrator_client.py @@ -0,0 +1,106 @@ +"""Unit: host-side orchestrator control-plane client (PRD 0070). HTTP mocked.""" + +from __future__ import annotations + +import json +import unittest +import urllib.error +from unittest.mock import MagicMock, patch + +from bot_bottle.orchestrator.client import ( + OrchestratorClient, + OrchestratorClientError, + RegisteredBottle, +) + +_URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen" + + +def _resp(status: int, payload: object) -> MagicMock: + m = MagicMock() + inner = m.__enter__.return_value + inner.status = status + inner.read.return_value = json.dumps(payload).encode() + return m + + +def _http_error(code: int, payload: object = None) -> urllib.error.HTTPError: + del payload # the client tolerates an empty error body; keep the signature + return urllib.error.HTTPError("http://x", code, "err", {}, None) # type: ignore[arg-type] + + +class TestRegister(unittest.TestCase): + def setUp(self) -> None: + self.c = OrchestratorClient("http://orch:8080") + + def test_register_returns_id_and_token(self) -> None: + with patch(_URLOPEN, return_value=_resp(201, {"bottle_id": "b1", "identity_token": "tok"})): + got = self.c.register_bottle("10.0.0.2", policy="routes: []\n", metadata="{}") + self.assertEqual(RegisteredBottle("b1", "tok"), got) + + def test_register_posts_source_ip_and_policy(self) -> None: + with patch(_URLOPEN, return_value=_resp(201, {"bottle_id": "b", "identity_token": "t"})) as m: + self.c.register_bottle("10.0.0.9", policy="P", metadata="M", image_ref="img") + sent = json.loads(m.call_args.args[0].data) + self.assertEqual("10.0.0.9", sent["source_ip"]) + self.assertEqual("P", sent["policy"]) + self.assertEqual("img", sent["image_ref"]) + + def test_register_missing_fields_raises(self) -> None: + with patch(_URLOPEN, return_value=_resp(201, {"bottle_id": "b1"})): + with self.assertRaises(OrchestratorClientError): + self.c.register_bottle("10.0.0.2") + + def test_register_non_2xx_raises(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(400, {"error": "bad"})): + with self.assertRaises(OrchestratorClientError): + self.c.register_bottle("") + + +class TestTeardown(unittest.TestCase): + def setUp(self) -> None: + self.c = OrchestratorClient("http://orch:8080") + + def test_teardown_true_on_success(self) -> None: + with patch(_URLOPEN, return_value=_resp(200, {"torn_down": True})): + self.assertTrue(self.c.teardown_bottle("b1")) + + def test_teardown_false_on_404(self) -> None: + # Idempotent: an already-gone bottle is a clean no-op. + with patch(_URLOPEN, side_effect=_http_error(404)): + self.assertFalse(self.c.teardown_bottle("gone")) + + def test_teardown_uses_delete(self) -> None: + with patch(_URLOPEN, return_value=_resp(200, {"torn_down": True})) as m: + self.c.teardown_bottle("b1") + self.assertEqual("DELETE", m.call_args.args[0].get_method()) + + +class TestHealthAndPolicy(unittest.TestCase): + def setUp(self) -> None: + self.c = OrchestratorClient("http://orch:8080") + + def test_health_true_on_200(self) -> None: + with patch(_URLOPEN, return_value=_resp(200, {"status": "ok"})): + self.assertTrue(self.c.health()) + + def test_health_false_when_unreachable(self) -> None: + with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")): + self.assertFalse(self.c.health()) + + def test_set_policy_false_on_404(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(404)): + self.assertFalse(self.c.set_policy("gone", "P")) + + def test_set_policy_true_on_success(self) -> None: + with patch(_URLOPEN, return_value=_resp(200, {"updated": True})): + self.assertTrue(self.c.set_policy("b1", "P")) + + def test_unreachable_raises(self) -> None: + with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")): + with self.assertRaises(OrchestratorClientError): + self.c.register_bottle("10.0.0.2") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_control_plane.py b/tests/unit/test_orchestrator_control_plane.py new file mode 100644 index 0000000..9ce9ebb --- /dev/null +++ b/tests/unit/test_orchestrator_control_plane.py @@ -0,0 +1,227 @@ +"""Unit tests for the orchestrator HTTP control plane (PRD 0070). + +Mostly exercises the pure `dispatch()` (socket-free, like the supervise +server tests), plus one real-socket round-trip to prove the handler wiring. +""" + +from __future__ import annotations + +import json +import secrets +import tempfile +import threading +import unittest +import urllib.request +from pathlib import Path + +from bot_bottle.orchestrator.broker import StubBroker +from bot_bottle.orchestrator.control_plane import dispatch, make_server +from bot_bottle.orchestrator.registry import RegistryStore +from bot_bottle.orchestrator.service import Orchestrator + + +def _body(obj: object) -> bytes: + return json.dumps(obj).encode() + + +def _orchestrator(db_path: Path) -> Orchestrator: + store = RegistryStore(db_path) + store.migrate() + secret = secrets.token_bytes(16) + return Orchestrator(store, StubBroker(secret), secret) + + +class TestDispatch(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.orch = _orchestrator(Path(self._tmp.name) / "r.db") + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_health(self) -> None: + status, payload = dispatch(self.orch, "GET", "/health", b"") + self.assertEqual(200, status) + self.assertEqual("ok", payload["status"]) + + def test_register_returns_id_and_token(self) -> None: + status, payload = dispatch( + self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}) + ) + self.assertEqual(201, status) + self.assertTrue(payload["bottle_id"]) + self.assertTrue(payload["identity_token"]) + + def test_register_requires_source_ip(self) -> None: + status, _ = dispatch(self.orch, "POST", "/bottles", _body({})) + self.assertEqual(400, status) + + def test_register_rejects_bad_json(self) -> None: + status, _ = dispatch(self.orch, "POST", "/bottles", b"{not json") + self.assertEqual(400, status) + + def test_list_redacts_identity_token(self) -> None: + dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})) + status, payload = dispatch(self.orch, "GET", "/bottles", b"") + self.assertEqual(200, status) + bottles = payload["bottles"] + assert isinstance(bottles, list) + self.assertEqual(1, len(bottles)) + first = bottles[0] + assert isinstance(first, dict) + self.assertNotIn("identity_token", first) + self.assertEqual("10.243.0.1", first["source_ip"]) + + def test_attribute_ok_and_forbidden(self) -> None: + _, reg = dispatch( + self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.5"}) + ) + token = reg["identity_token"] + ok_status, ok = dispatch( + self.orch, "POST", "/attribute", + _body({"source_ip": "10.243.0.5", "identity_token": token}), + ) + self.assertEqual(200, ok_status) + self.assertEqual(reg["bottle_id"], ok["bottle_id"]) + + bad_status, _ = dispatch( + self.orch, "POST", "/attribute", + _body({"source_ip": "10.243.0.5", "identity_token": "nope"}), + ) + self.assertEqual(403, bad_status) + + def test_attribute_requires_both_fields(self) -> None: + status, _ = dispatch( + self.orch, "POST", "/attribute", _body({"source_ip": "10.243.0.5"}) + ) + self.assertEqual(400, status) + + def test_delete(self) -> None: + _, reg = dispatch( + self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}) + ) + bid = reg["bottle_id"] + status, payload = dispatch(self.orch, "DELETE", f"/bottles/{bid}", b"") + self.assertEqual(200, status) + self.assertEqual(True, payload["torn_down"]) + self.assertEqual([], self.orch.registry.all()) + + def test_delete_missing_404(self) -> None: + status, _ = dispatch(self.orch, "DELETE", "/bottles/ghost", b"") + self.assertEqual(404, status) + + def test_unknown_route_404(self) -> None: + status, _ = dispatch(self.orch, "GET", "/nope", b"") + self.assertEqual(404, status) + + def test_trailing_slash_normalized(self) -> None: + status, _ = dispatch(self.orch, "GET", "/health/", b"") + self.assertEqual(200, status) + + def test_gateway_status_unconfigured(self) -> None: + status, payload = dispatch(self.orch, "GET", "/gateway", b"") + self.assertEqual(200, status) + self.assertEqual(False, payload["configured"]) + + def test_launch_stores_policy_and_resolve_returns_it(self) -> None: + _, reg = dispatch( + self.orch, "POST", "/bottles", + _body({"source_ip": "10.243.0.5", "policy": '{"a":1}'}), + ) + status, payload = dispatch( + self.orch, "POST", "/resolve", + _body({"source_ip": "10.243.0.5", "identity_token": reg["identity_token"]}), + ) + self.assertEqual(200, status) + self.assertEqual(reg["bottle_id"], payload["bottle_id"]) + self.assertEqual('{"a":1}', payload["policy"]) + + def test_resolve_forbidden_on_bad_token(self) -> None: + dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.5"})) + status, _ = dispatch( + self.orch, "POST", "/resolve", + _body({"source_ip": "10.243.0.5", "identity_token": "nope"}), + ) + self.assertEqual(403, status) + + def test_put_policy_updates_live(self) -> None: + _, reg = dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})) + bid = reg["bottle_id"] + status, payload = dispatch( + self.orch, "PUT", f"/bottles/{bid}/policy", _body({"policy": '{"v":9}'}) + ) + self.assertEqual(200, status) + self.assertEqual(True, payload["updated"]) + _, resolved = dispatch( + self.orch, "POST", "/resolve", + _body({"source_ip": "10.243.0.1", "identity_token": reg["identity_token"]}), + ) + self.assertEqual('{"v":9}', resolved["policy"]) + + def test_put_policy_missing_bottle_404(self) -> None: + status, _ = dispatch( + self.orch, "PUT", "/bottles/ghost/policy", _body({"policy": "{}"}) + ) + self.assertEqual(404, status) + + def test_put_policy_requires_string(self) -> None: + _, reg = dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"})) + status, _ = dispatch( + self.orch, "PUT", f"/bottles/{reg['bottle_id']}/policy", _body({}) + ) + self.assertEqual(400, status) + + def test_resolve_without_token_by_source_ip(self) -> None: + _, reg = dispatch( + self.orch, "POST", "/bottles", + _body({"source_ip": "10.243.0.5", "policy": "P"}), + ) + status, payload = dispatch( + self.orch, "POST", "/resolve", _body({"source_ip": "10.243.0.5"}) + ) + self.assertEqual(200, status) + self.assertEqual(reg["bottle_id"], payload["bottle_id"]) + self.assertEqual("P", payload["policy"]) + + def test_resolve_requires_source_ip(self) -> None: + status, _ = dispatch(self.orch, "POST", "/resolve", _body({})) + self.assertEqual(400, status) + + +class TestServerRoundTrip(unittest.TestCase): + def test_http_register_health_attribute(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + orch = _orchestrator(Path(tmp.name) / "r.db") + server = make_server(orch, "127.0.0.1", 0) + self.addCleanup(server.server_close) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.shutdown) + + host, port = server.server_address[0], server.server_address[1] + base = f"http://{host}:{port}" + + reg = json.load(urllib.request.urlopen( + urllib.request.Request( + f"{base}/bottles", data=_body({"source_ip": "10.243.0.7"}), + method="POST", headers={"Content-Type": "application/json"}, + ), timeout=5, + )) + self.assertTrue(reg["bottle_id"]) + + health = json.load(urllib.request.urlopen(f"{base}/health", timeout=5)) + self.assertEqual("ok", health["status"]) + + attr = json.load(urllib.request.urlopen( + urllib.request.Request( + f"{base}/attribute", + data=_body({"source_ip": "10.243.0.7", "identity_token": reg["identity_token"]}), + method="POST", headers={"Content-Type": "application/json"}, + ), timeout=5, + )) + self.assertEqual(reg["bottle_id"], attr["bottle_id"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_docker_broker.py b/tests/unit/test_orchestrator_docker_broker.py new file mode 100644 index 0000000..909a0b5 --- /dev/null +++ b/tests/unit/test_orchestrator_docker_broker.py @@ -0,0 +1,100 @@ +"""Unit tests for the Docker launch broker (PRD 0070). Docker is mocked.""" + +from __future__ import annotations + +import secrets +import unittest +from unittest.mock import Mock, patch + +from bot_bottle.orchestrator.broker import ( + BrokerAuthError, + LaunchRequest, + sign_request, +) +from bot_bottle.orchestrator.docker_broker import ( + BOTTLE_ID_LABEL, + DockerBroker, + DockerBrokerError, + container_name, + rm_argv, + run_argv, +) + + +class TestArgv(unittest.TestCase): + def test_run_argv_uses_only_static_fields(self) -> None: + req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox") + argv = run_argv(req) + self.assertEqual(["docker", "run"], argv[:2]) + self.assertIn("--name", argv) + self.assertIn(container_name("b1"), argv) + self.assertIn(f"{BOTTLE_ID_LABEL}=b1", argv) + self.assertEqual("busybox", argv[-1]) # image is the terminal arg + + def test_rm_argv(self) -> None: + req = LaunchRequest(op="teardown", bottle_id="b1") + self.assertEqual(["docker", "rm", "--force", container_name("b1")], rm_argv(req)) + + def test_container_name_is_prefixed(self) -> None: + name = container_name("b1") + self.assertTrue(name.startswith("bot-bottle-orch-")) + self.assertTrue(name.endswith("b1")) + + +class TestDockerBroker(unittest.TestCase): + def setUp(self) -> None: + self.secret = secrets.token_bytes(16) + self.broker = DockerBroker(self.secret) + + def _submit(self, req: LaunchRequest) -> None: + self.broker.submit(sign_request(req, self.secret)) + + def test_launch_invokes_docker_run(self) -> None: + req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox") + with patch.object(self.broker, "_docker", return_value=Mock(returncode=0, stderr="")) as m: + self._submit(req) + m.assert_called_once() + self.assertEqual(run_argv(req), m.call_args.args[0]) + + def test_launch_without_image_raises_and_skips_docker(self) -> None: + req = LaunchRequest(op="launch", bottle_id="b1", image_ref="") + with patch.object(self.broker, "_docker") as m: + with self.assertRaises(DockerBrokerError): + self._submit(req) + m.assert_not_called() + + def test_launch_docker_failure_raises(self) -> None: + req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox") + with patch.object(self.broker, "_docker", return_value=Mock(returncode=1, stderr="boom")): + with self.assertRaises(DockerBrokerError): + self._submit(req) + + def test_teardown_invokes_docker_rm(self) -> None: + req = LaunchRequest(op="teardown", bottle_id="b1") + with patch.object(self.broker, "_docker", return_value=Mock(returncode=0, stderr="")) as m: + self._submit(req) + self.assertEqual(rm_argv(req), m.call_args.args[0]) + + def test_teardown_is_idempotent_on_missing_container(self) -> None: + req = LaunchRequest(op="teardown", bottle_id="b1") + absent = Mock(returncode=1, stderr="Error: No such container: bot-bottle-orch-b1") + with patch.object(self.broker, "_docker", return_value=absent): + self._submit(req) # must not raise + + def test_teardown_other_failure_raises(self) -> None: + req = LaunchRequest(op="teardown", bottle_id="b1") + with patch.object(self.broker, "_docker", return_value=Mock(returncode=1, stderr="daemon down")): + with self.assertRaises(DockerBrokerError): + self._submit(req) + + def test_forged_token_never_touches_docker(self) -> None: + req = LaunchRequest(op="launch", bottle_id="b1", image_ref="busybox") + forged = sign_request(req, secrets.token_bytes(16)) + with patch.object(self.broker, "_docker") as m: + with self.assertRaises(BrokerAuthError): + self.broker.submit(forged) + m.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_gateway.py b/tests/unit/test_orchestrator_gateway.py new file mode 100644 index 0000000..2836528 --- /dev/null +++ b/tests/unit/test_orchestrator_gateway.py @@ -0,0 +1,215 @@ +"""Unit tests for the consolidated Docker gateway (PRD 0070). Docker mocked.""" + +from __future__ import annotations + +import unittest +from unittest.mock import Mock, patch + +from bot_bottle.orchestrator.gateway import ( + GATEWAY_CA_CERT, + GATEWAY_NAME, + DockerGateway, + GatewayError, +) + + +_CA_PEM = "-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----\n" + +_RUN_DOCKER = "bot_bottle.orchestrator.gateway.run_docker" + + +def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock: + return Mock(returncode=returncode, stdout=stdout, stderr=stderr) + + +class TestDockerGateway(unittest.TestCase): + def setUp(self) -> None: + self.sc = DockerGateway("bot-bottle-sidecars:latest") + + def test_default_name(self) -> None: + self.assertEqual(GATEWAY_NAME, self.sc.name) + + def test_is_running_reads_docker_ps(self) -> None: + with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name + "\n")): + self.assertTrue(self.sc.is_running()) + with patch(_RUN_DOCKER, return_value=_proc(stdout="")): + self.assertFalse(self.sc.is_running()) + + def test_ensure_running_noop_when_up_and_image_current(self) -> None: + calls: list[list[str]] = [] + + def fake(argv: list[str]) -> Mock: + calls.append(argv) + if argv[:2] == ["docker", "ps"]: + return _proc(stdout=self.sc.name) # running + if argv[:3] == ["docker", "image", "inspect"]: + return _proc(stdout="img-A") # current image id + if argv[:2] == ["docker", "inspect"]: + return _proc(stdout="img-A") # container's image (same) + return _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + self.sc.ensure_running() + self.assertEqual([], [c for c in calls if c[:2] == ["docker", "run"]]) + self.assertEqual([], [c for c in calls if c[:2] == ["docker", "rm"]]) + + def test_ensure_running_recreates_when_image_is_stale(self) -> None: + # Running, but the container was built from an OLD image → recreate so + # a rebuild's new flat daemons take effect. + calls: list[list[str]] = [] + + def fake(argv: list[str]) -> Mock: + calls.append(argv) + if argv[:2] == ["docker", "ps"]: + return _proc(stdout=self.sc.name) + if argv[:3] == ["docker", "image", "inspect"]: + return _proc(stdout="img-NEW") + if argv[:2] == ["docker", "inspect"]: + return _proc(stdout="img-OLD") + return _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + self.sc.ensure_running() + self.assertEqual(1, len([c for c in calls if c[:2] == ["docker", "run"]])) + self.assertTrue(any(c[:2] == ["docker", "rm"] for c in calls)) + + def test_ensure_running_starts_the_singleton_when_absent(self) -> None: + calls: list[list[str]] = [] + + def fake(argv: list[str]) -> Mock: + calls.append(argv) + return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + self.sc.ensure_running() + + runs = [c for c in calls if c[:2] == ["docker", "run"]] + self.assertEqual(1, len(runs)) + self.assertIn(self.sc.name, runs[0]) + self.assertIn("bot-bottle-sidecars:latest", runs[0]) + # Runs on the shared gateway network so agents can reach it by IP. + self.assertEqual(self.sc.network, runs[0][runs[0].index("--network") + 1]) + # Persists its CA on a named volume so agents keep trusting it. + self.assertTrue(any("mitmproxy" in a for a in runs[0])) + + def test_ensure_running_creates_network_when_missing(self) -> None: + calls: list[list[str]] = [] + + def fake(argv: list[str]) -> Mock: + calls.append(argv) + if argv[:3] == ["docker", "network", "inspect"]: + return _proc(returncode=1, stderr="No such network") + return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + self.sc.ensure_running() + creates = [c for c in calls if c[:3] == ["docker", "network", "create"]] + self.assertEqual([["docker", "network", "create", self.sc.network]], creates) + + def test_ca_cert_pem_reads_from_container(self) -> None: + with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m: + self.assertEqual(_CA_PEM, self.sc.ca_cert_pem()) + argv = m.call_args.args[0] + self.assertEqual(["docker", "exec", self.sc.name, "cat", GATEWAY_CA_CERT], argv) + + def test_ca_cert_pem_raises_when_absent(self) -> None: + # timeout=0 → one probe then give up (no polling delay in the test). + with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="No such file")): + with self.assertRaises(GatewayError): + self.sc.ca_cert_pem(timeout=0) + + def test_ca_cert_pem_polls_until_mitmproxy_writes_it(self) -> None: + # First read: CA not there yet; second read: present. + seq = [_proc(returncode=1, stderr="No such file"), _proc(stdout=_CA_PEM)] + with patch(_RUN_DOCKER, side_effect=seq), \ + patch("bot_bottle.orchestrator.gateway.time.sleep"): + self.assertEqual(_CA_PEM, self.sc.ca_cert_pem(timeout=5)) + + def test_ensure_running_reuses_existing_network(self) -> None: + calls: list[list[str]] = [] + + def fake(argv: list[str]) -> Mock: + calls.append(argv) + return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + self.sc.ensure_running() # network inspect returns 0 → exists + self.assertEqual([], [c for c in calls if c[:3] == ["docker", "network", "create"]]) + + def test_ensure_running_raises_on_docker_failure(self) -> None: + def fake(argv: list[str]) -> Mock: + if argv[:2] == ["docker", "ps"]: + return _proc(stdout="") + if argv[:2] == ["docker", "run"]: + return _proc(returncode=1, stderr="boom") + return _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + with self.assertRaises(GatewayError): + self.sc.ensure_running() + + def test_stop_is_idempotent_on_missing(self) -> None: + absent = _proc(returncode=1, stderr="Error: No such container: x") + with patch(_RUN_DOCKER, return_value=absent): + self.sc.stop() # must not raise + + def test_stop_raises_on_other_failure(self) -> None: + with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="daemon down")): + with self.assertRaises(GatewayError): + self.sc.stop() + + +class TestDockerGatewayBuild(unittest.TestCase): + def setUp(self) -> None: + self.sc = DockerGateway() # defaults to the real bundle image + dockerfile + + def test_image_exists_reads_docker_inspect(self) -> None: + with patch(_RUN_DOCKER, return_value=_proc(returncode=0)): + self.assertTrue(self.sc.image_exists()) + with patch(_RUN_DOCKER, return_value=_proc(returncode=1)): + self.assertFalse(self.sc.image_exists()) + + def test_ensure_built_builds_even_when_image_present(self) -> None: + # Always build (cache-aware) so a flat-source change rebuilds; the old + # build-if-missing silently ran a stale single-tenant image. + calls: list[list[str]] = [] + + def rec(argv: list[str]) -> Mock: + calls.append(argv) + return _proc() # image present, build succeeds + + with patch(_RUN_DOCKER, side_effect=rec): + self.sc.ensure_built() + builds = [c for c in calls if c[:2] == ["docker", "build"]] + self.assertEqual(1, len(builds)) + self.assertIn(self.sc.image_ref, builds[0]) + self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0])) + self.assertNotIn("--no-cache", builds[0]) + + def test_ensure_built_no_cache_env_forces_full_rebuild(self) -> None: + calls: list[list[str]] = [] + + def rec(argv: list[str]) -> Mock: + calls.append(argv) + return _proc() + + with patch(_RUN_DOCKER, side_effect=rec), \ + patch.dict("os.environ", {"BOT_BOTTLE_NO_CACHE": "1"}): + self.sc.ensure_built() + builds = [c for c in calls if c[:2] == ["docker", "build"]] + self.assertIn("--no-cache", builds[0]) + + def test_ensure_built_noop_when_no_dockerfile(self) -> None: + sc = DockerGateway("busybox", dockerfile=None) + with patch(_RUN_DOCKER) as m: + sc.ensure_built() + m.assert_not_called() + + def test_ensure_built_raises_on_build_failure(self) -> None: + with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="build boom")): + with self.assertRaises(GatewayError): + self.sc.ensure_built() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_lifecycle.py b/tests/unit/test_orchestrator_lifecycle.py new file mode 100644 index 0000000..4eeccba --- /dev/null +++ b/tests/unit/test_orchestrator_lifecycle.py @@ -0,0 +1,92 @@ +"""Unit: orchestrator+gateway container lifecycle — idempotent singleton (PRD 0070).""" + +from __future__ import annotations + +import tempfile +import unittest +import urllib.error +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +from bot_bottle.orchestrator.lifecycle import ( + ORCHESTRATOR_NAME, + OrchestratorService, + OrchestratorStartError, +) +from tests.unit import use_bottle_root + +_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen" +_RUN = "bot_bottle.orchestrator.lifecycle.run_docker" +_GATEWAY = "bot_bottle.orchestrator.lifecycle.DockerGateway" +_SLEEP = "bot_bottle.orchestrator.lifecycle.time.sleep" +_MONOTONIC = "bot_bottle.orchestrator.lifecycle.time.monotonic" + + +def _health(status: int) -> MagicMock: + m = MagicMock() + m.__enter__.return_value.status = status + return m + + +class TestOrchestratorService(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.addCleanup(use_bottle_root(Path(self._tmp.name))) + self.svc = OrchestratorService(port=8099) + + def test_urls(self) -> None: + self.assertEqual("http://127.0.0.1:8099", self.svc.url) + # The gateway reaches the control plane by container name over docker DNS. + self.assertEqual(f"http://{ORCHESTRATOR_NAME}:8099", self.svc.internal_url) + + def test_is_healthy(self) -> None: + with patch(_URLOPEN, return_value=_health(200)): + self.assertTrue(self.svc.is_healthy()) + with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")): + self.assertFalse(self.svc.is_healthy()) + + def test_ensure_running_always_recreates_orchestrator(self) -> None: + # Even when a control plane is already healthy, the orchestrator is + # recreated so bind-mounted code changes take effect (its process + # won't reload). The gateway is ensured too. + run = Mock(return_value=Mock(returncode=0, stderr="")) + with patch(_URLOPEN, return_value=_health(200)), \ + patch(_GATEWAY) as gw_cls, patch(_RUN, run), patch(_SLEEP): + self.assertEqual(self.svc.url, self.svc.ensure_running()) + gw_cls.return_value.ensure_running.assert_called() # gateway kept up + runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]] + self.assertEqual(1, len(runs)) # orchestrator recreated + self.assertIn(ORCHESTRATOR_NAME, runs[0]) + + def test_ensure_running_starts_orchestrator_container_when_absent(self) -> None: + run = Mock(return_value=Mock(returncode=0, stderr="")) + with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \ + patch(_GATEWAY), patch(_RUN, run), patch(_SLEEP): + self.assertEqual(self.svc.url, self.svc.ensure_running()) + runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]] + self.assertEqual(1, len(runs)) + argv = runs[0] + self.assertIn(ORCHESTRATOR_NAME, argv) + self.assertIn("--broker", argv) + self.assertEqual("stub", argv[argv.index("--broker") + 1]) # register-only, no socket + self.assertIn("bot_bottle.orchestrator", argv) + self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1]) + + def test_ensure_running_raises_on_timeout(self) -> None: + with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \ + patch(_GATEWAY), patch(_RUN, return_value=Mock(returncode=0, stderr="")), \ + patch(_SLEEP), patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]): + with self.assertRaises(OrchestratorStartError): + self.svc.ensure_running(startup_timeout=1.0) + + def test_stop_removes_orchestrator_and_gateway(self) -> None: + with patch(_RUN) as run, patch(_GATEWAY) as gw_cls: + self.svc.stop() + rms = [c.args[0] for c in run.call_args_list if c.args[0][:3] == ["docker", "rm", "--force"]] + self.assertTrue(any(ORCHESTRATOR_NAME in a for a in rms)) + gw_cls.return_value.stop.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_registration.py b/tests/unit/test_orchestrator_registration.py new file mode 100644 index 0000000..7e14684 --- /dev/null +++ b/tests/unit/test_orchestrator_registration.py @@ -0,0 +1,56 @@ +"""Unit: consolidated registration inputs — egress policy round-trip (PRD 0070).""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from bot_bottle.egress import EgressPlan, EgressRoute +from bot_bottle.egress_addon_core import LOG_BLOCKS, load_config +from bot_bottle.orchestrator.registration import ( + RegistrationInputs, + egress_policy, + registration_inputs, +) + + +def _plan(routes: tuple[EgressRoute, ...], *, slug: str = "demo", log: int = 0) -> EgressPlan: + return EgressPlan( + slug=slug, + routes_path=Path("/unused/routes.yaml"), + routes=routes, + token_env_map={}, + log=log, + ) + + +class TestEgressPolicy(unittest.TestCase): + def test_policy_round_trips_through_load_config(self) -> None: + # The policy the gateway serves must parse back to the same allow-list + # the per-bottle sidecar applied — moving onto the shared gateway must + # not change a bottle's egress. + routes = (EgressRoute(host="api.example.com"), EgressRoute(host="pypi.org")) + cfg = load_config(egress_policy(_plan(routes))) + self.assertEqual(("api.example.com", "pypi.org"), tuple(r.host for r in cfg.routes)) + + def test_policy_preserves_log_level(self) -> None: + plan = _plan((EgressRoute(host="x.example.com"),), log=LOG_BLOCKS) + self.assertEqual(LOG_BLOCKS, load_config(egress_policy(plan)).log) + + def test_empty_routes_yield_deny_all(self) -> None: + cfg = load_config(egress_policy(_plan(()))) + self.assertEqual((), cfg.routes) # no routes → default-deny + + +class TestRegistrationInputs(unittest.TestCase): + def test_bundles_policy_and_slug_metadata(self) -> None: + plan = _plan((EgressRoute(host="api.example.com"),), slug="my-bot") + inputs = registration_inputs(plan) + self.assertIsInstance(inputs, RegistrationInputs) + self.assertEqual("my-bot", json.loads(inputs.metadata)["slug"]) + self.assertEqual(egress_policy(plan), inputs.policy) # same blob egress_policy renders + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_registry.py b/tests/unit/test_orchestrator_registry.py new file mode 100644 index 0000000..58751ab --- /dev/null +++ b/tests/unit/test_orchestrator_registry.py @@ -0,0 +1,171 @@ +"""Unit tests for the orchestrator bottle registry + attribution (PRD 0070).""" + +from __future__ import annotations + +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from bot_bottle.orchestrator.registry import ( + BottleRecord, + RegistryStore, + new_identity_token, +) + + +class TestRegistryStore(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.db = Path(self._tmp.name) / "registry.db" + self.store = RegistryStore(self.db) + self.store.migrate() + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _force_active_row(self, source_ip: str, bottle_id: str) -> None: + """Insert a raw active row, bypassing `register`'s same-IP supersede — + the only way to manufacture the ambiguity the fail-closed guard exists + for (crash-orphaned / externally-injected duplicate).""" + conn = sqlite3.connect(self.db) + conn.execute( + "INSERT INTO orchestrator_bottles " + "(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) " + "VALUES (?, ?, ?, 'active', 0.0, '', '')", + (bottle_id, source_ip, "tok-" + bottle_id), + ) + conn.commit() + conn.close() + + def test_register_mints_id_and_token(self) -> None: + rec = self.store.register("10.243.0.1") + self.assertTrue(rec.bottle_id) + self.assertTrue(rec.identity_token) + self.assertEqual("active", rec.state) + self.assertEqual(rec, self.store.get(rec.bottle_id)) + + def test_identity_tokens_are_unique(self) -> None: + tokens = {new_identity_token() for _ in range(200)} + self.assertEqual(200, len(tokens)) + a = self.store.register("10.243.0.1") + b = self.store.register("10.243.0.3") + self.assertNotEqual(a.identity_token, b.identity_token) + + def test_all_and_deregister(self) -> None: + a = self.store.register("10.243.0.1") + b = self.store.register("10.243.0.3") + self.assertEqual({a.bottle_id, b.bottle_id}, {r.bottle_id for r in self.store.all()}) + self.assertTrue(self.store.deregister(a.bottle_id)) + self.assertEqual([b.bottle_id], [r.bottle_id for r in self.store.all()]) + + def test_deregister_missing_is_false(self) -> None: + self.assertFalse(self.store.deregister("nope")) + + def test_explicit_id_replaces(self) -> None: + self.store.register("10.243.0.1", bottle_id="fixed", metadata="a") + self.store.register("10.243.0.9", bottle_id="fixed", metadata="b") + rec = self.store.get("fixed") + assert rec is not None + self.assertEqual("10.243.0.9", rec.source_ip) + self.assertEqual("b", rec.metadata) + self.assertEqual(1, len(self.store.all())) + + def test_attribute_success_requires_ip_and_token(self) -> None: + rec = self.store.register("10.243.0.1") + got = self.store.attribute("10.243.0.1", rec.identity_token) + assert got is not None + self.assertEqual(rec.bottle_id, got.bottle_id) + + def test_attribute_wrong_token_denied(self) -> None: + self.store.register("10.243.0.1") + self.assertIsNone(self.store.attribute("10.243.0.1", "wrong-token")) + + def test_attribute_unknown_ip_denied(self) -> None: + rec = self.store.register("10.243.0.1") + self.assertIsNone(self.store.attribute("10.243.9.9", rec.identity_token)) + + def test_attribute_empty_token_denied(self) -> None: + self.store.register("10.243.0.1") + self.assertIsNone(self.store.attribute("10.243.0.1", "")) + + def test_attribute_ambiguous_ip_denied(self) -> None: + # Two active bottles on one source IP is a misconfiguration — deny + # rather than guess (fail-closed), even with a valid token. (Forced + # directly: `register` now supersedes, so this can only arise from an + # orphaned/injected row.) + a = self.store.register("10.243.0.1", bottle_id="a") + self._force_active_row("10.243.0.1", "b") + self.assertIsNone(self.store.attribute("10.243.0.1", a.identity_token)) + + def test_reregister_same_source_ip_supersedes(self) -> None: + # Re-registering a source IP (reused IP / crash recovery / persisted + # DB) must supersede the prior active bottle, not add a second active + # row that would fail-closed the whole IP to 403. + a = self.store.register("10.243.0.1", bottle_id="a", policy="A") + b = self.store.register("10.243.0.1", bottle_id="b", policy="B") + # exactly one active row at that IP, and it's the new bottle + got = self.store.by_source_ip("10.243.0.1") + assert got is not None + self.assertEqual("b", got.bottle_id) + self.assertEqual("B", got.policy) + # the superseded bottle is gone and its token no longer attributes + self.assertIsNone(self.store.get("a")) + self.assertIsNone(self.store.attribute("10.243.0.1", a.identity_token)) + self.assertIsNotNone(self.store.attribute("10.243.0.1", b.identity_token)) + + def test_state_persists_across_reopen(self) -> None: + rec = self.store.register("10.243.0.1") + reopened = RegistryStore(self.db) + got = reopened.get(rec.bottle_id) + assert got is not None + self.assertEqual(rec, got) + # Attribution works against the reopened (durable) store too. + self.assertIsNotNone(reopened.attribute("10.243.0.1", rec.identity_token)) + + def test_redacted_hides_token(self) -> None: + rec = BottleRecord( + bottle_id="x", source_ip="10.243.0.1", identity_token="secret" + ) + self.assertNotIn("identity_token", rec.redacted()) + self.assertEqual("10.243.0.1", rec.redacted()["source_ip"]) + + def test_register_with_policy_resolves_via_attribution(self) -> None: + rec = self.store.register("10.243.0.1", policy='{"allow":["x"]}') + self.assertEqual('{"allow":["x"]}', rec.policy) + got = self.store.attribute("10.243.0.1", rec.identity_token) + assert got is not None + self.assertEqual('{"allow":["x"]}', got.policy) + + def test_set_policy_updates_live(self) -> None: + rec = self.store.register("10.243.0.1") + self.assertEqual("", rec.policy) + self.assertTrue(self.store.set_policy(rec.bottle_id, '{"v":2}')) + got = self.store.get(rec.bottle_id) + assert got is not None + self.assertEqual('{"v":2}', got.policy) + + def test_set_policy_missing_is_false(self) -> None: + self.assertFalse(self.store.set_policy("ghost", "{}")) + + def test_policy_defaults_empty_and_persists(self) -> None: + rec = self.store.register("10.243.0.1") + got = RegistryStore(self.db).get(rec.bottle_id) + assert got is not None + self.assertEqual("", got.policy) + + def test_by_source_ip_returns_single_active(self) -> None: + rec = self.store.register("10.243.0.1") + got = self.store.by_source_ip("10.243.0.1") + assert got is not None + self.assertEqual(rec.bottle_id, got.bottle_id) + + def test_by_source_ip_unknown_or_ambiguous_denied(self) -> None: + self.assertIsNone(self.store.by_source_ip("10.243.9.9")) # unknown + self.store.register("10.243.0.1", bottle_id="a") + self._force_active_row("10.243.0.1", "b") # orphaned duplicate + self.assertIsNone(self.store.by_source_ip("10.243.0.1")) # ambiguous + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_orchestrator_service.py b/tests/unit/test_orchestrator_service.py new file mode 100644 index 0000000..5dcbbd1 --- /dev/null +++ b/tests/unit/test_orchestrator_service.py @@ -0,0 +1,156 @@ +"""Unit tests for the Orchestrator launch lifecycle (PRD 0070).""" + +from __future__ import annotations + +import secrets +import tempfile +import unittest +from pathlib import Path + +from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBroker +from bot_bottle.orchestrator.registry import RegistryStore +from bot_bottle.orchestrator.service import Orchestrator +from bot_bottle.orchestrator.gateway import Gateway + + +class _FailingBroker(LaunchBroker): + """Verifies the token like any broker, then fails the launch — to + exercise the orchestrator's registry rollback.""" + + def _launch(self, req: LaunchRequest) -> None: + raise RuntimeError("launch failed") + + def _teardown(self, req: LaunchRequest) -> None: + pass + + +class _FakeGateway(Gateway): + """In-memory gateway for wiring tests.""" + + def __init__(self) -> None: + self.name = "fake-gateway" + self.ensured = 0 + self.built = 0 + self._running = False + + def ensure_built(self) -> None: + self.built += 1 + + def ensure_running(self) -> None: + self.ensured += 1 + self._running = True + + def is_running(self) -> bool: + return self._running + + def stop(self) -> None: + self._running = False + + +class TestOrchestrator(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.secret = secrets.token_bytes(16) + self.store = RegistryStore(Path(self._tmp.name) / "r.db") + self.store.migrate() + self.broker = StubBroker(self.secret) + self.orch = Orchestrator(self.store, self.broker, self.secret) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_launch_registers_and_brokers_signed_request(self) -> None: + rec = self.orch.launch_bottle("10.243.0.1", image_ref="sha256:abc", slot=2) + self.assertIsNotNone(self.store.get(rec.bottle_id)) + self.assertEqual(1, len(self.broker.launched)) + req = self.broker.launched[0] + self.assertEqual("launch", req.op) + self.assertEqual(rec.bottle_id, req.bottle_id) + self.assertEqual("10.243.0.1", req.source_ip) + self.assertEqual("sha256:abc", req.image_ref) + self.assertEqual(2, req.slot) + + def test_launch_then_attribute(self) -> None: + rec = self.orch.launch_bottle("10.243.0.3") + got = self.orch.attribute("10.243.0.3", rec.identity_token) + assert got is not None + self.assertEqual(rec.bottle_id, got.bottle_id) + self.assertIsNone(self.orch.attribute("10.243.0.3", "wrong-token")) + + def test_teardown_brokers_and_deregisters(self) -> None: + rec = self.orch.launch_bottle("10.243.0.1") + self.assertTrue(self.orch.teardown_bottle(rec.bottle_id)) + self.assertIsNone(self.store.get(rec.bottle_id)) + self.assertEqual(["teardown"], [r.op for r in self.broker.torn_down]) + + def test_teardown_unknown_is_false(self) -> None: + self.assertFalse(self.orch.teardown_bottle("ghost")) + + def test_launch_with_policy_is_resolvable(self) -> None: + rec = self.orch.launch_bottle("10.243.0.1", policy='{"routes":[]}') + got = self.orch.attribute("10.243.0.1", rec.identity_token) + assert got is not None + self.assertEqual('{"routes":[]}', got.policy) + + def test_tokens_held_in_memory_and_cleared_on_teardown(self) -> None: + rec = self.orch.launch_bottle("10.243.0.5", tokens={"EGRESS_TOKEN_0": "sk"}) + self.assertEqual({"EGRESS_TOKEN_0": "sk"}, self.orch.tokens_for(rec.bottle_id)) + # not persisted in the registry (redacted record has no tokens field) + self.assertNotIn("tokens", rec.redacted()) + self.orch.teardown_bottle(rec.bottle_id) + self.assertEqual({}, self.orch.tokens_for(rec.bottle_id)) + + def test_tokens_default_empty(self) -> None: + rec = self.orch.launch_bottle("10.243.0.6") + self.assertEqual({}, self.orch.tokens_for(rec.bottle_id)) + + def test_set_policy_live_reload(self) -> None: + rec = self.orch.launch_bottle("10.243.0.3") + self.assertTrue(self.orch.set_policy(rec.bottle_id, '{"x":1}')) + got = self.orch.attribute("10.243.0.3", rec.identity_token) + assert got is not None + self.assertEqual('{"x":1}', got.policy) + + def test_set_policy_unknown_is_false(self) -> None: + self.assertFalse(self.orch.set_policy("ghost", "{}")) + + def test_resolve_by_source_ip_without_token(self) -> None: + rec = self.orch.launch_bottle("10.243.0.1", policy="P") + got = self.orch.resolve("10.243.0.1") # network-layer, no token + assert got is not None + self.assertEqual(rec.bottle_id, got.bottle_id) + self.assertEqual("P", got.policy) + + def test_resolve_with_token_stays_strict(self) -> None: + rec = self.orch.launch_bottle("10.243.0.3") + self.assertIsNotNone(self.orch.resolve("10.243.0.3", rec.identity_token)) + self.assertIsNone(self.orch.resolve("10.243.0.3", "wrong-token")) + + def test_launch_rolls_back_registry_on_broker_failure(self) -> None: + orch = Orchestrator(self.store, _FailingBroker(self.secret), self.secret) + with self.assertRaises(RuntimeError): + orch.launch_bottle("10.243.0.9") + self.assertEqual([], self.store.all()) # no orphan + + def test_gateway_unconfigured_by_default(self) -> None: + self.assertEqual({"configured": False}, self.orch.gateway_status()) + self.orch.ensure_gateway() # no-op, must not raise + + def test_gateway_wired_and_ensured(self) -> None: + sc = _FakeGateway() + orch = Orchestrator(self.store, self.broker, self.secret, gateway=sc) + self.assertEqual( + {"configured": True, "name": "fake-gateway", "running": False}, + orch.gateway_status(), + ) + orch.ensure_gateway() + self.assertEqual(1, sc.built) # ensure_gateway builds first, + self.assertEqual(1, sc.ensured) # then runs + self.assertEqual( + {"configured": True, "name": "fake-gateway", "running": True}, + orch.gateway_status(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_policy_resolver.py b/tests/unit/test_policy_resolver.py new file mode 100644 index 0000000..c06b7a4 --- /dev/null +++ b/tests/unit/test_policy_resolver.py @@ -0,0 +1,111 @@ +"""Unit tests for the gateway-side PolicyResolver (PRD 0070). HTTP mocked.""" + +from __future__ import annotations + +import json +import unittest +import urllib.error +from unittest.mock import MagicMock, patch + +from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver + +_URLOPEN = "bot_bottle.policy_resolver.urllib.request.urlopen" + + +def _resp(payload: object) -> MagicMock: + """A urlopen() return value: a context manager whose read() yields JSON.""" + m = MagicMock() + m.__enter__.return_value.read.return_value = json.dumps(payload).encode() + return m + + +def _http_error(code: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError("http://x/resolve", code, "err", {}, None) # type: ignore[arg-type] + + +class TestPolicyResolver(unittest.TestCase): + def setUp(self) -> None: + self.r = PolicyResolver("http://orch:8080") + + def test_resolve_returns_policy(self) -> None: + with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})): + self.assertEqual("P", self.r.resolve("10.243.0.1", "tok")) + + def test_resolve_always_fetches_fresh(self) -> None: + # No cache — every resolve hits the orchestrator so revocations / + # policy changes are honored immediately. + with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m: + self.r.resolve("10.243.0.1", "tok") + self.r.resolve("10.243.0.1", "tok") + self.assertEqual(2, m.call_count) + + def test_unattributed_403_is_none_fail_closed(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(403)): + self.assertIsNone(self.r.resolve("10.243.0.9", "tok")) + + def test_other_http_error_raises(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(500)): + with self.assertRaises(PolicyResolveError): + self.r.resolve("10.243.0.1", "tok") + + def test_unreachable_raises(self) -> None: + with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")): + with self.assertRaises(PolicyResolveError): + self.r.resolve("10.243.0.1", "tok") + + def test_missing_policy_field_is_empty(self) -> None: + with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1"})): + self.assertEqual("", self.r.resolve("10.243.0.1", "tok")) + + def test_posts_source_ip_and_token(self) -> None: + with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m: + self.r.resolve("10.243.0.7", "the-token") + req = m.call_args.args[0] + self.assertTrue(req.full_url.endswith("/resolve")) + sent = json.loads(req.data) + self.assertEqual("10.243.0.7", sent["source_ip"]) + self.assertEqual("the-token", sent["identity_token"]) + + def test_resolve_without_token_sends_empty(self) -> None: + with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m: + self.r.resolve("10.243.0.7") # token optional + self.assertEqual("", json.loads(m.call_args.args[0].data)["identity_token"]) + + def test_resolve_bottle_id_returns_id(self) -> None: + with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})): + self.assertEqual("b1", self.r.resolve_bottle_id("10.243.0.1", "tok")) + + def test_resolve_bottle_id_403_is_none_fail_closed(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(403)): + self.assertIsNone(self.r.resolve_bottle_id("10.243.0.9", "tok")) + + def test_resolve_bottle_id_missing_field_is_none(self) -> None: + with patch(_URLOPEN, return_value=_resp({"policy": "P"})): + self.assertIsNone(self.r.resolve_bottle_id("10.243.0.1", "tok")) + + def test_resolve_bottle_id_error_raises(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(500)): + with self.assertRaises(PolicyResolveError): + self.r.resolve_bottle_id("10.243.0.1", "tok") + + def test_resolve_policy_and_bottle_id_one_call(self) -> None: + payload = {"bottle_id": "b1", "policy": "P", "tokens": {"EGRESS_TOKEN_0": "s"}} + with patch(_URLOPEN, return_value=_resp(payload)) as m: + self.assertEqual( + ("P", "b1", {"EGRESS_TOKEN_0": "s"}), + self.r.resolve_policy_and_bottle_id("10.243.0.1", "t"), + ) + self.assertEqual(1, m.call_count) # policy + id + tokens from a single /resolve + + def test_resolve_policy_and_bottle_id_403_is_none_none_empty(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(403)): + self.assertEqual((None, None, {}), self.r.resolve_policy_and_bottle_id("10.243.0.9")) + + def test_resolve_policy_and_bottle_id_error_raises(self) -> None: + with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")): + with self.assertRaises(PolicyResolveError): + self.r.resolve_policy_and_bottle_id("10.243.0.1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index bc69b89..6381af0 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -8,7 +8,8 @@ from datetime import datetime, timezone from pathlib import Path from bot_bottle import supervise -from bot_bottle import supervise_types as sv_types +from bot_bottle.paths import host_db_path +from tests.unit import use_bottle_root from bot_bottle.audit_store import AuditStore from bot_bottle.queue_store import QueueStore from bot_bottle.supervise import ( @@ -21,7 +22,6 @@ from bot_bottle.supervise import ( TOOL_EGRESS_ALLOW, TOOL_GITLEAKS_ALLOW, archive_proposal, - host_db_path, list_pending_proposals, read_audit_entries, read_proposal, @@ -123,20 +123,7 @@ class TestQueueIO(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def test_write_and_read_proposal(self): p = _proposal() @@ -240,20 +227,7 @@ class TestAuditLog(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def test_write_then_read_single_entry(self): e = AuditEntry( @@ -400,20 +374,7 @@ class TestSupervisePrepare(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def test_prepare_creates_queue(self): plan = _StubSupervise().prepare("dev", self.stage_dir) diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index 9903da3..9c680c9 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -12,7 +12,7 @@ from pathlib import Path from unittest.mock import patch from bot_bottle import supervise -from bot_bottle import supervise_types as sv_types +from tests.unit import use_bottle_root from bot_bottle.audit_store import AuditStore from bot_bottle.cli import supervise as supervise_cli from bot_bottle.queue_store import QueueStore @@ -51,24 +51,11 @@ def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_ALLOW) -> Proposal: class _FakeHomeMixin: - """Patch supervise.bot_bottle_root to a temp dir for the test.""" + """Point bot_bottle_root at a temp dir (via BOT_BOTTLE_ROOT) for the test.""" def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.") - original_sv = supervise.bot_bottle_root - original_svt = sv_types.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - sv_types.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - supervise.bot_bottle_root = original_sv # type: ignore[assignment] - sv_types.bot_bottle_root = original_svt # type: ignore[assignment] - - self._restore_home = restore + self._restore_home = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") QueueStore("").migrate() AuditStore().migrate() diff --git a/tests/unit/test_supervise_cli_crash_logging.py b/tests/unit/test_supervise_cli_crash_logging.py index 56cb6f0..05c5463 100644 --- a/tests/unit/test_supervise_cli_crash_logging.py +++ b/tests/unit/test_supervise_cli_crash_logging.py @@ -11,12 +11,13 @@ from __future__ import annotations import contextlib import io +import os import tempfile import unittest from pathlib import Path from unittest import mock -from bot_bottle import supervise +from tests.unit import use_bottle_root from bot_bottle.cli import supervise as supervise_cli from bot_bottle.log import Die, die @@ -39,18 +40,16 @@ class TestDieCarriesMessage(unittest.TestCase): class _FakeHomeMixin: - """Point supervise.bot_bottle_root (what _write_crash_log resolves - through) at a temp dir so the crash log doesn't touch the real - ~/.bot-bottle.""" + """Point bot_bottle_root (what _write_crash_log resolves through) at a + temp dir so the crash log doesn't touch the real ~/.bot-bottle.""" def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.") - self._orig_root = supervise.bot_bottle_root self._root = Path(self._tmp.name) / ".bot-bottle" - supervise.bot_bottle_root = lambda: self._root # type: ignore[assignment] + self._restore_root = use_bottle_root(self._root) def _teardown_fake_home(self): - supervise.bot_bottle_root = self._orig_root # type: ignore[assignment] + self._restore_root() self._tmp.cleanup() @@ -127,11 +126,11 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase): # OSError and the helper must fall back to a tempfile. bad = Path(self._tmp.name) / "not-a-dir" bad.write_text("x") - supervise.bot_bottle_root = lambda: bad # type: ignore[assignment] - try: - raise RuntimeError("explode2") - except RuntimeError as e: - path = supervise_cli._write_crash_log(e) + with mock.patch.dict(os.environ, {"BOT_BOTTLE_ROOT": str(bad)}): + try: + raise RuntimeError("explode2") + except RuntimeError as e: + path = supervise_cli._write_crash_log(e) self.assertTrue(path.exists()) self.assertIn("explode2", path.read_text()) diff --git a/tests/unit/test_supervise_edge.py b/tests/unit/test_supervise_edge.py index 3cde845..4cedbfa 100644 --- a/tests/unit/test_supervise_edge.py +++ b/tests/unit/test_supervise_edge.py @@ -12,6 +12,7 @@ from unittest.mock import patch from bot_bottle import supervise from bot_bottle.audit_store import AuditStore +from bot_bottle.paths import bot_bottle_root from bot_bottle.queue_store import QueueStore from bot_bottle.supervise import ( AuditEntry, @@ -39,7 +40,7 @@ def _proposal() -> Proposal: class TestPathHelpers(unittest.TestCase): def test_bot_bottle_root(self) -> None: - self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle")) + self.assertTrue(str(bot_bottle_root()).endswith(".bot-bottle")) class TestReadMalformed(unittest.TestCase): diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index 9ea47e5..df81d3a 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -6,10 +6,13 @@ import sys import tempfile import threading import time +import types import unittest from pathlib import Path from unittest.mock import patch +from tests.unit import use_bottle_root + # The server module loads `supervise` via same-directory import inside # the container (Dockerfile.supervise WORKDIRs into /app). For tests @@ -19,10 +22,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "bot_bott import supervise as _sv # noqa: E402 # type: ignore import queue_store as _qs # noqa: E402 # type: ignore import audit_store as _as # noqa: E402 # type: ignore -import supervise_types as svt_flat # noqa: E402 # type: ignore from bot_bottle import supervise_server # noqa: E402 -from bot_bottle import supervise_types as svt_pkg # noqa: E402 from bot_bottle.supervise_server import ( ERR_INTERNAL, ERR_INVALID_PARAMS, @@ -279,23 +280,7 @@ class TestHandleToolsCall(unittest.TestCase): self._tmp.cleanup() def _patch_home(self, fake_home: Path): - original_sv = _sv.bot_bottle_root - original_flat = svt_flat.bot_bottle_root - original_pkg = svt_pkg.bot_bottle_root - - def fake_root() -> Path: - return fake_home / ".bot-bottle" - - _sv.bot_bottle_root = fake_root # type: ignore[assignment] - svt_flat.bot_bottle_root = fake_root # type: ignore[assignment] - svt_pkg.bot_bottle_root = fake_root # type: ignore[assignment] - - def restore() -> None: - _sv.bot_bottle_root = original_sv # type: ignore[assignment] - svt_flat.bot_bottle_root = original_flat # type: ignore[assignment] - svt_pkg.bot_bottle_root = original_pkg # type: ignore[assignment] - - return restore + return use_bottle_root(fake_home / ".bot-bottle") def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread: """Background thread: poll the queue for a fresh proposal, write a @@ -645,5 +630,57 @@ class TestHttpEndToEnd(unittest.TestCase): conn.close() +class _FakeResolver: + def __init__(self, bottle_id: str | None = None, raises: bool = False) -> None: + self._bottle_id = bottle_id + self._raises = raises + self.calls: list[str] = [] + + def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None: + del identity_token + self.calls.append(source_ip) + if self._raises: + # Raise the exact class supervise_server catches (it imports + # policy_resolver flat inside the bundle, package-side in tests). + raise supervise_server.PolicyResolveError("orchestrator down") + return self._bottle_id + + +def _handler(resolver: object) -> MCPHandler: + """A bare MCPHandler wired with a server (carrying the resolver) and a + client address, enough to exercise `_attributed_config` off-socket.""" + h: MCPHandler = MCPHandler.__new__(MCPHandler) + h.server = types.SimpleNamespace(policy_resolver=resolver) # type: ignore[assignment] + h.client_address = ("10.0.0.7", 4321) + return h + + +class TestAttributedConfig(unittest.TestCase): + """Consolidated supervise: each proposal is attributed to the calling + bottle by source IP; single-tenant keeps the env slug (PRD 0070).""" + + def test_single_tenant_keeps_env_slug(self) -> None: + cfg = _handler(None)._attributed_config(ServerConfig(bottle_slug="dev")) + self.assertEqual("dev", cfg.bottle_slug) + + def test_consolidated_binds_source_ip_bottle(self) -> None: + r = _FakeResolver(bottle_id="bottle-x") + cfg = _handler(r)._attributed_config(ServerConfig(bottle_slug="ignored")) + self.assertEqual("bottle-x", cfg.bottle_slug) # resolved slug wins + self.assertEqual(["10.0.0.7"], r.calls) + + def test_unattributed_source_fails_closed(self) -> None: + with self.assertRaises(_RpcInternalError): + _handler(_FakeResolver(bottle_id=None))._attributed_config( + ServerConfig(bottle_slug="x") + ) + + def test_resolver_error_fails_closed(self) -> None: + with self.assertRaises(_RpcInternalError): + _handler(_FakeResolver(raises=True))._attributed_config( + ServerConfig(bottle_slug="x") + ) + + if __name__ == "__main__": unittest.main()