Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eab7d6ccbc | |||
| 389a2c10ce | |||
| 9896986810 | |||
| 20e83258bf | |||
| c5ba33bf0a | |||
| 72286f188d | |||
| 77aaabae63 | |||
| d0b35b5506 | |||
| ea0c070cfe | |||
| 24df322c31 | |||
| 80d75de08a | |||
| 8827c64a83 | |||
| 115a64c161 | |||
| 08aef30d87 | |||
| e0b0429cd1 | |||
| d3e08cf039 | |||
| de9da29027 | |||
| 72ea500342 | |||
| f754c575d7 | |||
| a092d00312 | |||
| 67652899eb | |||
| f85cbdeebf | |||
| 44e611d14e | |||
| 4fb0f64249 | |||
| 9226d45041 | |||
| 6847fdf0ab | |||
| 421d31c32f | |||
| b4b4e08f62 | |||
| b174442c60 | |||
| ed7307e0e3 | |||
| 1702664b81 | |||
| 8d54fc38ea | |||
| 73a7582abe | |||
| 095896817c | |||
| a30dd49967 |
@@ -5,7 +5,7 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://coverage.readthedocs.io/)
|
[](https://coverage.readthedocs.io/)
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from abc import ABC, abstractmethod
|
|||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar
|
from typing import Any, Generic, Sequence, TypeVar
|
||||||
|
|
||||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
||||||
from ..egress import EgressPlan
|
from ..egress import EgressPlan
|
||||||
@@ -54,9 +54,6 @@ from ..workspace import WorkspacePlan, workspace_plan
|
|||||||
from .print_util import print_multi, visible_agent_env_names
|
from .print_util import print_multi, visible_agent_env_names
|
||||||
from .util import host_skill_dir
|
from .util import host_skill_dir
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .freeze import CommitCancelled, Freezer, get_freezer
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BottleSpec:
|
class BottleSpec:
|
||||||
@@ -575,63 +572,28 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
Not called by the launch path or the test suite."""
|
Not called by the launch path or the test suite."""
|
||||||
|
|
||||||
|
|
||||||
# _backends is None until the first call to _get_backends(), at which
|
# Import concrete backend classes AFTER the base types are defined, so
|
||||||
# point all three concrete backend classes are imported and instantiated.
|
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
||||||
# Keeping the imports out of module scope means that importing any
|
# via `from . import ...` without hitting a partially-initialized module.
|
||||||
# backend sub-module (e.g. `backend.docker.util`) no longer drags the
|
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
# firecracker and macos-container implementations into memory.
|
from .firecracker import FirecrackerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
#
|
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
# Tests may replace _backends with a {name: fake} dict via patch.object;
|
|
||||||
# _get_backends() returns the current module-level value as-is when it
|
# Freezer is imported after the backend classes for the same reason:
|
||||||
# is not None, so test fakes take effect without triggering real imports.
|
# Freezer.commit_slug constructs ActiveAgent, which must be fully
|
||||||
_backends: dict[str, BottleBackend[Any, Any]] | None = None
|
# defined first.
|
||||||
|
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
|
|
||||||
|
|
||||||
def _get_backends() -> dict[str, BottleBackend[Any, Any]]:
|
# The dict is heterogeneous: each value is a BottleBackend specialized
|
||||||
"""Return the registry of all backend instances, loading lazily on first call."""
|
# over its own plan type. Concrete plan types are erased here because
|
||||||
global _backends # pylint: disable=global-statement
|
# the registry is selected at runtime and the CLI only needs the
|
||||||
if _backends is None:
|
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
||||||
from .docker import DockerBottleBackend
|
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
||||||
from .firecracker import FirecrackerBottleBackend
|
"docker": DockerBottleBackend(),
|
||||||
from .macos_container import MacosContainerBottleBackend
|
"firecracker": FirecrackerBottleBackend(),
|
||||||
_backends = {
|
"macos-container": MacosContainerBottleBackend(),
|
||||||
"docker": DockerBottleBackend(),
|
}
|
||||||
"firecracker": FirecrackerBottleBackend(),
|
|
||||||
"macos-container": MacosContainerBottleBackend(),
|
|
||||||
}
|
|
||||||
return _backends
|
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str) -> Any:
|
|
||||||
"""Lazily surface concrete backend classes and freeze symbols at the
|
|
||||||
package level so existing `from bot_bottle.backend import X` and
|
|
||||||
`patch.object(backend_mod, X, ...)` call-sites keep working without
|
|
||||||
forcing an import of every backend at module-init time."""
|
|
||||||
if name == "DockerBottleBackend":
|
|
||||||
from .docker import DockerBottleBackend
|
|
||||||
globals()[name] = DockerBottleBackend
|
|
||||||
return DockerBottleBackend
|
|
||||||
if name == "FirecrackerBottleBackend":
|
|
||||||
from .firecracker import FirecrackerBottleBackend
|
|
||||||
globals()[name] = FirecrackerBottleBackend
|
|
||||||
return FirecrackerBottleBackend
|
|
||||||
if name == "MacosContainerBottleBackend":
|
|
||||||
from .macos_container import MacosContainerBottleBackend
|
|
||||||
globals()[name] = MacosContainerBottleBackend
|
|
||||||
return MacosContainerBottleBackend
|
|
||||||
if name == "CommitCancelled":
|
|
||||||
from .freeze import CommitCancelled
|
|
||||||
globals()[name] = CommitCancelled
|
|
||||||
return CommitCancelled
|
|
||||||
if name == "Freezer":
|
|
||||||
from .freeze import Freezer
|
|
||||||
globals()[name] = Freezer
|
|
||||||
return Freezer
|
|
||||||
if name == "get_freezer":
|
|
||||||
from .freeze import get_freezer
|
|
||||||
globals()[name] = get_freezer
|
|
||||||
return get_freezer
|
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_bottle_backend(
|
def get_bottle_backend(
|
||||||
@@ -649,11 +611,10 @@ def get_bottle_backend(
|
|||||||
Dies with a pointer at the known backends if the chosen name
|
Dies with a pointer at the known backends if the chosen name
|
||||||
isn't implemented."""
|
isn't implemented."""
|
||||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
|
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
|
||||||
backends = _get_backends()
|
if resolved not in _BACKENDS:
|
||||||
if resolved not in backends:
|
known = ", ".join(sorted(_BACKENDS))
|
||||||
known = ", ".join(sorted(backends))
|
|
||||||
die(f"unknown backend {resolved!r}; known backends: {known}")
|
die(f"unknown backend {resolved!r}; known backends: {known}")
|
||||||
return backends[resolved]
|
return _BACKENDS[resolved]
|
||||||
|
|
||||||
|
|
||||||
def _default_backend_name() -> str:
|
def _default_backend_name() -> str:
|
||||||
@@ -663,17 +624,16 @@ def _default_backend_name() -> str:
|
|||||||
# `firecracker` binary isn't installed yet: selecting it here routes
|
# `firecracker` binary isn't installed yet: selecting it here routes
|
||||||
# start through firecracker's preflight, which prints an install
|
# start through firecracker's preflight, which prints an install
|
||||||
# pointer, instead of silently falling back to docker.
|
# pointer, instead of silently falling back to docker.
|
||||||
from .firecracker import FirecrackerBottleBackend
|
|
||||||
if FirecrackerBottleBackend.is_host_capable():
|
if FirecrackerBottleBackend.is_host_capable():
|
||||||
return "firecracker"
|
return "firecracker"
|
||||||
return "docker"
|
return "docker"
|
||||||
|
|
||||||
|
|
||||||
def known_backend_names() -> tuple[str, ...]:
|
def known_backend_names() -> tuple[str, ...]:
|
||||||
"""Sorted tuple of all backend keys in `_get_backends()`. Used by
|
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by
|
||||||
argparse (`--backend` choices) and the dashboard's backend
|
argparse (`--backend` choices) and the dashboard's backend
|
||||||
picker."""
|
picker."""
|
||||||
return tuple(sorted(_get_backends()))
|
return tuple(sorted(_BACKENDS))
|
||||||
|
|
||||||
|
|
||||||
def has_backend(name: str) -> bool:
|
def has_backend(name: str) -> bool:
|
||||||
@@ -685,10 +645,9 @@ def has_backend(name: str) -> bool:
|
|||||||
|
|
||||||
Returns False for unknown names so callers can pass
|
Returns False for unknown names so callers can pass
|
||||||
arbitrary input without separate validation."""
|
arbitrary input without separate validation."""
|
||||||
backends = _get_backends()
|
if name not in _BACKENDS:
|
||||||
if name not in backends:
|
|
||||||
return False
|
return False
|
||||||
return backends[name].is_available()
|
return _BACKENDS[name].is_available()
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active_agents() -> list[ActiveAgent]:
|
def enumerate_active_agents() -> list[ActiveAgent]:
|
||||||
@@ -704,11 +663,10 @@ def enumerate_active_agents() -> list[ActiveAgent]:
|
|||||||
deterministic tiebreaker. Agents with missing metadata
|
deterministic tiebreaker. Agents with missing metadata
|
||||||
(`started_at == ""`) sort first."""
|
(`started_at == ""`) sort first."""
|
||||||
out: list[ActiveAgent] = []
|
out: list[ActiveAgent] = []
|
||||||
backends = _get_backends()
|
for name in known_backend_names():
|
||||||
for name in sorted(backends):
|
if not has_backend(name):
|
||||||
if not backends[name].is_available():
|
|
||||||
continue
|
continue
|
||||||
out.extend(backends[name].enumerate_active())
|
out.extend(_BACKENDS[name].enumerate_active())
|
||||||
out.sort(key=lambda a: (a.started_at, a.slug))
|
out.sort(key=lambda a: (a.started_at, a.slug))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -111,11 +111,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
plumbing needed; the alias resolves inside the bridge."""
|
plumbing needed; the alias resolves inside the bridge."""
|
||||||
if plan.supervise_plan is None:
|
if plan.supervise_plan is None:
|
||||||
return ""
|
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}/"
|
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
||||||
|
|
||||||
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
||||||
|
|||||||
@@ -28,30 +28,11 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
# accidental log of the plan dataclass.
|
# accidental log of the plan dataclass.
|
||||||
forwarded_env: dict[str, str] = field(repr=False)
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
# Consolidated mode (PRD 0070): the agent reaches git-gate over HTTP at the
|
|
||||||
# shared gateway's address (`http://<gateway_ip>: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://<gw>:9100/`);
|
|
||||||
# empty → the single-tenant `supervise` alias.
|
|
||||||
agent_supervise_url: str = ""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def container_name(self) -> str:
|
def container_name(self) -> str:
|
||||||
return self.agent_provision.instance_name
|
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
|
@property
|
||||||
def image(self) -> str:
|
def image(self) -> str:
|
||||||
return self.agent_provision.image
|
return self.agent_provision.image
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ...egress import EgressPlan
|
|||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...orchestrator.client import OrchestratorClient
|
from ...orchestrator.client import OrchestratorClient
|
||||||
from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK
|
from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK
|
||||||
from ...orchestrator.lifecycle import OrchestratorService
|
from ...orchestrator.lifecycle import OrchestratorProcess
|
||||||
from ...orchestrator.registration import registration_inputs
|
from ...orchestrator.registration import registration_inputs
|
||||||
from .gateway_net import next_free_ip
|
from .gateway_net import next_free_ip
|
||||||
from .gateway_provision import deprovision_git_gate, provision_git_gate
|
from .gateway_provision import deprovision_git_gate, provision_git_gate
|
||||||
@@ -76,21 +76,15 @@ def _container_ip(name: str, network: str) -> str:
|
|||||||
return ip
|
return ip
|
||||||
|
|
||||||
|
|
||||||
def _network_container_ips(network: str) -> list[str]:
|
def _taken_ips(client: OrchestratorClient, gateway_ip: str) -> list[str]:
|
||||||
"""Every address currently assigned on the gateway network — the ground
|
"""Every address already in use on the gateway network: the gateway
|
||||||
truth for "in use": the gateway + orchestrator infrastructure containers
|
container plus every live bottle the registry knows."""
|
||||||
and every live agent. Read from the network so a new bottle can't collide
|
taken = [gateway_ip]
|
||||||
with anything actually attached (a registry-only view would miss the
|
for rec in client.list_bottles():
|
||||||
orchestrator/gateway containers)."""
|
src = rec.get("source_ip")
|
||||||
proc = run_docker([
|
if isinstance(src, str) and src:
|
||||||
"docker", "network", "inspect", "--format",
|
taken.append(src)
|
||||||
"{{range .Containers}}{{.IPv4Address}} {{end}}", network,
|
return taken
|
||||||
])
|
|
||||||
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(
|
def launch_consolidated(
|
||||||
@@ -98,8 +92,7 @@ def launch_consolidated(
|
|||||||
git_gate_plan: GitGatePlan,
|
git_gate_plan: GitGatePlan,
|
||||||
*,
|
*,
|
||||||
image_ref: str = "",
|
image_ref: str = "",
|
||||||
tokens: dict[str, str] | None = None,
|
process: OrchestratorProcess | None = None,
|
||||||
service: OrchestratorService | None = None,
|
|
||||||
gateway_name: str = GATEWAY_NAME,
|
gateway_name: str = GATEWAY_NAME,
|
||||||
network: str = GATEWAY_NETWORK,
|
network: str = GATEWAY_NETWORK,
|
||||||
) -> LaunchContext:
|
) -> LaunchContext:
|
||||||
@@ -107,18 +100,17 @@ def launch_consolidated(
|
|||||||
bottle, and provision its git-gate state. Returns the agent's attach
|
bottle, and provision its git-gate state. Returns the agent's attach
|
||||||
context. Raises `ConsolidatedLaunchError` (or the primitives' own errors)
|
context. Raises `ConsolidatedLaunchError` (or the primitives' own errors)
|
||||||
if any step fails — the caller tears down on failure."""
|
if any step fails — the caller tears down on failure."""
|
||||||
service = service or OrchestratorService()
|
process = process or OrchestratorProcess()
|
||||||
url = service.ensure_running()
|
url = process.ensure_running()
|
||||||
client = OrchestratorClient(url)
|
client = OrchestratorClient(url)
|
||||||
|
|
||||||
cidr = _network_cidr(network)
|
cidr = _network_cidr(network)
|
||||||
gateway_ip = _container_ip(gateway_name, network)
|
gateway_ip = _container_ip(gateway_name, network)
|
||||||
source_ip = next_free_ip(cidr, _network_container_ips(network))
|
source_ip = next_free_ip(cidr, _taken_ips(client, gateway_ip))
|
||||||
|
|
||||||
inputs = registration_inputs(egress_plan)
|
inputs = registration_inputs(egress_plan)
|
||||||
reg = client.register_bottle(
|
reg = client.register_bottle(
|
||||||
source_ip, image_ref=image_ref, policy=inputs.policy,
|
source_ip, image_ref=image_ref, policy=inputs.policy, metadata=inputs.metadata,
|
||||||
metadata=inputs.metadata, tokens=tokens,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
provision_git_gate(gateway_name, reg.bottle_id, git_gate_plan)
|
provision_git_gate(gateway_name, reg.bottle_id, git_gate_plan)
|
||||||
|
|||||||
@@ -67,12 +67,6 @@ def provision_git_gate(gateway: str, bottle_id: str, plan: GitGatePlan) -> None:
|
|||||||
_require_safe(bottle_id)
|
_require_safe(bottle_id)
|
||||||
if not plan.upstreams:
|
if not plan.upstreams:
|
||||||
return
|
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)
|
creds = _creds_dir(bottle_id)
|
||||||
_exec(gateway, ["mkdir", "-p", creds])
|
_exec(gateway, ["mkdir", "-p", creds])
|
||||||
for u in plan.upstreams:
|
for u in plan.upstreams:
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from ...git_gate import (
|
|||||||
revoke_git_gate_provisioned_keys,
|
revoke_git_gate_provisioned_keys,
|
||||||
)
|
)
|
||||||
from ...log import info, warn
|
from ...log import info, warn
|
||||||
|
from . import network as network_mod
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
@@ -52,6 +53,7 @@ from ...bottle_state import (
|
|||||||
read_committed_image,
|
read_committed_image,
|
||||||
)
|
)
|
||||||
from .compose import (
|
from .compose import (
|
||||||
|
bottle_plan_to_compose,
|
||||||
compose_down,
|
compose_down,
|
||||||
compose_dump_logs,
|
compose_dump_logs,
|
||||||
compose_file_path,
|
compose_file_path,
|
||||||
@@ -60,9 +62,7 @@ from .compose import (
|
|||||||
compose_up,
|
compose_up,
|
||||||
write_compose_file,
|
write_compose_file,
|
||||||
)
|
)
|
||||||
from .consolidated_compose import consolidated_agent_compose
|
from .egress import egress_tls_init
|
||||||
from .consolidated_launch import launch_consolidated, teardown_consolidated
|
|
||||||
from ...orchestrator.gateway import DockerGateway
|
|
||||||
|
|
||||||
|
|
||||||
# Where the repo root lives, for `docker build` context. Computed once.
|
# Where the repo root lives, for `docker build` context. Computed once.
|
||||||
@@ -97,7 +97,8 @@ def launch(
|
|||||||
try:
|
try:
|
||||||
# Step 1: agent image. Use a committed snapshot when one exists
|
# Step 1: agent image. Use a committed snapshot when one exists
|
||||||
# and is present in the local daemon; otherwise build from the
|
# and is present in the local daemon; otherwise build from the
|
||||||
# Dockerfile. (The gateway image is built by the orchestrator.)
|
# Dockerfile. Sidecar images get built lazily by `docker compose
|
||||||
|
# up` via the renderer's `build:` directives.
|
||||||
committed = read_committed_image(plan.slug)
|
committed = read_committed_image(plan.slug)
|
||||||
if committed and docker_mod.image_exists(committed):
|
if committed and docker_mod.image_exists(committed):
|
||||||
info(f"using committed image {committed!r}")
|
info(f"using committed image {committed!r}")
|
||||||
@@ -111,82 +112,82 @@ def launch(
|
|||||||
dockerfile=plan.dockerfile_path,
|
dockerfile=plan.dockerfile_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any, before
|
internal_network = network_mod.network_name_for_slug(plan.slug)
|
||||||
# provisioning the bottle's repos into the shared gateway.
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
git_gate_plan = plan.git_gate_plan
|
git_gate_plan = plan.git_gate_plan
|
||||||
if git_gate_plan.upstreams:
|
if git_gate_plan.upstreams:
|
||||||
git_gate_plan = provision_git_gate_dynamic_keys(
|
git_gate_plan = provision_git_gate_dynamic_keys(
|
||||||
plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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(
|
egress_plan = dataclasses.replace(
|
||||||
plan.egress_plan,
|
plan.egress_plan,
|
||||||
mitmproxy_ca_host_path=ca_file,
|
internal_network=internal_network,
|
||||||
mitmproxy_ca_cert_only_host_path=ca_file,
|
egress_network=egress_network,
|
||||||
)
|
mitmproxy_ca_host_path=egress_ca_host,
|
||||||
# Point the agent's git-gate insteadOf rewrites at the shared gateway's
|
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
||||||
# 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 = dataclasses.replace(
|
||||||
plan,
|
plan,
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
egress_plan=egress_plan,
|
egress_plan=egress_plan,
|
||||||
agent_git_gate_url=git_gate_url,
|
supervise_plan=supervise_plan,
|
||||||
agent_supervise_url=supervise_url,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 5: render + up the agent-only compose, pinned on the shared
|
# Step 6: render + write the compose file. metadata.json
|
||||||
# gateway network and proxied through the gateway's address.
|
# was written at prepare time and already carries
|
||||||
|
# compose_project; nothing to update here.
|
||||||
state_dir = bottle_state_dir(plan.slug)
|
state_dir = bottle_state_dir(plan.slug)
|
||||||
spec = consolidated_agent_compose(
|
spec = bottle_plan_to_compose(plan)
|
||||||
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))
|
compose_file = write_compose_file(spec, compose_file_path(state_dir))
|
||||||
project = compose_project_name(plan.slug)
|
project = compose_project_name(plan.slug)
|
||||||
# Forwarded vars (OAuth token, host interpolations) flow through the
|
|
||||||
# subprocess env as bare names so values never land in the file.
|
# Step 7: compose up. Token values + the OAuth placeholder
|
||||||
compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env}
|
# 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,
|
||||||
|
}
|
||||||
info(
|
info(
|
||||||
f"docker compose up -d (project {project}, agent on shared "
|
f"docker compose up -d (project {project}, "
|
||||||
f"gateway {ctx.gateway_ip}, ip {ctx.source_ip})"
|
f"{len(spec['services'])} services)"
|
||||||
)
|
)
|
||||||
compose_up(project, compose_file, env=compose_env)
|
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_down, project, compose_file)
|
||||||
stack.callback(
|
stack.callback(
|
||||||
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 6: provision (CA install now uses the gateway CA) + yield.
|
# 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.
|
||||||
bottle = DockerBottle(
|
bottle = DockerBottle(
|
||||||
plan.container_name,
|
plan.container_name,
|
||||||
teardown,
|
teardown,
|
||||||
@@ -199,6 +200,10 @@ def launch(
|
|||||||
agent_workdir=plan.workspace_plan.workdir,
|
agent_workdir=plan.workspace_plan.workdir,
|
||||||
)
|
)
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
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
|
yield bottle
|
||||||
finally:
|
finally:
|
||||||
teardown()
|
teardown()
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Iterator
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
from ...docker_cmd import run_docker
|
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
# from ...workspace import WorkspacePlan
|
# from ...workspace import WorkspacePlan
|
||||||
|
|
||||||
@@ -31,7 +30,12 @@ def container_name_candidates(base: str) -> Iterator[str]:
|
|||||||
def runsc_available() -> bool:
|
def runsc_available() -> bool:
|
||||||
"""Return True if the Docker daemon has the gVisor (`runsc`) runtime
|
"""Return True if the Docker daemon has the gVisor (`runsc`) runtime
|
||||||
registered. Called once per prepare; the result lives on the plan."""
|
registered. Called once per prepare; the result lives on the plan."""
|
||||||
r = run_docker(["docker", "info", "--format", "{{json .Runtimes}}"])
|
r = subprocess.run(
|
||||||
|
["docker", "info", "--format", "{{json .Runtimes}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
return r.returncode == 0 and "runsc" in r.stdout
|
return r.returncode == 0 and "runsc" in r.stdout
|
||||||
|
|
||||||
|
|
||||||
@@ -45,15 +49,20 @@ def require_docker() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def image_exists(ref: str) -> bool:
|
def image_exists(ref: str) -> bool:
|
||||||
return run_docker(["docker", "image", "inspect", ref]).returncode == 0
|
return _silent_run(["docker", "image", "inspect", ref]) == 0
|
||||||
|
|
||||||
|
|
||||||
def container_exists(name: str) -> bool:
|
def container_exists(name: str) -> bool:
|
||||||
"""Returns True if a container (running or stopped) with the given
|
"""Returns True if a container (running or stopped) with the given
|
||||||
name exists. Uses `docker ps -a -q -f name=^<name>$` so substring
|
name exists. Uses `docker ps -a -q -f name=^<name>$` so substring
|
||||||
matches don't false-positive."""
|
matches don't false-positive."""
|
||||||
result = run_docker(["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"])
|
result = subprocess.run(
|
||||||
return result.returncode == 0 and bool(result.stdout.strip())
|
["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return bool(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
def force_remove_container(name: str) -> None:
|
def force_remove_container(name: str) -> None:
|
||||||
@@ -61,7 +70,12 @@ def force_remove_container(name: str) -> None:
|
|||||||
doesn't — and the rm itself is best-effort (errors swallowed) so
|
doesn't — and the rm itself is best-effort (errors swallowed) so
|
||||||
this is safe to register as a teardown callback."""
|
this is safe to register as a teardown callback."""
|
||||||
if container_exists(name):
|
if container_exists(name):
|
||||||
run_docker(["docker", "rm", "-f", name])
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def docker_exec_root(container: str, argv: list[str]) -> None:
|
def docker_exec_root(container: str, argv: list[str]) -> None:
|
||||||
@@ -141,10 +155,22 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|||||||
def commit_container(container_name: str, image_tag: str) -> None:
|
def commit_container(container_name: str, image_tag: str) -> None:
|
||||||
"""Run `docker commit <container_name> <image_tag>` to snapshot the
|
"""Run `docker commit <container_name> <image_tag>` to snapshot the
|
||||||
running container's filesystem state as a local Docker image."""
|
running container's filesystem state as a local Docker image."""
|
||||||
result = run_docker(["docker", "commit", container_name, image_tag])
|
result = subprocess.run(
|
||||||
|
["docker", "commit", container_name, image_tag],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
die(
|
die(
|
||||||
f"docker commit {container_name!r} → {image_tag!r} failed: "
|
f"docker commit {container_name!r} → {image_tag!r} failed: "
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
)
|
)
|
||||||
info(f"committed {container_name!r} → {image_tag!r}")
|
info(f"committed {container_name!r} → {image_tag!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _silent_run(cmd: Iterable[str]) -> int:
|
||||||
|
return subprocess.run(
|
||||||
|
list(cmd),
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
).returncode
|
||||||
|
|||||||
+33
-46
@@ -10,7 +10,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import typing
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
||||||
@@ -231,27 +230,21 @@ class EgressAddon:
|
|||||||
+ "\n"
|
+ "\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_flow(
|
def _resolve_flow(self, flow: http.HTTPFlow) -> "tuple[Config, str]":
|
||||||
self, flow: http.HTTPFlow,
|
"""The `(Config, supervise slug)` to apply to this request. Single-tenant
|
||||||
) -> "tuple[Config, str, typing.Mapping[str, str]]":
|
→ the static `self.config` and the env slug. Consolidated → the calling
|
||||||
"""The `(Config, supervise slug, env)` to apply to this request.
|
bottle's Config and its bottle id, resolved by source IP in one
|
||||||
Single-tenant → the static `self.config`, the env slug, and the process
|
round-trip (fail-closed to deny-all + empty slug if unattributed). The
|
||||||
env. Consolidated → the calling bottle's Config + bottle id + auth
|
identity token, if the agent injected one, is read then stripped so it
|
||||||
tokens, resolved by source IP in one round-trip (fail-closed to deny-all
|
never leaks upstream. The slug keys both the proposal queue and the
|
||||||
+ empty slug if unattributed); `env` is the process env overlaid with
|
per-bottle safelist, so an approval only ever affects its own bottle."""
|
||||||
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:
|
if self._resolver is None:
|
||||||
return self.config, self._supervise_slug, os.environ
|
return self.config, self._supervise_slug
|
||||||
conn = flow.client_conn
|
conn = flow.client_conn
|
||||||
client_ip = conn.peername[0] if conn and conn.peername else ""
|
client_ip = conn.peername[0] if conn and conn.peername else ""
|
||||||
token = flow.request.headers.get(IDENTITY_HEADER, "")
|
token = flow.request.headers.get(IDENTITY_HEADER, "")
|
||||||
flow.request.headers.pop(IDENTITY_HEADER, None)
|
flow.request.headers.pop(IDENTITY_HEADER, None)
|
||||||
config, slug, tokens = resolve_client_context(self._resolver, client_ip, token)
|
return 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:
|
async def request(self, flow: http.HTTPFlow) -> None:
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
@@ -260,14 +253,14 @@ class EgressAddon:
|
|||||||
self._serve_introspection(flow, request_path)
|
self._serve_introspection(flow, request_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
config, slug, env = self._resolve_flow(flow)
|
config, slug = self._resolve_flow(flow)
|
||||||
|
|
||||||
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
||||||
# agent tried to smuggle in any header, path, query param, or body.
|
# agent tried to smuggle in any header, path, query param, or body.
|
||||||
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
||||||
route = match_route(config.routes, flow.request.pretty_host)
|
route = match_route(config.routes, flow.request.pretty_host)
|
||||||
if route is not None:
|
if route is not None:
|
||||||
if not await self._handle_outbound_dlp(flow, route, slug, env):
|
if not await self._handle_outbound_dlp(flow, route, slug):
|
||||||
return
|
return
|
||||||
# The redact policy may have rewritten the request line; recompute
|
# The redact policy may have rewritten the request line; recompute
|
||||||
# the path/query the git checks below rely on.
|
# the path/query the git checks below rely on.
|
||||||
@@ -306,7 +299,7 @@ class EgressAddon:
|
|||||||
config.routes,
|
config.routes,
|
||||||
flow.request.pretty_host,
|
flow.request.pretty_host,
|
||||||
request_path,
|
request_path,
|
||||||
env,
|
os.environ,
|
||||||
request_method=flow.request.method,
|
request_method=flow.request.method,
|
||||||
request_headers=req_headers,
|
request_headers=req_headers,
|
||||||
)
|
)
|
||||||
@@ -332,12 +325,10 @@ class EgressAddon:
|
|||||||
flow: http.HTTPFlow,
|
flow: http.HTTPFlow,
|
||||||
route: Route,
|
route: Route,
|
||||||
slug: str,
|
slug: str,
|
||||||
env: "typing.Mapping[str, str]",
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Scan the outbound request and apply the route's on-match policy
|
"""Scan the outbound request and apply the route's on-match policy
|
||||||
(PRD 0062). `env` is the per-bottle env overlay (process env + this
|
(PRD 0062). Returns True if the request may be forwarded, False if a
|
||||||
bottle's tokens) used for DLP detection. Returns True if the request may
|
403 response has been written to `flow`.
|
||||||
be forwarded, False if a 403 response has been written to `flow`.
|
|
||||||
|
|
||||||
Loops so the supervise policy can re-scan after each approval — a
|
Loops so the supervise policy can re-scan after each approval — a
|
||||||
second, un-approved token in the same request is still caught."""
|
second, un-approved token in the same request is still caught."""
|
||||||
@@ -354,7 +345,7 @@ class EgressAddon:
|
|||||||
flow.request.pretty_host, request_path, query, headers, "",
|
flow.request.pretty_host, request_path, query, headers, "",
|
||||||
)
|
)
|
||||||
result = scan_outbound(
|
result = scan_outbound(
|
||||||
route, scan_text, env,
|
route, scan_text, os.environ,
|
||||||
safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text,
|
safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text,
|
||||||
)
|
)
|
||||||
if result is None or result.severity != "block":
|
if result is None or result.severity != "block":
|
||||||
@@ -365,7 +356,7 @@ class EgressAddon:
|
|||||||
# redact scrubs every detection (tokens and structural CRLF) and
|
# redact scrubs every detection (tokens and structural CRLF) and
|
||||||
# forwards; it fails closed only if a match survives the scrub.
|
# forwards; it fails closed only if a match survives the scrub.
|
||||||
if policy == ON_MATCH_REDACT:
|
if policy == ON_MATCH_REDACT:
|
||||||
if self._redact_outbound(flow, route, env):
|
if self._redact_outbound(flow, route):
|
||||||
if self.config.log >= LOG_BLOCKS:
|
if self.config.log >= LOG_BLOCKS:
|
||||||
sys.stderr.write(json.dumps({
|
sys.stderr.write(json.dumps({
|
||||||
"event": "egress_redacted",
|
"event": "egress_redacted",
|
||||||
@@ -393,31 +384,29 @@ class EgressAddon:
|
|||||||
if not self._supervise_available(slug):
|
if not self._supervise_available(slug):
|
||||||
self._block_dlp(flow, result)
|
self._block_dlp(flow, result)
|
||||||
return False
|
return False
|
||||||
approved = await self._supervise_token_block(flow, request_path, result, slug, env)
|
approved = await self._supervise_token_block(flow, request_path, result, slug)
|
||||||
if not approved:
|
if not approved:
|
||||||
return False # _supervise_token_block wrote the 403 response
|
return False # _supervise_token_block wrote the 403 response
|
||||||
# loop: the approved value is now in safe_tokens; re-scan.
|
# loop: the approved value is now in safe_tokens; re-scan.
|
||||||
|
|
||||||
def _redact_outbound(
|
def _redact_outbound(self, flow: http.HTTPFlow, route: Route) -> bool:
|
||||||
self, flow: http.HTTPFlow, route: Route, env: "typing.Mapping[str, str]",
|
|
||||||
) -> bool:
|
|
||||||
"""Scrub detected tokens (and CRLF injection sequences) from the mutable
|
"""Scrub detected tokens (and CRLF injection sequences) from the mutable
|
||||||
request surfaces (body, headers, path/query) and re-scan. `env` is the
|
request surfaces (body, headers, path/query) and re-scan. Returns True
|
||||||
per-bottle env overlay. Returns True if the request is now clean; False
|
if the request is now clean; False if a block-severity match remains on
|
||||||
if a block-severity match remains on a surface redaction cannot rewrite
|
a surface redaction cannot rewrite (the hostname) so the caller fails
|
||||||
(the hostname) so the caller fails closed."""
|
closed."""
|
||||||
body = flow.request.get_text(strict=False)
|
body = flow.request.get_text(strict=False)
|
||||||
if body:
|
if body:
|
||||||
redacted_body = redact_tokens(body, env=env)
|
redacted_body = redact_tokens(body, env=os.environ)
|
||||||
if redacted_body != body:
|
if redacted_body != body:
|
||||||
flow.request.text = redacted_body
|
flow.request.text = redacted_body
|
||||||
for name, value in list(flow.request.headers.items()):
|
for name, value in list(flow.request.headers.items()):
|
||||||
if name.lower() == "host":
|
if name.lower() == "host":
|
||||||
continue # routing-critical; never a legitimate token
|
continue # routing-critical; never a legitimate token
|
||||||
redacted = strip_crlf(redact_tokens(value, env=env))
|
redacted = strip_crlf(redact_tokens(value, env=os.environ))
|
||||||
if redacted != value:
|
if redacted != value:
|
||||||
flow.request.headers[name] = redacted
|
flow.request.headers[name] = redacted
|
||||||
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=env))
|
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=os.environ))
|
||||||
if redacted_path != flow.request.path:
|
if redacted_path != flow.request.path:
|
||||||
flow.request.path = redacted_path
|
flow.request.path = redacted_path
|
||||||
|
|
||||||
@@ -430,7 +419,7 @@ class EgressAddon:
|
|||||||
crlf_text = build_outbound_scan_text(
|
crlf_text = build_outbound_scan_text(
|
||||||
flow.request.pretty_host, request_path, query, headers, "",
|
flow.request.pretty_host, request_path, query, headers, "",
|
||||||
)
|
)
|
||||||
result = scan_outbound(route, scan_text, env, crlf_text=crlf_text)
|
result = scan_outbound(route, scan_text, os.environ, crlf_text=crlf_text)
|
||||||
return result is None or result.severity != "block"
|
return result is None or result.severity != "block"
|
||||||
|
|
||||||
async def _supervise_token_block(
|
async def _supervise_token_block(
|
||||||
@@ -439,22 +428,20 @@ class EgressAddon:
|
|||||||
request_path: str,
|
request_path: str,
|
||||||
result: ScanResult,
|
result: ScanResult,
|
||||||
slug: str,
|
slug: str,
|
||||||
env: "typing.Mapping[str, str]",
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Route a token DLP block to the operator's supervisor queue and wait.
|
"""Route a token DLP block to the operator's supervisor queue and wait.
|
||||||
|
|
||||||
`slug` attributes the proposal to the calling bottle (its own queue +
|
`slug` attributes the proposal to the calling bottle (its own queue +
|
||||||
safelist) — in the shared gateway this is what keeps one bottle's
|
safelist) — in the shared gateway this is what keeps one bottle's
|
||||||
approval from unblocking another's request. `env` is the per-bottle env
|
approval from unblocking another's request. Returns True if the operator
|
||||||
overlay used to redact secrets from the proposal text. Returns True if
|
approved (the matched value is added to that bottle's safelist and the
|
||||||
the operator approved (the matched value is added to that bottle's
|
caller re-scans); False if the request must be blocked (a 403 response
|
||||||
safelist and the caller re-scans); False if the request must be blocked
|
has been written to `flow`)."""
|
||||||
(a 403 response has been written to `flow`)."""
|
|
||||||
host = flow.request.pretty_host
|
host = flow.request.pretty_host
|
||||||
payload = build_token_allow_payload(
|
payload = build_token_allow_payload(
|
||||||
redact_tokens(host, env=env),
|
redact_tokens(host, env=os.environ),
|
||||||
flow.request.method,
|
flow.request.method,
|
||||||
redact_tokens(request_path, env=env),
|
redact_tokens(request_path, env=os.environ),
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
proposal = _sv.Proposal.new(
|
proposal = _sv.Proposal.new(
|
||||||
|
|||||||
@@ -450,31 +450,28 @@ def resolve_client_config(
|
|||||||
|
|
||||||
class ContextResolverLike(typing.Protocol):
|
class ContextResolverLike(typing.Protocol):
|
||||||
"""The bit of `policy_resolver.PolicyResolver` `resolve_client_context`
|
"""The bit of `policy_resolver.PolicyResolver` `resolve_client_context`
|
||||||
needs — one round-trip returning policy, bottle id, and auth tokens."""
|
needs — one round-trip returning both policy and bottle id."""
|
||||||
|
|
||||||
def resolve_policy_and_bottle_id(
|
def resolve_policy_and_bottle_id(
|
||||||
self, source_ip: str, identity_token: str = ...,
|
self, source_ip: str, identity_token: str = ...,
|
||||||
) -> "tuple[str | None, str | None, dict[str, str]]":
|
) -> "tuple[str | None, str | None]":
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
def resolve_client_context(
|
def resolve_client_context(
|
||||||
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
|
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
|
||||||
) -> "tuple[Config, str, dict[str, str]]":
|
) -> "tuple[Config, str]":
|
||||||
"""The calling client's `(Config, bottle_id, tokens)` in one round-trip —
|
"""The calling client's `(Config, bottle_id)` in one round-trip —
|
||||||
**fail-closed**. The Config follows `resolve_client_config`'s deny-all
|
**fail-closed**. The Config follows `resolve_client_config`'s deny-all
|
||||||
rules; the bottle id is `""` whenever unattributed or the orchestrator
|
rules; the bottle id is `""` whenever unattributed or the orchestrator
|
||||||
errored (caller treats as "supervise unavailable", never another bottle's
|
errored, which the caller treats as "supervise unavailable for this
|
||||||
queue); `tokens` are the per-bottle upstream auth values the addon injects.
|
bottle" (never another bottle's queue). One `/resolve` keys both the
|
||||||
One `/resolve` keys the egress policy, the supervise queue + safelist, and
|
per-request egress policy and the per-bottle supervise queue + safelist."""
|
||||||
auth injection."""
|
|
||||||
try:
|
try:
|
||||||
policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id(
|
policy, bottle_id = resolver.resolve_policy_and_bottle_id(client_ip, identity_token)
|
||||||
client_ip, identity_token,
|
|
||||||
)
|
|
||||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
return Config(routes=()), "", {} # orchestrator unreachable/errored → deny
|
return Config(routes=()), "" # orchestrator unreachable/errored → deny
|
||||||
return _config_from_policy(policy), (bottle_id or ""), tokens
|
return _config_from_policy(policy), (bottle_id or "")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,13 +51,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
# anything; 'docker' runs real containers (firecracker drops in later).
|
# anything; 'docker' runs real containers (firecracker drops in later).
|
||||||
secret = secrets.token_bytes(32)
|
secret = secrets.token_bytes(32)
|
||||||
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
|
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
|
||||||
# Standalone `--gateway` (not the consolidated flow, where the host
|
gateway: Gateway | None = DockerGateway() if args.gateway else None
|
||||||
# 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)
|
orchestrator = Orchestrator(registry, broker, secret, gateway)
|
||||||
|
|
||||||
# One persistent per-host gateway, shared by every bottle: build the
|
# One persistent per-host gateway, shared by every bottle: build the
|
||||||
|
|||||||
@@ -90,18 +90,14 @@ class OrchestratorClient:
|
|||||||
image_ref: str = "",
|
image_ref: str = "",
|
||||||
metadata: str = "",
|
metadata: str = "",
|
||||||
policy: str = "",
|
policy: str = "",
|
||||||
tokens: dict[str, str] | None = None,
|
|
||||||
) -> RegisteredBottle:
|
) -> RegisteredBottle:
|
||||||
"""Register a bottle and broker its launch (`POST /bottles`). `tokens`
|
"""Register a bottle and broker its launch (`POST /bottles`). Returns
|
||||||
are the per-bottle egress auth values (env_name -> value) the
|
its minted id + identity token."""
|
||||||
orchestrator holds in memory for the gateway to inject. Returns the
|
|
||||||
minted id + identity token."""
|
|
||||||
payload = self._ok("POST", "/bottles", {
|
payload = self._ok("POST", "/bottles", {
|
||||||
"source_ip": source_ip,
|
"source_ip": source_ip,
|
||||||
"image_ref": image_ref,
|
"image_ref": image_ref,
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"policy": policy,
|
"policy": policy,
|
||||||
"tokens": tokens or {},
|
|
||||||
})
|
})
|
||||||
bottle_id = payload.get("bottle_id")
|
bottle_id = payload.get("bottle_id")
|
||||||
token = payload.get("identity_token")
|
token = payload.get("identity_token")
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import http.server
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socketserver
|
import socketserver
|
||||||
import sys
|
|
||||||
import typing
|
import typing
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
@@ -81,16 +80,11 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
image_ref = data.get("image_ref")
|
image_ref = data.get("image_ref")
|
||||||
metadata = data.get("metadata")
|
metadata = data.get("metadata")
|
||||||
policy = data.get("policy")
|
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(
|
rec = orch.launch_bottle(
|
||||||
source_ip,
|
source_ip,
|
||||||
image_ref=image_ref if isinstance(image_ref, str) else "",
|
image_ref=image_ref if isinstance(image_ref, str) else "",
|
||||||
metadata=metadata if isinstance(metadata, str) else "",
|
metadata=metadata if isinstance(metadata, str) else "",
|
||||||
policy=policy if isinstance(policy, str) else "",
|
policy=policy if isinstance(policy, str) else "",
|
||||||
tokens=tokens,
|
|
||||||
)
|
)
|
||||||
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
||||||
|
|
||||||
@@ -142,13 +136,7 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
||||||
if rec is None:
|
if rec is None:
|
||||||
return 403, {"error": "unattributed"}
|
return 403, {"error": "unattributed"}
|
||||||
# tokens are the in-memory per-bottle egress auth values the gateway
|
return 200, {"bottle_id": rec.bottle_id, "policy": rec.policy}
|
||||||
# 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"}
|
return 404, {"error": "not found"}
|
||||||
|
|
||||||
@@ -163,20 +151,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
super().log_message(format, *args)
|
super().log_message(format, *args)
|
||||||
|
|
||||||
def _serve(self, method: str) -> None:
|
def _serve(self, method: str) -> None:
|
||||||
"""Read the request body, dispatch it, and write the JSON reply. A
|
"""Read the request body, dispatch it, and write the JSON reply."""
|
||||||
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
|
server = self.server
|
||||||
assert isinstance(server, ControlPlaneServer)
|
assert isinstance(server, ControlPlaneServer)
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
body = self.rfile.read(length) if length > 0 else b""
|
body = self.rfile.read(length) if length > 0 else b""
|
||||||
try:
|
status, payload = dispatch(server.orchestrator, method, self.path, body)
|
||||||
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()
|
data = json.dumps(payload).encode()
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -19,16 +19,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import abc
|
import abc
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..docker_cmd import run_docker
|
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_NAME = "bot-bottle-orch-gateway"
|
||||||
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
|
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
|
||||||
# The single user-defined network the gateway and every agent bottle share.
|
# The single user-defined network the gateway and every agent bottle share.
|
||||||
@@ -97,17 +91,12 @@ class DockerGateway(Gateway):
|
|||||||
*,
|
*,
|
||||||
name: str = GATEWAY_NAME,
|
name: str = GATEWAY_NAME,
|
||||||
network: str = GATEWAY_NETWORK,
|
network: str = GATEWAY_NETWORK,
|
||||||
orchestrator_url: str = "",
|
|
||||||
build_context: Path | None = None,
|
build_context: Path | None = None,
|
||||||
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.image_ref = image_ref
|
self.image_ref = image_ref
|
||||||
self.name = name
|
self.name = name
|
||||||
self.network = network
|
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._build_context = build_context or _REPO_ROOT
|
||||||
self._dockerfile = dockerfile
|
self._dockerfile = dockerfile
|
||||||
|
|
||||||
@@ -115,23 +104,17 @@ class DockerGateway(Gateway):
|
|||||||
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
|
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
|
||||||
|
|
||||||
def ensure_built(self) -> None:
|
def ensure_built(self) -> None:
|
||||||
"""Build the bundle image from its Dockerfile, **cache-aware** — cheap
|
"""Build the bundle image from its Dockerfile when it's missing.
|
||||||
(a cache check) when nothing changed, a real rebuild when the flat
|
No-op when the image is already present, or when no dockerfile is
|
||||||
sources (egress addon / git-http / policy_resolver / supervise) moved.
|
configured (e.g. a pre-pulled image)."""
|
||||||
|
if self._dockerfile is None or self.image_exists():
|
||||||
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
|
return
|
||||||
argv = ["docker", "build", "-t", self.image_ref,
|
proc = run_docker([
|
||||||
"-f", str(self._build_context / self._dockerfile),
|
"docker", "build",
|
||||||
str(self._build_context)]
|
"-t", self.image_ref,
|
||||||
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
"-f", str(self._build_context / self._dockerfile),
|
||||||
argv.insert(2, "--no-cache")
|
str(self._build_context),
|
||||||
proc = run_docker(argv)
|
])
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
|
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
|
||||||
|
|
||||||
@@ -144,18 +127,6 @@ class DockerGateway(Gateway):
|
|||||||
])
|
])
|
||||||
return self.name in proc.stdout.split()
|
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:
|
def _ensure_network(self) -> None:
|
||||||
"""Create the shared gateway network if it doesn't exist. Idempotent —
|
"""Create the shared gateway network if it doesn't exist. Idempotent —
|
||||||
a concurrent create loses harmlessly (the loser sees 'already exists').
|
a concurrent create loses harmlessly (the loser sees 'already exists').
|
||||||
@@ -169,17 +140,13 @@ class DockerGateway(Gateway):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def ensure_running(self) -> None:
|
def ensure_running(self) -> None:
|
||||||
# Recreate when the running container's image is stale (a rebuild),
|
if self.is_running():
|
||||||
# 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
|
return
|
||||||
self._ensure_network()
|
self._ensure_network()
|
||||||
# Clear any stale (stopped OR outdated-image) container holding the
|
# Clear any stale (stopped) container holding the fixed name, then
|
||||||
# fixed name, then start fresh. `rm --force` on an absent name is a
|
# start fresh. `rm --force` on an absent name is a tolerated no-op.
|
||||||
# tolerated no-op.
|
|
||||||
run_docker(["docker", "rm", "--force", self.name])
|
run_docker(["docker", "rm", "--force", self.name])
|
||||||
argv = [
|
proc = run_docker([
|
||||||
"docker", "run", "--detach",
|
"docker", "run", "--detach",
|
||||||
"--name", self.name,
|
"--name", self.name,
|
||||||
"--label", GATEWAY_LABEL,
|
"--label", GATEWAY_LABEL,
|
||||||
@@ -187,33 +154,22 @@ class DockerGateway(Gateway):
|
|||||||
# Persist the self-generated CA so it survives restarts (agents
|
# Persist the self-generated CA so it survives restarts (agents
|
||||||
# trust it) — see GATEWAY_CA_VOLUME.
|
# trust it) — see GATEWAY_CA_VOLUME.
|
||||||
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
|
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
|
||||||
]
|
self.image_ref,
|
||||||
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:
|
if proc.returncode != 0:
|
||||||
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
|
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
|
||||||
|
|
||||||
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
def ca_cert_pem(self) -> str:
|
||||||
"""The gateway's CA certificate (PEM) that agents install to trust its
|
"""The gateway's CA certificate (PEM) that agents install to trust its
|
||||||
TLS interception. mitmproxy generates it a moment after the container
|
TLS interception. Read from the running container; raises if the
|
||||||
starts, so this **polls** for it (up to `timeout`) rather than assuming
|
gateway hasn't generated it yet (it's created on first mitmproxy
|
||||||
it's already there on a fresh gateway — raising only if it never
|
start)."""
|
||||||
appears."""
|
proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT])
|
||||||
deadline = time.monotonic() + timeout
|
if proc.returncode != 0 or not proc.stdout.strip():
|
||||||
while True:
|
raise GatewayError(
|
||||||
proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT])
|
f"gateway CA cert not available: {proc.stderr.strip() or 'empty'}"
|
||||||
if proc.returncode == 0 and proc.stdout.strip():
|
)
|
||||||
return proc.stdout
|
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:
|
def stop(self) -> None:
|
||||||
proc = run_docker(["docker", "rm", "--force", self.name])
|
proc = run_docker(["docker", "rm", "--force", self.name])
|
||||||
|
|||||||
@@ -1,145 +1,91 @@
|
|||||||
"""Orchestrator + gateway lifecycle (PRD 0070, docker slice).
|
"""Orchestrator process lifecycle (PRD 0070, docker slice).
|
||||||
|
|
||||||
Runs the orchestrator control plane **as a container** on the shared gateway
|
Before the CLI can register or launch bottles against the consolidated
|
||||||
network, alongside the gateway container. This is the PRD's "virtualize the
|
model, exactly one orchestrator control plane — and the single per-host
|
||||||
orchestrator": container↔container between the gateway and the orchestrator
|
gateway it manages — must be running. This starts the orchestrator
|
||||||
avoids the host firewall (which drops container→host traffic), and the gateway
|
dev-harness (`python -m bot_bottle.orchestrator`) as a background host
|
||||||
reaches the control plane by container name over docker DNS. The host CLI
|
process and health-checks it.
|
||||||
reaches it via a published loopback port.
|
|
||||||
|
|
||||||
The orchestrator runs with the **register-only broker** — the *backend*
|
It is an **idempotent singleton**: `ensure_running` returns immediately if a
|
||||||
launches agent containers (compose), so the orchestrator needs no docker
|
healthy control plane already answers on the port, and otherwise spawns one
|
||||||
socket. That keeps this control-plane container unprivileged; the host manages
|
and waits for it to come up. The control-plane port is the singleton key —
|
||||||
both containers. `ensure_running` is an idempotent singleton (fixed container
|
a second orchestrator can't bind it, so a stray double-start fails fast
|
||||||
names + the published port).
|
rather than forking a rival.
|
||||||
|
|
||||||
|
Host-process (not container) on purpose: the PRD sequences the orchestrator
|
||||||
|
as a plain-process dev-harness first (fast iteration, and it already has the
|
||||||
|
host user's docker access to broker launches), while the data-plane
|
||||||
|
*gateway* it manages runs as a container. Wrapping the orchestrator itself
|
||||||
|
in a backend-native unit is a later step.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .. import log
|
from .. import log
|
||||||
from ..docker_cmd import run_docker
|
|
||||||
from ..paths import bot_bottle_root
|
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"
|
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8080
|
||||||
|
# Poll cadence + default ceiling while waiting for a freshly-spawned control
|
||||||
|
# plane to answer /health (the first start also builds/boots the gateway).
|
||||||
_HEALTH_POLL_SECONDS = 0.25
|
_HEALTH_POLL_SECONDS = 0.25
|
||||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
||||||
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
class OrchestratorStartError(RuntimeError):
|
class OrchestratorStartError(RuntimeError):
|
||||||
"""The orchestrator container did not become healthy within the timeout."""
|
"""The orchestrator process did not become healthy within the timeout."""
|
||||||
|
|
||||||
|
|
||||||
class OrchestratorService:
|
class OrchestratorProcess:
|
||||||
"""Manages the orchestrator control-plane container + the shared gateway.
|
"""Manages the local orchestrator control-plane process for the docker
|
||||||
Callers only need `ensure_running()` + `url`."""
|
backend. Backend-neutral callers only need `ensure_running()` + `url`."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
host: str = DEFAULT_HOST,
|
||||||
port: int = DEFAULT_PORT,
|
port: int = DEFAULT_PORT,
|
||||||
network: str = GATEWAY_NETWORK,
|
*,
|
||||||
image: str = GATEWAY_IMAGE,
|
broker: str = "docker",
|
||||||
repo_root: Path = _REPO_ROOT,
|
gateway: bool = True,
|
||||||
host_root: Path | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.network = network
|
self._broker = broker
|
||||||
self.image = image
|
self._gateway = gateway
|
||||||
self._repo_root = repo_root
|
|
||||||
self._host_root = host_root or bot_bottle_root()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
"""Host-side control-plane URL (published loopback port)."""
|
"""The control-plane base URL — also what the data plane's
|
||||||
return f"http://127.0.0.1:{self.port}"
|
BOT_BOTTLE_ORCHESTRATOR_URL points at."""
|
||||||
|
return f"http://{self.host}:{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:
|
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
|
||||||
|
"""True iff a control plane answers `GET /health` with 200 — the
|
||||||
|
singleton liveness check."""
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
|
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
|
||||||
return resp.status == 200
|
return resp.status == 200
|
||||||
except (urllib.error.URLError, TimeoutError, OSError):
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
return False
|
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(
|
def ensure_running(
|
||||||
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Ensure the control plane + shared gateway are up; return the host
|
"""Return the control-plane URL, starting the orchestrator first if it
|
||||||
control-plane URL. Idempotent — a healthy control plane and a running
|
isn't already healthy. Idempotent — a healthy control plane is left
|
||||||
gateway are left untouched. Raises `OrchestratorStartError` on
|
untouched. Raises `OrchestratorStartError` if a freshly-spawned one
|
||||||
timeout."""
|
doesn't answer within `startup_timeout`."""
|
||||||
gateway = self._gateway()
|
if self.is_healthy():
|
||||||
gateway.ensure_built() # rebuild the bundle image on a source change
|
return self.url
|
||||||
gateway.ensure_running() # creates the shared network + (re)starts gateway
|
log.info("starting orchestrator", context={"url": self.url, "broker": self._broker})
|
||||||
|
self._spawn()
|
||||||
# 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
|
deadline = time.monotonic() + startup_timeout
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
if self.is_healthy():
|
if self.is_healthy():
|
||||||
@@ -147,19 +93,49 @@ class OrchestratorService:
|
|||||||
return self.url
|
return self.url
|
||||||
time.sleep(_HEALTH_POLL_SECONDS)
|
time.sleep(_HEALTH_POLL_SECONDS)
|
||||||
raise OrchestratorStartError(
|
raise OrchestratorStartError(
|
||||||
f"orchestrator at {self.url} did not become healthy within {startup_timeout:g}s"
|
f"orchestrator at {self.url} did not become healthy within "
|
||||||
|
f"{startup_timeout:g}s"
|
||||||
)
|
)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def _argv(self) -> list[str]:
|
||||||
"""Remove the orchestrator + gateway containers (idempotent)."""
|
"""`python -m bot_bottle.orchestrator ...` — static flags only."""
|
||||||
run_docker(["docker", "rm", "--force", ORCHESTRATOR_NAME])
|
argv = [
|
||||||
self._gateway().stop()
|
sys.executable, "-m", "bot_bottle.orchestrator",
|
||||||
|
"--host", self.host, "--port", str(self.port),
|
||||||
|
"--broker", self._broker,
|
||||||
|
]
|
||||||
|
if self._gateway:
|
||||||
|
argv.append("--gateway")
|
||||||
|
return argv
|
||||||
|
|
||||||
|
def _log_path(self) -> str:
|
||||||
|
"""Where the detached orchestrator's stdout/stderr goes so a failed
|
||||||
|
start is diagnosable after the CLI has moved on."""
|
||||||
|
return str(bot_bottle_root() / "orchestrator.log")
|
||||||
|
|
||||||
|
def _spawn(self) -> None:
|
||||||
|
"""Launch the orchestrator detached so it outlives this CLI process,
|
||||||
|
with its output tee'd to a log file under the bot-bottle root."""
|
||||||
|
root = bot_bottle_root()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
logfile = open(self._log_path(), "a", encoding="utf-8") # noqa: SIM115 # pylint: disable=consider-using-with
|
||||||
|
try:
|
||||||
|
subprocess.Popen( # noqa: S603 # pylint: disable=consider-using-with
|
||||||
|
self._argv(),
|
||||||
|
stdout=logfile,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# The child inherits its own dup'd fd; this handle is ours to drop.
|
||||||
|
logfile.close()
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"OrchestratorService",
|
"OrchestratorProcess",
|
||||||
"OrchestratorStartError",
|
"OrchestratorStartError",
|
||||||
"ORCHESTRATOR_NAME",
|
"DEFAULT_HOST",
|
||||||
"DEFAULT_PORT",
|
"DEFAULT_PORT",
|
||||||
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
|
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -149,15 +149,7 @@ class RegistryStore(DbStore):
|
|||||||
policy: str = "",
|
policy: str = "",
|
||||||
) -> BottleRecord:
|
) -> BottleRecord:
|
||||||
"""Register (or replace) a bottle. Mints a bottle_id / identity token
|
"""Register (or replace) a bottle. Mints a bottle_id / identity token
|
||||||
when not supplied. Live — this is the control-plane reload path.
|
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(
|
rec = BottleRecord(
|
||||||
bottle_id=bottle_id or secrets.token_hex(8),
|
bottle_id=bottle_id or secrets.token_hex(8),
|
||||||
source_ip=source_ip,
|
source_ip=source_ip,
|
||||||
@@ -168,11 +160,6 @@ class RegistryStore(DbStore):
|
|||||||
policy=policy,
|
policy=policy,
|
||||||
)
|
)
|
||||||
with self._connect() as conn:
|
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(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO orchestrator_bottles "
|
"INSERT OR REPLACE INTO orchestrator_bottles "
|
||||||
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) "
|
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) "
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ class Orchestrator:
|
|||||||
self._broker = broker
|
self._broker = broker
|
||||||
self._secret = sign_secret
|
self._secret = sign_secret
|
||||||
self._gateway = gateway
|
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(
|
def launch_bottle(
|
||||||
self,
|
self,
|
||||||
@@ -53,14 +47,11 @@ class Orchestrator:
|
|||||||
slot: int | None = None,
|
slot: int | None = None,
|
||||||
metadata: str = "",
|
metadata: str = "",
|
||||||
policy: str = "",
|
policy: str = "",
|
||||||
tokens: dict[str, str] | None = None,
|
|
||||||
) -> BottleRecord:
|
) -> BottleRecord:
|
||||||
"""Register a bottle (with its gateway policy + in-memory egress auth
|
"""Register a bottle (with its gateway policy) and broker its launch.
|
||||||
tokens) and broker its launch. Rolls the registry entry back if the
|
Rolls the registry entry back if the launch doesn't take, so a
|
||||||
launch doesn't take, so a failure leaves no orphan."""
|
failure leaves no orphan."""
|
||||||
rec = self.registry.register(source_ip, metadata=metadata, policy=policy)
|
rec = self.registry.register(source_ip, metadata=metadata, policy=policy)
|
||||||
if tokens:
|
|
||||||
self._tokens[rec.bottle_id] = dict(tokens)
|
|
||||||
req = LaunchRequest(
|
req = LaunchRequest(
|
||||||
op="launch",
|
op="launch",
|
||||||
bottle_id=rec.bottle_id,
|
bottle_id=rec.bottle_id,
|
||||||
@@ -75,7 +66,6 @@ class Orchestrator:
|
|||||||
finally:
|
finally:
|
||||||
if not launched:
|
if not launched:
|
||||||
self.registry.deregister(rec.bottle_id)
|
self.registry.deregister(rec.bottle_id)
|
||||||
self._tokens.pop(rec.bottle_id, None)
|
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
def teardown_bottle(self, bottle_id: str) -> bool:
|
def teardown_bottle(self, bottle_id: str) -> bool:
|
||||||
@@ -86,15 +76,8 @@ class Orchestrator:
|
|||||||
req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip)
|
req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip)
|
||||||
self._broker.submit(sign_request(req, self._secret))
|
self._broker.submit(sign_request(req, self._secret))
|
||||||
self.registry.deregister(bottle_id)
|
self.registry.deregister(bottle_id)
|
||||||
self._tokens.pop(bottle_id, None)
|
|
||||||
return True
|
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:
|
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
||||||
"""Fail-closed attribution (delegates to the registry)."""
|
"""Fail-closed attribution (delegates to the registry)."""
|
||||||
return self.registry.attribute(source_ip, identity_token)
|
return self.registry.attribute(source_ip, identity_token)
|
||||||
|
|||||||
@@ -102,26 +102,21 @@ class PolicyResolver:
|
|||||||
|
|
||||||
def resolve_policy_and_bottle_id(
|
def resolve_policy_and_bottle_id(
|
||||||
self, source_ip: str, identity_token: str = "",
|
self, source_ip: str, identity_token: str = "",
|
||||||
) -> tuple[str | None, str | None, dict[str, str]]:
|
) -> tuple[str | None, str | None]:
|
||||||
"""The policy blob, bottle id, **and per-bottle egress auth tokens** in
|
"""Both the policy blob and the bottle id in a single `/resolve` — so
|
||||||
a single `/resolve` — so the egress addon gets everything it needs
|
a caller that needs each (the egress addon: policy for routing, bottle
|
||||||
(policy for routing, bottle id for the supervise queue/safelist, tokens
|
id to key the calling bottle's supervise queue + safelist) makes one
|
||||||
to inject upstream auth) in one round-trip. Returns `(None, None, {})`
|
round-trip, not two. Returns `(None, None)` when unattributed (a clean
|
||||||
when unattributed (a clean `403`). Raises `PolicyResolveError` if the
|
`403`). Raises `PolicyResolveError` if the orchestrator can't be
|
||||||
orchestrator can't be reached, so the caller still fails closed."""
|
reached, so the caller still fails closed."""
|
||||||
payload = self._post_resolve(source_ip, identity_token)
|
payload = self._post_resolve(source_ip, identity_token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return None, None, {}
|
return None, None
|
||||||
policy = payload.get("policy")
|
policy = payload.get("policy")
|
||||||
bottle_id = payload.get("bottle_id")
|
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 (
|
return (
|
||||||
policy if isinstance(policy, str) else "",
|
policy if isinstance(policy, str) else "",
|
||||||
bottle_id if isinstance(bottle_id, str) and bottle_id else None,
|
bottle_id if isinstance(bottle_id, str) and bottle_id else None,
|
||||||
tokens,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,25 +15,6 @@ the biggest credential risk).
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> **Status — implemented (2026-07-14).** This note's recommendation
|
|
||||||
> shipped, so the "today" / "in-flight" tenses below are now historical
|
|
||||||
> (kept as the reasoning that led here). Current reality: bot-bottle no
|
|
||||||
> longer injects raw provider tokens into the agent. The agent's environ
|
|
||||||
> carries only a **placeholder** (e.g.
|
|
||||||
> `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder`); the real token is held
|
|
||||||
> in the egress sidecar and injected as the upstream `Authorization`
|
|
||||||
> header. Rather than adding a *separate* in-container reverse proxy (as
|
|
||||||
> proposed below), credential injection was folded into the **existing
|
|
||||||
> pipelock/mitmproxy egress firewall**: an `EgressRoute` carries
|
|
||||||
> `auth_scheme` + `token_ref`, the host token is forwarded into the
|
|
||||||
> sidecar addon under an `EGRESS_TOKEN_N` slot, and the addon rewrites
|
|
||||||
> `Authorization` on matching routes — **one MITM, not two** (this
|
|
||||||
> resolves the Infisical-doubling concern noted later). The same
|
|
||||||
> mechanism now covers Codex and Pi provider tokens and git-host PATs.
|
|
||||||
> A `printenv` inside the bottle yields the placeholder, not the secret.
|
|
||||||
> For current wiring see `bot_bottle/egress.py` and
|
|
||||||
> `bot_bottle/contrib/*/agent_provider.py`.
|
|
||||||
|
|
||||||
Today every bot-bottle agent gets `CLAUDE_CODE_OAUTH_TOKEN` (and
|
Today every bot-bottle agent gets `CLAUDE_CODE_OAUTH_TOKEN` (and
|
||||||
any `bottle.env` secrets like a Gitea PAT) injected as env vars,
|
any `bottle.env` secrets like a Gitea PAT) injected as env vars,
|
||||||
which means the agent process can read them with `printenv` or
|
which means the agent process can read them with `printenv` or
|
||||||
@@ -55,24 +36,6 @@ small bot-bottle-specific reverse proxy modeled on the
|
|||||||
phantom-token shape is probably the right call. For Gitea / GitHub /
|
phantom-token shape is probably the right call. For Gitea / GitHub /
|
||||||
GitLab, the same proxy generalizes by config.
|
GitLab, the same proxy generalizes by config.
|
||||||
|
|
||||||
**Updated 2026-07-14:** OneCLI ([onecli.sh](https://onecli.sh/)) —
|
|
||||||
already listed below — has matured into a GA, YC-backed Rust
|
|
||||||
credential gateway ("The Identity Gateway for AI Agents", ~2.5k⭐,
|
|
||||||
300k+ downloads) that implements the **same phantom-token pattern**
|
|
||||||
this note recommends: the agent holds a placeholder token, the
|
|
||||||
gateway swaps it for the real (AES-256-GCM-encrypted) credential at
|
|
||||||
request time. It's now the most mature open-source realization of the
|
|
||||||
exact design proposed here — a production-ready alternative to
|
|
||||||
alpha-stage nono — at the cost of being a broader product (built-in
|
|
||||||
vault + management dashboard + hosted cloud tier + 50+ app
|
|
||||||
integrations) rather than a minimal proxy. Its managed/cloud tier and
|
|
||||||
per-agent dashboard also overlap bot-bottle's own planned paid control
|
|
||||||
plane (bot-bottle-console, issue #327), so it's worth tracking as both
|
|
||||||
a build-vs-adopt option *and* a product-level competitor. The
|
|
||||||
build-first recommendation still stands (see synthesis below), but
|
|
||||||
adopting OneCLI's OSS core is now a credible alternative where nono was
|
|
||||||
too green.
|
|
||||||
|
|
||||||
## The shared problem
|
## The shared problem
|
||||||
|
|
||||||
Linux has no per-env-var ACL. Once a var is in a process's
|
Linux has no per-env-var ACL. Once a var is in a process's
|
||||||
@@ -114,9 +77,7 @@ The remaining credible designs reduce to three:
|
|||||||
|
|
||||||
### Anthropic / Claude Code
|
### Anthropic / Claude Code
|
||||||
|
|
||||||
**Original wiring** (pre-gateway; **superseded 2026-07-14** — see the
|
**Today's wiring** (`bot_bottle/cli/start.py`): the host's
|
||||||
Status note in the Summary; the token now lives in the egress sidecar
|
|
||||||
and the agent gets a placeholder): the host's
|
|
||||||
`BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` is forwarded into the bottle as
|
`BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` is forwarded into the bottle as
|
||||||
`CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e CLAUDE_CODE_OAUTH_TOKEN`
|
`CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e CLAUDE_CODE_OAUTH_TOKEN`
|
||||||
(no `=value`, so the value never lands on argv — good). Inside the
|
(no `=value`, so the value never lands on argv — good). Inside the
|
||||||
@@ -262,7 +223,7 @@ Two categories:
|
|||||||
| **Infisical Agent Vault** | B | MIT (EE carve-out) | In-process HTTPS_PROXY forward proxy | TLS MITM, dummy-to-real swap | No — HTTPS_PROXY model | Service-level | Active; v0.19.0 May 2026, ~1k⭐ |
|
| **Infisical Agent Vault** | B | MIT (EE carve-out) | In-process HTTPS_PROXY forward proxy | TLS MITM, dummy-to-real swap | No — HTTPS_PROXY model | Service-level | Active; v0.19.0 May 2026, ~1k⭐ |
|
||||||
| **nono** | B | Apache-2.0 | In-process reverse proxy | Phantom token, explicit URL routing | **Yes** — `BASE_URL=http://127.0.0.1:PORT/…` | Host + endpoint | Early alpha; v0.53.0 May 2026, 2.4k⭐ |
|
| **nono** | B | Apache-2.0 | In-process reverse proxy | Phantom token, explicit URL routing | **Yes** — `BASE_URL=http://127.0.0.1:PORT/…` | Host + endpoint | Early alpha; v0.53.0 May 2026, 2.4k⭐ |
|
||||||
| **Aegis** | B | Apache-2.0 | In-process reverse proxy | Path routing (`localhost:3100/{svc}/…`) | Configurable, undocumented for Anthropic | Method/path/rate/time | Very new, 10⭐ |
|
| **Aegis** | B | Apache-2.0 | In-process reverse proxy | Path routing (`localhost:3100/{svc}/…`) | Configurable, undocumented for Anthropic | Method/path/rate/time | Very new, 10⭐ |
|
||||||
| **OneCLI** | B | Apache-2.0 | Reverse proxy + built-in vault + dashboard | **Phantom token** (placeholder→real swap at request time), AES-256-GCM vault | Yes (host match) | Per-agent + endpoint/method + rate limits | GA; Rust; YC-backed; ~2.5k⭐, 300k+ downloads (Jul 2026) |
|
| **OneCLI** | B | Apache-2.0 | Reverse proxy + management UI | Host/path matching, Bitwarden integration | Configurable | Per-agent scoping | Active; v1.23.0 May 2026, 2.1k⭐ |
|
||||||
| **Aembit** | B | Proprietary | Sidecar + cloud control plane | TLS intercept, SPIFFE, JIT creds | No — intercepts by destination | Policy-based | GA (Apr 2026) |
|
| **Aembit** | B | Proprietary | Sidecar + cloud control plane | TLS intercept, SPIFFE, JIT creds | No — intercepts by destination | Policy-based | GA (Apr 2026) |
|
||||||
| **LiteLLM Proxy** | A | MIT | Reverse proxy | Virtual key → upstream key | Yes — set base URL to LiteLLM | Route-level | 45k⭐; **CVE-2026-42208 exploited Apr 2026**, patch v1.83.7 |
|
| **LiteLLM Proxy** | A | MIT | Reverse proxy | Virtual key → upstream key | Yes — set base URL to LiteLLM | Route-level | 45k⭐; **CVE-2026-42208 exploited Apr 2026**, patch v1.83.7 |
|
||||||
| **Portkey Gateway** | A | MIT (OSS core) | Reverse proxy | Virtual key vault (cloud or Enterprise self-host) | Yes — documented for Claude Code | Config-based | Production; virtual-key vault needs Enterprise for self-host |
|
| **Portkey Gateway** | A | MIT (OSS core) | Reverse proxy | Virtual key vault (cloud or Enterprise self-host) | Yes — documented for Claude Code | Config-based | Production; virtual-key vault needs Enterprise for self-host |
|
||||||
@@ -281,18 +242,6 @@ Two categories:
|
|||||||
`ANTHROPIC_BASE_URL`. **Blocker:** nono is explicitly
|
`ANTHROPIC_BASE_URL`. **Blocker:** nono is explicitly
|
||||||
"early alpha, not security audited."
|
"early alpha, not security audited."
|
||||||
|
|
||||||
**Update (2026-07-14):** OneCLI ([onecli.sh](https://onecli.sh/))
|
|
||||||
ships the same phantom-token shape but is GA and YC-backed (Rust,
|
|
||||||
~2.5k⭐, 300k+ downloads) — the maturity nono lacks. Trade-offs: it's
|
|
||||||
a full identity-gateway *product* (encrypted vault, management
|
|
||||||
dashboard, 50+ one-click app integrations, hosted cloud tier), much
|
|
||||||
heavier than the ~100-line proxy proposed below, and its hosted tier
|
|
||||||
is a third-party credential custodian — precisely the trust
|
|
||||||
dependency bot-bottle's isolation model exists to avoid. So: its
|
|
||||||
Apache-2.0 OSS core is a credible *adopt* candidate for the
|
|
||||||
phantom-token slice; its managed offering is a *competitor*, not a
|
|
||||||
dependency to lean on.
|
|
||||||
|
|
||||||
- **TLS-MITM forward proxies** (Infisical Agent Vault, Cloudflare
|
- **TLS-MITM forward proxies** (Infisical Agent Vault, Cloudflare
|
||||||
Sandbox Auth, Aembit, the existing pipelock) all double up on
|
Sandbox Auth, Aembit, the existing pipelock) all double up on
|
||||||
the CA-trust machinery PRD 0006 already built for pipelock.
|
the CA-trust machinery PRD 0006 already built for pipelock.
|
||||||
@@ -324,15 +273,6 @@ routing matches the design recommended here exactly; zero TLS
|
|||||||
work. But "not security audited" + "early alpha" means adopting it
|
work. But "not security audited" + "early alpha" means adopting it
|
||||||
is a bet on the project rather than a buy-vs-build win.
|
is a bet on the project rather than a buy-vs-build win.
|
||||||
|
|
||||||
**Mature phantom-token option (added 2026-07-14):** OneCLI — same
|
|
||||||
architecture as nono, but GA, Rust, and YC-backed. Its Apache-2.0 OSS
|
|
||||||
core is now the strongest *adopt* candidate for the phantom-token slice
|
|
||||||
if building is undesirable; the caveats are product surface you don't
|
|
||||||
need (bundled vault + dashboard) and that its hosted tier is a
|
|
||||||
competitor rather than a dependency. Doesn't change the build-first
|
|
||||||
recommendation for the narrow Anthropic-token slice, but it does mean
|
|
||||||
"is there a mature drop-in?" now has a real answer.
|
|
||||||
|
|
||||||
**Most mature OSS purpose-built:** Infisical Agent Vault. MIT,
|
**Most mature OSS purpose-built:** Infisical Agent Vault. MIT,
|
||||||
v0.19.0 active, v0.17.0 added a containerized agent mode that
|
v0.19.0 active, v0.17.0 added a containerized agent mode that
|
||||||
maps directly to bot-bottle. Friction is the TLS-MITM topology
|
maps directly to bot-bottle. Friction is the TLS-MITM topology
|
||||||
@@ -353,12 +293,6 @@ exposed.
|
|||||||
|
|
||||||
## Recommended path forward
|
## Recommended path forward
|
||||||
|
|
||||||
> **Mostly implemented (2026-07-14).** Steps 1–3 shipped — the token
|
|
||||||
> injection moved into the egress sidecar (folded into pipelock rather
|
|
||||||
> than a standalone reverse proxy) and generalized to Codex/Pi/git-host
|
|
||||||
> tokens. Steps 4–5 (issuance-side scope narrowing, per-route allowlists)
|
|
||||||
> remain good hygiene. See the Status note in the Summary.
|
|
||||||
|
|
||||||
In priority order:
|
In priority order:
|
||||||
|
|
||||||
1. **In-container reverse proxy holding `CLAUDE_CODE_OAUTH_TOKEN`.**
|
1. **In-container reverse proxy holding `CLAUDE_CODE_OAUTH_TOKEN`.**
|
||||||
@@ -442,9 +376,6 @@ already gives us for upstream push credentials.
|
|||||||
- [nono — phantom token blog](https://nono.sh/blog/blog-credential-injection)
|
- [nono — phantom token blog](https://nono.sh/blog/blog-credential-injection)
|
||||||
- [Aegis — GitHub](https://github.com/getaegis/aegis)
|
- [Aegis — GitHub](https://github.com/getaegis/aegis)
|
||||||
- [OneCLI — GitHub](https://github.com/onecli/onecli)
|
- [OneCLI — GitHub](https://github.com/onecli/onecli)
|
||||||
- [OneCLI — homepage](https://onecli.sh/)
|
|
||||||
- [OneCLI — Y Combinator company page](https://www.ycombinator.com/companies/onecli)
|
|
||||||
- [Show HN: OneCLI – Vault for AI Agents in Rust](https://news.ycombinator.com/item?id=47353558)
|
|
||||||
- [Sandbox0 — GitHub](https://github.com/sandbox0-ai/sandbox0)
|
- [Sandbox0 — GitHub](https://github.com/sandbox0-ai/sandbox0)
|
||||||
- [Buildkite Cleanroom — GitHub](https://github.com/buildkite/cleanroom)
|
- [Buildkite Cleanroom — GitHub](https://github.com/buildkite/cleanroom)
|
||||||
- [Aembit IAM for Agentic AI — GA](https://aembit.io/blog/aembit-iam-for-agentic-ai-is-now-generally-available/)
|
- [Aembit IAM for Agentic AI — GA](https://aembit.io/blog/aembit-iam-for-agentic-ai-is-now-generally-available/)
|
||||||
|
|||||||
@@ -71,56 +71,6 @@ manifest merge.
|
|||||||
network egress is logged by pipelock/mitmproxy, and per-run op-log/audit state
|
network egress is logged by pipelock/mitmproxy, and per-run op-log/audit state
|
||||||
is persisted to SQLite.
|
is persisted to SQLite.
|
||||||
|
|
||||||
- **OneCLI** ([onecli.sh](https://onecli.sh/)) — YC-backed, GA, open-source
|
|
||||||
(Apache-2.0, Rust) "identity gateway for AI agents": a credential/secret
|
|
||||||
broker that holds API keys and OAuth tokens out of the agent's reach and
|
|
||||||
injects them at the network layer (phantom-token — the agent sees a
|
|
||||||
placeholder, the gateway swaps in the real, AES-256-GCM-encrypted credential
|
|
||||||
at request time). Framework-agnostic and drop-in for any HTTP-calling agent,
|
|
||||||
50+ app integrations, plus a hosted cloud tier with a per-agent dashboard and
|
|
||||||
audit logs. Full technical breakdown in
|
|
||||||
[`agent-credential-proxy-landscape.md`](agent-credential-proxy-landscape.md).
|
|
||||||
|
|
||||||
**How close a competitor:** near-exact on the *single axis of agent secret
|
|
||||||
custody* — the exact thing bot-bottle sells as "the agent never sees real
|
|
||||||
credentials, even via `printenv`." OneCLI does that one job well, is mature
|
|
||||||
and funded, and is *more portable* (it sits in front of anything; bot-bottle
|
|
||||||
only helps agents launched through bot-bottle). Takeaway: bot-bottle should
|
|
||||||
stop treating secret custody as a *unique* differentiator. But OneCLI is
|
|
||||||
**not** a competitor to bot-bottle's actual product — it does no agent
|
|
||||||
sandboxing (containers/microVMs), no fleet/manifest layer, no named agents /
|
|
||||||
skills / per-agent system prompts, no multi-provider launching, no egress
|
|
||||||
firewall.
|
|
||||||
|
|
||||||
**Our edge:** (1) *Isolation is the product, not a proxy.* OneCLI keeps the
|
|
||||||
key out of reach at the network layer, but the agent itself still runs
|
|
||||||
unsandboxed — a hijacked agent behind OneCLI has full run of its host and can
|
|
||||||
exfil captured data through any allowed host. bot-bottle runs the agent inside
|
|
||||||
a kernel/VM-enforced sandbox, injects credentials across that same
|
|
||||||
out-of-process boundary, *and* clamps egress with pipelock — defense in depth
|
|
||||||
vs. a single network layer. (2) *Fleet + manifest model* with named agents,
|
|
||||||
skills, per-agent system prompts, multi-provider and multi-backend — OneCLI
|
|
||||||
has no equivalent. (3) *Trust posture:* OneCLI's managed tier reintroduces a
|
|
||||||
third-party credential custodian, whereas bot-bottle's OSS-runtime +
|
|
||||||
paid-control-plane split keeps custody inside the operator's own boundary —
|
|
||||||
the stronger story for the security-minded self-hoster. (4) *Runs inside your
|
|
||||||
network boundary — local/internal reach.* Because bot-bottle executes the
|
|
||||||
agent on your own host (homelab, corporate LAN, a Tailnet) and egress is a
|
|
||||||
manifest field, giving an agent *scoped* access to **internal** resources — a
|
|
||||||
private Gitea, a LAN database, a Tailscale node — is just another egress-route
|
|
||||||
line, not a networking project (the same move an operator already makes to
|
|
||||||
reach their Tailscale services). OneCLI's OSS core can self-host too, but it's
|
|
||||||
a credential *broker* for outbound API calls, not an agent runtime, and its
|
|
||||||
managed tier + 50+ integrations are oriented at public SaaS — it doesn't put
|
|
||||||
the agent behind your firewall for you. This is a reach advantage, distinct
|
|
||||||
from the isolation ones above, and it's a wedge cloud-first agent products
|
|
||||||
(Devin, Copilot Workspace, OneCLI Cloud) structurally can't match. **Tactical
|
|
||||||
read:**
|
|
||||||
adopt OneCLI's OSS core for the credential slice if building is undesirable
|
|
||||||
(it's mature now); don't build atop its managed tier (competitor, not
|
|
||||||
dependency); re-position bot-bottle on isolation + fleet + self-hosted custody
|
|
||||||
rather than "we hide your secrets."
|
|
||||||
|
|
||||||
## What no found project does
|
## What no found project does
|
||||||
|
|
||||||
None combine:
|
None combine:
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
"""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://<host>/ through the gateway proxy from a container
|
|
||||||
# pinned to the bottle's source IP, printing "<status> <body>". 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()
|
|
||||||
@@ -40,7 +40,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(backend_mod, "_BACKENDS", {
|
||||||
"macos-container": _FakeBackend(),
|
"macos-container": _FakeBackend(),
|
||||||
"docker": _FakeBackend(),
|
"docker": _FakeBackend(),
|
||||||
}):
|
}):
|
||||||
@@ -61,7 +61,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: False)), \
|
"is_host_capable", classmethod(lambda cls: False)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(backend_mod, "_BACKENDS", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"docker": _FakeBackend("docker", True),
|
"docker": _FakeBackend("docker", True),
|
||||||
}):
|
}):
|
||||||
@@ -83,7 +83,7 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=True), \
|
with patch.dict(os.environ, {}, clear=True), \
|
||||||
patch.object(backend_mod.FirecrackerBottleBackend,
|
patch.object(backend_mod.FirecrackerBottleBackend,
|
||||||
"is_host_capable", classmethod(lambda cls: True)), \
|
"is_host_capable", classmethod(lambda cls: True)), \
|
||||||
patch.object(backend_mod, "_backends", {
|
patch.object(backend_mod, "_BACKENDS", {
|
||||||
"macos-container": _FakeBackend("macos-container", False),
|
"macos-container": _FakeBackend("macos-container", False),
|
||||||
"firecracker": _FakeBackend("firecracker", False),
|
"firecracker": _FakeBackend("firecracker", False),
|
||||||
"docker": _FakeBackend("docker", True),
|
"docker": _FakeBackend("docker", True),
|
||||||
@@ -133,7 +133,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
backend_mod, "_BACKENDS",
|
||||||
{"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])},
|
{"docker": _FakeBackend([a]), "firecracker": _FakeBackend([b])},
|
||||||
):
|
):
|
||||||
self.assertEqual([a, b], enumerate_active_agents())
|
self.assertEqual([a, b], enumerate_active_agents())
|
||||||
@@ -167,7 +167,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
backend_mod, "_BACKENDS",
|
||||||
{
|
{
|
||||||
"docker": _FakeBackend([newer, tie_b]),
|
"docker": _FakeBackend([newer, tie_b]),
|
||||||
"firecracker": _FakeBackend([missing_metadata, tie_a]),
|
"firecracker": _FakeBackend([missing_metadata, tie_a]),
|
||||||
@@ -187,7 +187,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
backend_mod, "_BACKENDS",
|
||||||
{"docker": _FakeBackend(), "firecracker": _FakeBackend()},
|
{"docker": _FakeBackend(), "firecracker": _FakeBackend()},
|
||||||
):
|
):
|
||||||
self.assertEqual([], enumerate_active_agents())
|
self.assertEqual([], enumerate_active_agents())
|
||||||
@@ -218,7 +218,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
|
|||||||
return self._items
|
return self._items
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends",
|
backend_mod, "_BACKENDS",
|
||||||
{
|
{
|
||||||
"docker": _FakeBackend([present], available=True),
|
"docker": _FakeBackend([present], available=True),
|
||||||
"firecracker": _FakeBackend([hidden], available=False),
|
"firecracker": _FakeBackend([hidden], available=False),
|
||||||
@@ -234,7 +234,7 @@ class TestHasBackend(unittest.TestCase):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
backend_mod, "_backends", {"docker": _FakeBackend()},
|
backend_mod, "_BACKENDS", {"docker": _FakeBackend()},
|
||||||
):
|
):
|
||||||
from bot_bottle.backend import has_backend
|
from bot_bottle.backend import has_backend
|
||||||
self.assertFalse(has_backend("docker"))
|
self.assertFalse(has_backend("docker"))
|
||||||
|
|||||||
@@ -39,18 +39,14 @@ def _client(*, bottles: list[dict[str, object]] | None = None) -> Mock:
|
|||||||
|
|
||||||
|
|
||||||
class TestLaunchConsolidated(unittest.TestCase):
|
class TestLaunchConsolidated(unittest.TestCase):
|
||||||
def _run(
|
def _run(self, client: Mock, provision: Mock | None = None):
|
||||||
self, client: Mock, provision: Mock | None = None,
|
process = MagicMock()
|
||||||
*, on_network: tuple[str, ...] = ("172.18.0.2",),
|
process.ensure_running.return_value = "http://orch:8080"
|
||||||
):
|
|
||||||
service = MagicMock()
|
|
||||||
service.ensure_running.return_value = "http://orch:8080"
|
|
||||||
with patch(f"{_MOD}._network_cidr", return_value="172.18.0.0/16"), \
|
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}._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}.OrchestratorClient", return_value=client), \
|
||||||
patch(f"{_MOD}.provision_git_gate", provision or Mock()):
|
patch(f"{_MOD}.provision_git_gate", provision or Mock()):
|
||||||
return launch_consolidated(_egress_plan(), _git_plan(), service=service)
|
return launch_consolidated(_egress_plan(), _git_plan(), process=process)
|
||||||
|
|
||||||
def test_allocates_ip_registers_and_provisions(self) -> None:
|
def test_allocates_ip_registers_and_provisions(self) -> None:
|
||||||
client = _client()
|
client = _client()
|
||||||
@@ -68,10 +64,10 @@ class TestLaunchConsolidated(unittest.TestCase):
|
|||||||
self.assertIn("api.example.com", kwargs.kwargs["policy"])
|
self.assertIn("api.example.com", kwargs.kwargs["policy"])
|
||||||
provision.assert_called_once()
|
provision.assert_called_once()
|
||||||
|
|
||||||
def test_skips_all_addresses_on_the_network(self) -> None:
|
def test_skips_gateway_and_live_bottle_addresses(self) -> None:
|
||||||
# Gateway .2 + orchestrator .3 already attached -> agent gets .4.
|
client = _client(bottles=[{"source_ip": "172.18.0.3"}])
|
||||||
ctx = self._run(_client(), on_network=("172.18.0.2", "172.18.0.3"))
|
ctx = self._run(client)
|
||||||
self.assertEqual("172.18.0.4", ctx.source_ip)
|
self.assertEqual("172.18.0.4", ctx.source_ip) # .2 gw, .3 taken → .4
|
||||||
|
|
||||||
def test_provision_failure_rolls_back_registration(self) -> None:
|
def test_provision_failure_rolls_back_registration(self) -> None:
|
||||||
client = _client()
|
client = _client()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit: Docker launch step uses committed image when available (consolidated)."""
|
"""Unit: Docker launch step uses committed image when available."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,7 +6,6 @@ import contextlib
|
|||||||
import io
|
import io
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Callable, Generator
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -15,11 +14,9 @@ from bot_bottle.agent_provider import AgentProvisionPlan
|
|||||||
from bot_bottle.backend import BottleSpec
|
from bot_bottle.backend import BottleSpec
|
||||||
from bot_bottle.backend.docker import launch as launch_mod
|
from bot_bottle.backend.docker import launch as launch_mod
|
||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
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.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from tests.unit import use_bottle_root
|
|
||||||
|
|
||||||
|
|
||||||
_SLUG = "dev-abc12"
|
_SLUG = "dev-abc12"
|
||||||
@@ -31,111 +28,163 @@ _IDX = ManifestIndex.from_json_obj({
|
|||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"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:
|
def _plan(tmp: str) -> DockerBottlePlan:
|
||||||
stage = Path(tmp)
|
stage = Path(tmp)
|
||||||
spec = BottleSpec(
|
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(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
manifest=_IDX.load_for_agent("demo"),
|
manifest=_IDX.load_for_agent("demo"),
|
||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=GitGatePlan(
|
git_gate_plan=GitGatePlan(
|
||||||
slug=_SLUG, entrypoint_script=stage / "e.sh", hook_script=stage / "h.sh",
|
slug=_SLUG,
|
||||||
access_hook_script=stage / "a.sh", upstreams=(),
|
entrypoint_script=stage / "entrypoint.sh",
|
||||||
|
hook_script=stage / "hook.sh",
|
||||||
|
access_hook_script=stage / "access-hook.sh",
|
||||||
|
upstreams=(),
|
||||||
),
|
),
|
||||||
egress_plan=EgressPlan(
|
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,
|
supervise_plan=None,
|
||||||
agent_provision=AgentProvisionPlan(
|
agent_provision=AgentProvisionPlan(
|
||||||
template="claude", command="claude", prompt_mode="append_file",
|
template="claude",
|
||||||
image=_DEFAULT_IMAGE, dockerfile="", guest_home="/home/node",
|
command="claude",
|
||||||
instance_name=f"bot-bottle-{_SLUG}", prompt_file=stage / "prompt.txt",
|
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={},
|
guest_env={},
|
||||||
),
|
),
|
||||||
slug=_SLUG, forwarded_env={}, use_runsc=False,
|
slug=_SLUG,
|
||||||
|
forwarded_env={},
|
||||||
|
use_runsc=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestLaunchCommittedImage(unittest.TestCase):
|
class TestLaunchCommittedImage(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
|
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)))
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
def tearDown(self) -> None:
|
||||||
def _patched(
|
import shutil
|
||||||
|
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def _run_launch(
|
||||||
self,
|
self,
|
||||||
|
plan: DockerBottlePlan,
|
||||||
*,
|
*,
|
||||||
committed_tag: str | None,
|
committed_tag: str | None = None,
|
||||||
image_present: bool,
|
image_present: bool = True,
|
||||||
compose: Callable[..., dict[str, Any]],
|
) -> list[str]:
|
||||||
) -> Generator[list[str], None, None]:
|
"""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)."""
|
||||||
built: list[str] = []
|
built: list[str] = []
|
||||||
gw = mock.Mock()
|
|
||||||
gw.ca_cert_pem.return_value = "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n"
|
|
||||||
|
|
||||||
def _build(image: str, ctx: str, *, dockerfile: str = "") -> None:
|
def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None:
|
||||||
del ctx, dockerfile
|
del ctx, dockerfile
|
||||||
built.append(image)
|
built.append(image)
|
||||||
|
|
||||||
with mock.patch.object(launch_mod, "read_committed_image", return_value=committed_tag), \
|
with mock.patch.object(
|
||||||
mock.patch.object(launch_mod.docker_mod, "image_exists", return_value=image_present), \
|
launch_mod, "read_committed_image", return_value=committed_tag,
|
||||||
mock.patch.object(launch_mod.docker_mod, "build_image", side_effect=_build), \
|
), mock.patch.object(
|
||||||
mock.patch.object(launch_mod, "launch_consolidated", return_value=_CTX), \
|
launch_mod.docker_mod, "image_exists", return_value=image_present,
|
||||||
mock.patch.object(launch_mod, "teardown_consolidated"), \
|
), mock.patch.object(
|
||||||
mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \
|
launch_mod.docker_mod, "build_image", side_effect=fake_build,
|
||||||
mock.patch.object(launch_mod, "consolidated_agent_compose", side_effect=compose), \
|
), mock.patch.object(
|
||||||
mock.patch.object(launch_mod, "write_compose_file", return_value=Path("/tmp/c.yml")), \
|
launch_mod, "egress_tls_init",
|
||||||
mock.patch.object(launch_mod, "compose_up"), \
|
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
||||||
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
), mock.patch.object(
|
||||||
mock.patch.object(launch_mod, "compose_down"), \
|
launch_mod.network_mod, "network_name_for_slug",
|
||||||
contextlib.redirect_stderr(io.StringIO()):
|
return_value="bb-internal",
|
||||||
yield built
|
), mock.patch.object(
|
||||||
|
launch_mod.network_mod, "network_egress_name_for_slug",
|
||||||
def _run_launch(
|
return_value="bb-egress",
|
||||||
self, plan: DockerBottlePlan, *,
|
), mock.patch.object(
|
||||||
committed_tag: str | None = None, image_present: bool = True,
|
launch_mod, "bottle_plan_to_compose",
|
||||||
) -> list[str]:
|
return_value={"services": {"agent": {}}},
|
||||||
def compose(_p: DockerBottlePlan, **_kw: Any) -> dict[str, Any]:
|
), mock.patch.object(
|
||||||
return {"services": {"agent": {}}}
|
launch_mod, "write_compose_file",
|
||||||
with self._patched(
|
return_value=Path("/tmp/compose.yml"),
|
||||||
committed_tag=committed_tag, image_present=image_present, compose=compose,
|
), mock.patch.object(launch_mod, "compose_up"), \
|
||||||
) as built:
|
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
||||||
with launch_mod.launch(plan, provision=mock.Mock(return_value=None)):
|
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
|
pass
|
||||||
|
|
||||||
return built
|
return built
|
||||||
|
|
||||||
def test_skips_build_when_committed_image_present(self) -> None:
|
def test_skips_build_when_committed_image_present(self) -> None:
|
||||||
built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=True)
|
plan = _plan(self._tmp)
|
||||||
self.assertEqual([], built) # committed image reused, no build
|
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")
|
||||||
|
|
||||||
def test_uses_committed_image_in_compose_spec(self) -> None:
|
def test_uses_committed_image_in_compose_spec(self) -> None:
|
||||||
captured: list[DockerBottlePlan] = []
|
"""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] = []
|
||||||
|
|
||||||
def compose(p: DockerBottlePlan, **_kw: Any) -> dict[str, Any]:
|
def fake_compose(p: DockerBottlePlan) -> dict[str, Any]:
|
||||||
captured.append(p)
|
captured_plans.append(p)
|
||||||
return {"services": {"agent": {}}}
|
return {"services": {"agent": {}}}
|
||||||
|
|
||||||
with self._patched(committed_tag=_COMMITTED_TAG, image_present=True, compose=compose):
|
with mock.patch.object(
|
||||||
with launch_mod.launch(_plan(self._tmp), provision=mock.Mock(return_value=None)):
|
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):
|
||||||
pass
|
pass
|
||||||
self.assertEqual(_COMMITTED_TAG, captured[0].image)
|
|
||||||
|
self.assertEqual(1, len(captured_plans))
|
||||||
|
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
|
||||||
|
|
||||||
def test_falls_back_to_build_when_no_committed_image(self) -> None:
|
def test_falls_back_to_build_when_no_committed_image(self) -> None:
|
||||||
self.assertEqual([_DEFAULT_IMAGE], self._run_launch(_plan(self._tmp), committed_tag=None))
|
plan = _plan(self._tmp)
|
||||||
|
built = self._run_launch(plan, committed_tag=None)
|
||||||
|
self.assertEqual([_DEFAULT_IMAGE], built)
|
||||||
|
|
||||||
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
|
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
|
||||||
built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=False)
|
plan = _plan(self._tmp)
|
||||||
|
built = self._run_launch(
|
||||||
|
plan, committed_tag=_COMMITTED_TAG, image_present=False,
|
||||||
|
)
|
||||||
self.assertEqual([_DEFAULT_IMAGE], built)
|
self.assertEqual([_DEFAULT_IMAGE], built)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,9 @@ from bot_bottle.agent_provider import AgentProvisionPlan
|
|||||||
from bot_bottle.backend import BottleSpec
|
from bot_bottle.backend import BottleSpec
|
||||||
from bot_bottle.backend.docker import launch as launch_mod
|
from bot_bottle.backend.docker import launch as launch_mod
|
||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
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.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from tests.unit import use_bottle_root
|
|
||||||
|
|
||||||
_INDEX = ManifestIndex.from_json_obj({
|
_INDEX = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
@@ -79,36 +77,41 @@ def _plan(tmp: str) -> DockerBottlePlan:
|
|||||||
class TestTeardownWarning(unittest.TestCase):
|
class TestTeardownWarning(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._tmp = tempfile.mkdtemp(prefix="docker-launch-teardown-test.")
|
self._tmp = tempfile.mkdtemp(prefix="docker-launch-teardown-test.")
|
||||||
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 tearDown(self) -> None:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||||
|
|
||||||
def test_teardown_failure_emits_warning_with_container_and_operation(self):
|
def test_teardown_failure_emits_warning_with_container_and_operation(self):
|
||||||
plan = _plan(self._tmp)
|
plan = _plan(self._tmp)
|
||||||
buf = io.StringIO()
|
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"), \
|
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(
|
mock.patch.object(
|
||||||
launch_mod, "consolidated_agent_compose",
|
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",
|
||||||
return_value={"services": {"agent": {}}},
|
return_value={"services": {"agent": {}}},
|
||||||
), \
|
), \
|
||||||
mock.patch.object(
|
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_up"), \
|
||||||
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
launch_mod, "compose_down",
|
launch_mod, "compose_down",
|
||||||
side_effect=RuntimeError("compose down failed"),
|
side_effect=RuntimeError("network remove failed"),
|
||||||
), \
|
), \
|
||||||
contextlib.redirect_stderr(buf):
|
contextlib.redirect_stderr(buf):
|
||||||
provision = mock.Mock(return_value=None)
|
provision = mock.Mock(return_value=None)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
|
|||||||
class TestCommitContainer(unittest.TestCase):
|
class TestCommitContainer(unittest.TestCase):
|
||||||
def test_runs_docker_commit(self):
|
def test_runs_docker_commit(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
docker_mod, "run_docker", return_value=_ok(),
|
docker_mod.subprocess, "run", return_value=_ok(),
|
||||||
) as run, patch.object(docker_mod, "info"):
|
) as run, patch.object(docker_mod, "info"):
|
||||||
docker_mod.commit_container(
|
docker_mod.commit_container(
|
||||||
"bot-bottle-dev-abc12",
|
"bot-bottle-dev-abc12",
|
||||||
@@ -47,7 +47,7 @@ class TestCommitContainer(unittest.TestCase):
|
|||||||
|
|
||||||
def test_dies_on_docker_commit_failure(self):
|
def test_dies_on_docker_commit_failure(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
docker_mod, "run_docker", return_value=_fail("No such container"),
|
docker_mod.subprocess, "run", return_value=_fail("No such container"),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
docker_mod, "die", side_effect=SystemExit("die"),
|
docker_mod, "die", side_effect=SystemExit("die"),
|
||||||
) as die:
|
) as die:
|
||||||
@@ -58,7 +58,7 @@ class TestCommitContainer(unittest.TestCase):
|
|||||||
|
|
||||||
def test_die_message_includes_image_tag(self):
|
def test_die_message_includes_image_tag(self):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
docker_mod, "run_docker", return_value=_fail("boom"),
|
docker_mod.subprocess, "run", return_value=_fail("boom"),
|
||||||
), patch.object(
|
), patch.object(
|
||||||
docker_mod, "die", side_effect=SystemExit("die"),
|
docker_mod, "die", side_effect=SystemExit("die"),
|
||||||
) as die:
|
) as die:
|
||||||
|
|||||||
@@ -233,19 +233,16 @@ class _CtxResolver:
|
|||||||
"""Fake orchestrator resolver: maps source IP -> bottle id, and grants the
|
"""Fake orchestrator resolver: maps source IP -> bottle id, and grants the
|
||||||
same allow-list to any attributed bottle (unattributed -> deny)."""
|
same allow-list to any attributed bottle (unattributed -> deny)."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, ip_to_bottle: dict[str, str]) -> None:
|
||||||
self, ip_to_bottle: dict[str, str], tokens: dict[str, str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._map = ip_to_bottle
|
self._map = ip_to_bottle
|
||||||
self._tokens = tokens or {}
|
|
||||||
|
|
||||||
def resolve_policy_and_bottle_id(
|
def resolve_policy_and_bottle_id(
|
||||||
self, source_ip: str, identity_token: str = "",
|
self, source_ip: str, identity_token: str = "",
|
||||||
) -> tuple[str | None, str | None, dict[str, str]]:
|
) -> tuple[str | None, str | None]:
|
||||||
del identity_token
|
del identity_token
|
||||||
bottle_id = self._map.get(source_ip)
|
bottle_id = self._map.get(source_ip)
|
||||||
policy = "routes:\n - host: api.example.com\n" if bottle_id else None
|
policy = "routes:\n - host: api.example.com\n" if bottle_id else None
|
||||||
return policy, bottle_id, (self._tokens if bottle_id else {})
|
return policy, bottle_id
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -806,46 +803,6 @@ class TestSuperviseMultiTenant(unittest.TestCase):
|
|||||||
_run_request(addon, flow)
|
_run_request(addon, flow)
|
||||||
self.assertEqual(["bottle-b"], seen) # proposal keyed by the resolved bottle
|
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:
|
def test_unattributed_source_ip_cannot_supervise(self) -> None:
|
||||||
addon = self._consolidated_addon()
|
addon = self._consolidated_addon()
|
||||||
# 10.9.9.9 is not in the resolver map -> deny-all config, empty slug.
|
# 10.9.9.9 is not in the resolver map -> deny-all config, empty slug.
|
||||||
|
|||||||
@@ -51,55 +51,45 @@ class TestResolveClientConfig(unittest.TestCase):
|
|||||||
|
|
||||||
class _FakeContextResolver:
|
class _FakeContextResolver:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, policy: str | None = None, bottle_id: str | None = None,
|
self, policy: str | None = None, bottle_id: str | None = None, raises: bool = False,
|
||||||
raises: bool = False, tokens: dict[str, str] | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._policy = policy
|
self._policy = policy
|
||||||
self._bottle_id = bottle_id
|
self._bottle_id = bottle_id
|
||||||
self._raises = raises
|
self._raises = raises
|
||||||
self._tokens = tokens or {}
|
|
||||||
|
|
||||||
def resolve_policy_and_bottle_id(
|
def resolve_policy_and_bottle_id(
|
||||||
self, source_ip: str, identity_token: str = "",
|
self, source_ip: str, identity_token: str = "",
|
||||||
) -> tuple[str | None, str | None, dict[str, str]]:
|
) -> tuple[str | None, str | None]:
|
||||||
if self._raises:
|
if self._raises:
|
||||||
raise PolicyResolveError("orchestrator down")
|
raise PolicyResolveError("orchestrator down")
|
||||||
return self._policy, self._bottle_id, self._tokens
|
return self._policy, self._bottle_id
|
||||||
|
|
||||||
|
|
||||||
class TestResolveClientContext(unittest.TestCase):
|
class TestResolveClientContext(unittest.TestCase):
|
||||||
def test_returns_config_bottle_id_and_tokens(self) -> None:
|
def test_returns_config_and_bottle_id(self) -> None:
|
||||||
cfg, slug, tokens = resolve_client_context(
|
cfg, slug = resolve_client_context(
|
||||||
_FakeContextResolver(
|
_FakeContextResolver(policy="routes:\n - host: example.com\n", bottle_id="b1"),
|
||||||
policy="routes:\n - host: example.com\n", bottle_id="b1",
|
|
||||||
tokens={"EGRESS_TOKEN_0": "sekret"},
|
|
||||||
),
|
|
||||||
"10.243.0.1",
|
"10.243.0.1",
|
||||||
)
|
)
|
||||||
self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes))
|
self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes))
|
||||||
self.assertEqual("b1", slug)
|
self.assertEqual("b1", slug)
|
||||||
self.assertEqual({"EGRESS_TOKEN_0": "sekret"}, tokens) # for auth injection
|
|
||||||
|
|
||||||
def test_unattributed_denies_and_empty_slug(self) -> None:
|
def test_unattributed_denies_and_empty_slug(self) -> None:
|
||||||
cfg, slug, tokens = resolve_client_context(
|
cfg, slug = resolve_client_context(
|
||||||
_FakeContextResolver(policy=None, bottle_id=None), "10.243.0.9",
|
_FakeContextResolver(policy=None, bottle_id=None), "10.243.0.9",
|
||||||
)
|
)
|
||||||
self.assertEqual((), cfg.routes)
|
self.assertEqual((), cfg.routes)
|
||||||
self.assertEqual("", slug) # no bottle → supervise unavailable
|
self.assertEqual("", slug) # no bottle → supervise unavailable
|
||||||
self.assertEqual({}, tokens)
|
|
||||||
|
|
||||||
def test_resolver_error_denies_empty_slug_no_tokens(self) -> None:
|
def test_resolver_error_denies_and_empty_slug(self) -> None:
|
||||||
cfg, slug, tokens = resolve_client_context(
|
cfg, slug = resolve_client_context(_FakeContextResolver(raises=True), "10.243.0.1")
|
||||||
_FakeContextResolver(raises=True), "10.243.0.1",
|
|
||||||
)
|
|
||||||
self.assertEqual((), cfg.routes)
|
self.assertEqual((), cfg.routes)
|
||||||
self.assertEqual("", slug)
|
self.assertEqual("", slug)
|
||||||
self.assertEqual({}, tokens)
|
|
||||||
|
|
||||||
def test_unparseable_policy_denies_but_keeps_slug(self) -> None:
|
def test_unparseable_policy_denies_but_keeps_slug(self) -> None:
|
||||||
# A bad policy denies egress, but the bottle is still attributed (its
|
# A bad policy denies egress, but the bottle is still attributed (its
|
||||||
# supervise queue is keyed by the id, independent of route parsing).
|
# supervise queue is keyed by the id, independent of route parsing).
|
||||||
cfg, slug, _tokens = resolve_client_context(
|
cfg, slug = resolve_client_context(
|
||||||
_FakeContextResolver(policy="routes: notalist\n", bottle_id="b2"), "10.243.0.1",
|
_FakeContextResolver(policy="routes: notalist\n", bottle_id="b2"), "10.243.0.1",
|
||||||
)
|
)
|
||||||
self.assertEqual((), cfg.routes)
|
self.assertEqual((), cfg.routes)
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ def _recorder(calls: list[list[str]]):
|
|||||||
def _plan(*upstreams: GitGateUpstream) -> GitGatePlan:
|
def _plan(*upstreams: GitGateUpstream) -> GitGatePlan:
|
||||||
return GitGatePlan(
|
return GitGatePlan(
|
||||||
slug="demo",
|
slug="demo",
|
||||||
entrypoint_script=Path("/stage/entrypoint.sh"),
|
entrypoint_script=Path(),
|
||||||
hook_script=Path("/stage/pre-receive"),
|
hook_script=Path(),
|
||||||
access_hook_script=Path("/stage/access-hook"),
|
access_hook_script=Path(),
|
||||||
upstreams=tuple(upstreams),
|
upstreams=tuple(upstreams),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,8 +61,6 @@ class TestProvisionGitGate(unittest.TestCase):
|
|||||||
self.assertIn(
|
self.assertIn(
|
||||||
["docker", "cp", "/host/kh", "gw:/git-gate/creds/bottle1/foo-known_hosts"], cps,
|
["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.
|
# 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"]
|
exec_scripts = [c for c in calls if c[:3] == ["docker", "exec", "gw"] and c[3] == "sh"]
|
||||||
self.assertEqual(1, len(exec_scripts))
|
self.assertEqual(1, len(exec_scripts))
|
||||||
@@ -72,9 +70,9 @@ class TestProvisionGitGate(unittest.TestCase):
|
|||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
with patch(_RUN, side_effect=_recorder(calls)):
|
with patch(_RUN, side_effect=_recorder(calls)):
|
||||||
provision_git_gate("gw", "b1", _plan(_up("foo"))) # no known_hosts
|
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]]
|
cps = [c for c in calls if c[:2] == ["docker", "cp"]]
|
||||||
self.assertEqual(1, len(creds_cps)) # only the key, not known_hosts
|
self.assertEqual(1, len(cps)) # only the key, not known_hosts
|
||||||
self.assertTrue(creds_cps[0][3].endswith("/foo-key"))
|
self.assertTrue(cps[0][3].endswith("/foo-key"))
|
||||||
|
|
||||||
def test_no_upstreams_is_noop(self) -> None:
|
def test_no_upstreams_is_noop(self) -> None:
|
||||||
with patch(_RUN) as m:
|
with patch(_RUN) as m:
|
||||||
|
|||||||
@@ -35,43 +35,11 @@ class TestDockerGateway(unittest.TestCase):
|
|||||||
with patch(_RUN_DOCKER, return_value=_proc(stdout="")):
|
with patch(_RUN_DOCKER, return_value=_proc(stdout="")):
|
||||||
self.assertFalse(self.sc.is_running())
|
self.assertFalse(self.sc.is_running())
|
||||||
|
|
||||||
def test_ensure_running_noop_when_up_and_image_current(self) -> None:
|
def test_ensure_running_is_noop_when_already_up(self) -> None:
|
||||||
calls: list[list[str]] = []
|
with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name)) as m:
|
||||||
|
|
||||||
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.sc.ensure_running()
|
||||||
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "run"]])
|
# Only the is_running() ps probe — no rm / run.
|
||||||
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "rm"]])
|
self.assertEqual(1, m.call_count)
|
||||||
|
|
||||||
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:
|
def test_ensure_running_starts_the_singleton_when_absent(self) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
@@ -113,17 +81,9 @@ class TestDockerGateway(unittest.TestCase):
|
|||||||
self.assertEqual(["docker", "exec", self.sc.name, "cat", GATEWAY_CA_CERT], argv)
|
self.assertEqual(["docker", "exec", self.sc.name, "cat", GATEWAY_CA_CERT], argv)
|
||||||
|
|
||||||
def test_ca_cert_pem_raises_when_absent(self) -> None:
|
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 patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="No such file")):
|
||||||
with self.assertRaises(GatewayError):
|
with self.assertRaises(GatewayError):
|
||||||
self.sc.ca_cert_pem(timeout=0)
|
self.sc.ca_cert_pem()
|
||||||
|
|
||||||
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:
|
def test_ensure_running_reuses_existing_network(self) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
@@ -169,35 +129,28 @@ class TestDockerGatewayBuild(unittest.TestCase):
|
|||||||
with patch(_RUN_DOCKER, return_value=_proc(returncode=1)):
|
with patch(_RUN_DOCKER, return_value=_proc(returncode=1)):
|
||||||
self.assertFalse(self.sc.image_exists())
|
self.assertFalse(self.sc.image_exists())
|
||||||
|
|
||||||
def test_ensure_built_builds_even_when_image_present(self) -> None:
|
def test_ensure_built_is_noop_when_image_present(self) -> None:
|
||||||
# Always build (cache-aware) so a flat-source change rebuilds; the old
|
with patch(_RUN_DOCKER, return_value=_proc(returncode=0)) as m:
|
||||||
# build-if-missing silently ran a stale single-tenant image.
|
self.sc.ensure_built()
|
||||||
|
self.assertEqual(1, m.call_count) # only the image-inspect probe
|
||||||
|
|
||||||
|
def test_ensure_built_builds_from_dockerfile_when_missing(self) -> None:
|
||||||
calls: list[list[str]] = []
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
def rec(argv: list[str]) -> Mock:
|
def fake(argv: list[str]) -> Mock:
|
||||||
calls.append(argv)
|
calls.append(argv)
|
||||||
return _proc() # image present, build succeeds
|
if argv[:3] == ["docker", "image", "inspect"]:
|
||||||
|
return _proc(returncode=1) # missing
|
||||||
|
return _proc()
|
||||||
|
|
||||||
with patch(_RUN_DOCKER, side_effect=rec):
|
with patch(_RUN_DOCKER, side_effect=fake):
|
||||||
self.sc.ensure_built()
|
self.sc.ensure_built()
|
||||||
|
|
||||||
builds = [c for c in calls if c[:2] == ["docker", "build"]]
|
builds = [c for c in calls if c[:2] == ["docker", "build"]]
|
||||||
self.assertEqual(1, len(builds))
|
self.assertEqual(1, len(builds))
|
||||||
self.assertIn(self.sc.image_ref, builds[0])
|
self.assertIn(self.sc.image_ref, builds[0])
|
||||||
|
self.assertIn("-f", builds[0])
|
||||||
self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in 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:
|
def test_ensure_built_noop_when_no_dockerfile(self) -> None:
|
||||||
sc = DockerGateway("busybox", dockerfile=None)
|
sc = DockerGateway("busybox", dockerfile=None)
|
||||||
@@ -206,7 +159,12 @@ class TestDockerGatewayBuild(unittest.TestCase):
|
|||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
def test_ensure_built_raises_on_build_failure(self) -> None:
|
def test_ensure_built_raises_on_build_failure(self) -> None:
|
||||||
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="build boom")):
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
if argv[:3] == ["docker", "image", "inspect"]:
|
||||||
|
return _proc(returncode=1)
|
||||||
|
return _proc(returncode=1, stderr="build boom")
|
||||||
|
|
||||||
|
with patch(_RUN_DOCKER, side_effect=fake):
|
||||||
with self.assertRaises(GatewayError):
|
with self.assertRaises(GatewayError):
|
||||||
self.sc.ensure_built()
|
self.sc.ensure_built()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit: orchestrator+gateway container lifecycle — idempotent singleton (PRD 0070)."""
|
"""Unit: orchestrator process lifecycle — idempotent singleton (PRD 0070)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,86 +6,81 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from bot_bottle.orchestrator.lifecycle import (
|
from bot_bottle.orchestrator.lifecycle import (
|
||||||
ORCHESTRATOR_NAME,
|
OrchestratorProcess,
|
||||||
OrchestratorService,
|
|
||||||
OrchestratorStartError,
|
OrchestratorStartError,
|
||||||
)
|
)
|
||||||
from tests.unit import use_bottle_root
|
from tests.unit import use_bottle_root
|
||||||
|
|
||||||
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
|
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
|
||||||
_RUN = "bot_bottle.orchestrator.lifecycle.run_docker"
|
_POPEN = "bot_bottle.orchestrator.lifecycle.subprocess.Popen"
|
||||||
_GATEWAY = "bot_bottle.orchestrator.lifecycle.DockerGateway"
|
|
||||||
_SLEEP = "bot_bottle.orchestrator.lifecycle.time.sleep"
|
_SLEEP = "bot_bottle.orchestrator.lifecycle.time.sleep"
|
||||||
_MONOTONIC = "bot_bottle.orchestrator.lifecycle.time.monotonic"
|
_MONOTONIC = "bot_bottle.orchestrator.lifecycle.time.monotonic"
|
||||||
|
|
||||||
|
|
||||||
def _health(status: int) -> MagicMock:
|
def _health(status: int) -> MagicMock:
|
||||||
|
"""A urlopen() context-manager whose `.status` is `status`."""
|
||||||
m = MagicMock()
|
m = MagicMock()
|
||||||
m.__enter__.return_value.status = status
|
m.__enter__.return_value.status = status
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
class TestOrchestratorService(unittest.TestCase):
|
class TestOrchestratorProcess(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(self._tmp.cleanup)
|
self.addCleanup(self._tmp.cleanup)
|
||||||
self.addCleanup(use_bottle_root(Path(self._tmp.name)))
|
self.addCleanup(use_bottle_root(Path(self._tmp.name)))
|
||||||
self.svc = OrchestratorService(port=8099)
|
self.p = OrchestratorProcess(port=8099)
|
||||||
|
|
||||||
def test_urls(self) -> None:
|
def test_url(self) -> None:
|
||||||
self.assertEqual("http://127.0.0.1:8099", self.svc.url)
|
self.assertEqual("http://127.0.0.1:8099", self.p.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:
|
def test_is_healthy_true_on_200(self) -> None:
|
||||||
with patch(_URLOPEN, return_value=_health(200)):
|
with patch(_URLOPEN, return_value=_health(200)):
|
||||||
self.assertTrue(self.svc.is_healthy())
|
self.assertTrue(self.p.is_healthy())
|
||||||
|
|
||||||
|
def test_is_healthy_false_on_error(self) -> None:
|
||||||
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||||
self.assertFalse(self.svc.is_healthy())
|
self.assertFalse(self.p.is_healthy())
|
||||||
|
|
||||||
def test_ensure_running_always_recreates_orchestrator(self) -> None:
|
def test_ensure_running_noop_when_already_healthy(self) -> None:
|
||||||
# Even when a control plane is already healthy, the orchestrator is
|
with patch(_URLOPEN, return_value=_health(200)), patch(_POPEN) as popen:
|
||||||
# recreated so bind-mounted code changes take effect (its process
|
self.assertEqual(self.p.url, self.p.ensure_running())
|
||||||
# won't reload). The gateway is ensured too.
|
popen.assert_not_called() # a live control plane is left untouched
|
||||||
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:
|
def test_ensure_running_spawns_then_waits_for_health(self) -> None:
|
||||||
run = Mock(return_value=Mock(returncode=0, stderr=""))
|
# First check (before spawn) fails; after spawn the poll succeeds.
|
||||||
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
||||||
patch(_GATEWAY), patch(_RUN, run), patch(_SLEEP):
|
patch(_POPEN) as popen, patch(_SLEEP):
|
||||||
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
url = self.p.ensure_running()
|
||||||
runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]]
|
self.assertEqual(self.p.url, url)
|
||||||
self.assertEqual(1, len(runs))
|
popen.assert_called_once()
|
||||||
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:
|
def test_ensure_running_raises_on_startup_timeout(self) -> None:
|
||||||
with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \
|
||||||
patch(_GATEWAY), patch(_RUN, return_value=Mock(returncode=0, stderr="")), \
|
patch(_POPEN), patch(_SLEEP), \
|
||||||
patch(_SLEEP), patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]):
|
patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]):
|
||||||
with self.assertRaises(OrchestratorStartError):
|
with self.assertRaises(OrchestratorStartError):
|
||||||
self.svc.ensure_running(startup_timeout=1.0)
|
self.p.ensure_running(startup_timeout=1.0)
|
||||||
|
|
||||||
def test_stop_removes_orchestrator_and_gateway(self) -> None:
|
def test_argv_includes_gateway_and_broker(self) -> None:
|
||||||
with patch(_RUN) as run, patch(_GATEWAY) as gw_cls:
|
argv = OrchestratorProcess(port=8099, broker="docker", gateway=True)._argv()
|
||||||
self.svc.stop()
|
self.assertIn("--gateway", argv)
|
||||||
rms = [c.args[0] for c in run.call_args_list if c.args[0][:3] == ["docker", "rm", "--force"]]
|
self.assertIn("bot_bottle.orchestrator", argv)
|
||||||
self.assertTrue(any(ORCHESTRATOR_NAME in a for a in rms))
|
self.assertEqual("docker", argv[argv.index("--broker") + 1])
|
||||||
gw_cls.return_value.stop.assert_called_once()
|
|
||||||
|
def test_argv_omits_gateway_when_disabled(self) -> None:
|
||||||
|
self.assertNotIn("--gateway", OrchestratorProcess(gateway=False)._argv())
|
||||||
|
|
||||||
|
def test_spawn_launches_detached_and_logs(self) -> None:
|
||||||
|
with patch(_POPEN) as popen:
|
||||||
|
self.p._spawn()
|
||||||
|
popen.assert_called_once()
|
||||||
|
kwargs = popen.call_args.kwargs
|
||||||
|
self.assertTrue(kwargs["start_new_session"]) # outlives the CLI
|
||||||
|
self.assertTrue((Path(self._tmp.name) / "orchestrator.log").exists())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -24,20 +23,6 @@ class TestRegistryStore(unittest.TestCase):
|
|||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self._tmp.cleanup()
|
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:
|
def test_register_mints_id_and_token(self) -> None:
|
||||||
rec = self.store.register("10.243.0.1")
|
rec = self.store.register("10.243.0.1")
|
||||||
self.assertTrue(rec.bottle_id)
|
self.assertTrue(rec.bottle_id)
|
||||||
@@ -91,29 +76,11 @@ class TestRegistryStore(unittest.TestCase):
|
|||||||
|
|
||||||
def test_attribute_ambiguous_ip_denied(self) -> None:
|
def test_attribute_ambiguous_ip_denied(self) -> None:
|
||||||
# Two active bottles on one source IP is a misconfiguration — deny
|
# Two active bottles on one source IP is a misconfiguration — deny
|
||||||
# rather than guess (fail-closed), even with a valid token. (Forced
|
# rather than guess (fail-closed), even with a valid token.
|
||||||
# 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")
|
a = self.store.register("10.243.0.1", bottle_id="a")
|
||||||
self._force_active_row("10.243.0.1", "b")
|
self.store.register("10.243.0.1", bottle_id="b")
|
||||||
self.assertIsNone(self.store.attribute("10.243.0.1", a.identity_token))
|
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:
|
def test_state_persists_across_reopen(self) -> None:
|
||||||
rec = self.store.register("10.243.0.1")
|
rec = self.store.register("10.243.0.1")
|
||||||
reopened = RegistryStore(self.db)
|
reopened = RegistryStore(self.db)
|
||||||
@@ -163,7 +130,7 @@ class TestRegistryStore(unittest.TestCase):
|
|||||||
def test_by_source_ip_unknown_or_ambiguous_denied(self) -> None:
|
def test_by_source_ip_unknown_or_ambiguous_denied(self) -> None:
|
||||||
self.assertIsNone(self.store.by_source_ip("10.243.9.9")) # unknown
|
self.assertIsNone(self.store.by_source_ip("10.243.9.9")) # unknown
|
||||||
self.store.register("10.243.0.1", bottle_id="a")
|
self.store.register("10.243.0.1", bottle_id="a")
|
||||||
self._force_active_row("10.243.0.1", "b") # orphaned duplicate
|
self.store.register("10.243.0.1", bottle_id="b")
|
||||||
self.assertIsNone(self.store.by_source_ip("10.243.0.1")) # ambiguous
|
self.assertIsNone(self.store.by_source_ip("10.243.0.1")) # ambiguous
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -92,18 +92,6 @@ class TestOrchestrator(unittest.TestCase):
|
|||||||
assert got is not None
|
assert got is not None
|
||||||
self.assertEqual('{"routes":[]}', got.policy)
|
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:
|
def test_set_policy_live_reload(self) -> None:
|
||||||
rec = self.orch.launch_bottle("10.243.0.3")
|
rec = self.orch.launch_bottle("10.243.0.3")
|
||||||
self.assertTrue(self.orch.set_policy(rec.bottle_id, '{"x":1}'))
|
self.assertTrue(self.orch.set_policy(rec.bottle_id, '{"x":1}'))
|
||||||
|
|||||||
@@ -89,17 +89,13 @@ class TestPolicyResolver(unittest.TestCase):
|
|||||||
self.r.resolve_bottle_id("10.243.0.1", "tok")
|
self.r.resolve_bottle_id("10.243.0.1", "tok")
|
||||||
|
|
||||||
def test_resolve_policy_and_bottle_id_one_call(self) -> None:
|
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({"bottle_id": "b1", "policy": "P"})) as m:
|
||||||
with patch(_URLOPEN, return_value=_resp(payload)) as m:
|
self.assertEqual(("P", "b1"), self.r.resolve_policy_and_bottle_id("10.243.0.1", "t"))
|
||||||
self.assertEqual(
|
self.assertEqual(1, m.call_count) # both from a single /resolve
|
||||||
("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:
|
def test_resolve_policy_and_bottle_id_403_is_none_none(self) -> None:
|
||||||
with patch(_URLOPEN, side_effect=_http_error(403)):
|
with patch(_URLOPEN, side_effect=_http_error(403)):
|
||||||
self.assertEqual((None, None, {}), self.r.resolve_policy_and_bottle_id("10.243.0.9"))
|
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:
|
def test_resolve_policy_and_bottle_id_error_raises(self) -> None:
|
||||||
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||||
|
|||||||
Reference in New Issue
Block a user