Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec55dfde0c | |||
| 56d879f0b3 | |||
| 09393b354b | |||
| 77948ef56c | |||
| c10d1cb6e0 | |||
| 96b84eb84d | |||
| 910267b8a8 |
@@ -8,10 +8,10 @@ broad permissions inside a sandbox, so a misbehaving agent cannot reach the
|
|||||||
host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
|
host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
|
||||||
the runtime lifecycle and the copying of skills and env vars into it.
|
the runtime lifecycle and the copying of skills and env vars into it.
|
||||||
The default backend on compatible macOS hosts is macos-container:
|
The default backend on compatible macOS hosts is macos-container:
|
||||||
agents and sidecar bundles run through Apple's `container` CLI without
|
agents and gateways run through Apple's `container` CLI without
|
||||||
requiring Docker. On KVM-capable Linux hosts the default is firecracker:
|
requiring Docker. On KVM-capable Linux hosts the default is firecracker:
|
||||||
agents run in a Firecracker microVM reached over SSH on a point-to-point
|
agents run in a Firecracker microVM reached over SSH on a point-to-point
|
||||||
TAP, while the sidecar bundle still uses Docker. The legacy Docker
|
TAP, while the gateway still uses Docker. The legacy Docker
|
||||||
backend remains available with `BOT_BOTTLE_BACKEND=docker` or
|
backend remains available with `BOT_BOTTLE_BACKEND=docker` or
|
||||||
`--backend=docker`.
|
`--backend=docker`.
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ backend remains available with `BOT_BOTTLE_BACKEND=docker` or
|
|||||||
in a PRD, research note, or decision record.
|
in a PRD, research note, or decision record.
|
||||||
- Low dependencies by default. The project is Python, stdlib-first (no
|
- Low dependencies by default. The project is Python, stdlib-first (no
|
||||||
runtime pip dependencies in the package itself; the only language
|
runtime pip dependencies in the package itself; the only language
|
||||||
runtime is the Python 3.13 used by the CLI + sidecars). Ask before
|
runtime is the Python 3.13 used by the CLI + companion containers). Ask before
|
||||||
adding new tools, runtimes, or package managers.
|
adding new tools, runtimes, or package managers.
|
||||||
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
|
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
|
||||||
`<type>[(scope)][!]: <description>`, where `<type>` is one of `feat`, `fix`,
|
`<type>[(scope)][!]: <description>`, where `<type>` is one of `feat`, `fix`,
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
# Per-bottle sidecar bundle image (PRD 0024).
|
# Gateway data-plane image (PRD 0024 bundle shape; PRD 0070 gateway).
|
||||||
#
|
#
|
||||||
# Collapses the prior per-sidecar images (egress, git-gate,
|
# The egress / git-gate / supervise *data plane* — one image, run by
|
||||||
|
# the consolidated per-host gateway (PRD 0070). It is NOT the
|
||||||
|
# orchestrator control plane: that is the separate, lean
|
||||||
|
# `bot-bottle-orchestrator` image (Dockerfile.orchestrator, #384), which
|
||||||
|
# ships only python + the stdlib-only `bot_bottle` package and none of
|
||||||
|
# this image's mitmproxy / git / gitleaks payload.
|
||||||
|
#
|
||||||
|
# Collapses the prior per-daemon images (egress, git-gate,
|
||||||
# supervise) into one. A small stdlib-Python init supervisor at
|
# supervise) into one. A small stdlib-Python init supervisor at
|
||||||
# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
|
# /app/gateway_init.py spawns all daemons, forwards SIGTERM, and
|
||||||
# propagates per-daemon stdout/stderr to the container log with a
|
# propagates per-daemon stdout/stderr to the container log with a
|
||||||
# `[name]` prefix. See PRD 0024 for the rationale.
|
# `[name]` prefix. See PRD 0024 for the rationale.
|
||||||
#
|
#
|
||||||
@@ -12,7 +19,7 @@
|
|||||||
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
||||||
# /app/egress-entrypoint.sh mitmdump launcher
|
# /app/egress-entrypoint.sh mitmdump launcher
|
||||||
# /app/supervise_server.py + .py supervise MCP server
|
# /app/supervise_server.py + .py supervise MCP server
|
||||||
# /app/sidecar_init.py PID 1 supervisor
|
# /app/gateway_init.py PID 1 supervisor
|
||||||
# /etc/egress/routes.yaml bind-mounted at run time
|
# /etc/egress/routes.yaml bind-mounted at run time
|
||||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
# /etc/git-gate/pre-receive docker-cp'd at start time
|
||||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||||
@@ -76,7 +83,7 @@ COPY bot_bottle/audit_store.py /app/audit_store.py
|
|||||||
COPY bot_bottle/store_manager.py /app/store_manager.py
|
COPY bot_bottle/store_manager.py /app/store_manager.py
|
||||||
COPY bot_bottle/supervise.py /app/supervise.py
|
COPY bot_bottle/supervise.py /app/supervise.py
|
||||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
COPY bot_bottle/gateway_init.py /app/gateway_init.py
|
||||||
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
||||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||||
RUN chmod +x /app/egress-entrypoint.sh
|
RUN chmod +x /app/egress-entrypoint.sh
|
||||||
@@ -102,4 +109,4 @@ WORKDIR /app
|
|||||||
|
|
||||||
# PID 1 is the supervisor. It owns signal handling and exit-code
|
# PID 1 is the supervisor. It owns signal handling and exit-code
|
||||||
# propagation; no `exec` chain in the entrypoint itself.
|
# propagation; no `exec` chain in the entrypoint itself.
|
||||||
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
|
ENTRYPOINT ["python3", "/app/gateway_init.py"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Orchestrator control-plane image (PRD 0070, #384).
|
||||||
|
#
|
||||||
|
# The per-host orchestrator runs `python3 -m bot_bottle.orchestrator`.
|
||||||
|
# The `bot_bottle` package is **stdlib-only** by design, so the control
|
||||||
|
# plane needs nothing but a Python runtime — none of the gateway's
|
||||||
|
# mitmproxy / git / gitleaks payload (that is the separate
|
||||||
|
# `bot-bottle-gateway` image, Dockerfile.gateway). Splitting them keeps
|
||||||
|
# the secret-dense control plane (it concentrates every bottle's egress
|
||||||
|
# tokens — see PRD 0070's "secret concentration") on a minimal
|
||||||
|
# dependency surface.
|
||||||
|
#
|
||||||
|
# The repo is bind-mounted read-only into the container at run time (see
|
||||||
|
# `orchestrator/lifecycle.py`), so the source is NOT copied in here: the
|
||||||
|
# image is just the runtime. `ensure_running` recreates the container
|
||||||
|
# only when the bind-mounted source hash changes (#381), which is why
|
||||||
|
# the code stays a mount rather than a baked layer.
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# No third-party deps to install — stdlib only. Kept as an explicit,
|
||||||
|
# self-documenting stage so a future confinement step (baking the
|
||||||
|
# package, dropping the bind mount) has an obvious home.
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Documentation only; lifecycle.py overrides the entrypoint to
|
||||||
|
# `python3 -m bot_bottle.orchestrator` with the runtime flags.
|
||||||
|
ENTRYPOINT ["python3", "-m", "bot_bottle.orchestrator"]
|
||||||
@@ -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.
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
|
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
|
||||||
- **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny.
|
- **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny.
|
||||||
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
- **Tokens the agent never sees** — host secrets live in a gateway; the agent dials `http://gateway:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
||||||
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
|
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
|
||||||
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||||
- **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host.
|
- **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host.
|
||||||
@@ -24,17 +24,17 @@
|
|||||||
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
|
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
|
||||||
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
||||||
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
|
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
|
||||||
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
|
- **Apple Container backend (macOS default when available)** — runs the agent and gateway with Apple's `container` CLI, using a host-only agent network plus a separate gateway egress network.
|
||||||
- **Firecracker backend (Linux default when available)** — runs the agent in a KVM Firecracker microVM reached over SSH on a point-to-point TAP, with the sidecar bundle in Docker. A dedicated, fail-closed `nftables` table isolates the guest, closing the raw DNS/IP exfiltration gap that exists in the legacy Docker backend. Requires KVM (`/dev/kvm`) and a one-time privileged network-pool setup.
|
- **Firecracker backend (Linux default when available)** — runs the agent in a KVM Firecracker microVM reached over SSH on a point-to-point TAP, with the gateway in Docker. A dedicated, fail-closed `nftables` table isolates the guest, closing the raw DNS/IP exfiltration gap that exists in the legacy Docker backend. Requires KVM (`/dev/kvm`) and a one-time privileged network-pool setup.
|
||||||
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container or KVM via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container or KVM via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists.
|
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a gateway attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the gateway's internal-network IP, so HTTP/HTTPS traffic flows through the gateway instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists.
|
||||||
|
|
||||||
On the Firecracker backend, a bottle is an agent microVM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the sidecars. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege.
|
On the Firecracker backend, a bottle is an agent microVM plus a Docker gateway for egress, git-gate, and supervise. The VM reaches the gateway over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the gateway. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege.
|
||||||
|
|
||||||
On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
|
On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `companion containers` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
|
||||||
|
|
||||||
The Docker topology looks like this:
|
The Docker topology looks like this:
|
||||||
|
|
||||||
@@ -67,11 +67,11 @@ The Docker topology looks like this:
|
|||||||
└─────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
|
When the agent exits, `cli.py` tears down every gateway and both networks; nothing about a bottle persists between runs.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the sidecar bundle plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
||||||
|
|
||||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ On Linux, a KVM-capable host defaults to the Firecracker backend. It needs:
|
|||||||
|
|
||||||
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
|
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
|
||||||
- **`firecracker`** on `PATH`: grab a release from <https://github.com/firecracker-microvm/firecracker/releases>. Start flows print this pointer when the binary is missing.
|
- **`firecracker`** on `PATH`: grab a release from <https://github.com/firecracker-microvm/firecracker/releases>. Start flows print this pointer when the binary is missing.
|
||||||
- **Docker** for the sidecar bundle and image build.
|
- **Docker** for the gateway and image build.
|
||||||
- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `./cli.py backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host.
|
- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `./cli.py backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -204,9 +204,9 @@ class AgentProvider(ABC):
|
|||||||
bottle: "Bottle",
|
bottle: "Bottle",
|
||||||
supervise_url: str,
|
supervise_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register the per-bottle supervise sidecar as an MCP server
|
"""Register the per-bottle supervise daemon as an MCP server
|
||||||
in the provider's in-guest config. Called by the backend after
|
in the provider's in-guest config. Called by the backend after
|
||||||
the supervise sidecar is reachable. No-op when
|
the supervise daemon is reachable. No-op when
|
||||||
`plan.supervise_plan is None`."""
|
`plan.supervise_plan is None`."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -202,7 +199,7 @@ class ActiveAgent:
|
|||||||
bottle is the container, the agent is what runs in it.)
|
bottle is the container, the agent is what runs in it.)
|
||||||
|
|
||||||
Fields are deliberately backend-neutral. `services` is the set
|
Fields are deliberately backend-neutral. `services` is the set
|
||||||
of sidecar daemons currently up for this bottle (`egress`,
|
of gateway daemons currently up for this bottle (`egress`,
|
||||||
`git-gate`, `supervise`); the dashboard uses it to
|
`git-gate`, `supervise`); the dashboard uses it to
|
||||||
gate edit verbs. `backend_name` is the matching key in
|
gate edit verbs. `backend_name` is the matching key in
|
||||||
`_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active-
|
`_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active-
|
||||||
@@ -254,7 +251,7 @@ class Bottle(ABC):
|
|||||||
`user` (default `node`, matching the agent image's USER
|
`user` (default `node`, matching the agent image's USER
|
||||||
directive) and return the captured stdout/stderr/returncode.
|
directive) and return the captured stdout/stderr/returncode.
|
||||||
The bottle's environment (including HTTPS_PROXY pointing at
|
The bottle's environment (including HTTPS_PROXY pointing at
|
||||||
the egress sidecar) is inherited by the child. Non-zero
|
the egress daemon) is inherited by the child. Non-zero
|
||||||
exit does not raise — callers inspect `returncode`
|
exit does not raise — callers inspect `returncode`
|
||||||
themselves.
|
themselves.
|
||||||
|
|
||||||
@@ -457,7 +454,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
declarative provision-plan apply, supervise MCP registration)
|
declarative provision-plan apply, supervise MCP registration)
|
||||||
live on the `AgentProvider` plugin. The backend only owns the
|
live on the `AgentProvider` plugin. The backend only owns the
|
||||||
steps that are about backend infrastructure (CA, workspace,
|
steps that are about backend infrastructure (CA, workspace,
|
||||||
git) and surfaces the supervise sidecar URL its launch step
|
git) and surfaces the supervise daemon URL its launch step
|
||||||
knows about via `supervise_mcp_url`.
|
knows about via `supervise_mcp_url`.
|
||||||
|
|
||||||
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
PRD 0017: cred-proxy's agent-side dotfile rewrites (~/.npmrc,
|
||||||
@@ -505,7 +502,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
|
|
||||||
def supervise_mcp_url(self, plan: PlanT) -> str:
|
def supervise_mcp_url(self, plan: PlanT) -> str:
|
||||||
"""Return the agent-side URL of the per-bottle supervise
|
"""Return the agent-side URL of the per-bottle supervise
|
||||||
sidecar, or "" when this bottle has no sidecar. The provider
|
gateway, or "" when this bottle has no gateway. The provider
|
||||||
plugin's `provision_supervise_mcp` uses it to register the
|
plugin's `provision_supervise_mcp` uses it to register the
|
||||||
MCP entry inside the guest.
|
MCP entry inside the guest.
|
||||||
|
|
||||||
@@ -527,7 +524,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||||
"""Return every currently-running agent on this backend.
|
"""Return every currently-running agent on this backend.
|
||||||
Empty when none. Backend-specific: docker queries `docker
|
Empty when none. Backend-specific: docker queries `docker
|
||||||
compose ls`; firecracker cross-references its running sidecar
|
compose ls`; firecracker cross-references its running gateway
|
||||||
containers against per-bottle metadata."""
|
containers against per-bottle metadata."""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
|
||||||
"""Docker bottles reach the supervise sidecar via the
|
"""Docker bottles reach the supervise daemon via the
|
||||||
compose-network alias `supervise:9100`. No per-bottle URL
|
compose-network alias `supervise:9100`. No per-bottle URL
|
||||||
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:
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
"""Compose-spec rendering for a Docker bottle (PRD 0018, chunk 1).
|
"""Docker compose lifecycle helpers (PRD 0018).
|
||||||
|
|
||||||
`bottle_plan_to_compose(plan)` returns a Compose v2 spec dict
|
Serialize a compose spec to disk, drive `docker compose up/down`,
|
||||||
describing the per-bottle container topology — one project per
|
dump the merged log on teardown, and enumerate `bot-bottle-*`
|
||||||
bottle instance, services for the agent + every applicable sidecar,
|
projects. The spec itself is built by `consolidated_compose.py`
|
||||||
two networks, no named volumes.
|
(the consolidated per-host gateway topology); this module owns the
|
||||||
|
I/O side that persists and runs it.
|
||||||
Pure function. No I/O, no subprocess. Expects every launch-time
|
|
||||||
field (network names, CA host paths, etc.) on the plan's inner
|
|
||||||
plans to be populated; chunks 2+3 own that ordering.
|
|
||||||
|
|
||||||
Conditional services follow the plan content:
|
|
||||||
|
|
||||||
- agent + sidecars bundle: always.
|
|
||||||
- git-gate: iff plan.git_gate_plan.upstreams.
|
|
||||||
- egress: iff plan.egress_plan.routes.
|
|
||||||
- supervise: iff plan.supervise_plan is not None.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,233 +15,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ...egress import (
|
|
||||||
EGRESS_HOSTNAME,
|
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
|
||||||
egress_agent_env_entries,
|
|
||||||
egress_sidecar_env_entries,
|
|
||||||
)
|
|
||||||
from ...git_gate import GIT_GATE_HOSTNAME
|
|
||||||
from ...log import die, warn
|
from ...log import die, warn
|
||||||
from ...supervise import (
|
|
||||||
DB_PATH_IN_CONTAINER,
|
|
||||||
SUPERVISE_HOSTNAME,
|
|
||||||
SUPERVISE_PORT,
|
|
||||||
)
|
|
||||||
from ...util import expand_tilde
|
|
||||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
|
||||||
from .bottle_plan import DockerBottlePlan
|
|
||||||
from .egress import (
|
|
||||||
EGRESS_CA_IN_CONTAINER,
|
|
||||||
EGRESS_PORT,
|
|
||||||
)
|
|
||||||
from .git_gate import (
|
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
|
||||||
)
|
|
||||||
from . import network as network_mod
|
|
||||||
from .sidecar_bundle import (
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
sidecar_bundle_container_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Repo root, used as the build context for the bundle Dockerfile.
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
|
|
||||||
|
|
||||||
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Render a Compose v2 spec dict from a fully-resolved
|
|
||||||
DockerBottlePlan.
|
|
||||||
|
|
||||||
The plan must have its inner plans (`git_gate_plan`,
|
|
||||||
`egress_plan`, `supervise_plan`) populated with launch-time
|
|
||||||
fields — network names, CA host paths. The renderer doesn't
|
|
||||||
validate; callers feed it a fully-resolved plan or get an
|
|
||||||
incomplete compose spec back.
|
|
||||||
"""
|
|
||||||
project = f"bot-bottle-{plan.slug}"
|
|
||||||
services: dict[str, Any] = {
|
|
||||||
"sidecars": _sidecar_bundle_service(plan),
|
|
||||||
"agent": _agent_service(plan),
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"name": project,
|
|
||||||
"services": services,
|
|
||||||
"networks": _networks(plan),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _networks(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Compose-managed networks with explicit `name:` matching the
|
|
||||||
existing slug-suffixed convention. Compose creates them on `up`
|
|
||||||
and destroys them on `down`. The internal one is `--internal`
|
|
||||||
(no default gateway); the egress one is a normal user-defined
|
|
||||||
bridge."""
|
|
||||||
return {
|
|
||||||
"internal": {
|
|
||||||
"name": network_mod.network_name_for_slug(plan.slug),
|
|
||||||
"internal": True,
|
|
||||||
},
|
|
||||||
"egress": {
|
|
||||||
"name": network_mod.network_egress_name_for_slug(plan.slug),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str, Any]:
|
|
||||||
"""One bind-mount entry in the long-form `volumes:` shape.
|
|
||||||
Long form is preferred over `host:target:ro` strings because
|
|
||||||
it's easier to inspect in tests and survives whitespace in
|
|
||||||
host paths."""
|
|
||||||
return {
|
|
||||||
"type": "bind",
|
|
||||||
"source": str(host),
|
|
||||||
"target": target,
|
|
||||||
"read_only": read_only,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""The `sidecars` service: one container per bottle, bundle
|
|
||||||
image, all daemons under a Python init supervisor.
|
|
||||||
|
|
||||||
Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env.
|
|
||||||
egress is always present; git-gate / supervise are conditional.
|
|
||||||
"""
|
|
||||||
daemons: list[str] = ["egress"]
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
daemons.append("git-gate")
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
daemons.append("supervise")
|
|
||||||
|
|
||||||
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
|
||||||
volumes: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
# --- egress -------------------------------------------------------
|
|
||||||
ep = plan.egress_plan
|
|
||||||
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
|
||||||
if ep.routes:
|
|
||||||
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
|
|
||||||
env.extend(egress_sidecar_env_entries(ep))
|
|
||||||
|
|
||||||
# --- git-gate -----------------------------------------------------
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
if gp.upstreams:
|
|
||||||
volumes += [
|
|
||||||
_bind(gp.entrypoint_script, GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
|
||||||
_bind(gp.hook_script, GIT_GATE_HOOK_IN_CONTAINER),
|
|
||||||
_bind(gp.access_hook_script, GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
|
||||||
]
|
|
||||||
for u in gp.upstreams:
|
|
||||||
keypath = expand_tilde(u.identity_file)
|
|
||||||
volumes.append(_bind(
|
|
||||||
keypath,
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-key",
|
|
||||||
))
|
|
||||||
if u.known_hosts_file:
|
|
||||||
volumes.append(_bind(
|
|
||||||
u.known_hosts_file,
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
|
|
||||||
))
|
|
||||||
|
|
||||||
# --- supervise ----------------------------------------------------
|
|
||||||
sp = plan.supervise_plan
|
|
||||||
if sp is not None:
|
|
||||||
env += [
|
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
|
||||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
|
||||||
]
|
|
||||||
volumes.append({
|
|
||||||
"type": "bind",
|
|
||||||
"source": str(sp.db_path),
|
|
||||||
"target": DB_PATH_IN_CONTAINER,
|
|
||||||
"read_only": False,
|
|
||||||
})
|
|
||||||
internal_aliases = [EGRESS_HOSTNAME]
|
|
||||||
if gp.upstreams:
|
|
||||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
|
||||||
if sp is not None:
|
|
||||||
internal_aliases.append(SUPERVISE_HOSTNAME)
|
|
||||||
|
|
||||||
service: dict[str, Any] = {
|
|
||||||
"image": SIDECAR_BUNDLE_IMAGE,
|
|
||||||
"build": {
|
|
||||||
"context": _REPO_DIR,
|
|
||||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
},
|
|
||||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
|
||||||
"networks": {
|
|
||||||
"internal": {"aliases": internal_aliases},
|
|
||||||
"egress": None,
|
|
||||||
},
|
|
||||||
"environment": env,
|
|
||||||
"volumes": volumes,
|
|
||||||
}
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
"""Agent container. Runs `sleep infinity`; claude is `docker
|
|
||||||
exec -it`'d into it later. HTTP_PROXY/HTTPS_PROXY point at the
|
|
||||||
egress sidecar."""
|
|
||||||
proxy_url = _agent_proxy_url(plan)
|
|
||||||
no_proxy = _agent_no_proxy(plan)
|
|
||||||
env: list[str] = [
|
|
||||||
f"HTTPS_PROXY={proxy_url}",
|
|
||||||
f"HTTP_PROXY={proxy_url}",
|
|
||||||
f"https_proxy={proxy_url}",
|
|
||||||
f"http_proxy={proxy_url}",
|
|
||||||
f"NO_PROXY={no_proxy}",
|
|
||||||
f"no_proxy={no_proxy}",
|
|
||||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
|
||||||
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
|
||||||
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
|
||||||
]
|
|
||||||
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
|
||||||
env.append(f"{name}={value}")
|
|
||||||
# Forwarded vars (OAuth token, manifest host-interpolations):
|
|
||||||
# bare name → inherits from compose-up process env, value
|
|
||||||
# never lands on argv or in the compose file.
|
|
||||||
for name in sorted(plan.forwarded_env.keys()):
|
|
||||||
env.append(name)
|
|
||||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
|
||||||
|
|
||||||
service: dict[str, Any] = {
|
|
||||||
"image": plan.image,
|
|
||||||
"container_name": plan.container_name,
|
|
||||||
"command": ["sleep", "infinity"],
|
|
||||||
"networks": {"internal": None},
|
|
||||||
"environment": env,
|
|
||||||
}
|
|
||||||
if plan.use_runsc:
|
|
||||||
service["runtime"] = "runsc"
|
|
||||||
|
|
||||||
# The init supervisor inside the bundle owns intra-bundle
|
|
||||||
# daemon ordering, so the agent only waits for the bundle
|
|
||||||
# container itself.
|
|
||||||
service["depends_on"] = ["sidecars"]
|
|
||||||
|
|
||||||
return service
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
|
||||||
"""Agent's HTTP_PROXY — always points at egress."""
|
|
||||||
return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
|
||||||
"""NO_PROXY for the agent: loopback always; supervise hostname
|
|
||||||
when the supervise sidecar is up (MCP long-poll must bypass
|
|
||||||
the egress proxy)."""
|
|
||||||
hosts = ["localhost", "127.0.0.1"]
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
hosts.append(SUPERVISE_HOSTNAME)
|
|
||||||
return ",".join(hosts)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
|
# --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
|
||||||
@@ -442,7 +206,6 @@ __all__ = [
|
|||||||
"COMPOSE_FILE_NAME",
|
"COMPOSE_FILE_NAME",
|
||||||
"COMPOSE_LOG_NAME",
|
"COMPOSE_LOG_NAME",
|
||||||
"COMPOSE_PROJECT_PREFIX",
|
"COMPOSE_PROJECT_PREFIX",
|
||||||
"bottle_plan_to_compose",
|
|
||||||
"compose_down",
|
"compose_down",
|
||||||
"compose_dump_logs",
|
"compose_dump_logs",
|
||||||
"compose_file_path",
|
"compose_file_path",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Agent-only compose for the consolidated docker backend (PRD 0070).
|
"""Agent-only compose for the consolidated docker backend (PRD 0070).
|
||||||
|
|
||||||
The per-bottle model rendered a compose project with the agent *and* a
|
The per-bottle model rendered a compose project with the agent *and* a
|
||||||
sidecar bundle on two per-bottle networks. In the consolidated model the
|
gateway on two per-bottle networks. In the consolidated model the
|
||||||
sidecars are gone — one shared gateway serves every bottle — so this renders
|
per-bottle companion containers are gone — one shared gateway serves every bottle — so this renders
|
||||||
just the agent, attached to the **external shared gateway network** with the
|
just the agent, attached to the **external shared gateway network** with the
|
||||||
pinned source IP the orchestrator allocated, and pointed at the gateway's
|
pinned source IP the orchestrator allocated, and pointed at the gateway's
|
||||||
address for egress (and, around the proxy, for git-http / supervise).
|
address for egress (and, around the proxy, for git-http / supervise).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Consolidated bottle launch sequence for the docker backend (PRD 0070).
|
"""Consolidated bottle launch sequence for the docker backend (PRD 0070).
|
||||||
|
|
||||||
Composes the orchestrator primitives into the register/teardown sequence that
|
Composes the orchestrator primitives into the register/teardown sequence that
|
||||||
replaces the per-bottle sidecar bundle:
|
replaces the per-bottle gateway:
|
||||||
|
|
||||||
1. ensure the orchestrator control plane + shared gateway are up;
|
1. ensure the orchestrator control plane + shared gateway are up;
|
||||||
2. allocate the bottle a pinned source IP on the gateway network (the
|
2. allocate the bottle a pinned source IP on the gateway network (the
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ prepare-time routes-yaml rendering itself lives on the
|
|||||||
platform-neutral `Egress` ABC — backends instantiate it directly.
|
platform-neutral `Egress` ABC — backends instantiate it directly.
|
||||||
|
|
||||||
The per-container `.start()` / `.stop()` lifecycle was removed in
|
The per-container `.start()` / `.stop()` lifecycle was removed in
|
||||||
PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress
|
PRD 0024 chunk 3; the gateway (PRD 0024) runs egress
|
||||||
under its python init supervisor."""
|
under its python init supervisor."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -1,60 +1,29 @@
|
|||||||
"""Host-side helper for egress sidecar inspection and live updates.
|
"""Host-side egress route-apply for the docker backend.
|
||||||
|
|
||||||
The approve path uses this module to validate a proposed routes file,
|
The per-bottle companion container this used to signal (`docker kill
|
||||||
write it to the bottle's live egress state dir, and signal the sidecar
|
--signal HUP <container>`) was removed in the companion-container removal (#385).
|
||||||
bundle so the mitmproxy addon reloads it.
|
In the consolidated model the shared gateway resolves egress policy
|
||||||
|
per-request against the orchestrator rather than reloading a per-bottle
|
||||||
|
routes file, so the live per-bottle reload is not supported here and
|
||||||
|
fails closed until the gateway-side apply lands.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
|
||||||
from ...log import warn
|
|
||||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||||
from .sidecar_bundle import sidecar_bundle_container_name
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_current_routes(slug: str) -> str:
|
|
||||||
container = sidecar_bundle_container_name(slug)
|
|
||||||
r = subprocess.run(
|
|
||||||
["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if r.returncode != 0:
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not read routes.yaml from {container}: "
|
|
||||||
f"{(r.stderr or '').strip() or 'container not running?'}"
|
|
||||||
)
|
|
||||||
return r.stdout
|
|
||||||
|
|
||||||
|
|
||||||
class DockerEgressApplicator(EgressApplicator):
|
class DockerEgressApplicator(EgressApplicator):
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
def _signal_bundle_reload(self, slug: str) -> None:
|
||||||
container = sidecar_bundle_container_name(slug)
|
del slug
|
||||||
result = subprocess.run(
|
raise EgressApplyError(
|
||||||
["docker", "kill", "--signal", "HUP", container],
|
"live egress route-apply was removed with the per-bottle "
|
||||||
capture_output=True, text=True, check=False, env=os.environ,
|
"companion container (#385); route changes will flow through "
|
||||||
|
"the consolidated gateway in a follow-up."
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
|
||||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
|
||||||
warn(
|
|
||||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
|
||||||
f"{last_error or 'docker kill failed'}"
|
|
||||||
)
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not reload egress bundle {container}: "
|
|
||||||
f"{last_error or 'docker kill failed'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
applicator = DockerEgressApplicator()
|
applicator = DockerEgressApplicator()
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["DockerEgressApplicator", "EgressApplyError", "applicator"]
|
||||||
"DockerEgressApplicator",
|
|
||||||
"EgressApplyError",
|
|
||||||
"applicator",
|
|
||||||
"fetch_current_routes",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
bind-mounts target + the listening port. The prepare-time entrypoint
|
bind-mounts target + the listening port. The prepare-time entrypoint
|
||||||
/ hook render lives on the platform-neutral `GitGate` ABC — backends
|
/ hook render lives on the platform-neutral `GitGate` ABC — backends
|
||||||
instantiate it directly. The git-gate daemon's container lifecycle
|
instantiate it directly. The git-gate daemon's container lifecycle
|
||||||
is owned by the sidecar bundle (PRD 0024)."""
|
is owned by the gateway (PRD 0024)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ PRD 0018 chunk 3: each instance is one `docker compose` project.
|
|||||||
The flow is:
|
The flow is:
|
||||||
|
|
||||||
1. Build the agent image from the provider Dockerfile (compose
|
1. Build the agent image from the provider Dockerfile (compose
|
||||||
builds the sidecar images via the `build:` directive on first up).
|
builds the gateway image on first up).
|
||||||
2. Mint the per-bottle egress CA (chunk 2 writes it under
|
2. Mint the per-bottle egress CA (chunk 2 writes it under
|
||||||
state/<slug>/egress/).
|
state/<slug>/egress/).
|
||||||
3. Populate the inner plans with launch-time fields so the
|
3. Populate the inner plans with launch-time fields so the
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def network_create_internal(slug: str) -> str:
|
|||||||
|
|
||||||
def network_create_egress(slug: str) -> str:
|
def network_create_egress(slug: str) -> str:
|
||||||
"""Create a per-agent user-defined bridge (NOT the legacy `bridge`)
|
"""Create a per-agent user-defined bridge (NOT the legacy `bridge`)
|
||||||
so the egress sidecar has working DNS for upstream hostnames."""
|
so the egress daemon has working DNS for upstream hostnames."""
|
||||||
return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False)
|
return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Host setup + status for the Docker backend.
|
"""Host setup + status for the Docker backend.
|
||||||
|
|
||||||
Unlike Firecracker, the Docker backend needs no privileged one-time
|
Unlike Firecracker, the Docker backend needs no privileged one-time
|
||||||
host provisioning (no TAP pool / nft table) — networks and the sidecar
|
host provisioning (no TAP pool / nft table) — networks and the gateway
|
||||||
bundle are created per-launch. So `setup()` is mostly an install/daemon
|
bundle are created per-launch. So `setup()` is mostly an install/daemon
|
||||||
pointer, and `status()` reports whether docker is usable.
|
pointer, and `status()` reports whether docker is usable.
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ def setup() -> int:
|
|||||||
return 1
|
return 1
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"Docker backend: no privileged host setup required — networks and "
|
"Docker backend: no privileged host setup required — networks and "
|
||||||
"the sidecar bundle are created per-launch.\n"
|
"the gateway are created per-launch.\n"
|
||||||
)
|
)
|
||||||
if not _daemon_reachable():
|
if not _daemon_reachable():
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
@@ -64,7 +64,7 @@ def setup() -> int:
|
|||||||
def teardown() -> int:
|
def teardown() -> int:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"Docker backend: nothing to undo — it provisions no privileged host "
|
"Docker backend: nothing to undo — it provisions no privileged host "
|
||||||
"state (networks and the sidecar bundle are per-launch and are "
|
"state (networks and the gateway are per-launch and are "
|
||||||
"removed by `./cli.py cleanup`). Docker itself is left installed.\n"
|
"removed by `./cli.py cleanup`). Docker itself is left installed.\n"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
"""Sidecar bundle constants + helpers for the Docker backend
|
|
||||||
(PRD 0024).
|
|
||||||
|
|
||||||
The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
|
|
||||||
runs egress + git-gate + supervise as one container per bottle
|
|
||||||
under a small Python init supervisor. As of chunk 5 the bundle
|
|
||||||
is the only shape — the legacy four-sidecar topology and its
|
|
||||||
`BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
# Bundle image. Defaults to a built-locally tag (built from the
|
|
||||||
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
|
||||||
# pinning to a published digest can override via env.
|
|
||||||
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
|
||||||
"BOT_BOTTLE_SIDECAR_IMAGE",
|
|
||||||
"bot-bottle-sidecars:latest",
|
|
||||||
)
|
|
||||||
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE = "Dockerfile.sidecars"
|
|
||||||
|
|
||||||
|
|
||||||
def sidecar_bundle_container_name(slug: str) -> str:
|
|
||||||
"""`bot-bottle-sidecars-<slug>`. Same prefix scheme as the
|
|
||||||
per-sidecar containers it replaces, so the dashboard's
|
|
||||||
discovery-by-prefix logic keeps working."""
|
|
||||||
return f"bot-bottle-sidecars-{slug}"
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ session. `ssh -t` forwards the host terminal's SIGWINCH to the remote
|
|||||||
PTY natively, so no separate resize bridge is needed.
|
PTY natively, so no separate resize bridge is needed.
|
||||||
|
|
||||||
Commands run as the image's `node` user via `runuser`, with HOME/USER/
|
Commands run as the image's `node` user via `runuser`, with HOME/USER/
|
||||||
PATH and the bottle env (HTTPS_PROXY at the sidecar, CA paths, …) set
|
PATH and the bottle env (HTTPS_PROXY at the gateway, CA paths, …) set
|
||||||
per-invocation through `env` (the VM itself just runs the init; it has
|
per-invocation through `env` (the VM itself just runs the init; it has
|
||||||
no baked-in process env like a `docker run` container would).
|
no baked-in process env like a `docker run` container would).
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ from .. import BottleCleanupPlan
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
||||||
# PIDs of orphaned firecracker VMM processes and the sidecar
|
# PIDs of orphaned firecracker VMM processes and the per-bottle run
|
||||||
# containers left behind by previous bottles.
|
# dirs left behind by previous bottles.
|
||||||
vm_pids: tuple[int, ...] = ()
|
vm_pids: tuple[int, ...] = ()
|
||||||
containers: tuple[str, ...] = ()
|
|
||||||
run_dirs: tuple[str, ...] = ()
|
run_dirs: tuple[str, ...] = ()
|
||||||
|
|
||||||
def print(self) -> None:
|
def print(self) -> None:
|
||||||
@@ -22,11 +21,9 @@ class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
|||||||
return
|
return
|
||||||
for pid in self.vm_pids:
|
for pid in self.vm_pids:
|
||||||
info(f"firecracker VM process: pid {pid}")
|
info(f"firecracker VM process: pid {pid}")
|
||||||
for name in self.containers:
|
|
||||||
info(f"firecracker sidecar container: {name}")
|
|
||||||
for path in self.run_dirs:
|
for path in self.run_dirs:
|
||||||
info(f"firecracker run dir: {path}")
|
info(f"firecracker run dir: {path}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def empty(self) -> bool:
|
def empty(self) -> bool:
|
||||||
return not (self.vm_pids or self.containers or self.run_dirs)
|
return not (self.vm_pids or self.run_dirs)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from .. import BottlePlan
|
|||||||
class FirecrackerBottlePlan(BottlePlan):
|
class FirecrackerBottlePlan(BottlePlan):
|
||||||
slug: str
|
slug: str
|
||||||
forwarded_env: dict[str, str] = field(repr=False)
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
# Stamped by launch once the sidecar is up and its ports are
|
# Stamped by launch once the gateway is up and its ports are
|
||||||
# published on the host-side TAP IP (empty at prepare time).
|
# published on the host-side TAP IP (empty at prepare time).
|
||||||
agent_proxy_url: str = ""
|
agent_proxy_url: str = ""
|
||||||
agent_git_gate_url: str = ""
|
agent_git_gate_url: str = ""
|
||||||
@@ -21,7 +21,7 @@ class FirecrackerBottlePlan(BottlePlan):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def container_name(self) -> str:
|
def container_name(self) -> str:
|
||||||
"""Instance name, reused for the sidecar container + VM run dir.
|
"""Instance name, reused for the gateway container + VM run dir.
|
||||||
Matches the `bot-bottle-<slug>` convention the other backends
|
Matches the `bot-bottle-<slug>` convention the other backends
|
||||||
use so cleanup/enumerate discovery-by-prefix keeps working."""
|
use so cleanup/enumerate discovery-by-prefix keeps working."""
|
||||||
return self.agent_provision.instance_name
|
return self.agent_provision.instance_name
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Cleanup for the Firecracker backend.
|
"""Cleanup for the Firecracker backend.
|
||||||
|
|
||||||
Orphans are: firecracker VMM processes whose config lives under our run
|
Orphans are: firecracker VMM processes whose config lives under our run
|
||||||
dir, the `bot-bottle-sidecars-*` containers, and the per-bottle run
|
dir, and the per-bottle run dirs. TAP slots free themselves (the flock
|
||||||
dirs. TAP slots free themselves (the flock drops when the launcher
|
drops when the launcher exits), so there is nothing to reclaim there.
|
||||||
exits), so there is nothing to reclaim there.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,8 +17,6 @@ from ...log import info
|
|||||||
from . import util
|
from . import util
|
||||||
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
||||||
|
|
||||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def _run_root() -> Path:
|
def _run_root() -> Path:
|
||||||
return util.cache_dir() / "run"
|
return util.cache_dir() / "run"
|
||||||
@@ -46,17 +43,6 @@ def _orphan_vm_pids() -> list[int]:
|
|||||||
return pids
|
return pids
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_containers() -> list[str]:
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "-a", "--format", "{{.Names}}",
|
|
||||||
"--filter", f"name={_SIDECAR_PREFIX}"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
return sorted(n.strip() for n in result.stdout.splitlines() if n.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _run_dirs() -> list[str]:
|
def _run_dirs() -> list[str]:
|
||||||
run_root = _run_root()
|
run_root = _run_root()
|
||||||
if not run_root.is_dir():
|
if not run_root.is_dir():
|
||||||
@@ -67,7 +53,6 @@ def _run_dirs() -> list[str]:
|
|||||||
def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
||||||
return FirecrackerBottleCleanupPlan(
|
return FirecrackerBottleCleanupPlan(
|
||||||
vm_pids=tuple(_orphan_vm_pids()),
|
vm_pids=tuple(_orphan_vm_pids()),
|
||||||
containers=tuple(_sidecar_containers()),
|
|
||||||
run_dirs=tuple(_run_dirs()),
|
run_dirs=tuple(_run_dirs()),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,12 +64,6 @@ def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
|
|||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
for name in plan.containers:
|
|
||||||
info(f"docker rm -f {name}")
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
||||||
)
|
|
||||||
for path in plan.run_dirs:
|
for path in plan.run_dirs:
|
||||||
info(f"rm -rf {path}")
|
info(f"rm -rf {path}")
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
|||||||
@@ -1,43 +1,14 @@
|
|||||||
"""Active-agent enumeration for the Firecracker backend.
|
"""Active-agent enumeration for the Firecracker backend.
|
||||||
|
|
||||||
The agent runs in a VM (no container to list), so a live bottle is
|
The backend is disabled during the companion-container removal (#385) — it can't
|
||||||
identified by its running sidecar container `bot-bottle-sidecars-<slug>`
|
launch bottles, so there are none to enumerate. Real enumeration returns
|
||||||
— the same discovery-by-prefix the other backends use.
|
with the backend's consolidated relaunch (#354).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...bottle_state import read_metadata
|
|
||||||
from .. import ActiveAgent
|
from .. import ActiveAgent
|
||||||
|
|
||||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
result = subprocess.run(
|
return []
|
||||||
["docker", "ps", "--format", "{{.Names}}",
|
|
||||||
"--filter", f"name={_SIDECAR_PREFIX}"],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
out: list[ActiveAgent] = []
|
|
||||||
for name in sorted(n.strip() for n in result.stdout.splitlines() if n.strip()):
|
|
||||||
slug = name[len(_SIDECAR_PREFIX):]
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
if metadata is None or metadata.backend != "firecracker":
|
|
||||||
# Skip sidecars owned by another backend (docker shares the
|
|
||||||
# container-name prefix).
|
|
||||||
continue
|
|
||||||
out.append(ActiveAgent(
|
|
||||||
backend_name="firecracker",
|
|
||||||
slug=slug,
|
|
||||||
agent_name=metadata.agent_name,
|
|
||||||
started_at=metadata.started_at,
|
|
||||||
services=(),
|
|
||||||
label=metadata.label,
|
|
||||||
color=metadata.color,
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
|
|||||||
@@ -1,404 +1,39 @@
|
|||||||
"""Launch flow for the Firecracker backend.
|
"""Launch flow for the Firecracker backend — temporarily disabled (#385).
|
||||||
|
|
||||||
Per bottle:
|
The firecracker backend launched a per-bottle companion container (the
|
||||||
1. mint the egress CA, build the agent image (docker), export it to a
|
egress / git-gate / supervise data plane) alongside each microVM. That
|
||||||
cached ext4 rootfs;
|
per-bottle-companion architecture was removed in the companion-container removal;
|
||||||
2. claim a free TAP pool slot (rootless flock);
|
firecracker's replacement — the consolidated per-host gateway — lands in
|
||||||
3. bring up the Docker sidecar bundle, publishing egress / git-gate /
|
its own cutover (#354).
|
||||||
supervise on the slot's host-side TAP IP at fixed ports;
|
|
||||||
4. boot the microVM on that TAP; wait for SSH;
|
|
||||||
5. provision (CA, prompt, skills, workspace, git, supervise) over SSH.
|
|
||||||
|
|
||||||
Isolation is enforced by the operator-provisioned nft table (checked
|
Until that lands, launching a firecracker bottle fails closed rather than
|
||||||
fail-closed in preflight): a VM reaches only its sidecar (DNAT'd from
|
silently running the removed path. `prepare` / `status` / cleanup still
|
||||||
the host TAP IP) and nothing else. The agent's HTTPS_PROXY therefore
|
work, so `backend status --backend=firecracker` and orphan cleanup are
|
||||||
points at `http://<host_tap_ip>:9099`, its only route to the world.
|
unaffected.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
from contextlib import contextmanager
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from contextlib import ExitStack, contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...bottle_state import (
|
from ...log import die
|
||||||
egress_state_dir,
|
|
||||||
git_gate_state_dir,
|
|
||||||
read_committed_image,
|
|
||||||
)
|
|
||||||
from ...egress import (
|
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
|
||||||
egress_agent_env_entries,
|
|
||||||
egress_resolve_token_values,
|
|
||||||
egress_sidecar_env_entries,
|
|
||||||
)
|
|
||||||
from ...git_gate import (
|
|
||||||
provision_git_gate_dynamic_keys,
|
|
||||||
revoke_git_gate_provisioned_keys,
|
|
||||||
)
|
|
||||||
from ...log import die, info, warn
|
|
||||||
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
|
||||||
from ...util import expand_tilde
|
|
||||||
from ..docker.egress import (
|
|
||||||
EGRESS_CA_IN_CONTAINER,
|
|
||||||
EGRESS_PORT,
|
|
||||||
egress_tls_init,
|
|
||||||
)
|
|
||||||
from ..docker.git_gate import (
|
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
|
||||||
)
|
|
||||||
from ..docker.sidecar_bundle import (
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
)
|
|
||||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
|
||||||
from . import firecracker_vm, isolation_probe, netpool, util
|
|
||||||
from .bottle import FirecrackerBottle
|
from .bottle import FirecrackerBottle
|
||||||
from .bottle_plan import FirecrackerBottlePlan
|
from .bottle_plan import FirecrackerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
_GIT_HTTP_PORT = 9420
|
|
||||||
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
|
|
||||||
|
|
||||||
|
|
||||||
def sidecar_container_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-sidecars-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: FirecrackerBottlePlan,
|
plan: FirecrackerBottlePlan,
|
||||||
*,
|
*,
|
||||||
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
|
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
|
||||||
) -> Generator[FirecrackerBottle, None, None]:
|
) -> Generator[FirecrackerBottle, None, None]:
|
||||||
stack = ExitStack()
|
"""Fail closed: the firecracker backend is disabled while its
|
||||||
bottle_for_revoke = plan.manifest.bottle
|
consolidated (gateway-backed) launch is built in #354."""
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
del plan, provision
|
||||||
|
die(
|
||||||
def teardown() -> None:
|
"the firecracker backend is temporarily disabled during the "
|
||||||
teardown_exc: BaseException | None = None
|
"companion-container removal (#385); its consolidated relaunch "
|
||||||
try:
|
"lands in #354. Use --backend=docker for now."
|
||||||
stack.close()
|
|
||||||
except BaseException as exc: # noqa: W0718 - teardown must continue
|
|
||||||
teardown_exc = exc
|
|
||||||
warn(f"firecracker teardown failed: {exc!r}")
|
|
||||||
revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
|
|
||||||
if teardown_exc is not None:
|
|
||||||
raise teardown_exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
plan = _mint_certs(plan)
|
|
||||||
plan = _build_agent_image(plan)
|
|
||||||
|
|
||||||
# Claim a TAP slot; the flock is held until teardown closes it.
|
|
||||||
slot, lock = netpool.allocate(plan.slug)
|
|
||||||
stack.callback(lock.close)
|
|
||||||
info(f"firecracker slot {slot.iface}: host={slot.host_ip} "
|
|
||||||
f"guest={slot.guest_ip}")
|
|
||||||
|
|
||||||
plan = _provision_git_gate_keys(plan)
|
|
||||||
|
|
||||||
sidecar_name = sidecar_container_name(plan.slug)
|
|
||||||
_force_remove_container(sidecar_name)
|
|
||||||
_start_sidecar_bundle(plan, sidecar_name, slot.host_ip)
|
|
||||||
stack.callback(_force_remove_container, sidecar_name)
|
|
||||||
_stage_git_gate(plan, sidecar_name)
|
|
||||||
|
|
||||||
plan = _stamp_agent_urls(plan, slot.host_ip)
|
|
||||||
|
|
||||||
# Build the per-bottle rootfs + SSH key, then boot.
|
|
||||||
base_dir = util.build_base_rootfs_dir(plan.image)
|
|
||||||
run_dir = util.cache_dir() / "run" / plan.slug
|
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
rootfs = run_dir / "rootfs.ext4"
|
|
||||||
util.build_rootfs_ext4(base_dir, rootfs)
|
|
||||||
private_key, pubkey = util.generate_keypair(run_dir)
|
|
||||||
|
|
||||||
vm = firecracker_vm.boot(
|
|
||||||
name=plan.container_name,
|
|
||||||
rootfs=rootfs,
|
|
||||||
tap=slot.iface,
|
|
||||||
guest_ip=slot.guest_ip,
|
|
||||||
host_ip=slot.host_ip,
|
|
||||||
pubkey=pubkey,
|
|
||||||
run_dir=run_dir,
|
|
||||||
)
|
|
||||||
stack.callback(vm.terminate)
|
|
||||||
firecracker_vm.wait_for_ssh(vm, private_key)
|
|
||||||
|
|
||||||
# Authoritative fail-closed egress-boundary check, before the
|
|
||||||
# agent runs: prove the VM cannot reach the host directly.
|
|
||||||
isolation_probe.verify_isolation(private_key, slot.guest_ip)
|
|
||||||
|
|
||||||
bottle = FirecrackerBottle(
|
|
||||||
plan.container_name,
|
|
||||||
private_key=private_key,
|
|
||||||
guest_ip=slot.guest_ip,
|
|
||||||
guest_env=_agent_guest_env(plan, slot.host_ip),
|
|
||||||
agent_command=plan.agent_command,
|
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
|
||||||
agent_provider_template=plan.agent_provider_template,
|
|
||||||
terminal_title=(
|
|
||||||
f"{plan.spec.label} ({plan.spec.agent_name})"
|
|
||||||
if plan.spec.label else plan.spec.agent_name
|
|
||||||
),
|
|
||||||
terminal_color=plan.spec.color,
|
|
||||||
agent_workdir=plan.workspace_plan.workdir,
|
|
||||||
)
|
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
|
||||||
|
|
||||||
yield bottle
|
|
||||||
finally:
|
|
||||||
teardown()
|
|
||||||
|
|
||||||
|
|
||||||
def _mint_certs(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
|
|
||||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(egress_state_dir(plan.slug))
|
|
||||||
egress_plan = dataclasses.replace(
|
|
||||||
plan.egress_plan,
|
|
||||||
mitmproxy_ca_host_path=egress_ca_host,
|
|
||||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
|
||||||
)
|
)
|
||||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
yield # unreachable — `die` raises; keeps this a generator/contextmanager
|
||||||
|
|
||||||
|
|
||||||
def _build_agent_image(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
|
|
||||||
_docker_build(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed and _image_exists(committed):
|
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
|
||||||
)
|
|
||||||
_docker_build(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_gate_keys(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
|
|
||||||
if not plan.git_gate_plan.upstreams:
|
|
||||||
return plan
|
|
||||||
git_gate_plan = provision_git_gate_dynamic_keys(
|
|
||||||
plan.manifest.bottle, plan.git_gate_plan, git_gate_state_dir(plan.slug),
|
|
||||||
)
|
|
||||||
return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
|
|
||||||
|
|
||||||
|
|
||||||
def _stamp_agent_urls(
|
|
||||||
plan: FirecrackerBottlePlan, host_ip: str,
|
|
||||||
) -> FirecrackerBottlePlan:
|
|
||||||
proxy_url = f"http://{host_ip}:{EGRESS_PORT}"
|
|
||||||
supervise_url = (
|
|
||||||
f"http://{host_ip}:{SUPERVISE_PORT}/" if plan.supervise_plan is not None else ""
|
|
||||||
)
|
|
||||||
git_gate_url = (
|
|
||||||
f"http://{host_ip}:{_GIT_HTTP_PORT}" if plan.git_gate_plan.upstreams else ""
|
|
||||||
)
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_proxy_url=proxy_url,
|
|
||||||
agent_git_gate_url=git_gate_url,
|
|
||||||
agent_supervise_url=supervise_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- sidecar bundle (Docker) ----------------------------------------
|
|
||||||
|
|
||||||
def _start_sidecar_bundle(
|
|
||||||
plan: FirecrackerBottlePlan, sidecar_name: str, host_ip: str,
|
|
||||||
) -> None:
|
|
||||||
argv = ["docker", "run", "--name", sidecar_name, "--detach", "--rm",
|
|
||||||
"-e", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}"]
|
|
||||||
for entry in _sidecar_env_entries(plan):
|
|
||||||
argv += ["-e", entry]
|
|
||||||
for host_path, container_path, read_only in _sidecar_mounts(plan):
|
|
||||||
argv += ["-v", f"{host_path}:{container_path}{':ro' if read_only else ''}"]
|
|
||||||
# Publish on the slot's host TAP IP at fixed ports — each bottle
|
|
||||||
# has a distinct host_ip, so fixed ports never collide, and the VM
|
|
||||||
# reaches them at a stable, well-known address (its only route out).
|
|
||||||
for port in _sidecar_ports(plan):
|
|
||||||
argv += ["-p", f"{host_ip}:{port}:{port}"]
|
|
||||||
argv.append(SIDECAR_BUNDLE_IMAGE)
|
|
||||||
|
|
||||||
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
|
|
||||||
token_values = egress_resolve_token_values(
|
|
||||||
plan.egress_plan.token_env_map, effective_env,
|
|
||||||
)
|
|
||||||
env = {**os.environ, **token_values}
|
|
||||||
info(f"docker run sidecar bundle {sidecar_name} (published on {host_ip})")
|
|
||||||
result = subprocess.run(argv, capture_output=True, text=True, env=env, check=False)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(f"docker run for sidecar bundle {sidecar_name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_daemons(plan: FirecrackerBottlePlan) -> tuple[str, ...]:
|
|
||||||
daemons = ["egress"]
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
daemons += ["git-gate", "git-http"]
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
daemons.append("supervise")
|
|
||||||
return tuple(daemons)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_ports(plan: FirecrackerBottlePlan) -> tuple[int, ...]:
|
|
||||||
ports = [EGRESS_PORT]
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
ports.append(_GIT_HTTP_PORT)
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
ports.append(SUPERVISE_PORT)
|
|
||||||
return tuple(ports)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_env_entries(plan: FirecrackerBottlePlan) -> tuple[str, ...]:
|
|
||||||
env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan))
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
env += [
|
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
|
||||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
|
||||||
]
|
|
||||||
return tuple(env)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_mounts(
|
|
||||||
plan: FirecrackerBottlePlan,
|
|
||||||
) -> tuple[tuple[str, str, bool], ...]:
|
|
||||||
mounts: list[tuple[str, str, bool]] = []
|
|
||||||
ep = plan.egress_plan
|
|
||||||
mounts.append((str(ep.mitmproxy_ca_host_path.parent),
|
|
||||||
str(Path(EGRESS_CA_IN_CONTAINER).parent), False))
|
|
||||||
if ep.routes:
|
|
||||||
mounts.append((str(ep.routes_path.parent),
|
|
||||||
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True))
|
|
||||||
sp = plan.supervise_plan
|
|
||||||
if sp is not None:
|
|
||||||
mounts.append((str(sp.db_path.parent),
|
|
||||||
str(Path(DB_PATH_IN_CONTAINER).parent), False))
|
|
||||||
return tuple(mounts)
|
|
||||||
|
|
||||||
|
|
||||||
def _stage_git_gate(plan: FirecrackerBottlePlan, sidecar_name: str) -> None:
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
if not gp.upstreams:
|
|
||||||
return
|
|
||||||
_docker_exec(sidecar_name, [
|
|
||||||
"mkdir", "-p",
|
|
||||||
str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER, "/git",
|
|
||||||
str(Path(_GIT_GATE_READY_FILE).parent),
|
|
||||||
])
|
|
||||||
for host_path, container_path in _git_gate_files(plan):
|
|
||||||
_docker_cp(host_path, f"{sidecar_name}:{container_path}")
|
|
||||||
_docker_exec(sidecar_name, [
|
|
||||||
"sh", "-c",
|
|
||||||
f"chmod 755 {GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
|
|
||||||
f"{GIT_GATE_HOOK_IN_CONTAINER} {GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && "
|
|
||||||
f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && "
|
|
||||||
f"touch {_GIT_GATE_READY_FILE}",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def _git_gate_files(plan: FirecrackerBottlePlan) -> tuple[tuple[str, str], ...]:
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
files: list[tuple[str, str]] = [
|
|
||||||
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
|
||||||
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER),
|
|
||||||
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
|
||||||
]
|
|
||||||
for upstream in gp.upstreams:
|
|
||||||
files.append((expand_tilde(upstream.identity_file),
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key"))
|
|
||||||
if upstream.known_hosts_file:
|
|
||||||
files.append((str(upstream.known_hosts_file),
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts"))
|
|
||||||
return tuple(files)
|
|
||||||
|
|
||||||
|
|
||||||
# --- agent guest env -------------------------------------------------
|
|
||||||
|
|
||||||
def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str]:
|
|
||||||
"""Env injected into every agent/exec call over SSH. The VM has no
|
|
||||||
baked process env (it just runs init), so the proxy/CA/git/supervise
|
|
||||||
wiring is applied per-invocation."""
|
|
||||||
proxy_url = f"http://{host_ip}:{EGRESS_PORT}"
|
|
||||||
no_proxy = f"localhost,127.0.0.1,{host_ip}"
|
|
||||||
env: dict[str, str] = {
|
|
||||||
"HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url,
|
|
||||||
"https_proxy": proxy_url, "http_proxy": proxy_url,
|
|
||||||
"NO_PROXY": no_proxy, "no_proxy": no_proxy,
|
|
||||||
"NODE_EXTRA_CA_CERTS": AGENT_CA_PATH,
|
|
||||||
"SSL_CERT_FILE": AGENT_CA_BUNDLE,
|
|
||||||
"REQUESTS_CA_BUNDLE": AGENT_CA_BUNDLE,
|
|
||||||
}
|
|
||||||
if plan.agent_git_gate_url:
|
|
||||||
env["GIT_GATE_URL"] = plan.agent_git_gate_url
|
|
||||||
if plan.agent_supervise_url:
|
|
||||||
env["MCP_SUPERVISE_URL"] = plan.agent_supervise_url
|
|
||||||
for entry in egress_agent_env_entries(plan.egress_plan):
|
|
||||||
key, _, value = entry.partition("=")
|
|
||||||
env[key] = value
|
|
||||||
env.update(plan.agent_provision.guest_env)
|
|
||||||
# Forwarded (bare-name) env: resolve host values now, since the VM
|
|
||||||
# can't inherit them from a `docker run --env NAME`.
|
|
||||||
for name in plan.forwarded_env:
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is not None:
|
|
||||||
env[name] = value
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
# --- docker helpers --------------------------------------------------
|
|
||||||
|
|
||||||
def _docker_build(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
info(f"docker build {ref}")
|
|
||||||
args = ["docker", "build", "-t", ref]
|
|
||||||
if dockerfile:
|
|
||||||
if not os.path.isabs(dockerfile):
|
|
||||||
dockerfile = os.path.join(context, dockerfile)
|
|
||||||
args += ["-f", dockerfile]
|
|
||||||
args.append(context)
|
|
||||||
result = subprocess.run(args, check=False)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(f"docker build for {ref!r} failed")
|
|
||||||
|
|
||||||
|
|
||||||
def _image_exists(ref: str) -> bool:
|
|
||||||
return subprocess.run(
|
|
||||||
["docker", "image", "inspect", ref],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
||||||
).returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def _force_remove_container(name: str) -> None:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "rm", "-f", name],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _docker_exec(name: str, argv: list[str]) -> None:
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "exec", name, *argv], capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(f"docker exec in {name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
|
||||||
|
|
||||||
|
|
||||||
def _docker_cp(host_path: str, dest: str) -> None:
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "cp", host_path, dest], capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(f"docker cp {host_path} -> {dest} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Topology (per slot i):
|
|||||||
* a /31 host<->guest link: host = base + 2i (the gateway the VM
|
* a /31 host<->guest link: host = base + 2i (the gateway the VM
|
||||||
routes through), guest = base + 2i + 1 (the VM's address).
|
routes through), guest = base + 2i + 1 (the VM's address).
|
||||||
* isolation via ``table inet bot_bottle_fc``: a VM reaches only its
|
* isolation via ``table inet bot_bottle_fc``: a VM reaches only its
|
||||||
own sidecar (DNAT'd from the host TAP IP) and nothing else.
|
own gateway (DNAT'd from the host TAP IP) and nothing else.
|
||||||
|
|
||||||
The default IP block is ``10.243.0.0/16`` — an intentionally obscure
|
The default IP block is ``10.243.0.0/16`` — an intentionally obscure
|
||||||
corner of RFC-1918 private space. RFC-1918 is the range *designated*
|
corner of RFC-1918 private space. RFC-1918 is the range *designated*
|
||||||
@@ -88,10 +88,10 @@ def ip_base() -> str:
|
|||||||
return _cfg("BOT_BOTTLE_FC_IP_BASE")
|
return _cfg("BOT_BOTTLE_FC_IP_BASE")
|
||||||
|
|
||||||
|
|
||||||
# Sidecar ports the VM reaches at its host-side TAP IP. Kept in sync
|
# Gateway ports the VM reaches at its host-side TAP IP. Kept in sync
|
||||||
# with the backend constants (egress 9099, supervise 9100, git-http
|
# with the backend constants (egress 9099, supervise 9100, git-http
|
||||||
# 9420); rendered into the setup output for operator visibility.
|
# 9420); rendered into the setup output for operator visibility.
|
||||||
SIDECAR_PORTS = (9099, 9100, 9420)
|
GATEWAY_PORTS = (9099, 9100, 9420)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns
|
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns
|
||||||
the Apple `container` CLI integration; launch remains gated until the
|
the Apple `container` CLI integration; launch remains gated until the
|
||||||
sidecar network enforcement shape is implemented.
|
gateway network enforcement shape is implemented.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .backend import MacosContainerBottleBackend
|
from .backend import MacosContainerBottleBackend
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from . import util as container_mod
|
|||||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
_PREFIX = "bot-bottle-"
|
||||||
_BUNDLE_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def _list_prefixed_containers() -> list[str]:
|
def _list_prefixed_containers() -> list[str]:
|
||||||
@@ -24,7 +23,7 @@ def _list_prefixed_containers() -> list[str]:
|
|||||||
return []
|
return []
|
||||||
return sorted(
|
return sorted(
|
||||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
name for name in (line.strip() for line in result.stdout.splitlines())
|
||||||
if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX)
|
if name.startswith(_PREFIX)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,24 @@
|
|||||||
"""Host-side egress apply for the macos-container backend.
|
"""Host-side egress route-apply for the macos-container backend.
|
||||||
|
|
||||||
Uses `container kill --signal HUP` (Apple Container framework) instead
|
The per-bottle companion container this used to signal (`container kill
|
||||||
of `docker kill` to signal the sidecar bundle.
|
--signal HUP <container>`) was removed in the companion-container removal (#385),
|
||||||
|
along with the disabled macOS launch path. Fails closed until the macOS
|
||||||
|
backend grows the consolidated gateway.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...log import warn
|
|
||||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||||
from .launch import sidecar_container_name
|
|
||||||
|
|
||||||
|
|
||||||
class MacOSContainerEgressApplicator(EgressApplicator):
|
class MacOSContainerEgressApplicator(EgressApplicator):
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
def _signal_bundle_reload(self, slug: str) -> None:
|
||||||
container = sidecar_container_name(slug)
|
del slug
|
||||||
result = subprocess.run(
|
raise EgressApplyError(
|
||||||
["container", "kill", "--signal", "HUP", container],
|
"live egress route-apply was removed with the per-bottle "
|
||||||
capture_output=True, text=True, check=False, env=os.environ,
|
"companion container (#385); the macos-container backend is "
|
||||||
|
"disabled until it uses the consolidated gateway."
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
|
||||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
|
||||||
warn(
|
|
||||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
|
||||||
f"{last_error or 'container kill failed'}"
|
|
||||||
)
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not reload egress bundle {container}: "
|
|
||||||
f"{last_error or 'container kill failed'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
applicator = MacOSContainerEgressApplicator()
|
applicator = MacOSContainerEgressApplicator()
|
||||||
|
|||||||
@@ -1,40 +1,14 @@
|
|||||||
"""Active-agent enumeration for the macOS Apple Container backend."""
|
"""Active-agent enumeration for the macOS Apple Container backend.
|
||||||
|
|
||||||
|
The backend is disabled during the companion-container removal (#385) — it can't
|
||||||
|
launch bottles, so there are none to enumerate. Enumeration returns when
|
||||||
|
the backend grows the consolidated gateway.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...bottle_state import read_metadata
|
|
||||||
from .. import ActiveAgent
|
from .. import ActiveAgent
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
|
||||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
result = subprocess.run(
|
return []
|
||||||
["container", "list", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
out: list[ActiveAgent] = []
|
|
||||||
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
|
||||||
if not name.startswith(_PREFIX):
|
|
||||||
continue
|
|
||||||
if name.startswith(_SIDECAR_PREFIX):
|
|
||||||
continue
|
|
||||||
slug = name[len(_PREFIX):]
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
out.append(ActiveAgent(
|
|
||||||
backend_name="macos-container",
|
|
||||||
slug=slug,
|
|
||||||
agent_name=metadata.agent_name if metadata else "?",
|
|
||||||
started_at=metadata.started_at if metadata else "",
|
|
||||||
services=(),
|
|
||||||
label=metadata.label if metadata else "",
|
|
||||||
color=metadata.color if metadata else "",
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
|
|||||||
@@ -1,458 +1,39 @@
|
|||||||
"""Launch flow for the macOS Apple Container backend.
|
"""Launch flow for the macOS Apple Container backend — disabled (#385).
|
||||||
|
|
||||||
This backend keeps the explicit proxy-env enforcement model for v1:
|
This backend launched a per-bottle companion container (the egress /
|
||||||
the agent container is attached only to a host-only Apple Container
|
git-gate / supervise data plane) alongside the agent container, with the
|
||||||
network, while the sidecar bundle is attached to a NAT network first
|
agent's proxy env pointed at the companion's host-only IP. That
|
||||||
and the host-only network second. The sidecar's host-only IP is
|
per-bottle-companion architecture was removed in the companion-container removal;
|
||||||
discovered from `container inspect` and stamped into the agent's
|
the macOS backend will be re-enabled once it grows the consolidated
|
||||||
HTTP_PROXY / HTTPS_PROXY env vars.
|
per-host gateway the docker backend already uses.
|
||||||
|
|
||||||
|
Until then, launching a macOS bottle fails closed. `prepare` / `status`
|
||||||
|
/ cleanup still work.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
from contextlib import contextmanager
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from contextlib import ExitStack, contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...bottle_state import (
|
from ...log import die
|
||||||
egress_state_dir,
|
|
||||||
git_gate_state_dir,
|
|
||||||
read_committed_image,
|
|
||||||
)
|
|
||||||
from ...egress import (
|
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
|
||||||
egress_agent_env_entries,
|
|
||||||
egress_resolve_token_values,
|
|
||||||
egress_sidecar_env_entries,
|
|
||||||
)
|
|
||||||
from ...git_gate import (
|
|
||||||
provision_git_gate_dynamic_keys,
|
|
||||||
revoke_git_gate_provisioned_keys,
|
|
||||||
)
|
|
||||||
from ...log import die, info, warn
|
|
||||||
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
|
||||||
from ...util import expand_tilde
|
|
||||||
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
|
||||||
from ..docker.git_gate import (
|
|
||||||
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
|
||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
|
||||||
)
|
|
||||||
from ..docker.sidecar_bundle import (
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
)
|
|
||||||
from ..docker.egress import egress_tls_init
|
|
||||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
|
||||||
from . import util as container_mod
|
|
||||||
from .bottle import MacosContainerBottle
|
from .bottle import MacosContainerBottle
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
from .bottle_plan import MacosContainerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
|
||||||
_GIT_HTTP_PORT = 9420
|
|
||||||
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
|
|
||||||
|
|
||||||
|
|
||||||
def internal_network_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-net-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def egress_network_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-egress-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def sidecar_container_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-sidecars-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launch(
|
def launch(
|
||||||
plan: MacosContainerBottlePlan,
|
plan: MacosContainerBottlePlan,
|
||||||
*,
|
*,
|
||||||
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
||||||
) -> Generator[MacosContainerBottle, None, None]:
|
) -> Generator[MacosContainerBottle, None, None]:
|
||||||
"""Build, run, provision, and yield an Apple Container bottle."""
|
"""Fail closed: the macOS backend is disabled until it grows the
|
||||||
stack = ExitStack()
|
consolidated per-host gateway (the companion-container path it used
|
||||||
bottle_for_revoke = plan.manifest.bottle
|
was removed in #385)."""
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
del plan, provision
|
||||||
|
die(
|
||||||
def teardown() -> None:
|
"the macos-container backend is temporarily disabled during the "
|
||||||
teardown_exc: BaseException | None = None
|
"companion-container removal (#385); it will return once it uses "
|
||||||
try:
|
"the consolidated gateway. Use --backend=docker for now."
|
||||||
stack.close()
|
|
||||||
except BaseException as exc: # noqa: W0718 - teardown must continue
|
|
||||||
teardown_exc = exc
|
|
||||||
warn(f"macos-container teardown failed: {exc!r}")
|
|
||||||
revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
|
|
||||||
if teardown_exc is not None:
|
|
||||||
raise teardown_exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
plan = _mint_certs(plan)
|
|
||||||
plan = _build_images(plan)
|
|
||||||
|
|
||||||
internal_network = internal_network_name(plan.slug)
|
|
||||||
egress_network = egress_network_name(plan.slug)
|
|
||||||
_create_networks(internal_network, egress_network, stack)
|
|
||||||
|
|
||||||
plan = _provision_git_gate_keys(plan)
|
|
||||||
|
|
||||||
sidecar_name = sidecar_container_name(plan.slug)
|
|
||||||
container_mod.force_remove_container(sidecar_name)
|
|
||||||
_start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network)
|
|
||||||
stack.callback(container_mod.force_remove_container, sidecar_name)
|
|
||||||
_stage_git_gate(plan, sidecar_name)
|
|
||||||
|
|
||||||
sidecar_ip = container_mod.container_ipv4_on_network(
|
|
||||||
sidecar_name, internal_network,
|
|
||||||
)
|
|
||||||
plan = _stamp_agent_urls(plan, sidecar_ip)
|
|
||||||
|
|
||||||
container_mod.force_remove_container(plan.container_name)
|
|
||||||
_start_agent(plan, internal_network, sidecar_ip)
|
|
||||||
stack.callback(container_mod.force_remove_container, plan.container_name)
|
|
||||||
|
|
||||||
bottle = MacosContainerBottle(
|
|
||||||
plan.container_name,
|
|
||||||
teardown,
|
|
||||||
None,
|
|
||||||
agent_command=plan.agent_command,
|
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
|
||||||
agent_provider_template=plan.agent_provider_template,
|
|
||||||
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
|
||||||
terminal_color=plan.spec.color,
|
|
||||||
agent_workdir=plan.workspace_plan.workdir,
|
|
||||||
)
|
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
|
||||||
|
|
||||||
yield bottle
|
|
||||||
finally:
|
|
||||||
teardown()
|
|
||||||
|
|
||||||
|
|
||||||
def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|
||||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
|
||||||
egress_state_dir(plan.slug),
|
|
||||||
)
|
)
|
||||||
egress_plan = dataclasses.replace(
|
yield # unreachable — `die` raises; keeps this a generator/contextmanager
|
||||||
plan.egress_plan,
|
|
||||||
mitmproxy_ca_host_path=egress_ca_host,
|
|
||||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
|
||||||
)
|
|
||||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|
||||||
container_mod.build_image(
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
_REPO_DIR,
|
|
||||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
)
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed and container_mod.image_exists(committed):
|
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(
|
|
||||||
plan.agent_provision,
|
|
||||||
image=committed,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
container_mod.build_image(
|
|
||||||
plan.image,
|
|
||||||
_REPO_DIR,
|
|
||||||
dockerfile=plan.dockerfile_path,
|
|
||||||
)
|
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
def _create_networks(
|
|
||||||
internal_network: str,
|
|
||||||
egress_network: str,
|
|
||||||
stack: ExitStack,
|
|
||||||
) -> None:
|
|
||||||
container_mod.create_network(internal_network, internal=True)
|
|
||||||
stack.callback(container_mod.remove_network, internal_network)
|
|
||||||
container_mod.create_network(egress_network)
|
|
||||||
stack.callback(container_mod.remove_network, egress_network)
|
|
||||||
|
|
||||||
|
|
||||||
def _start_sidecar_bundle(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_name: str,
|
|
||||||
internal_network: str,
|
|
||||||
egress_network: str,
|
|
||||||
) -> None:
|
|
||||||
argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network)
|
|
||||||
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
|
|
||||||
token_values = egress_resolve_token_values(
|
|
||||||
plan.egress_plan.token_env_map, effective_env,
|
|
||||||
)
|
|
||||||
env = {**os.environ, **token_values}
|
|
||||||
info(f"container run sidecar bundle {sidecar_name}")
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True, env=env, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container run for sidecar bundle {sidecar_name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _start_agent(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
internal_network: str,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> None:
|
|
||||||
argv = _agent_run_argv(plan, internal_network, sidecar_ip)
|
|
||||||
env = {
|
|
||||||
**os.environ,
|
|
||||||
**plan.forwarded_env,
|
|
||||||
}
|
|
||||||
info(f"container run agent {plan.container_name}")
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True, env=env, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container run for agent {plan.container_name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stamp_agent_urls(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
|
||||||
supervise_url = ""
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
|
|
||||||
git_gate_url = ""
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}"
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_proxy_url=proxy_url,
|
|
||||||
agent_git_gate_url=git_gate_url,
|
|
||||||
agent_supervise_url=supervise_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_git_gate_keys(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
if not plan.git_gate_plan.upstreams:
|
|
||||||
return plan
|
|
||||||
git_gate_plan = provision_git_gate_dynamic_keys(
|
|
||||||
plan.manifest.bottle,
|
|
||||||
plan.git_gate_plan,
|
|
||||||
git_gate_state_dir(plan.slug),
|
|
||||||
)
|
|
||||||
return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
|
|
||||||
|
|
||||||
|
|
||||||
def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None:
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
if not gp.upstreams:
|
|
||||||
return
|
|
||||||
|
|
||||||
container_mod.exec_container(
|
|
||||||
sidecar_name,
|
|
||||||
[
|
|
||||||
"mkdir",
|
|
||||||
"-p",
|
|
||||||
str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
|
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
|
||||||
"/git",
|
|
||||||
str(Path(_GIT_GATE_READY_FILE).parent),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
for host_path, container_path in _git_gate_files(plan):
|
|
||||||
container_mod.copy_into_container(
|
|
||||||
sidecar_name, host_path, container_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
container_mod.exec_container(
|
|
||||||
sidecar_name,
|
|
||||||
[
|
|
||||||
"sh",
|
|
||||||
"-c",
|
|
||||||
"chmod 755 "
|
|
||||||
f"{GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
|
|
||||||
f"{GIT_GATE_HOOK_IN_CONTAINER} "
|
|
||||||
f"{GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && "
|
|
||||||
f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && "
|
|
||||||
f"touch {_GIT_GATE_READY_FILE}",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _git_gate_files(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
) -> tuple[tuple[str, str], ...]:
|
|
||||||
gp = plan.git_gate_plan
|
|
||||||
files: list[tuple[str, str]] = [
|
|
||||||
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER),
|
|
||||||
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER),
|
|
||||||
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
|
|
||||||
]
|
|
||||||
for upstream in gp.upstreams:
|
|
||||||
files.append((
|
|
||||||
expand_tilde(upstream.identity_file),
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key",
|
|
||||||
))
|
|
||||||
if upstream.known_hosts_file:
|
|
||||||
files.append((
|
|
||||||
str(upstream.known_hosts_file),
|
|
||||||
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts",
|
|
||||||
))
|
|
||||||
return tuple(files)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_run_argv(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_name: str,
|
|
||||||
internal_network: str,
|
|
||||||
egress_network: str,
|
|
||||||
) -> list[str]:
|
|
||||||
argv = [
|
|
||||||
"container", "run",
|
|
||||||
"--name", sidecar_name,
|
|
||||||
"--detach",
|
|
||||||
"--rm",
|
|
||||||
"--network", egress_network,
|
|
||||||
"--network", internal_network,
|
|
||||||
"--dns", _sidecar_dns(),
|
|
||||||
"--env", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}",
|
|
||||||
]
|
|
||||||
for entry in _sidecar_env_entries(plan):
|
|
||||||
argv += ["--env", entry]
|
|
||||||
for host_path, container_path, read_only in _sidecar_mounts(plan):
|
|
||||||
argv += ["--mount", _mount_spec(host_path, container_path, read_only)]
|
|
||||||
argv.append(SIDECAR_BUNDLE_IMAGE)
|
|
||||||
return argv
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_run_argv(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
internal_network: str,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> list[str]:
|
|
||||||
argv = [
|
|
||||||
"container", "run",
|
|
||||||
"--name", plan.container_name,
|
|
||||||
"--detach",
|
|
||||||
"--network", internal_network,
|
|
||||||
]
|
|
||||||
for entry in _agent_env_entries(plan, sidecar_ip):
|
|
||||||
argv += ["--env", entry]
|
|
||||||
argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS]
|
|
||||||
return argv
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_dns() -> str:
|
|
||||||
return container_mod.dns_server()
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
|
||||||
daemons = ["egress"]
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
daemons += ["git-gate", "git-http"]
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
daemons.append("supervise")
|
|
||||||
return tuple(daemons)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
|
||||||
env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan))
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
env += [
|
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
|
||||||
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
|
||||||
]
|
|
||||||
return tuple(env)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_mounts(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
) -> tuple[tuple[str, str, bool], ...]:
|
|
||||||
mounts: list[tuple[str, str, bool]] = []
|
|
||||||
|
|
||||||
ep = plan.egress_plan
|
|
||||||
mounts.append((
|
|
||||||
str(ep.mitmproxy_ca_host_path.parent),
|
|
||||||
str(Path(EGRESS_CA_IN_CONTAINER).parent),
|
|
||||||
False,
|
|
||||||
))
|
|
||||||
if ep.routes:
|
|
||||||
mounts.append((
|
|
||||||
str(ep.routes_path.parent),
|
|
||||||
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
|
|
||||||
True,
|
|
||||||
))
|
|
||||||
|
|
||||||
sp = plan.supervise_plan
|
|
||||||
if sp is not None:
|
|
||||||
# `container run --mount type=bind` only accepts directory
|
|
||||||
# sources (a file source fails with "is not a directory") —
|
|
||||||
# mount db_path's dedicated parent dir instead of the file
|
|
||||||
# itself, same as the CA/routes mounts above.
|
|
||||||
mounts.append((
|
|
||||||
str(sp.db_path.parent),
|
|
||||||
str(Path(DB_PATH_IN_CONTAINER).parent),
|
|
||||||
False,
|
|
||||||
))
|
|
||||||
|
|
||||||
return tuple(mounts)
|
|
||||||
|
|
||||||
def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str:
|
|
||||||
spec = f"type=bind,source={host_path},target={container_path}"
|
|
||||||
if read_only:
|
|
||||||
spec += ",readonly"
|
|
||||||
return spec
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_env_entries(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
|
||||||
no_proxy = _agent_no_proxy(plan, sidecar_ip)
|
|
||||||
env = [
|
|
||||||
f"HTTPS_PROXY={proxy_url}",
|
|
||||||
f"HTTP_PROXY={proxy_url}",
|
|
||||||
f"https_proxy={proxy_url}",
|
|
||||||
f"http_proxy={proxy_url}",
|
|
||||||
f"NO_PROXY={no_proxy}",
|
|
||||||
f"no_proxy={no_proxy}",
|
|
||||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
|
||||||
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
|
||||||
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
|
||||||
]
|
|
||||||
if plan.agent_git_gate_url:
|
|
||||||
env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}")
|
|
||||||
if plan.agent_supervise_url:
|
|
||||||
env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}")
|
|
||||||
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
|
||||||
env.append(f"{name}={value}")
|
|
||||||
for name in sorted(plan.forwarded_env.keys()):
|
|
||||||
env.append(name)
|
|
||||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
|
||||||
return tuple(env)
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str:
|
|
||||||
hosts = ["localhost", "127.0.0.1", sidecar_ip]
|
|
||||||
return ",".join(hosts)
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def prepare_egress(
|
|||||||
|
|
||||||
|
|
||||||
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
|
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
|
||||||
"""Prepare the supervise sidecar state dir. Returns None when
|
"""Prepare the supervise daemon state dir. Returns None when
|
||||||
bottle.supervise is falsy."""
|
bottle.supervise is falsy."""
|
||||||
if not bottle.supervise:
|
if not bottle.supervise:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -44,16 +44,16 @@ _STATE_SUBDIR = "state"
|
|||||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||||
_COMMITTED_IMAGE_NAME = "committed-image"
|
_COMMITTED_IMAGE_NAME = "committed-image"
|
||||||
_TRANSCRIPT_SUBDIR = "transcript"
|
_TRANSCRIPT_SUBDIR = "transcript"
|
||||||
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
# Per-daemon scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
||||||
# live here so chunk 3's `docker compose up` can find them at stable
|
# live here so chunk 3's `docker compose up` can find them at stable
|
||||||
# paths. Each sidecar's `prepare()` writes config + CAs into its own
|
# paths. Each daemon's `prepare()` writes config + CAs into its own
|
||||||
# subdir; the launch step is unchanged today (still `docker cp`).
|
# subdir; the launch step is unchanged today (still `docker cp`).
|
||||||
_EGRESS_SUBDIR = "egress"
|
_EGRESS_SUBDIR = "egress"
|
||||||
_GIT_GATE_SUBDIR = "git-gate"
|
_GIT_GATE_SUBDIR = "git-gate"
|
||||||
_SUPERVISE_SUBDIR = "supervise"
|
_SUPERVISE_SUBDIR = "supervise"
|
||||||
_AGENT_SUBDIR = "agent"
|
_AGENT_SUBDIR = "agent"
|
||||||
_METADATA_NAME = "metadata.json"
|
_METADATA_NAME = "metadata.json"
|
||||||
# Live-config dir bind-mounted into the supervise sidecar (read-only).
|
# Live-config dir bind-mounted into the supervise daemon (read-only).
|
||||||
# Host's apply paths keep these files fresh so supervise's
|
# Host's apply paths keep these files fresh so supervise's
|
||||||
# `list-egress-routes` MCP tool returns the current state —
|
# `list-egress-routes` MCP tool returns the current state —
|
||||||
# not a snapshot from launch time.
|
# not a snapshot from launch time.
|
||||||
@@ -222,7 +222,7 @@ def per_bottle_image_tag(identity: str) -> str:
|
|||||||
|
|
||||||
def live_config_dir(identity: str) -> Path:
|
def live_config_dir(identity: str) -> Path:
|
||||||
"""Per-bottle live-config dir. Bind-mounted read-only into the
|
"""Per-bottle live-config dir. Bind-mounted read-only into the
|
||||||
supervise sidecar; the host's apply paths refresh the files on
|
supervise daemon; the host's apply paths refresh the files on
|
||||||
every operator approval so the agent's `list-*` MCP tools always
|
every operator approval so the agent's `list-*` MCP tools always
|
||||||
return current state."""
|
return current state."""
|
||||||
return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR
|
return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR
|
||||||
@@ -260,9 +260,9 @@ def transcript_snapshot_dir(identity: str) -> Path:
|
|||||||
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
# --- Per-sidecar scratch subdirs (PRD 0018 chunk 2) ------------------------
|
# --- Per-daemon scratch subdirs (PRD 0018 chunk 2) ------------------------
|
||||||
#
|
#
|
||||||
# Each sidecar gets its own subdir under the bottle's state dir for
|
# Each daemon gets its own subdir under the bottle's state dir for
|
||||||
# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes
|
# bind-mount sources (config, CAs, hooks, etc.). Prepare-time writes
|
||||||
# land here; the state dir's normal cleanup (`cleanup_state`) reaps
|
# land here; the state dir's normal cleanup (`cleanup_state`) reaps
|
||||||
# them along with everything else when the bottle session ends and
|
# them along with everything else when the bottle session ends and
|
||||||
@@ -270,20 +270,20 @@ def transcript_snapshot_dir(identity: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def egress_state_dir(identity: str) -> Path:
|
def egress_state_dir(identity: str) -> Path:
|
||||||
"""State subdir for the egress sidecar: routes.yaml + the
|
"""State subdir for the egress daemon: routes.yaml + the
|
||||||
per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward."""
|
per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward."""
|
||||||
return bottle_state_dir(identity) / _EGRESS_SUBDIR
|
return bottle_state_dir(identity) / _EGRESS_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
def git_gate_state_dir(identity: str) -> Path:
|
def git_gate_state_dir(identity: str) -> Path:
|
||||||
"""State subdir for the git-gate sidecar: entrypoint + hooks +
|
"""State subdir for the git-gate daemon: entrypoint + hooks +
|
||||||
per-upstream known_hosts. Bind-mount source from chunk 3
|
per-upstream known_hosts. Bind-mount source from chunk 3
|
||||||
onward."""
|
onward."""
|
||||||
return bottle_state_dir(identity) / _GIT_GATE_SUBDIR
|
return bottle_state_dir(identity) / _GIT_GATE_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
def supervise_state_dir(identity: str) -> Path:
|
def supervise_state_dir(identity: str) -> Path:
|
||||||
"""State subdir reserved for supervise sidecar bind-mount sources.
|
"""State subdir reserved for supervise daemon bind-mount sources.
|
||||||
Runtime queue/audit rows live in the host-level bot-bottle SQLite
|
Runtime queue/audit rows live in the host-level bot-bottle SQLite
|
||||||
database, so they survive state-dir cleanup."""
|
database, so they survive state-dir cleanup."""
|
||||||
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Walks every registered backend (docker, firecracker, macos-container)
|
Walks every registered backend (docker, firecracker, macos-container)
|
||||||
so a single `./cli.py cleanup` reaps every backend's leftovers — a
|
so a single `./cli.py cleanup` reaps every backend's leftovers — a
|
||||||
firecracker bottle's sidecars won't survive a docker-only cleanup pass
|
firecracker bottle's VM processes and run dirs won't survive a
|
||||||
(issue addressed alongside #77).
|
docker-only cleanup pass (issue addressed alongside #77).
|
||||||
|
|
||||||
Each backend's `prepare_cleanup` enumerates its own resources;
|
Each backend's `prepare_cleanup` enumerates its own resources;
|
||||||
docker's `_list_orphan_state_dirs` consults
|
docker's `_list_orphan_state_dirs` consults
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ The Claude-specific behavior previously inlined under
|
|||||||
`agent_provider.agent_provision_plan` (claude.json trust marker,
|
`agent_provider.agent_provision_plan` (claude.json trust marker,
|
||||||
api.anthropic.com egress route, OAuth-token placeholder), plus
|
api.anthropic.com egress route, OAuth-token placeholder), plus
|
||||||
the `claude mcp add` invocation that registers the supervise
|
the `claude mcp add` invocation that registers the supervise
|
||||||
sidecar in claude-code's user config (PRD 0013)."""
|
gateway in claude-code's user config (PRD 0013)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
supervise_url: str,
|
supervise_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run `claude mcp add` inside the agent guest to register the
|
"""Run `claude mcp add` inside the agent guest to register the
|
||||||
supervise sidecar in claude-code's user config (~/.claude.json).
|
supervise daemon in claude-code's user config (~/.claude.json).
|
||||||
|
|
||||||
Failure is logged but not fatal — the bottle still works without
|
Failure is logged but not fatal — the bottle still works without
|
||||||
the entry; the operator can register it manually."""
|
the entry; the operator can register it manually."""
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ The Codex-specific behavior previously inlined under
|
|||||||
`agent_provider.agent_provision_plan` (config.toml trust marker,
|
`agent_provider.agent_provision_plan` (config.toml trust marker,
|
||||||
chatgpt.com / api.openai.com egress routes, optional host-credential
|
chatgpt.com / api.openai.com egress routes, optional host-credential
|
||||||
forwarding with dummy-auth.json + verify), plus the `codex mcp add`
|
forwarding with dummy-auth.json + verify), plus the `codex mcp add`
|
||||||
invocation that registers the supervise sidecar in Codex's
|
invocation that registers the supervise daemon in Codex's
|
||||||
~/.codex/config.toml (PRD 0050)."""
|
~/.codex/config.toml (PRD 0050)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -266,7 +266,7 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
supervise_url: str,
|
supervise_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run `codex mcp add` inside the agent guest to register the
|
"""Run `codex mcp add` inside the agent guest to register the
|
||||||
supervise sidecar in Codex's user config (~/.codex/config.toml).
|
supervise daemon in Codex's user config (~/.codex/config.toml).
|
||||||
|
|
||||||
Mirrors the Claude provider's `claude mcp add` flow — failure
|
Mirrors the Claude provider's `claude mcp add` flow — failure
|
||||||
is logged but not fatal."""
|
is logged but not fatal."""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Pure Python, no mitmproxy dependency. Each detector is a module-level
|
Pure Python, no mitmproxy dependency. Each detector is a module-level
|
||||||
function returning `ScanResult | None`.
|
function returning `ScanResult | None`.
|
||||||
|
|
||||||
Ships flat into the sidecar bundle image alongside
|
Ships flat into the gateway image alongside
|
||||||
`egress_addon_core.py` — both this file and the package source use
|
`egress_addon_core.py` — both this file and the package source use
|
||||||
the same try/except import shim pattern.
|
the same try/except import shim pattern.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This module defines the abstract proxy (`Egress`), its plan
|
This module defines the abstract proxy (`Egress`), its plan
|
||||||
dataclass (`EgressPlan`), and the resolved per-route shape
|
dataclass (`EgressPlan`), and the resolved per-route shape
|
||||||
(`EgressRoute`). The sidecar's start/stop lifecycle is backend-
|
(`EgressRoute`). The gateway's start/stop lifecycle is backend-
|
||||||
specific and lives on concrete subclasses (see
|
specific and lives on concrete subclasses (see
|
||||||
`bot_bottle/backend/docker/egress.py`).
|
`bot_bottle/backend/docker/egress.py`).
|
||||||
"""
|
"""
|
||||||
@@ -63,8 +63,8 @@ def _random_canary_env() -> str:
|
|||||||
return f"{first}_{second}_SECRET"
|
return f"{first}_{second}_SECRET"
|
||||||
|
|
||||||
|
|
||||||
def egress_sidecar_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
def egress_gateway_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
||||||
"""Return sidecar env entries needed by egress across all backends."""
|
"""Return gateway env entries needed by egress across all backends."""
|
||||||
env: list[str] = []
|
env: list[str] = []
|
||||||
if plan.routes:
|
if plan.routes:
|
||||||
env.extend(sorted(plan.token_env_map.keys()))
|
env.extend(sorted(plan.token_env_map.keys()))
|
||||||
@@ -87,7 +87,7 @@ class EgressRoute(Route):
|
|||||||
|
|
||||||
Inherits `host`, `matches`, `auth_scheme`, and `token_env`
|
Inherits `host`, `matches`, `auth_scheme`, and `token_env`
|
||||||
from `egress_addon_core.Route` — those are the fields that cross the
|
from `egress_addon_core.Route` — those are the fields that cross the
|
||||||
YAML wire into the sidecar. The fields below are host-only and
|
YAML wire into the gateway. The fields below are host-only and
|
||||||
are never serialised to the addon.
|
are never serialised to the addon.
|
||||||
|
|
||||||
`token_ref` is the host env var the CLI reads at launch and forwards
|
`token_ref` is the host env var the CLI reads at launch and forwards
|
||||||
@@ -386,7 +386,7 @@ class Egress(ABC):
|
|||||||
routes_path.write_text(egress_render_routes(routes, log=log))
|
routes_path.write_text(egress_render_routes(routes, log=log))
|
||||||
routes_path.chmod(0o600)
|
routes_path.chmod(0o600)
|
||||||
# Generate a per-session fake secret under a plausible random env name.
|
# Generate a per-session fake secret under a plausible random env name.
|
||||||
# The sidecar marks that exact env name as sensitive for known-secret
|
# The gateway marks that exact env name as sensitive for known-secret
|
||||||
# scanning; the agent receives the same name/value as exfil bait.
|
# scanning; the agent receives the same name/value as exfil bait.
|
||||||
canary = secrets.token_urlsafe(32)
|
canary = secrets.token_urlsafe(32)
|
||||||
return EgressPlan(
|
return EgressPlan(
|
||||||
@@ -412,6 +412,6 @@ __all__ = [
|
|||||||
"egress_resolve_token_values",
|
"egress_resolve_token_values",
|
||||||
"egress_routes_for_bottle",
|
"egress_routes_for_bottle",
|
||||||
"egress_agent_env_entries",
|
"egress_agent_env_entries",
|
||||||
"egress_sidecar_env_entries",
|
"egress_gateway_env_entries",
|
||||||
"egress_token_env_map",
|
"egress_token_env_map",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ class EgressAddon:
|
|||||||
tokens, resolved by source IP in one round-trip (fail-closed to deny-all
|
tokens, resolved by source IP in one round-trip (fail-closed to deny-all
|
||||||
+ empty slug if unattributed); `env` is the process env overlaid with
|
+ empty slug if unattributed); `env` is the process env overlaid with
|
||||||
the bottle's tokens, so upstream-auth injection (and DLP) use *this*
|
the bottle's tokens, so upstream-auth injection (and DLP) use *this*
|
||||||
bottle's credentials — exactly what the per-bottle sidecar's env did.
|
bottle's credentials — exactly what the per-bottle gateway daemon's env did.
|
||||||
The identity token, if the agent injected one, is read then stripped so
|
The identity token, if the agent injected one, is read then stripped so
|
||||||
it never leaks upstream."""
|
it never leaks upstream."""
|
||||||
if self._resolver is None:
|
if self._resolver is None:
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ exercise the parse + decision functions without depending on the
|
|||||||
container.
|
container.
|
||||||
|
|
||||||
Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
|
Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
|
||||||
ships flat into the sidecar bundle image alongside this file —
|
ships flat into the gateway image alongside this file —
|
||||||
see `Dockerfile.sidecars`)."""
|
see `Dockerfile.gateway`)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ except ImportError: # pragma: no cover - host-side path
|
|||||||
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||||
|
|
||||||
# DLP detector-config parsing lives in a sibling module (also flat-bundled
|
# DLP detector-config parsing lives in a sibling module (also flat-bundled
|
||||||
# into the gateway — see Dockerfile.sidecars). Re-exported below so existing
|
# into the gateway — see Dockerfile.gateway). Re-exported below so existing
|
||||||
# `from egress_addon_core import ON_MATCH_*` callers keep working.
|
# `from egress_addon_core import ON_MATCH_*` callers keep working.
|
||||||
try:
|
try:
|
||||||
from egress_dlp_config import ( # type: ignore[import-not-found]
|
from egress_dlp_config import ( # type: ignore[import-not-found]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ and what the proxy does when an outbound detector matches a token
|
|||||||
kept apart from the request-time scan/decision flow in `egress_addon_core`
|
kept apart from the request-time scan/decision flow in `egress_addon_core`
|
||||||
so each half reads top-to-bottom without scrolling past the other.
|
so each half reads top-to-bottom without scrolling past the other.
|
||||||
|
|
||||||
Stdlib-only; ships flat into the sidecar bundle image alongside
|
Stdlib-only; ships flat into the gateway image alongside
|
||||||
`egress_addon_core.py` — see `Dockerfile.sidecars`."""
|
`egress_addon_core.py` — see `Dockerfile.gateway`."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Egress daemon entrypoint inside the sidecar bundle (PRD 0024).
|
# Egress daemon entrypoint inside the gateway (PRD 0024).
|
||||||
#
|
#
|
||||||
# Extracted verbatim from Dockerfile.egress's prior inline `sh -c`
|
# Extracted verbatim from Dockerfile.egress's prior inline `sh -c`
|
||||||
# ENTRYPOINT so the supervisor in bot_bottle/sidecar_init.py can
|
# ENTRYPOINT so the supervisor in bot_bottle/gateway_init.py can
|
||||||
# call it as a normal child. Behavior is unchanged:
|
# call it as a normal child. Behavior is unchanged:
|
||||||
#
|
#
|
||||||
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
|
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
|
||||||
@@ -22,7 +22,7 @@ set -e
|
|||||||
|
|
||||||
# Pin mitmproxy's config dir to the bind-mount location of its CA
|
# Pin mitmproxy's config dir to the bind-mount location of its CA
|
||||||
# regardless of which user mitmdump runs as. In the legacy
|
# regardless of which user mitmdump runs as. In the legacy
|
||||||
# four-sidecar setup (Dockerfile.egress, USER mitmproxy) this
|
# four-daemon setup (Dockerfile.egress, USER mitmproxy) this
|
||||||
# resolved naturally to `~mitmproxy/.mitmproxy`. In the PRD 0024
|
# resolved naturally to `~mitmproxy/.mitmproxy`. In the PRD 0024
|
||||||
# bundle (USER root) `~root/.mitmproxy` is empty, so without this
|
# bundle (USER root) `~root/.mitmproxy` is empty, so without this
|
||||||
# flag mitmdump would generate a fresh CA on the wrong path and
|
# flag mitmdump would generate a fresh CA on the wrong path and
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
"""Per-bottle sidecar supervisor (PRD 0024 chunk 1).
|
"""Gateway data-plane supervisor (PRD 0070; PRD 0024 bundle shape).
|
||||||
|
|
||||||
PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns
|
PID 1 inside the `bot-bottle-gateway` data-plane image. Spawns
|
||||||
the configured daemons (egress, git-gate, supervise),
|
the configured daemons (egress, git-gate, supervise),
|
||||||
forwards SIGTERM/SIGINT to each child, and propagates per-daemon
|
forwards SIGTERM/SIGINT to each child, and propagates per-daemon
|
||||||
stdout+stderr to the container log with a `[name] ` prefix.
|
stdout+stderr to the container log with a `[name] ` prefix.
|
||||||
|
|
||||||
Failure policy (interim): when a child dies unexpectedly, the
|
Failure policy (interim): when a child dies unexpectedly, the
|
||||||
supervisor logs the death and leaves the surviving children
|
supervisor logs the death and leaves the surviving children
|
||||||
running. The bundle stays up; whatever the dead daemon served
|
running. The gateway stays up; whatever the dead daemon served
|
||||||
will start failing, surfacing in the agent's own error path.
|
will start failing, surfacing in the agent's own error path.
|
||||||
The supervisor itself exits only when (a) the operator/compose
|
The supervisor itself exits only when (a) the operator sends
|
||||||
sends SIGTERM/SIGINT, or (b) every child has died.
|
SIGTERM/SIGINT, or (b) every child has died.
|
||||||
|
|
||||||
Failure policy (eventual): on unexpected death, the supervisor
|
Failure policy (eventual): on unexpected death, the supervisor
|
||||||
restarts the daemon and emits a notification to the supervise
|
restarts the daemon and emits a notification to the supervise
|
||||||
sidecar so the operator sees the event. That lands in a later
|
daemon so the operator sees the event. That lands in a later
|
||||||
PR; the interim policy is "don't take the bundle down for one
|
PR; the interim policy is "don't take the gateway down for one
|
||||||
sick daemon."
|
sick daemon."
|
||||||
|
|
||||||
Daemon subset is env-driven. The compose renderer narrows it via
|
Daemon subset is env-driven via `BOT_BOTTLE_GATEWAY_DAEMONS=egress`
|
||||||
`BOT_BOTTLE_SIDECAR_DAEMONS=egress` for bottles that
|
for callers that don't use git-gate or supervise. Default: all
|
||||||
don't use git-gate or supervise. Default: all daemons.
|
daemons.
|
||||||
|
|
||||||
Stdlib-only by design — adding supervisord/s6/runit for four
|
Stdlib-only by design — adding supervisord/s6/runit for four
|
||||||
daemons is heavier than this script.
|
daemons is heavier than this script.
|
||||||
@@ -103,8 +103,8 @@ def _selected_daemons(
|
|||||||
env: dict[str, str],
|
env: dict[str, str],
|
||||||
all_daemons: Sequence[_DaemonSpec] | None = None,
|
all_daemons: Sequence[_DaemonSpec] | None = None,
|
||||||
) -> tuple[_DaemonSpec, ...]:
|
) -> tuple[_DaemonSpec, ...]:
|
||||||
"""Filter the daemon set by the BOT_BOTTLE_SIDECAR_DAEMONS env
|
"""Filter the daemon set by the BOT_BOTTLE_GATEWAY_DAEMONS env
|
||||||
var. Unknown names in the list are ignored — the renderer is the
|
var. Unknown names in the list are ignored — the caller is the
|
||||||
source of truth for which daemons are wired.
|
source of truth for which daemons are wired.
|
||||||
|
|
||||||
`all_daemons` defaults to `_DAEMONS` resolved at call time (not
|
`all_daemons` defaults to `_DAEMONS` resolved at call time (not
|
||||||
@@ -112,7 +112,7 @@ def _selected_daemons(
|
|||||||
`_DAEMONS` and have the new value take effect."""
|
`_DAEMONS` and have the new value take effect."""
|
||||||
if all_daemons is None:
|
if all_daemons is None:
|
||||||
all_daemons = _DAEMONS
|
all_daemons = _DAEMONS
|
||||||
raw = env.get("BOT_BOTTLE_SIDECAR_DAEMONS", "").strip()
|
raw = env.get("BOT_BOTTLE_GATEWAY_DAEMONS", "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return tuple(all_daemons)
|
return tuple(all_daemons)
|
||||||
wanted = {n.strip() for n in raw.split(",") if n.strip()}
|
wanted = {n.strip() for n in raw.split(",") if n.strip()}
|
||||||
@@ -120,7 +120,7 @@ def _selected_daemons(
|
|||||||
|
|
||||||
|
|
||||||
def _log(msg: str) -> None:
|
def _log(msg: str) -> None:
|
||||||
sys.stdout.write(f"sidecar-init: {msg}\n")
|
sys.stdout.write(f"gateway-init: {msg}\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Per-agent git-gate (PRD 0008).
|
"""Per-agent git-gate (PRD 0008).
|
||||||
|
|
||||||
A third per-agent sidecar that fronts the bottle's declared git
|
A third per-agent daemon that fronts the bottle's declared git
|
||||||
upstreams as a transparent mirror. Each `bottle.git` entry maps to
|
upstreams as a transparent mirror. Each `bottle.git` entry maps to
|
||||||
a bare repo on the gate; `git daemon` serves the bare repos over
|
a bare repo on the gate; `git daemon` serves the bare repos over
|
||||||
`git://<gate>/<name>.git`. Two hooks make the mirror bidirectional:
|
`git://<gate>/<name>.git`. Two hooks make the mirror bidirectional:
|
||||||
@@ -15,7 +15,7 @@ a bare repo on the gate; `git daemon` serves the bare repos over
|
|||||||
|
|
||||||
The agent never sees the upstream credential under either path.
|
The agent never sees the upstream credential under either path.
|
||||||
|
|
||||||
Why a separate sidecar (not folded into egress or ssh-gate): the
|
Why a separate daemon (not folded into egress or ssh-gate): the
|
||||||
gate is the only one of the three that holds upstream push
|
gate is the only one of the three that holds upstream push
|
||||||
credentials. Mixing it with egress would put push creds in the
|
credentials. Mixing it with egress would put push creds in the
|
||||||
same blast radius as internet-facing TLS interception; mixing it
|
same blast radius as internet-facing TLS interception; mixing it
|
||||||
@@ -23,7 +23,7 @@ with ssh-gate would force ssh-gate above L4 and into git-protocol
|
|||||||
land. See `docs/prds/0008-git-gate.md`.
|
land. See `docs/prds/0008-git-gate.md`.
|
||||||
|
|
||||||
This module defines the abstract gate (`GitGate`) and its plan
|
This module defines the abstract gate (`GitGate`) and its plan
|
||||||
dataclass (`GitGatePlan`). The sidecar's start/stop lifecycle is
|
dataclass (`GitGatePlan`). The gateway's start/stop lifecycle is
|
||||||
backend-specific and lives on concrete subclasses (see
|
backend-specific and lives on concrete subclasses (see
|
||||||
`bot_bottle/backend/docker/git_gate.py`)."""
|
`bot_bottle/backend/docker/git_gate.py`)."""
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ class GitGatePlan:
|
|||||||
|
|
||||||
class GitGate(ABC):
|
class GitGate(ABC):
|
||||||
"""The per-agent git-gate. Encapsulates the host-side prepare
|
"""The per-agent git-gate. Encapsulates the host-side prepare
|
||||||
(upstream lift + entrypoint/hook render); the sidecar's
|
(upstream lift + entrypoint/hook render); the gateway's
|
||||||
start/stop lifecycle is backend-specific and lives on concrete
|
start/stop lifecycle is backend-specific and lives on concrete
|
||||||
subclasses."""
|
subclasses."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Pure host-side rendering for the per-agent git-gate (PRD 0008).
|
"""Pure host-side rendering for the per-agent git-gate (PRD 0008).
|
||||||
|
|
||||||
Builds the agent's `.gitconfig` insteadOf rewrites, the known_hosts
|
Builds the agent's `.gitconfig` insteadOf rewrites, the known_hosts
|
||||||
line, and the entrypoint / pre-receive / access-hook scripts the sidecar
|
line, and the entrypoint / pre-receive / access-hook scripts the gateway
|
||||||
runs. No docker or forge calls — exposed for tests and reuse across
|
runs. No docker or forge calls — exposed for tests and reuse across
|
||||||
backends. Split out of `git_gate.py` so the control surface (`GitGate`)
|
backends. Split out of `git_gate.py` so the control surface (`GitGate`)
|
||||||
and the deploy-key lifecycle (`git_gate_provision`) each read on their
|
and the deploy-key lifecycle (`git_gate_provision`) each read on their
|
||||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .manifest import ManifestBottle, ManifestGitEntry
|
from .manifest import ManifestBottle, ManifestGitEntry
|
||||||
|
|
||||||
# Short network alias for git-gate inside the sidecar bundle. The
|
# Short network alias for git-gate inside the gateway. The
|
||||||
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
||||||
GIT_GATE_HOSTNAME = "git-gate"
|
GIT_GATE_HOSTNAME = "git-gate"
|
||||||
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
||||||
@@ -38,7 +38,7 @@ class GitGateUpstream:
|
|||||||
KnownHostKey string from the manifest; the gate's start step
|
KnownHostKey string from the manifest; the gate's start step
|
||||||
materialises it into a known_hosts file if non-empty.
|
materialises it into a known_hosts file if non-empty.
|
||||||
|
|
||||||
the gate credential paths inside the running sidecar."""
|
the gate credential paths inside the running gateway."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
upstream_url: str
|
upstream_url: str
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Used where `git://` push traffic over a host-published Docker port can
|
Used where `git://` push traffic over a host-published Docker port can
|
||||||
hang before receive-pack reaches hooks (e.g. the firecracker backend,
|
hang before receive-pack reaches hooks (e.g. the firecracker backend,
|
||||||
where the guest reaches the sidecar over the point-to-point TAP). The
|
where the guest reaches the gateway over the point-to-point TAP). The
|
||||||
wrapper serves the same `/git/*.git` bare repos through
|
wrapper serves the same `/git/*.git` bare repos through
|
||||||
`git http-backend`, so pre-receive and upstream forwarding remain the
|
`git http-backend`, so pre-receive and upstream forwarding remain the
|
||||||
git-gate enforcement point.
|
git-gate enforcement point.
|
||||||
@@ -27,8 +27,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
# policy_resolver ships flat alongside this file in the sidecar bundle
|
# policy_resolver ships flat alongside this file in the gateway
|
||||||
# image (see Dockerfile.sidecars); the bot_bottle.* fallback is the
|
# image (see Dockerfile.gateway); the bot_bottle.* fallback is the
|
||||||
# host-side / test path. Mirrors egress_addon's import shape.
|
# host-side / test path. Mirrors egress_addon's import shape.
|
||||||
try:
|
try:
|
||||||
from policy_resolver import ( # type: ignore[import-not-found]
|
from policy_resolver import ( # type: ignore[import-not-found]
|
||||||
@@ -103,8 +103,8 @@ def resolve_sandbox_root(
|
|||||||
return namespace
|
return namespace
|
||||||
|
|
||||||
# Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than
|
# Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than
|
||||||
# imported: this module ships as a flat top-level sibling in the sidecar
|
# imported: this module ships as a flat top-level sibling in the gateway
|
||||||
# bundle image (see Dockerfile.sidecars), not as part of the bot_bottle
|
# bundle image (see Dockerfile.gateway), not as part of the bot_bottle
|
||||||
# package, so `bot_bottle.git_gate` and its dependency chain aren't
|
# package, so `bot_bottle.git_gate` and its dependency chain aren't
|
||||||
# available at runtime.
|
# available at runtime.
|
||||||
GIT_GATE_TIMEOUT_SECS = 15
|
GIT_GATE_TIMEOUT_SECS = 15
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ManifestAgentProvider:
|
|||||||
|
|
||||||
`template` selects a built-in launch/runtime contract. `dockerfile`
|
`template` selects a built-in launch/runtime contract. `dockerfile`
|
||||||
optionally points at a custom agent-image Dockerfile while leaving
|
optionally points at a custom agent-image Dockerfile while leaving
|
||||||
bot-bottle's sidecar infrastructure intact.
|
bot-bottle's gateway infrastructure intact.
|
||||||
|
|
||||||
`auth_token` names the host env var that holds the provider's OAuth
|
`auth_token` names the host env var that holds the provider's OAuth
|
||||||
token (Claude only). The provisioner injects a provider-owned egress
|
token (Claude only). The provisioner injects a provider-owned egress
|
||||||
@@ -26,7 +26,7 @@ class ManifestAgentProvider:
|
|||||||
so the Claude Code CLI starts.
|
so the Claude Code CLI starts.
|
||||||
|
|
||||||
`forward_host_credentials` forwards the host Codex auth token into
|
`forward_host_credentials` forwards the host Codex auth token into
|
||||||
the egress sidecar (Codex only).
|
the egress daemon (Codex only).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template: str = "claude"
|
template: str = "claude"
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ class ManifestBottle:
|
|||||||
# identity without any git-gate.repos upstreams, and vice versa.
|
# identity without any git-gate.repos upstreams, and vice versa.
|
||||||
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
||||||
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||||
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
# Per-bottle stuck-recovery daemon (PRD 0013). When true (the
|
||||||
# default, issue #249), the launch step brings up a supervise
|
# default, issue #249), the launch step brings up a supervise
|
||||||
# sidecar that exposes egress MCP tools to the agent. Set
|
# daemon that exposes egress MCP tools to the agent. Set
|
||||||
# `supervise: false` to skip the sidecar.
|
# `supervise: false` to skip the gateway.
|
||||||
supervise: bool = True
|
supervise: bool = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -61,7 +61,7 @@ class ManifestBottle:
|
|||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{name}' has an 'ssh' field, which has been removed "
|
f"bottle '{name}' has an 'ssh' field, which has been removed "
|
||||||
f"(PRD 0009). Declare upstreams under 'git-gate.repos' with "
|
f"(PRD 0009). Declare upstreams under 'git-gate.repos' with "
|
||||||
f"url + identity + host_key; the git-gate sidecar (PRD 0008) "
|
f"url + identity + host_key; the git-gate daemon (PRD 0008) "
|
||||||
f"holds the credential and gitleaks-scans pushes."
|
f"holds the credential and gitleaks-scans pushes."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--gateway", action="store_true",
|
"--gateway", action="store_true",
|
||||||
help="run one consolidated per-host sidecar bundle (build-if-missing)",
|
help="run one consolidated per-host gateway (build-if-missing)",
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
On a verified launch request it starts a Docker container; on teardown it
|
On a verified launch request it starts a Docker container; on teardown it
|
||||||
removes it. This proves the orchestrator -> backend seam on the cheapest
|
removes it. This proves the orchestrator -> backend seam on the cheapest
|
||||||
backend (the sidecar bundle is already containers). Only the request's
|
backend (the gateway is already containers). Only the request's
|
||||||
static ids/flags reach `docker`, so nothing free-form crosses the boundary.
|
static ids/flags reach `docker`, so nothing free-form crosses the boundary.
|
||||||
|
|
||||||
Slice 3 launches a single container from the request's `image_ref`, named
|
Slice 3 launches a single container from the request's `image_ref`, named
|
||||||
after the bottle id and labelled for cleanup. Wiring the full agent +
|
after the bottle id and labelled for cleanup. Wiring the full agent +
|
||||||
sidecar bundle (networks, mounts, the consolidated sidecar) is a later
|
gateway (networks, mounts, the consolidated gateway) is a later
|
||||||
slice — this is the seam, not the finished launcher.
|
slice — this is the seam, not the finished launcher.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""The consolidated per-host gateway (PRD 0070).
|
"""The consolidated per-host gateway (PRD 0070).
|
||||||
|
|
||||||
The core consolidation win: **one** persistent gateway per host, shared by
|
The core consolidation win: **one** persistent gateway per host, shared by
|
||||||
every bottle, instead of a sidecar bundle per bottle. It's safe to share
|
every bottle, instead of a gateway per bottle. It's safe to share
|
||||||
because the attribution invariant (source IP + identity token, see
|
because the attribution invariant (source IP + identity token, see
|
||||||
`registry`) lets the gateway attribute each request to the right bottle —
|
`registry`) lets the gateway attribute each request to the right bottle —
|
||||||
so per-bottle policy lives in one long-lived process keyed on who's calling.
|
so per-bottle policy lives in one long-lived process keyed on who's calling.
|
||||||
@@ -45,12 +45,12 @@ MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
|
|||||||
GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy"
|
GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy"
|
||||||
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
|
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
|
||||||
|
|
||||||
# The real sidecar-bundle image + its Dockerfile. Kept as a local constant
|
# The gateway data-plane image + its Dockerfile. Kept as a local constant
|
||||||
# rather than imported from backend.docker.sidecar_bundle, which would drag
|
# rather than imported from the backend layer, which would drag
|
||||||
# the whole backend layer into the lean orchestrator (see #359); unify when
|
# the whole backend layer into the lean orchestrator (see #359); unify when
|
||||||
# that lands. Env override matches the backend's BOT_BOTTLE_SIDECAR_IMAGE.
|
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
|
||||||
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest")
|
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||||
GATEWAY_DOCKERFILE = "Dockerfile.sidecars"
|
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
@@ -86,8 +86,8 @@ class Gateway(abc.ABC):
|
|||||||
class DockerGateway(Gateway):
|
class DockerGateway(Gateway):
|
||||||
"""The consolidated gateway as a single, fixed-name Docker container.
|
"""The consolidated gateway as a single, fixed-name Docker container.
|
||||||
|
|
||||||
`image_ref` defaults to the real sidecar-bundle image; `ensure_built`
|
`image_ref` defaults to the gateway data-plane image; `ensure_built`
|
||||||
builds it from `Dockerfile.sidecars` when it's missing. (Note: slice 5
|
builds it from `Dockerfile.gateway` when it's missing. (Note: slice 5
|
||||||
builds + launches the bundle container; wiring its per-bottle,
|
builds + launches the bundle container; wiring its per-bottle,
|
||||||
source-IP-keyed config is a later slice — see PRD 0070.)"""
|
source-IP-keyed config is a later slice — see PRD 0070.)"""
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ names + the published port).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -24,15 +26,27 @@ from pathlib import Path
|
|||||||
from .. import log
|
from .. import log
|
||||||
from ..docker_cmd import run_docker
|
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
|
from .gateway import GATEWAY_IMAGE, GATEWAY_NETWORK, DockerGateway, GatewayError
|
||||||
|
|
||||||
DEFAULT_PORT = 8099
|
DEFAULT_PORT = 8099
|
||||||
ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
|
ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
|
||||||
ORCHESTRATOR_LABEL = "bot-bottle-orchestrator=1"
|
ORCHESTRATOR_LABEL = "bot-bottle-orchestrator=1"
|
||||||
|
# The control-plane's own runtime image — lean (python + the stdlib-only
|
||||||
|
# `bot_bottle` package, bind-mounted at run time), distinct from the heavy
|
||||||
|
# gateway data-plane image it used to borrow (#384). Env override for
|
||||||
|
# operators pinning a published build.
|
||||||
|
ORCHESTRATOR_IMAGE = os.environ.get(
|
||||||
|
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
|
||||||
|
)
|
||||||
|
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
|
||||||
|
# Baked onto the container as a label so `ensure_running` can tell whether the
|
||||||
|
# running process is executing the *current* bind-mounted source — see
|
||||||
|
# `_source_hash`.
|
||||||
|
ORCHESTRATOR_SOURCE_HASH_LABEL = "bot-bottle-orchestrator-source-hash"
|
||||||
|
|
||||||
# The repo root is bind-mounted into the control-plane container so
|
# The repo root is bind-mounted into the control-plane container so
|
||||||
# `python -m bot_bottle.orchestrator` resolves the package (the orchestrator
|
# `python -m bot_bottle.orchestrator` resolves the package (the orchestrator
|
||||||
# is stdlib-only, so the bundle image's python is enough).
|
# is stdlib-only, so the lean orchestrator image's python is enough).
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
_APP_DIR = "/app"
|
_APP_DIR = "/app"
|
||||||
_ROOT_IN_CONTAINER = "/bot-bottle-root"
|
_ROOT_IN_CONTAINER = "/bot-bottle-root"
|
||||||
@@ -46,6 +60,22 @@ class OrchestratorStartError(RuntimeError):
|
|||||||
"""The orchestrator container did not become healthy within the timeout."""
|
"""The orchestrator container did not become healthy within the timeout."""
|
||||||
|
|
||||||
|
|
||||||
|
def _source_hash(repo_root: Path) -> str:
|
||||||
|
"""Content hash of the orchestrator's bind-mounted Python source (the
|
||||||
|
`bot_bottle` package the control-plane process imports). This only
|
||||||
|
changes when the code that would actually run inside the container
|
||||||
|
changes — `ensure_running` recreates the container on a mismatch and
|
||||||
|
otherwise leaves a healthy one alone, so a bottle launch that isn't
|
||||||
|
accompanied by a code change doesn't restart the process and drop every
|
||||||
|
*other* active bottle's in-memory egress tokens (`Orchestrator._tokens`
|
||||||
|
in `service.py`, never persisted to disk by design)."""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
for path in sorted((repo_root / "bot_bottle").rglob("*.py")):
|
||||||
|
h.update(str(path.relative_to(repo_root)).encode())
|
||||||
|
h.update(path.read_bytes())
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class OrchestratorService:
|
class OrchestratorService:
|
||||||
"""Manages the orchestrator control-plane container + the shared gateway.
|
"""Manages the orchestrator control-plane container + the shared gateway.
|
||||||
Callers only need `ensure_running()` + `url`."""
|
Callers only need `ensure_running()` + `url`."""
|
||||||
@@ -55,13 +85,19 @@ class OrchestratorService:
|
|||||||
*,
|
*,
|
||||||
port: int = DEFAULT_PORT,
|
port: int = DEFAULT_PORT,
|
||||||
network: str = GATEWAY_NETWORK,
|
network: str = GATEWAY_NETWORK,
|
||||||
image: str = GATEWAY_IMAGE,
|
image: str = ORCHESTRATOR_IMAGE,
|
||||||
|
gateway_image: str = GATEWAY_IMAGE,
|
||||||
repo_root: Path = _REPO_ROOT,
|
repo_root: Path = _REPO_ROOT,
|
||||||
host_root: Path | None = None,
|
host_root: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.port = port
|
self.port = port
|
||||||
self.network = network
|
self.network = network
|
||||||
|
# Two distinct images (#384): `image` is the lean control-plane
|
||||||
|
# runtime this container runs; `_gateway_image` is the heavy egress /
|
||||||
|
# git-gate / supervise data plane the gateway container runs. They
|
||||||
|
# were one conflated image before the split.
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self._gateway_image = gateway_image
|
||||||
self._repo_root = repo_root
|
self._repo_root = repo_root
|
||||||
self._host_root = host_root or bot_bottle_root()
|
self._host_root = host_root or bot_bottle_root()
|
||||||
|
|
||||||
@@ -88,14 +124,17 @@ class OrchestratorService:
|
|||||||
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
|
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
|
||||||
return name in proc.stdout.split()
|
return name in proc.stdout.split()
|
||||||
|
|
||||||
def _run_orchestrator_container(self) -> None:
|
def _run_orchestrator_container(self, source_hash: str) -> None:
|
||||||
"""Start the control-plane container (idempotent: clears a stale
|
"""Start the control-plane container (idempotent: clears a stale
|
||||||
fixed-name container first). Register-only broker → no docker socket."""
|
fixed-name container first). Register-only broker → no docker socket.
|
||||||
|
Labels the container with `source_hash` so a later `ensure_running`
|
||||||
|
can detect a real code change (see `_source_hash`)."""
|
||||||
run_docker(["docker", "rm", "--force", ORCHESTRATOR_NAME])
|
run_docker(["docker", "rm", "--force", ORCHESTRATOR_NAME])
|
||||||
proc = run_docker([
|
proc = run_docker([
|
||||||
"docker", "run", "--detach",
|
"docker", "run", "--detach",
|
||||||
"--name", ORCHESTRATOR_NAME,
|
"--name", ORCHESTRATOR_NAME,
|
||||||
"--label", ORCHESTRATOR_LABEL,
|
"--label", ORCHESTRATOR_LABEL,
|
||||||
|
"--label", f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={source_hash}",
|
||||||
"--network", self.network,
|
"--network", self.network,
|
||||||
# Host CLI reaches the control plane here; bound to loopback so it
|
# Host CLI reaches the control plane here; bound to loopback so it
|
||||||
# is not exposed on the host's external interfaces.
|
# is not exposed on the host's external interfaces.
|
||||||
@@ -117,28 +156,71 @@ class OrchestratorService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _gateway(self) -> DockerGateway:
|
def _gateway(self) -> DockerGateway:
|
||||||
return DockerGateway(self.image, network=self.network, orchestrator_url=self.internal_url)
|
return DockerGateway(
|
||||||
|
self._gateway_image, network=self.network, orchestrator_url=self.internal_url
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_orchestrator_image(self) -> None:
|
||||||
|
"""Build the lean control-plane image from `Dockerfile.orchestrator`
|
||||||
|
when it's missing (#384). Cheap — a `FROM python:*-slim` base with no
|
||||||
|
deps to install, so the layer cache makes rebuilds a no-op. Unlike the
|
||||||
|
gateway image this is build-if-missing, not build-every-time: the
|
||||||
|
control plane bind-mounts its source, so a code change is caught by the
|
||||||
|
source-hash recreate (below), not by an image rebuild."""
|
||||||
|
if run_docker(["docker", "image", "inspect", self.image]).returncode == 0:
|
||||||
|
return
|
||||||
|
argv = ["docker", "build", "-t", self.image,
|
||||||
|
"-f", str(self._repo_root / ORCHESTRATOR_DOCKERFILE),
|
||||||
|
str(self._repo_root)]
|
||||||
|
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||||
|
argv.insert(2, "--no-cache")
|
||||||
|
proc = run_docker(argv)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise GatewayError(
|
||||||
|
f"orchestrator image build failed: {proc.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _orchestrator_source_current(self, current_hash: str) -> bool:
|
||||||
|
"""True iff the running orchestrator container was created from the
|
||||||
|
*current* bind-mounted source. Mirrors `DockerGateway`'s
|
||||||
|
image-staleness check, but by content hash rather than image id since
|
||||||
|
the orchestrator runs bind-mounted source, not a built image."""
|
||||||
|
if not self._container_running(ORCHESTRATOR_NAME):
|
||||||
|
return False
|
||||||
|
proc = run_docker([
|
||||||
|
"docker", "inspect", "--format",
|
||||||
|
"{{ index .Config.Labels \"" + ORCHESTRATOR_SOURCE_HASH_LABEL + "\" }}",
|
||||||
|
ORCHESTRATOR_NAME,
|
||||||
|
])
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return True # can't compare -> don't churn a working container
|
||||||
|
return proc.stdout.strip() == current_hash
|
||||||
|
|
||||||
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
|
"""Ensure the control plane + shared gateway are up; return the host
|
||||||
control-plane URL. Idempotent — a healthy control plane and a running
|
control-plane URL. Idempotent — a healthy control plane running
|
||||||
gateway are left untouched. Raises `OrchestratorStartError` on
|
current code and a running gateway are left untouched. Raises
|
||||||
timeout."""
|
`OrchestratorStartError` on timeout."""
|
||||||
gateway = self._gateway()
|
gateway = self._gateway()
|
||||||
gateway.ensure_built() # rebuild the bundle image on a source change
|
gateway.ensure_built() # rebuild the bundle image on a source change
|
||||||
gateway.ensure_running() # creates the shared network + (re)starts gateway
|
gateway.ensure_running() # creates the shared network + (re)starts gateway
|
||||||
|
|
||||||
# Always (re)create the orchestrator container. It runs the repo's code
|
# Recreate the orchestrator container only when its bind-mounted
|
||||||
# bind-mounted, but the Python process loaded that code at startup and
|
# source has actually changed since it started — its Python process
|
||||||
# won't reload — so reusing a healthy-but-stale container would keep
|
# loaded that code at startup and won't reload, so a stale container
|
||||||
# running OLD control-plane code (e.g. dropping the tokens field). Cheap
|
# would keep running OLD control-plane code. Recreating on *every*
|
||||||
# (~seconds); the registry DB persists and the current launch
|
# launch (the prior behaviour) would drop every other active
|
||||||
# re-registers its own in-memory state. (The dedicated orchestrator
|
# bottle's in-memory egress tokens each time a new bottle starts,
|
||||||
# image follow-up replaces this with image-staleness detection.)
|
# since the orchestrator process holds them only in memory (#381).
|
||||||
|
current_hash = _source_hash(self._repo_root)
|
||||||
|
if self.is_healthy() and self._orchestrator_source_current(current_hash):
|
||||||
|
return self.url
|
||||||
|
|
||||||
|
self._ensure_orchestrator_image()
|
||||||
log.info("starting orchestrator container", context={"name": ORCHESTRATOR_NAME})
|
log.info("starting orchestrator container", context={"name": ORCHESTRATOR_NAME})
|
||||||
self._run_orchestrator_container()
|
self._run_orchestrator_container(current_hash)
|
||||||
|
|
||||||
deadline = time.monotonic() + startup_timeout
|
deadline = time.monotonic() + startup_timeout
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
@@ -160,6 +242,7 @@ __all__ = [
|
|||||||
"OrchestratorService",
|
"OrchestratorService",
|
||||||
"OrchestratorStartError",
|
"OrchestratorStartError",
|
||||||
"ORCHESTRATOR_NAME",
|
"ORCHESTRATOR_NAME",
|
||||||
|
"ORCHESTRATOR_IMAGE",
|
||||||
"DEFAULT_PORT",
|
"DEFAULT_PORT",
|
||||||
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
|
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ registry: turns a prepared bottle's egress plan into the backend-neutral
|
|||||||
inputs `Orchestrator.launch_bottle` takes — the egress **policy** blob and
|
inputs `Orchestrator.launch_bottle` takes — the egress **policy** blob and
|
||||||
launch **metadata**.
|
launch **metadata**.
|
||||||
|
|
||||||
The policy blob is the exact routes YAML the per-bottle egress sidecar used
|
The policy blob is the exact routes YAML the per-bottle egress daemon used
|
||||||
to read from a file; in the consolidated model the multi-tenant gateway's
|
to read from a file; in the consolidated model the multi-tenant gateway's
|
||||||
`PolicyResolver` fetches it from the registry per request (keyed by source
|
`PolicyResolver` fetches it from the registry per request (keyed by source
|
||||||
IP) instead. Same render, so consolidated and single-tenant egress apply
|
IP) instead. Same render, so consolidated and single-tenant egress apply
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@ suite points it at a throwaway dir instead of monkey-patching the function
|
|||||||
override covers them all), and operators can relocate the root if needed.
|
override covers them all), and operators can relocate the root if needed.
|
||||||
|
|
||||||
This module has no bot-bottle imports, so it is safe to import from any
|
This module has no bot-bottle imports, so it is safe to import from any
|
||||||
layer (and to COPY flat into the sidecar bundle).
|
layer (and to COPY flat into the gateway).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -35,7 +35,7 @@ def host_db_path() -> Path:
|
|||||||
|
|
||||||
Kept in its own `db/` subdirectory (not directly under the root) so a
|
Kept in its own `db/` subdirectory (not directly under the root) so a
|
||||||
backend that can only bind-mount *directories* can share this one file
|
backend that can only bind-mount *directories* can share this one file
|
||||||
with a sidecar without exposing the root's other contents (git-gate
|
with a gateway without exposing the root's other contents (git-gate
|
||||||
keys, per-bottle state, ...)."""
|
keys, per-bottle state, ...)."""
|
||||||
return bot_bottle_root() / "db" / HOST_DB_FILENAME
|
return bot_bottle_root() / "db" / HOST_DB_FILENAME
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ closed too rather than silently serving stale or empty policy.
|
|||||||
The resolved value is the policy blob the orchestrator stores verbatim; the
|
The resolved value is the policy blob the orchestrator stores verbatim; the
|
||||||
consumer parses it (e.g. the egress addon's `load_config`). This module is
|
consumer parses it (e.g. the egress addon's `load_config`). This module is
|
||||||
stdlib-only and free of bot-bottle imports so it can be COPYed flat into
|
stdlib-only and free of bot-bottle imports so it can be COPYed flat into
|
||||||
the sidecar bundle.
|
the gateway.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class QueueStore(DbStore):
|
|||||||
if db_path is not None:
|
if db_path is not None:
|
||||||
resolved = db_path
|
resolved = db_path
|
||||||
else:
|
else:
|
||||||
# In the sidecar container SUPERVISE_DB_PATH points at the
|
# In the gateway container SUPERVISE_DB_PATH points at the
|
||||||
# bind-mounted host DB. On the host this env var is never set,
|
# bind-mounted host DB. On the host this env var is never set,
|
||||||
# so we always fall through to host_db_path().
|
# so we always fall through to host_db_path().
|
||||||
env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip()
|
env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip()
|
||||||
|
|||||||
+14
-14
@@ -1,25 +1,25 @@
|
|||||||
"""Per-bottle supervise plane (PRD 0013).
|
"""Per-bottle supervise plane (PRD 0013).
|
||||||
|
|
||||||
The supervise plane is the per-bottle MCP sidecar plus its host-side
|
The supervise plane is the per-bottle MCP daemon plus its host-side
|
||||||
queue/audit support. The sidecar (bot_bottle.supervise_server)
|
queue/audit support. The daemon (bot_bottle.supervise_server)
|
||||||
sits on the bottle's internal network and exposes MCP tools the agent
|
sits on the bottle's internal network and exposes MCP tools the agent
|
||||||
calls when it needs an operator-reviewed egress change:
|
calls when it needs an operator-reviewed egress change:
|
||||||
|
|
||||||
* egress-block / allow — agent proposes a new routes.yaml
|
* egress-block / allow — agent proposes a new routes.yaml
|
||||||
|
|
||||||
Each tool call: the agent passes the full proposed file plus a
|
Each tool call: the agent passes the full proposed file plus a
|
||||||
justification text. The sidecar validates the proposal syntactically,
|
justification text. The gateway validates the proposal syntactically,
|
||||||
writes it to the host SQLite queue table, and holds the tool-call
|
writes it to the host SQLite queue table, and holds the tool-call
|
||||||
connection open. The operator's supervise TUI
|
connection open. The operator's supervise TUI
|
||||||
(bot_bottle.cli.supervise) sees the proposal, accepts
|
(bot_bottle.cli.supervise) sees the proposal, accepts
|
||||||
approve / modify / reject, and writes a response row. The sidecar sees
|
approve / modify / reject, and writes a response row. The gateway sees
|
||||||
the response and returns `{status, notes}` to the agent.
|
the response and returns `{status, notes}` to the agent.
|
||||||
|
|
||||||
This module defines the host-side library: dataclasses for the queue
|
This module defines the host-side library: dataclasses for the queue
|
||||||
record shapes, queue read/write helpers, the audit log writer, and the
|
record shapes, queue read/write helpers, the audit log writer, and the
|
||||||
diff renderer. The in-container sidecar lives in
|
diff renderer. The in-gateway daemon lives in
|
||||||
bot_bottle/supervise_server.py; the supervise daemon's container
|
bot_bottle/supervise_server.py; the supervise daemon's container
|
||||||
lifecycle is owned by the sidecar bundle (PRD 0024).
|
lifecycle is owned by the gateway (PRD 0024).
|
||||||
|
|
||||||
For 0013 the supervisor's approval handlers are deliberately no-ops:
|
For 0013 the supervisor's approval handlers are deliberately no-ops:
|
||||||
on approval the audit log is written and the response file is
|
on approval the audit log is written and the response file is
|
||||||
@@ -75,18 +75,18 @@ except ImportError:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from .paths import bot_bottle_root
|
from .paths import bot_bottle_root
|
||||||
except ImportError: # flat imports inside the sidecar bundle
|
except ImportError: # flat imports inside the gateway
|
||||||
from paths import bot_bottle_root # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
|
from paths import bot_bottle_root # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
|
||||||
|
|
||||||
|
|
||||||
SUPERVISE_HOSTNAME = "supervise"
|
SUPERVISE_HOSTNAME = "supervise"
|
||||||
SUPERVISE_PORT = 9100
|
SUPERVISE_PORT = 9100
|
||||||
|
|
||||||
# The supervise sidecar uses these to query egress's
|
# The supervise daemon uses these to query egress's
|
||||||
# introspection endpoint for the `list-egress-routes` MCP
|
# introspection endpoint for the `list-egress-routes` MCP
|
||||||
# tool. The hostname + port match egress's docker network
|
# tool. The hostname + port match egress's docker network
|
||||||
# listen port (see backend.docker.egress.EGRESS_PORT). The supervise
|
# listen port (see backend.docker.egress.EGRESS_PORT). The supervise
|
||||||
# daemon runs inside the sidecar bundle alongside egress, so loopback
|
# daemon runs inside the gateway alongside egress, so loopback
|
||||||
# is the stable address across docker, firecracker, and Apple
|
# is the stable address across docker, firecracker, and Apple
|
||||||
# Container backends.
|
# Container backends.
|
||||||
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
|
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
|
||||||
@@ -117,7 +117,7 @@ try:
|
|||||||
from .audit_store import AuditStore
|
from .audit_store import AuditStore
|
||||||
from .store_manager import StoreManager
|
from .store_manager import StoreManager
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Sidecar bundle: files are flat-copied under /app, not a package.
|
# Gateway: files are flat-copied under /app, not a package.
|
||||||
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
from store_manager import StoreManager # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
from store_manager import StoreManager # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
@@ -222,14 +222,14 @@ def sha256_hex(content: str) -> str:
|
|||||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
# --- Sidecar plan + abstract lifecycle -------------------------------------
|
# --- Gateway plan + abstract lifecycle -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SupervisePlan:
|
class SupervisePlan:
|
||||||
"""Output of Supervise.prepare; consumed by .start.
|
"""Output of Supervise.prepare; consumed by .start.
|
||||||
|
|
||||||
`db_path` is the host database bind-mounted into the sidecar at
|
`db_path` is the host database bind-mounted into the gateway at
|
||||||
/run/supervise/bot-bottle.db. `internal_network` is empty at
|
/run/supervise/bot-bottle.db. `internal_network` is empty at
|
||||||
prepare time; the backend's launch step fills it via
|
prepare time; the backend's launch step fills it via
|
||||||
dataclasses.replace before calling .start."""
|
dataclasses.replace before calling .start."""
|
||||||
@@ -240,8 +240,8 @@ class SupervisePlan:
|
|||||||
|
|
||||||
|
|
||||||
class Supervise(ABC):
|
class Supervise(ABC):
|
||||||
"""Per-bottle supervise sidecar. Encapsulates host-side database
|
"""Per-bottle supervise daemon. Encapsulates host-side database
|
||||||
staging; the sidecar's start/stop lifecycle is backend-specific."""
|
staging; the gateway's start/stop lifecycle is backend-specific."""
|
||||||
|
|
||||||
def prepare(
|
def prepare(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Supervise sidecar HTTP server (PRD 0013).
|
"""Supervise daemon HTTP server (PRD 0013).
|
||||||
|
|
||||||
Per-bottle MCP server exposing tools the agent calls to propose egress
|
Per-bottle MCP server exposing tools the agent calls to propose egress
|
||||||
config changes when stuck. The tools are `egress-allow`,
|
config changes when stuck. The tools are `egress-allow`,
|
||||||
@@ -50,7 +50,7 @@ from dataclasses import dataclass, replace
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Same-directory imports inside the bundle container; these files are
|
# Same-directory imports inside the bundle container; these files are
|
||||||
# COPYed flat under /app by Dockerfile.sidecars.
|
# COPYed flat under /app by Dockerfile.gateway.
|
||||||
from egress_addon_core import LOG_OFF, load_config
|
from egress_addon_core import LOG_OFF, load_config
|
||||||
from policy_resolver import PolicyResolveError, PolicyResolver
|
from policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
import supervise as _sv
|
import supervise as _sv
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class YamlSubsetError(ValueError):
|
|||||||
that want fatal-exit semantics (manifest loader, egress-apply,
|
that want fatal-exit semantics (manifest loader, egress-apply,
|
||||||
etc.) catch this at their own boundary and forward to `die`;
|
etc.) catch this at their own boundary and forward to `die`;
|
||||||
callers running outside the bot-bottle CLI process (the
|
callers running outside the bot-bottle CLI process (the
|
||||||
egress sidecar's addon) handle it as a normal exception."""
|
egress daemon's addon) handle it as a normal exception."""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -22,8 +22,7 @@ mounted in. That topology breaks two assumptions those tests make:
|
|||||||
`http://127.0.0.1:<host_port>` from inside the job time out.
|
`http://127.0.0.1:<host_port>` from inside the job time out.
|
||||||
|
|
||||||
The affected tests (`test_orphan_cleanup.test_create_and_remove`,
|
The affected tests (`test_orphan_cleanup.test_create_and_remove`,
|
||||||
`test_sidecar_bundle_image.TestSidecarBundleImage`,
|
`test_gateway_image.TestGatewayImage`) still run
|
||||||
`test_sidecar_bundle_compose.TestSidecarBundleCompose`) still run
|
|
||||||
locally where the test process and Docker daemon share a host.
|
locally where the test process and Docker daemon share a host.
|
||||||
Making them work in CI is a follow-up: either re-write them to
|
Making them work in CI is a follow-up: either re-write them to
|
||||||
discover container IPs via `docker inspect`, or reconfigure the
|
discover container IPs via `docker inspect`, or reconfigure the
|
||||||
|
|||||||
+2
-2
@@ -38,7 +38,7 @@ Type "y"
|
|||||||
Enter
|
Enter
|
||||||
|
|
||||||
# Wait for the bottle to launch: networks created, pipelock + git-gate
|
# Wait for the bottle to launch: networks created, pipelock + git-gate
|
||||||
# sidecars started, agent container started, claude boots.
|
# companion containers started, agent container started, claude boots.
|
||||||
Sleep 22s
|
Sleep 22s
|
||||||
|
|
||||||
# Probe 1 — warm-up. A reply at all proves api.anthropic.com is
|
# Probe 1 — warm-up. A reply at all proves api.anthropic.com is
|
||||||
@@ -72,7 +72,7 @@ Type "init /tmp/r, commit AKIAQRJHK7N5ZPM2VXTL to leak.txt, push to ssh://git@up
|
|||||||
Enter
|
Enter
|
||||||
Sleep 30s
|
Sleep 30s
|
||||||
|
|
||||||
# Leave claude. The launcher tears down the container, sidecars, and
|
# Leave claude. The launcher tears down the container, companion containers, and
|
||||||
# networks on session end.
|
# networks on session end.
|
||||||
Ctrl+D
|
Ctrl+D
|
||||||
Sleep 4s
|
Sleep 4s
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
#
|
#
|
||||||
# The one-time privileged setup the Firecracker backend needs: a pool of
|
# The one-time privileged setup the Firecracker backend needs: a pool of
|
||||||
# user- (or group-) owned point-to-point TAP devices plus a fail-closed
|
# user- (or group-) owned point-to-point TAP devices plus a fail-closed
|
||||||
# nftables table that confines every microVM to its own sidecar.
|
# nftables table that confines every microVM to its own gateway.
|
||||||
#
|
#
|
||||||
# NON-INVASIVE BY DESIGN. It does NOT flip `networking.nftables.enable`
|
# NON-INVASIVE BY DESIGN. It does NOT flip `networking.nftables.enable`
|
||||||
# (which would switch your whole host firewall backend) or
|
# (which would switch your whole host firewall backend) or
|
||||||
@@ -150,7 +150,7 @@ in
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
# VM->sidecar traffic is DNAT'd and forwarded, so forwarding must be on.
|
# VM->gateway traffic is DNAT'd and forwarded, so forwarding must be on.
|
||||||
boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
|
boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
|
||||||
|
|
||||||
# One oneshot brings up the whole pool (TAPs + independent nft table)
|
# One oneshot brings up the whole pool (TAPs + independent nft table)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# Creates a pool of point-to-point TAP devices (owned by the invoking
|
# Creates a pool of point-to-point TAP devices (owned by the invoking
|
||||||
# user so the backend can open them without root at launch) and a
|
# user so the backend can open them without root at launch) and a
|
||||||
# dedicated nftables table that isolates every VM: a bottle VM can
|
# dedicated nftables table that isolates every VM: a bottle VM can
|
||||||
# reach only its own sidecar (published on the host-side TAP IP) and
|
# reach only its own gateway (published on the host-side TAP IP) and
|
||||||
# nothing else on the host or network.
|
# nothing else on the host or network.
|
||||||
#
|
#
|
||||||
# Why a pool + one-time setup: creating a TAP and assigning it an IP
|
# Why a pool + one-time setup: creating a TAP and assigning it an IP
|
||||||
@@ -72,9 +72,9 @@ for _v in POOL_SIZE IP_BASE PREFIX TABLE; do
|
|||||||
[ -n "${!_v}" ] || { echo "error: $_v unresolved (set BOT_BOTTLE_FC_* or fix $_DEFAULTS)" >&2; exit 1; }
|
[ -n "${!_v}" ] || { echo "error: $_v unresolved (set BOT_BOTTLE_FC_* or fix $_DEFAULTS)" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
# Sidecar ports (must match the backend). egress=9099, supervise=9100,
|
# Gateway ports (must match the backend). egress=9099, supervise=9100,
|
||||||
# git-http=9420. Reached by the VM at its host-side TAP IP.
|
# git-http=9420. Reached by the VM at its host-side TAP IP.
|
||||||
SIDECAR_PORTS="9099,9100,9420"
|
GATEWAY_PORTS="9099,9100,9420"
|
||||||
|
|
||||||
# --- IP math ---------------------------------------------------------
|
# --- IP math ---------------------------------------------------------
|
||||||
# Slot i occupies the /31 {base+2i, base+2i+1}: host = base+2i (the
|
# Slot i occupies the /31 {base+2i, base+2i+1}: host = base+2i (the
|
||||||
@@ -107,7 +107,7 @@ cmd_up() {
|
|||||||
fi
|
fi
|
||||||
echo "firecracker net pool: $POOL_SIZE slots, base $IP_BASE, $own_desc"
|
echo "firecracker net pool: $POOL_SIZE slots, base $IP_BASE, $own_desc"
|
||||||
|
|
||||||
# VM->sidecar traffic is DNAT'd to the sidecar container and
|
# VM->gateway traffic is DNAT'd to the gateway container and
|
||||||
# forwarded, so forwarding must be enabled (Docker also sets this).
|
# forwarded, so forwarding must be enabled (Docker also sets this).
|
||||||
sysctl -qw net.ipv4.ip_forward=1
|
sysctl -qw net.ipv4.ip_forward=1
|
||||||
|
|
||||||
@@ -135,11 +135,11 @@ _install_nft() {
|
|||||||
# tool's traffic is affected. Priority -10 runs before Docker's
|
# tool's traffic is affected. Priority -10 runs before Docker's
|
||||||
# filter hooks (priority 0); a drop here is terminal for the packet.
|
# filter hooks (priority 0); a drop here is terminal for the packet.
|
||||||
#
|
#
|
||||||
# forward: VM egress is DNAT'd to the sidecar (established via
|
# forward: VM egress is DNAT'd to the gateway (established via
|
||||||
# `ct status dnat`); return traffic via `ct state established`.
|
# `ct status dnat`); return traffic via `ct state established`.
|
||||||
# Anything else from a VM is dropped -> no route to the internet
|
# Anything else from a VM is dropped -> no route to the internet
|
||||||
# or the rest of the host except through the sidecar proxy.
|
# or the rest of the host except through the gateway proxy.
|
||||||
# input: a VM never needs host-local delivery (its sidecar is
|
# input: a VM never needs host-local delivery (its gateway is
|
||||||
# reached via DNAT->forward), so drop all direct input from VMs
|
# reached via DNAT->forward), so drop all direct input from VMs
|
||||||
# -> host services bound on 0.0.0.0 are unreachable from the VM.
|
# -> host services bound on 0.0.0.0 are unreachable from the VM.
|
||||||
nft -f - <<EOF
|
nft -f - <<EOF
|
||||||
|
|||||||
+3
-7
@@ -18,8 +18,7 @@ tests/
|
|||||||
test_manifest_runtime.py
|
test_manifest_runtime.py
|
||||||
... # many others; see unit/ directory
|
... # many others; see unit/ directory
|
||||||
integration/
|
integration/
|
||||||
test_sidecar_bundle_image.py
|
test_gateway_image.py
|
||||||
test_sidecar_bundle_compose.py
|
|
||||||
test_dry_run_plan.py
|
test_dry_run_plan.py
|
||||||
test_orphan_cleanup.py
|
test_orphan_cleanup.py
|
||||||
...
|
...
|
||||||
@@ -48,12 +47,9 @@ Discovery is invoked with `-t .` (top-level dir = repo root) so the
|
|||||||
the bottle's runtime, and creates zero Docker resources.
|
the bottle's runtime, and creates zero Docker resources.
|
||||||
- `test_orphan_cleanup.py` — `network_remove` is idempotent against
|
- `test_orphan_cleanup.py` — `network_remove` is idempotent against
|
||||||
missing resources, so the EXIT trap can call it unconditionally.
|
missing resources, so the EXIT trap can call it unconditionally.
|
||||||
- `test_sidecar_bundle_image.py` — builds Dockerfile.sidecars and
|
- `test_gateway_image.py` — builds Dockerfile.gateway and
|
||||||
probes that gitleaks / mitmdump / supervise are all reachable
|
probes that gitleaks / mitmdump / supervise are all reachable
|
||||||
inside the bundle.
|
inside the gateway image.
|
||||||
- `test_sidecar_bundle_compose.py` — end-to-end compose-up of an
|
|
||||||
agent + bundle pair; verifies the agent reaches the bundle via
|
|
||||||
the legacy network aliases.
|
|
||||||
|
|
||||||
## Canaries
|
## Canaries
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
"""Integration: Firecracker microVM launch.
|
|
||||||
|
|
||||||
End-to-end against a real Firecracker microVM: prepare + launch a bottle
|
|
||||||
on the firecracker backend and verify the agent execs after provisioning
|
|
||||||
and that the egress proxy env is wired to the sidecar.
|
|
||||||
|
|
||||||
Gated on the `backend status` result for firecracker (0 == the privileged
|
|
||||||
TAP pool + nft isolation table are provisioned). Skips cleanly with setup
|
|
||||||
instructions otherwise, so the suite runs on hosts without the pool.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
|
||||||
from bot_bottle.backend.firecracker import FirecrackerBottleBackend
|
|
||||||
from bot_bottle.manifest import ManifestIndex
|
|
||||||
|
|
||||||
|
|
||||||
def _firecracker_status_ok() -> bool:
|
|
||||||
"""Gate on `./cli.py backend status --backend=firecracker`: a 0 exit
|
|
||||||
means the pool + nft table are ready. Output is captured so the
|
|
||||||
decorator stays quiet during collection; any error → not ready."""
|
|
||||||
buf = io.StringIO()
|
|
||||||
try:
|
|
||||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
|
||||||
return FirecrackerBottleBackend.status() == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
_SKIP_MSG = (
|
|
||||||
"firecracker backend not ready — provision the network pool with "
|
|
||||||
"`./cli.py backend setup --backend=firecracker`, then confirm with "
|
|
||||||
"`./cli.py backend status --backend=firecracker`"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _minimal_agent_dockerfile(path: Path) -> None:
|
|
||||||
path.write_text(
|
|
||||||
"\n".join((
|
|
||||||
"FROM node:22-slim",
|
|
||||||
"RUN apt-get update \\",
|
|
||||||
" && apt-get install -y --no-install-recommends \\",
|
|
||||||
" ca-certificates curl git \\",
|
|
||||||
" && rm -rf /var/lib/apt/lists/*",
|
|
||||||
"USER node",
|
|
||||||
"WORKDIR /home/node",
|
|
||||||
"CMD [\"sleep\", \"infinity\"]",
|
|
||||||
"",
|
|
||||||
)),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _minimal_manifest(dockerfile: Path) -> ManifestIndex:
|
|
||||||
return ManifestIndex.from_json_obj({
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"agent_provider": {
|
|
||||||
"template": "pi",
|
|
||||||
"dockerfile": str(dockerfile),
|
|
||||||
"settings": {
|
|
||||||
"provider": "example",
|
|
||||||
"base_url": "https://example.com/v1",
|
|
||||||
"models": ["smoke"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"egress": {"routes": [{"host": "example.com"}]},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"demo": {"skills": [], "prompt": "smoke", "bottle": "dev"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipIf(
|
|
||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
|
||||||
"skipped under act_runner: cannot host Firecracker microVMs",
|
|
||||||
)
|
|
||||||
@unittest.skipUnless(_firecracker_status_ok(), _SKIP_MSG)
|
|
||||||
class TestFirecrackerLaunch(unittest.TestCase):
|
|
||||||
"""Launch once, reuse the bottle across probes."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls) -> None:
|
|
||||||
cls.stage = Path(tempfile.mkdtemp(prefix="cb-firecracker-launch."))
|
|
||||||
cls._launch = None
|
|
||||||
cls.bottle = None
|
|
||||||
dockerfile = cls.stage / "Dockerfile.agent-smoke"
|
|
||||||
_minimal_agent_dockerfile(dockerfile)
|
|
||||||
os.environ["BOT_BOTTLE_BACKEND"] = "firecracker"
|
|
||||||
try:
|
|
||||||
backend = get_bottle_backend()
|
|
||||||
spec = BottleSpec(
|
|
||||||
manifest=_minimal_manifest(dockerfile),
|
|
||||||
agent_name="demo",
|
|
||||||
copy_cwd=False,
|
|
||||||
user_cwd=str(cls.stage),
|
|
||||||
)
|
|
||||||
cls.plan = backend.prepare(spec, stage_dir=cls.stage)
|
|
||||||
cls._launch = backend.launch(cls.plan)
|
|
||||||
cls.bottle = cls._launch.__enter__()
|
|
||||||
except BaseException:
|
|
||||||
if cls._launch is not None:
|
|
||||||
cls._launch.__exit__(None, None, None)
|
|
||||||
shutil.rmtree(cls.stage, ignore_errors=True)
|
|
||||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
|
||||||
raise
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tearDownClass(cls) -> None:
|
|
||||||
try:
|
|
||||||
if cls._launch is not None:
|
|
||||||
cls._launch.__exit__(None, None, None)
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(cls.stage, ignore_errors=True)
|
|
||||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
|
||||||
|
|
||||||
def test_smoke_exec_echo(self) -> None:
|
|
||||||
r = self.bottle.exec("echo hello-from-firecracker") # type: ignore[union-attr]
|
|
||||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
|
||||||
self.assertIn("hello-from-firecracker", r.stdout)
|
|
||||||
|
|
||||||
def test_proxy_env_points_at_sidecar(self) -> None:
|
|
||||||
r = self.bottle.exec( # type: ignore[union-attr]
|
|
||||||
"printf '%s\\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\""
|
|
||||||
)
|
|
||||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
|
||||||
self.assertIn("http", r.stdout.lower())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
+8
-8
@@ -1,4 +1,4 @@
|
|||||||
"""Integration: PRD 0024 chunk 1 — the sidecar bundle image builds
|
"""Integration: PRD 0024 chunk 1 — the gateway image builds
|
||||||
and the daemon binaries are present + executable inside it.
|
and the daemon binaries are present + executable inside it.
|
||||||
|
|
||||||
This test does NOT exercise the daemons running against real
|
This test does NOT exercise the daemons running against real
|
||||||
@@ -6,11 +6,11 @@ config (routes.yaml, etc) — that lands in chunk 2 when the
|
|||||||
renderer wires the bundle into compose. What we verify here is
|
renderer wires the bundle into compose. What we verify here is
|
||||||
the chunk-1 contract:
|
the chunk-1 contract:
|
||||||
|
|
||||||
- Dockerfile.sidecars builds (multi-stage works, base layers
|
- Dockerfile.gateway builds (multi-stage works, base layers
|
||||||
pull, COPYs resolve).
|
pull, COPYs resolve).
|
||||||
- gitleaks, mitmdump are at the documented paths and answer
|
- gitleaks, mitmdump are at the documented paths and answer
|
||||||
`--version`.
|
`--version`.
|
||||||
- The Python init at /app/sidecar_init.py runs and prints the
|
- The Python init at /app/gateway_init.py runs and prints the
|
||||||
expected "no daemons selected" line when the supervisor is
|
expected "no daemons selected" line when the supervisor is
|
||||||
pointed at an empty daemon set.
|
pointed at an empty daemon set.
|
||||||
|
|
||||||
@@ -28,18 +28,18 @@ import unittest
|
|||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
_IMAGE = "bot-bottle-sidecars-test:chunk1"
|
_IMAGE = "bot-bottle-gateway-test:chunk1"
|
||||||
_DOCKERFILE = "Dockerfile.sidecars"
|
_DOCKERFILE = "Dockerfile.gateway"
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker()
|
@skip_unless_docker()
|
||||||
@unittest.skipIf(
|
@unittest.skipIf(
|
||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
"skipped under act_runner: multi-stage build pulls a 200+MB "
|
"skipped under act_runner: multi-stage build pulls a 200+MB "
|
||||||
"mitmproxy base + two upstream sidecar images; runner storage "
|
"mitmproxy base + two upstream gateway images; runner storage "
|
||||||
"+ time budget make this an interactive-only test",
|
"+ time budget make this an interactive-only test",
|
||||||
)
|
)
|
||||||
class TestSidecarBundleImage(unittest.TestCase):
|
class TestGatewayImage(unittest.TestCase):
|
||||||
"""Builds the image once for the class, then runs a few
|
"""Builds the image once for the class, then runs a few
|
||||||
`docker run` probes against it."""
|
`docker run` probes against it."""
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class TestSidecarBundleImage(unittest.TestCase):
|
|||||||
# ENTRYPOINT wiring works.
|
# ENTRYPOINT wiring works.
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["docker", "run", "--rm",
|
["docker", "run", "--rm",
|
||||||
"-e", "BOT_BOTTLE_SIDECAR_DAEMONS=nothing",
|
"-e", "BOT_BOTTLE_GATEWAY_DAEMONS=nothing",
|
||||||
_IMAGE],
|
_IMAGE],
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
timeout=10.0,
|
timeout=10.0,
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
"""Integration: macOS Container launch topology.
|
|
||||||
|
|
||||||
End-to-end against Apple's real `container` runtime. The smoke launches
|
|
||||||
a bottle with the experimental macOS Container backend and verifies the
|
|
||||||
properties that make the explicit-proxy launch acceptable:
|
|
||||||
|
|
||||||
- the agent can exec commands after provisioning;
|
|
||||||
- HTTP(S)_PROXY points at the sidecar's internal-network IP;
|
|
||||||
- allowlisted HTTPS reaches the egress sidecar;
|
|
||||||
- direct egress with proxy env removed fails from the internal-only
|
|
||||||
agent network;
|
|
||||||
- non-allowlisted proxy traffic is blocked.
|
|
||||||
|
|
||||||
Skipped under Gitea Actions and on hosts without Apple's `container`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
|
||||||
from bot_bottle.backend.macos_container.util import (
|
|
||||||
dns_server as _container_dns_server,
|
|
||||||
is_available as _container_available,
|
|
||||||
)
|
|
||||||
from bot_bottle.manifest import ManifestIndex
|
|
||||||
|
|
||||||
|
|
||||||
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
|
|
||||||
|
|
||||||
|
|
||||||
def _minimal_agent_dockerfile(path: Path) -> None:
|
|
||||||
path.write_text(
|
|
||||||
"\n".join((
|
|
||||||
"FROM node:22-slim",
|
|
||||||
"RUN apt-get update \\",
|
|
||||||
" && apt-get install -y --no-install-recommends \\",
|
|
||||||
" ca-certificates curl git \\",
|
|
||||||
" && rm -rf /var/lib/apt/lists/*",
|
|
||||||
"USER node",
|
|
||||||
"WORKDIR /home/node",
|
|
||||||
"CMD [\"sleep\", \"infinity\"]",
|
|
||||||
"",
|
|
||||||
)),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _minimal_manifest(dockerfile: Path) -> ManifestIndex:
|
|
||||||
return ManifestIndex.from_json_obj({
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"agent_provider": {
|
|
||||||
"template": "pi",
|
|
||||||
"dockerfile": str(dockerfile),
|
|
||||||
"settings": {
|
|
||||||
"provider": "example",
|
|
||||||
"base_url": "https://example.com/v1",
|
|
||||||
"models": ["smoke"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"egress": {
|
|
||||||
"routes": [
|
|
||||||
{"host": "example.com"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"demo": {
|
|
||||||
"skills": [],
|
|
||||||
"prompt": _AGENT_PROMPT,
|
|
||||||
"bottle": "dev",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _buildkit_dns_available() -> bool:
|
|
||||||
if platform.system() != "Darwin" or not _container_available():
|
|
||||||
return False
|
|
||||||
stage = Path(tempfile.mkdtemp(prefix="cb-container-buildkit-dns."))
|
|
||||||
image = "bot-bottle-buildkit-dns-check:latest"
|
|
||||||
try:
|
|
||||||
dockerfile = stage / "Dockerfile"
|
|
||||||
dockerfile.write_text(
|
|
||||||
"FROM debian:bookworm-slim\n"
|
|
||||||
"RUN getent hosts deb.debian.org\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"container", "build",
|
|
||||||
"--dns", _container_dns_server(),
|
|
||||||
"-t", image,
|
|
||||||
"-f", str(dockerfile),
|
|
||||||
str(stage),
|
|
||||||
],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
finally:
|
|
||||||
subprocess.run(
|
|
||||||
["container", "image", "delete", image],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
shutil.rmtree(stage, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipIf(
|
|
||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
|
||||||
"skipped under act_runner: cannot host Apple Container VMs",
|
|
||||||
)
|
|
||||||
@unittest.skipUnless(
|
|
||||||
platform.system() == "Darwin",
|
|
||||||
"Apple Container is macOS-only",
|
|
||||||
)
|
|
||||||
@unittest.skipUnless(
|
|
||||||
_container_available(),
|
|
||||||
"Apple Container not on PATH; install from "
|
|
||||||
"https://github.com/apple/container/releases",
|
|
||||||
)
|
|
||||||
@unittest.skipUnless(
|
|
||||||
_buildkit_dns_available(),
|
|
||||||
"Apple Container BuildKit cannot resolve deb.debian.org on this host",
|
|
||||||
)
|
|
||||||
class TestMacosContainerLaunch(unittest.TestCase):
|
|
||||||
"""Launch once and reuse the bottle across probes."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls) -> None:
|
|
||||||
cls.stage = Path(tempfile.mkdtemp(prefix="cb-macos-container-launch."))
|
|
||||||
cls._launch = None
|
|
||||||
cls.bottle = None
|
|
||||||
dockerfile = cls.stage / "Dockerfile.agent-smoke"
|
|
||||||
_minimal_agent_dockerfile(dockerfile)
|
|
||||||
os.environ["BOT_BOTTLE_BACKEND"] = "macos-container"
|
|
||||||
try:
|
|
||||||
backend = get_bottle_backend()
|
|
||||||
spec = BottleSpec(
|
|
||||||
manifest=_minimal_manifest(dockerfile),
|
|
||||||
agent_name="demo",
|
|
||||||
copy_cwd=False,
|
|
||||||
user_cwd=str(cls.stage),
|
|
||||||
)
|
|
||||||
cls.plan = backend.prepare(spec, stage_dir=cls.stage)
|
|
||||||
cls._launch = backend.launch(cls.plan)
|
|
||||||
cls.bottle = cls._launch.__enter__()
|
|
||||||
except BaseException:
|
|
||||||
if cls._launch is not None:
|
|
||||||
cls._launch.__exit__(None, None, None)
|
|
||||||
shutil.rmtree(cls.stage, ignore_errors=True)
|
|
||||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
|
||||||
raise
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tearDownClass(cls) -> None:
|
|
||||||
try:
|
|
||||||
if cls._launch is not None:
|
|
||||||
cls._launch.__exit__(None, None, None)
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(cls.stage, ignore_errors=True)
|
|
||||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
|
||||||
|
|
||||||
def test_smoke_exec_echo(self):
|
|
||||||
r = self.bottle.exec( # type: ignore[union-attr]
|
|
||||||
"echo hello-from-macos-container"
|
|
||||||
)
|
|
||||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
|
||||||
self.assertIn("hello-from-macos-container", r.stdout)
|
|
||||||
|
|
||||||
def test_proxy_env_points_at_sidecar_internal_ip(self):
|
|
||||||
r = self.bottle.exec( # type: ignore[union-attr]
|
|
||||||
"printf '%s\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\" "
|
|
||||||
"\"$NO_PROXY\" \"$NODE_EXTRA_CA_CERTS\""
|
|
||||||
)
|
|
||||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
|
||||||
values = [line.strip() for line in r.stdout.splitlines()]
|
|
||||||
self.assertEqual(4, len(values), values)
|
|
||||||
self.assertEqual(values[0], values[1], values)
|
|
||||||
self.assertRegex(values[0], r"^http://[0-9.]+:9099$")
|
|
||||||
self.assertNotIn("127.0.0.1", values[0])
|
|
||||||
sidecar_host = values[0].removeprefix("http://").removesuffix(":9099")
|
|
||||||
self.assertIn(sidecar_host, values[2])
|
|
||||||
self.assertEqual(
|
|
||||||
"/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt",
|
|
||||||
values[3],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_allowlisted_https_reaches_egress_proxy(self):
|
|
||||||
r = self.bottle.exec( # type: ignore[union-attr]
|
|
||||||
"curl -fsS --max-time 20 https://example.com >/dev/null && echo OK"
|
|
||||||
)
|
|
||||||
self.assertEqual(0, r.returncode, msg=r.stderr + r.stdout)
|
|
||||||
self.assertIn("OK", r.stdout)
|
|
||||||
|
|
||||||
def test_direct_egress_bypass_without_proxy_fails(self):
|
|
||||||
r = self.bottle.exec( # type: ignore[union-attr]
|
|
||||||
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
|
|
||||||
"curl -s --show-error --max-time 5 https://example.com 2>&1 || true"
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
"refused" in r.stdout.lower()
|
|
||||||
or "timed out" in r.stdout.lower()
|
|
||||||
or "unreachable" in r.stdout.lower()
|
|
||||||
or "failed" in r.stdout.lower()
|
|
||||||
or "could not resolve" in r.stdout.lower()
|
|
||||||
or "connection reset" in r.stdout.lower(),
|
|
||||||
f"expected direct egress to fail; got: {r.stdout!r}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_non_allowlisted_host_fails_through_proxy(self):
|
|
||||||
r = self.bottle.exec( # type: ignore[union-attr]
|
|
||||||
"curl -s --show-error --max-time 10 https://iana.org 2>&1 || true"
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
"403" in r.stdout
|
|
||||||
or "502" in r.stdout
|
|
||||||
or "blocked" in r.stdout.lower()
|
|
||||||
or "not allowed" in r.stdout.lower()
|
|
||||||
or "not in the bottle's egress.routes allowlist" in r.stdout.lower()
|
|
||||||
or "forbidden" in r.stdout.lower()
|
|
||||||
or "failed" in r.stdout.lower(),
|
|
||||||
f"expected non-allowlisted proxy request to fail; got: {r.stdout!r}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Integration: DockerGateway.image_exists reflects real docker state.
|
"""Integration: DockerGateway.image_exists reflects real docker state.
|
||||||
|
|
||||||
Gated on a reachable Docker daemon. Deliberately does NOT build the full
|
Gated on a reachable Docker daemon. Deliberately does NOT build the full
|
||||||
sidecar bundle (a heavy, slow image build) — it exercises the `image_exists`
|
gateway (a heavy, slow image build) — it exercises the `image_exists`
|
||||||
primitive that `ensure_built` gates on, against a tiny pulled image and a
|
primitive that `ensure_built` gates on, against a tiny pulled image and a
|
||||||
name that can't exist.
|
name that can't exist.
|
||||||
"""
|
"""
|
||||||
@@ -21,7 +21,7 @@ IMAGE = "busybox"
|
|||||||
class TestDockerGatewayImageExists(unittest.TestCase):
|
class TestDockerGatewayImageExists(unittest.TestCase):
|
||||||
def test_image_exists_true_for_present_false_for_absent(self) -> None:
|
def test_image_exists_true_for_present_false_for_absent(self) -> None:
|
||||||
# Ensure the tiny image is present (build_if_missing is disabled here
|
# Ensure the tiny image is present (build_if_missing is disabled here
|
||||||
# so this never triggers a Dockerfile.sidecars build).
|
# so this never triggers a Dockerfile.gateway build).
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "pull", IMAGE],
|
["docker", "pull", IMAGE],
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ resources.
|
|||||||
|
|
||||||
The PipelockProxy.stop idempotency case that used to live here was
|
The PipelockProxy.stop idempotency case that used to live here was
|
||||||
removed in PRD 0024 chunk 3 when the per-container .stop method
|
removed in PRD 0024 chunk 3 when the per-container .stop method
|
||||||
went away — sidecar teardown is now compose's responsibility, and
|
went away — gateway teardown is now compose's responsibility, and
|
||||||
`compose down` already no-ops on missing containers."""
|
`compose down` already no-ops on missing containers."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ _DUMMY_HOST_KEY = (
|
|||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
"skipped under act_runner: egress_tls_init uses a host bind mount "
|
"skipped under act_runner: egress_tls_init uses a host bind mount "
|
||||||
"the runner container can't see, and the network topology hides "
|
"the runner container can't see, and the network topology hides "
|
||||||
"sibling-sidecar visibility — same constraint as the other "
|
"sibling-gateway visibility — same constraint as the other "
|
||||||
"bottle-bringup integration tests",
|
"bottle-bringup integration tests",
|
||||||
)
|
)
|
||||||
class TestSandboxEscape(unittest.TestCase):
|
class TestSandboxEscape(unittest.TestCase):
|
||||||
@@ -90,8 +90,8 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls) -> None:
|
def setUpClass(cls) -> None:
|
||||||
# Docker is always required (the agent + sidecars run under it,
|
# Docker is always required (the agent + companion containers run under it,
|
||||||
# and VM backends still use it for the sidecar bundle); the
|
# and VM backends still use it for the gateway); the
|
||||||
# class-level @skip_unless_docker already covers that. Pin
|
# class-level @skip_unless_docker already covers that. Pin
|
||||||
# Docker when BOT_BOTTLE_BACKEND is unset to preserve the
|
# Docker when BOT_BOTTLE_BACKEND is unset to preserve the
|
||||||
# Docker-backed CI path.
|
# Docker-backed CI path.
|
||||||
@@ -121,7 +121,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
"egress": {
|
"egress": {
|
||||||
"routes": [{"host": "api.anthropic.com"}],
|
"routes": [{"host": "api.anthropic.com"}],
|
||||||
},
|
},
|
||||||
# git-gate sidecar so attack 5 can push. Upstream
|
# git-gate daemon so attack 5 can push. Upstream
|
||||||
# is intentionally unreachable — the pre-receive
|
# is intentionally unreachable — the pre-receive
|
||||||
# gitleaks hook must reject BEFORE git-gate
|
# gitleaks hook must reject BEFORE git-gate
|
||||||
# attempts the upstream push. A preset `host_key`
|
# attempts the upstream push. A preset `host_key`
|
||||||
@@ -275,7 +275,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
|
|
||||||
def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore
|
def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore
|
||||||
"""A real sandbox block produces an HTTP 403 with a
|
"""A real sandbox block produces an HTTP 403 with a
|
||||||
recognizable sandbox sidecar marker in the body. ANY
|
recognizable sandbox gateway marker in the body. ANY
|
||||||
other outcome (200 from upstream, 401/404 from upstream,
|
other outcome (200 from upstream, 401/404 from upstream,
|
||||||
non-marker 5xx) means the request escaped — the secret
|
non-marker 5xx) means the request escaped — the secret
|
||||||
reached the network."""
|
reached the network."""
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Integration: end-to-end smoke for the PRD 0024 bundle shape.
|
|
||||||
|
|
||||||
Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a
|
|
||||||
working bottle: `docker compose up` brings the agent + bundle pair
|
|
||||||
online, the daemons inside the bundle bind their ports, and the
|
|
||||||
agent can reach egress + supervise via the bundle's network
|
|
||||||
aliases (no agent-side config changes between flag positions).
|
|
||||||
|
|
||||||
Skipped under GITEA_ACTIONS — the bundle image is a multi-stage
|
|
||||||
build pulling 200+MB of base layers, and the bind-mounts won't
|
|
||||||
share filesystem with the runner container. Same constraint as
|
|
||||||
the chunk-1 image-probe test.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
|
||||||
from bot_bottle.manifest import ManifestIndex
|
|
||||||
from tests._docker import skip_unless_docker
|
|
||||||
|
|
||||||
|
|
||||||
def _manifest() -> ManifestIndex:
|
|
||||||
"""Bottle with supervise on so the bundle exercises egress +
|
|
||||||
supervise. Git is off because a meaningful git-gate test needs
|
|
||||||
a real upstream and SSH keys — out of scope for a bundle smoke."""
|
|
||||||
return ManifestIndex.from_json_obj({
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"supervise": True,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@skip_unless_docker()
|
|
||||||
@unittest.skipIf(
|
|
||||||
os.environ.get("GITEA_ACTIONS") == "true",
|
|
||||||
"skipped under act_runner: multi-stage bundle build pulls 200+MB "
|
|
||||||
"of base layers and bind-mounts don't share fs with the runner",
|
|
||||||
)
|
|
||||||
class TestSidecarBundleCompose(unittest.TestCase):
|
|
||||||
"""One end-to-end pass with the bundle flag on. Skipping under
|
|
||||||
act_runner; the local docker daemon does the work."""
|
|
||||||
|
|
||||||
def test_bottle_up_with_bundle_flag_on(self):
|
|
||||||
stage_dir = Path(tempfile.mkdtemp(prefix="cb-bundle-smoke."))
|
|
||||||
try:
|
|
||||||
with patch.dict(os.environ, {"BOT_BOTTLE_SIDECAR_BUNDLE": "1"}):
|
|
||||||
backend = get_bottle_backend("docker")
|
|
||||||
spec = BottleSpec(
|
|
||||||
manifest=_manifest(),
|
|
||||||
agent_name="demo",
|
|
||||||
copy_cwd=False,
|
|
||||||
user_cwd=str(stage_dir),
|
|
||||||
)
|
|
||||||
plan = backend.prepare(spec, stage_dir=stage_dir)
|
|
||||||
with backend.launch(plan) as bottle:
|
|
||||||
# The agent's HTTPS_PROXY URL (resolved at
|
|
||||||
# renderer-time) should reach egress inside
|
|
||||||
# the bundle. A bare CONNECT with no upstream
|
|
||||||
# URL gets rejected with 400 or 405 but proves
|
|
||||||
# the listener is alive at the alias.
|
|
||||||
probe = bottle.exec(
|
|
||||||
"set -eu\n"
|
|
||||||
"echo HTTPS_PROXY=$HTTPS_PROXY\n"
|
|
||||||
"PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n"
|
|
||||||
"HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n"
|
|
||||||
"echo HOST=$HOST PORT=$PORT\n"
|
|
||||||
"curl -sS --max-time 5 -o /dev/null -w 'http=%{http_code}\\n' "
|
|
||||||
" \"http://$HOST:$PORT/\" || true\n"
|
|
||||||
)
|
|
||||||
# The supervise URL resolves to the same bundle
|
|
||||||
# via its supervise alias, on a different port.
|
|
||||||
supervise_probe = bottle.exec(
|
|
||||||
"set -eu\n"
|
|
||||||
"curl -sS --max-time 5 -o /dev/null "
|
|
||||||
" -w 'http=%{http_code}\\n' "
|
|
||||||
" \"http://supervise:9100/health\" || true\n"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
self.assertEqual(0, probe.returncode, msg=probe.stderr)
|
|
||||||
# egress answered SOMETHING — any 4xx is fine, just proves
|
|
||||||
# the egress daemon is listening at the proxy address.
|
|
||||||
self.assertIn("http=", probe.stdout,
|
|
||||||
f"no HTTP response from egress: {probe.stdout!r}")
|
|
||||||
# supervise's /health endpoint exists (PRD 0013); it should
|
|
||||||
# answer 200 or similar — anything non-empty proves the
|
|
||||||
# third daemon's alias resolves to the same bundle.
|
|
||||||
self.assertEqual(0, supervise_probe.returncode, msg=supervise_probe.stderr)
|
|
||||||
self.assertIn("http=", supervise_probe.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -5,7 +5,7 @@ no test ever reads or writes the real ``~/.bot-bottle`` (state, queue,
|
|||||||
and audit dirs all derive from ``paths.bot_bottle_root()`` →
|
and audit dirs all derive from ``paths.bot_bottle_root()`` →
|
||||||
``Path.home()``). Without this, a test that takes a ``flock`` on the
|
``Path.home()``). Without this, a test that takes a ``flock`` on the
|
||||||
real audit log can **block indefinitely** when a live bottle's supervise
|
real audit log can **block indefinitely** when a live bottle's supervise
|
||||||
sidecar holds that lock — observed as a hung ``coverage run`` at 0% CPU —
|
gateway holds that lock — observed as a hung ``coverage run`` at 0% CPU —
|
||||||
and unisolated tests otherwise pollute the developer's home dir.
|
and unisolated tests otherwise pollute the developer's home dir.
|
||||||
|
|
||||||
Individual tests that need their own ``HOME`` still override
|
Individual tests that need their own ``HOME`` still override
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Shared in-memory `DockerBottlePlan` fixture for docker-backend tests.
|
||||||
|
|
||||||
|
A fully-resolved plan with toggles for the conditional-service matrix
|
||||||
|
(git-gate / egress / supervise / canary). Consumed by the consolidated
|
||||||
|
compose tests; kept here (rather than in a test module) so it survives
|
||||||
|
independent of any one test file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||||
|
from bot_bottle.backend import BottleSpec
|
||||||
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
|
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||||
|
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||||
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
from bot_bottle.supervise import SupervisePlan
|
||||||
|
|
||||||
|
|
||||||
|
SLUG = "demo-abc12"
|
||||||
|
STAGE = Path("/tmp/cb-stage")
|
||||||
|
STATE = Path("/tmp/cb-state")
|
||||||
|
|
||||||
|
# Exported to consumers (e.g. test_consolidated_compose); named with a
|
||||||
|
# leading underscore for the historical fixture convention.
|
||||||
|
__all__ = ["_plan"]
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex:
|
||||||
|
"""Minimal manifest with the toggles the matrix needs. The renderer
|
||||||
|
only reads from the plan, not the manifest, so this is just here to
|
||||||
|
back BottleSpec."""
|
||||||
|
bottle: dict[str, object] = {}
|
||||||
|
if supervise:
|
||||||
|
bottle["supervise"] = True
|
||||||
|
if with_git:
|
||||||
|
bottle["git-gate"] = {"repos": {
|
||||||
|
"upstream": {
|
||||||
|
"url": "ssh://git@example.com:22/x/y.git",
|
||||||
|
"key": {"provider": "static", "path": "/etc/hostname"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
if with_egress:
|
||||||
|
bottle["egress"] = {
|
||||||
|
"routes": [{
|
||||||
|
"host": "api.example",
|
||||||
|
"auth": {"scheme": "Bearer", "token_ref": "TOK"},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
return ManifestIndex.from_json_obj({
|
||||||
|
"bottles": {"dev": bottle},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
|
||||||
|
return GitGatePlan(
|
||||||
|
slug=SLUG,
|
||||||
|
entrypoint_script=STATE / "git-gate" / "entrypoint.sh",
|
||||||
|
hook_script=STATE / "git-gate" / "pre-receive",
|
||||||
|
access_hook_script=STATE / "git-gate" / "access-hook",
|
||||||
|
upstreams=upstreams,
|
||||||
|
internal_network=f"bot-bottle-net-{SLUG}",
|
||||||
|
egress_network=f"bot-bottle-egress-{SLUG}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _egress_plan(
|
||||||
|
routes: tuple[EgressRoute, ...] = (),
|
||||||
|
*,
|
||||||
|
canary: bool = False,
|
||||||
|
) -> EgressPlan:
|
||||||
|
token_env_map = {
|
||||||
|
r.token_env: r.token_ref
|
||||||
|
for r in routes
|
||||||
|
if r.token_env
|
||||||
|
}
|
||||||
|
return EgressPlan(
|
||||||
|
slug=SLUG,
|
||||||
|
routes_path=STATE / "egress" / "routes.yaml",
|
||||||
|
routes=routes,
|
||||||
|
token_env_map=token_env_map,
|
||||||
|
internal_network=f"bot-bottle-net-{SLUG}",
|
||||||
|
egress_network=f"bot-bottle-egress-{SLUG}",
|
||||||
|
mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem",
|
||||||
|
mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem",
|
||||||
|
canary="fake-canary-value" if canary else "",
|
||||||
|
canary_env="CANON_ALPHA_SECRET" if canary else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _supervise_plan() -> SupervisePlan:
|
||||||
|
return SupervisePlan(
|
||||||
|
slug=SLUG,
|
||||||
|
db_path=STATE / "bot-bottle.db",
|
||||||
|
internal_network=f"bot-bottle-net-{SLUG}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan(
|
||||||
|
*,
|
||||||
|
with_git: bool = False,
|
||||||
|
with_egress: bool = False,
|
||||||
|
supervise: bool = False,
|
||||||
|
canary: bool = False,
|
||||||
|
) -> DockerBottlePlan:
|
||||||
|
"""Build a fully-resolved DockerBottlePlan. Toggles cover the
|
||||||
|
matrix the renderer's conditional-service logic branches on."""
|
||||||
|
upstreams: tuple[GitGateUpstream, ...] = ()
|
||||||
|
if with_git:
|
||||||
|
upstreams = (GitGateUpstream(
|
||||||
|
name="upstream",
|
||||||
|
upstream_url="ssh://git@example.com:22/x/y.git",
|
||||||
|
upstream_host="example.com",
|
||||||
|
upstream_port="22",
|
||||||
|
identity_file="/etc/hostname",
|
||||||
|
known_host_key="",
|
||||||
|
known_hosts_file=STATE / "git-gate" / "upstream-known_hosts",
|
||||||
|
),)
|
||||||
|
routes: tuple[EgressRoute, ...] = ()
|
||||||
|
if with_egress:
|
||||||
|
routes = (EgressRoute(
|
||||||
|
host="api.example",
|
||||||
|
auth_scheme="Bearer",
|
||||||
|
token_env="EGRESS_TOKEN_0",
|
||||||
|
token_ref="TOK",
|
||||||
|
roles=(),
|
||||||
|
),)
|
||||||
|
|
||||||
|
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
||||||
|
spec = BottleSpec(
|
||||||
|
manifest=index,
|
||||||
|
agent_name="demo",
|
||||||
|
copy_cwd=False,
|
||||||
|
user_cwd="/tmp/x",
|
||||||
|
)
|
||||||
|
return DockerBottlePlan(
|
||||||
|
spec=spec,
|
||||||
|
manifest=index.load_for_agent("demo"),
|
||||||
|
stage_dir=STAGE,
|
||||||
|
slug=SLUG,
|
||||||
|
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
|
||||||
|
git_gate_plan=_git_gate_plan(upstreams),
|
||||||
|
egress_plan=_egress_plan(routes, canary=canary),
|
||||||
|
supervise_plan=_supervise_plan() if supervise else None,
|
||||||
|
use_runsc=False,
|
||||||
|
agent_provision=AgentProvisionPlan(
|
||||||
|
template="claude",
|
||||||
|
command="claude",
|
||||||
|
prompt_mode="append_file",
|
||||||
|
image="bot-bottle-claude:latest",
|
||||||
|
dockerfile="",
|
||||||
|
guest_home="/home/node",
|
||||||
|
instance_name=f"bot-bottle-{SLUG}",
|
||||||
|
prompt_file=STAGE / "prompt",
|
||||||
|
guest_env={},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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"))
|
||||||
|
|||||||
+4
-415
@@ -1,434 +1,23 @@
|
|||||||
"""Unit: compose-spec renderer (PRD 0018 chunk 1).
|
"""Unit: docker compose lifecycle helpers (PRD 0018).
|
||||||
|
|
||||||
Pure-function tests for `bottle_plan_to_compose`. Fixtures build a
|
The compose *spec* is built by `consolidated_compose.py`; these
|
||||||
fully-resolved DockerBottlePlan in memory; the renderer just
|
tests cover the I/O-side helpers in `compose.py` — the slug ↔
|
||||||
translates it to the compose dict. Conditional-service matrix is
|
project mapping and `docker compose ls` enumeration.
|
||||||
covered via parameterized cases (git on/off × egress on/off ×
|
|
||||||
supervise on/off).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
|
||||||
from bot_bottle.backend import BottleSpec
|
|
||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
|
||||||
from bot_bottle.backend.docker.compose import (
|
from bot_bottle.backend.docker.compose import (
|
||||||
COMPOSE_PROJECT_PREFIX,
|
COMPOSE_PROJECT_PREFIX,
|
||||||
bottle_plan_to_compose,
|
|
||||||
compose_project_name,
|
compose_project_name,
|
||||||
list_active_slugs,
|
list_active_slugs,
|
||||||
list_compose_projects,
|
list_compose_projects,
|
||||||
slug_from_compose_project,
|
slug_from_compose_project,
|
||||||
)
|
)
|
||||||
from bot_bottle.egress import (
|
|
||||||
EgressPlan,
|
|
||||||
EgressRoute,
|
|
||||||
)
|
|
||||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
|
||||||
from bot_bottle.manifest import ManifestIndex
|
|
||||||
from bot_bottle.supervise import SupervisePlan
|
|
||||||
|
|
||||||
|
|
||||||
SLUG = "demo-abc12"
|
|
||||||
STAGE = Path("/tmp/cb-stage")
|
|
||||||
STATE = Path("/tmp/cb-state")
|
|
||||||
|
|
||||||
|
|
||||||
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex:
|
|
||||||
"""Minimal manifest with the toggles the chunk-1 matrix needs.
|
|
||||||
The renderer only reads from the plan, not the manifest, so this
|
|
||||||
is just here to back BottleSpec."""
|
|
||||||
bottle: dict[str, object] = {}
|
|
||||||
if supervise:
|
|
||||||
bottle["supervise"] = True
|
|
||||||
if with_git:
|
|
||||||
bottle["git-gate"] = {"repos": {
|
|
||||||
"upstream": {
|
|
||||||
"url": "ssh://git@example.com:22/x/y.git",
|
|
||||||
"key": {"provider": "static", "path": "/etc/hostname"},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
if with_egress:
|
|
||||||
bottle["egress"] = {
|
|
||||||
"routes": [{
|
|
||||||
"host": "api.example",
|
|
||||||
"auth": {"scheme": "Bearer", "token_ref": "TOK"},
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
return ManifestIndex.from_json_obj({
|
|
||||||
"bottles": {"dev": bottle},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
|
|
||||||
return GitGatePlan(
|
|
||||||
slug=SLUG,
|
|
||||||
entrypoint_script=STATE / "git-gate" / "entrypoint.sh",
|
|
||||||
hook_script=STATE / "git-gate" / "pre-receive",
|
|
||||||
access_hook_script=STATE / "git-gate" / "access-hook",
|
|
||||||
upstreams=upstreams,
|
|
||||||
internal_network=f"bot-bottle-net-{SLUG}",
|
|
||||||
egress_network=f"bot-bottle-egress-{SLUG}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _egress_plan(
|
|
||||||
routes: tuple[EgressRoute, ...] = (),
|
|
||||||
*,
|
|
||||||
canary: bool = False,
|
|
||||||
) -> EgressPlan:
|
|
||||||
token_env_map = {
|
|
||||||
r.token_env: r.token_ref
|
|
||||||
for r in routes
|
|
||||||
if r.token_env
|
|
||||||
}
|
|
||||||
return EgressPlan(
|
|
||||||
slug=SLUG,
|
|
||||||
routes_path=STATE / "egress" / "routes.yaml",
|
|
||||||
routes=routes,
|
|
||||||
token_env_map=token_env_map,
|
|
||||||
internal_network=f"bot-bottle-net-{SLUG}",
|
|
||||||
egress_network=f"bot-bottle-egress-{SLUG}",
|
|
||||||
mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem",
|
|
||||||
mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem",
|
|
||||||
canary="fake-canary-value" if canary else "",
|
|
||||||
canary_env="CANON_ALPHA_SECRET" if canary else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _supervise_plan() -> SupervisePlan:
|
|
||||||
return SupervisePlan(
|
|
||||||
slug=SLUG,
|
|
||||||
db_path=STATE / "bot-bottle.db",
|
|
||||||
internal_network=f"bot-bottle-net-{SLUG}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _plan(
|
|
||||||
*,
|
|
||||||
with_git: bool = False,
|
|
||||||
with_egress: bool = False,
|
|
||||||
supervise: bool = False,
|
|
||||||
canary: bool = False,
|
|
||||||
) -> DockerBottlePlan:
|
|
||||||
"""Build a fully-resolved DockerBottlePlan. Toggles cover the
|
|
||||||
matrix the renderer's conditional-service logic branches on."""
|
|
||||||
upstreams: tuple[GitGateUpstream, ...] = ()
|
|
||||||
if with_git:
|
|
||||||
upstreams = (GitGateUpstream(
|
|
||||||
name="upstream",
|
|
||||||
upstream_url="ssh://git@example.com:22/x/y.git",
|
|
||||||
upstream_host="example.com",
|
|
||||||
upstream_port="22",
|
|
||||||
identity_file="/etc/hostname",
|
|
||||||
known_host_key="",
|
|
||||||
known_hosts_file=STATE / "git-gate" / "upstream-known_hosts",
|
|
||||||
),)
|
|
||||||
routes: tuple[EgressRoute, ...] = ()
|
|
||||||
if with_egress:
|
|
||||||
routes = (EgressRoute(
|
|
||||||
host="api.example",
|
|
||||||
auth_scheme="Bearer",
|
|
||||||
token_env="EGRESS_TOKEN_0",
|
|
||||||
token_ref="TOK",
|
|
||||||
roles=(),
|
|
||||||
),)
|
|
||||||
|
|
||||||
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
|
||||||
spec = BottleSpec(
|
|
||||||
manifest=index,
|
|
||||||
agent_name="demo",
|
|
||||||
copy_cwd=False,
|
|
||||||
user_cwd="/tmp/x",
|
|
||||||
)
|
|
||||||
return DockerBottlePlan(
|
|
||||||
spec=spec,
|
|
||||||
manifest=index.load_for_agent("demo"),
|
|
||||||
stage_dir=STAGE,
|
|
||||||
slug=SLUG,
|
|
||||||
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
|
|
||||||
git_gate_plan=_git_gate_plan(upstreams),
|
|
||||||
egress_plan=_egress_plan(routes, canary=canary),
|
|
||||||
supervise_plan=_supervise_plan() if supervise else None,
|
|
||||||
use_runsc=False,
|
|
||||||
agent_provision=AgentProvisionPlan(
|
|
||||||
template="claude",
|
|
||||||
command="claude",
|
|
||||||
prompt_mode="append_file",
|
|
||||||
image="bot-bottle-claude:latest",
|
|
||||||
dockerfile="",
|
|
||||||
guest_home="/home/node",
|
|
||||||
instance_name=f"bot-bottle-{SLUG}",
|
|
||||||
prompt_file=STAGE / "prompt",
|
|
||||||
guest_env={},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestProjectAndNetworks(unittest.TestCase):
|
|
||||||
def test_project_name(self):
|
|
||||||
spec = bottle_plan_to_compose(_plan())
|
|
||||||
self.assertEqual(f"bot-bottle-{SLUG}", spec["name"])
|
|
||||||
|
|
||||||
def test_internal_network_is_internal(self):
|
|
||||||
spec = bottle_plan_to_compose(_plan())
|
|
||||||
net = spec["networks"]["internal"]
|
|
||||||
self.assertEqual(f"bot-bottle-net-{SLUG}", net["name"])
|
|
||||||
self.assertTrue(net["internal"])
|
|
||||||
|
|
||||||
def test_egress_network_is_external_bridge(self):
|
|
||||||
spec = bottle_plan_to_compose(_plan())
|
|
||||||
net = spec["networks"]["egress"]
|
|
||||||
self.assertEqual(f"bot-bottle-egress-{SLUG}", net["name"])
|
|
||||||
# No `internal:` key on the egress network — defaults to a
|
|
||||||
# normal user-defined bridge.
|
|
||||||
self.assertNotIn("internal", net)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAgentAlwaysPresent(unittest.TestCase):
|
|
||||||
def test_agent_in_services(self):
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]
|
|
||||||
self.assertIn("agent", s)
|
|
||||||
|
|
||||||
def test_agent_command(self):
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["agent"]
|
|
||||||
self.assertEqual(["sleep", "infinity"], s["command"])
|
|
||||||
|
|
||||||
def test_agent_image_uses_runtime_image(self):
|
|
||||||
plan = _plan()
|
|
||||||
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
|
||||||
self.assertEqual(plan.image, s["image"])
|
|
||||||
|
|
||||||
def test_agent_only_on_internal_network(self):
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["agent"]
|
|
||||||
self.assertEqual({"internal"}, set(s["networks"].keys()))
|
|
||||||
|
|
||||||
def test_agent_proxy_always_via_egress(self):
|
|
||||||
for with_egress in (False, True):
|
|
||||||
with self.subTest(with_egress=with_egress):
|
|
||||||
s = bottle_plan_to_compose(
|
|
||||||
_plan(with_egress=with_egress)
|
|
||||||
)["services"]["agent"]
|
|
||||||
proxy_lines = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")]
|
|
||||||
self.assertEqual(1, len(proxy_lines))
|
|
||||||
self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy_lines[0])
|
|
||||||
|
|
||||||
def test_agent_proxy_via_egress_when_egress_present(self):
|
|
||||||
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["agent"]
|
|
||||||
proxy = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")][0]
|
|
||||||
self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy)
|
|
||||||
|
|
||||||
def test_agent_no_proxy_adds_supervise_when_enabled(self):
|
|
||||||
s = bottle_plan_to_compose(
|
|
||||||
_plan(supervise=True)
|
|
||||||
)["services"]["agent"]
|
|
||||||
no_proxy = [e for e in s["environment"] if e.startswith("NO_PROXY=")][0]
|
|
||||||
self.assertIn("supervise", no_proxy)
|
|
||||||
|
|
||||||
def test_agent_forwarded_env_uses_bare_names(self):
|
|
||||||
# Bare NAME → compose inherits value from the up-process env,
|
|
||||||
# so secret token values stay out of the file.
|
|
||||||
s = bottle_plan_to_compose(_plan())["services"]["agent"]
|
|
||||||
self.assertIn("CLAUDE_CODE_OAUTH_TOKEN", s["environment"])
|
|
||||||
|
|
||||||
def test_agent_provider_env_uses_literal_values(self):
|
|
||||||
plan = _plan()
|
|
||||||
provision = AgentProvisionPlan(
|
|
||||||
template="codex",
|
|
||||||
command="codex",
|
|
||||||
prompt_mode="read_prompt_file",
|
|
||||||
image="bot-bottle-codex:latest",
|
|
||||||
dockerfile="",
|
|
||||||
guest_home="/home/node",
|
|
||||||
instance_name=f"bot-bottle-{SLUG}",
|
|
||||||
prompt_file=STAGE / "prompt",
|
|
||||||
guest_env={"CODEX_HOME": "/home/node/.codex"},
|
|
||||||
)
|
|
||||||
plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore
|
|
||||||
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
|
||||||
self.assertIn("CODEX_HOME=/home/node/.codex", s["environment"])
|
|
||||||
|
|
||||||
def test_agent_runsc_runtime(self):
|
|
||||||
plan = _plan()
|
|
||||||
plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore
|
|
||||||
s = bottle_plan_to_compose(plan)["services"]["agent"]
|
|
||||||
self.assertEqual("runsc", s["runtime"])
|
|
||||||
|
|
||||||
def test_agent_depends_only_on_sidecars(self):
|
|
||||||
# Bundle shape: the init supervisor owns intra-bundle daemon
|
|
||||||
# ordering, so the agent waits on the bundle container alone.
|
|
||||||
for kwargs in [{}, {"with_git": True, "with_egress": True, "supervise": True}]:
|
|
||||||
with self.subTest(**kwargs):
|
|
||||||
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
|
|
||||||
self.assertEqual(["sidecars"], s["depends_on"])
|
|
||||||
|
|
||||||
def test_agent_has_no_current_config_mount_with_supervise(self):
|
|
||||||
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
|
|
||||||
self.assertNotIn("volumes", with_sv)
|
|
||||||
without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"]
|
|
||||||
self.assertNotIn("volumes", without_sv)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSidecarBundleShape(unittest.TestCase):
|
|
||||||
"""The compose renderer emits exactly one `sidecars` service in
|
|
||||||
place of the daemons it owns (egress + git-gate + supervise).
|
|
||||||
PRD 0024 chunk 5 dropped the legacy four-sidecar shape entirely,
|
|
||||||
so the bundle is the only thing exercised here."""
|
|
||||||
|
|
||||||
def _render(self, **plan_kwargs: object) -> Any: # type: ignore
|
|
||||||
return bottle_plan_to_compose(_plan(**plan_kwargs)) # type: ignore
|
|
||||||
|
|
||||||
def test_emits_two_services_minimal(self):
|
|
||||||
spec = self._render()
|
|
||||||
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
|
|
||||||
|
|
||||||
def test_emits_two_services_full_matrix(self):
|
|
||||||
spec = self._render(with_git=True, with_egress=True, supervise=True)
|
|
||||||
# Still two services — the bundle absorbs git-gate/egress/supervise.
|
|
||||||
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
|
|
||||||
|
|
||||||
def test_bundle_uses_bundle_image_and_dockerfile(self):
|
|
||||||
sc = self._render()["services"]["sidecars"]
|
|
||||||
self.assertEqual("bot-bottle-sidecars:latest", sc["image"])
|
|
||||||
self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"])
|
|
||||||
|
|
||||||
def test_bundle_container_name_uses_sidecars_prefix(self):
|
|
||||||
sc = self._render()["services"]["sidecars"]
|
|
||||||
self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"])
|
|
||||||
|
|
||||||
def test_bundle_joins_both_networks(self):
|
|
||||||
sc = self._render()["services"]["sidecars"]
|
|
||||||
self.assertEqual({"internal", "egress"}, set(sc["networks"].keys()))
|
|
||||||
|
|
||||||
def test_internal_aliases_include_egress_shortname(self):
|
|
||||||
sc = self._render()["services"]["sidecars"]
|
|
||||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
|
||||||
self.assertIn("egress", aliases)
|
|
||||||
|
|
||||||
def test_internal_aliases_omit_inactive_sidecars(self):
|
|
||||||
# With no git-gate / supervise, those names are NOT aliased
|
|
||||||
# — keeps the alias list honest about what's actually
|
|
||||||
# listening inside the bundle.
|
|
||||||
sc = self._render()["services"]["sidecars"]
|
|
||||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
|
||||||
self.assertNotIn("git-gate", aliases)
|
|
||||||
self.assertNotIn("supervise", aliases)
|
|
||||||
|
|
||||||
def test_internal_aliases_include_active_sidecars(self):
|
|
||||||
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
|
|
||||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
|
||||||
self.assertIn("git-gate", aliases)
|
|
||||||
self.assertIn("supervise", aliases)
|
|
||||||
|
|
||||||
def test_daemons_csv_lists_only_active(self):
|
|
||||||
sc = self._render()["services"]["sidecars"]
|
|
||||||
daemons = {
|
|
||||||
line.split("=", 1)[1]
|
|
||||||
for line in sc["environment"]
|
|
||||||
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=")
|
|
||||||
}
|
|
||||||
self.assertEqual({"egress"}, daemons)
|
|
||||||
|
|
||||||
def test_daemons_csv_expands_with_optional_sidecars(self):
|
|
||||||
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
|
|
||||||
for line in sc["environment"]:
|
|
||||||
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS="):
|
|
||||||
csv = line.split("=", 1)[1]
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env")
|
|
||||||
self.assertEqual(
|
|
||||||
["egress", "git-gate", "supervise"],
|
|
||||||
csv.split(","),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_bundle_env_does_not_set_https_proxy(self):
|
|
||||||
# HTTPS_PROXY at the container level would route git-gate's
|
|
||||||
# git fetches through the proxy. Scoping it to mitmdump is
|
|
||||||
# the job of egress_entrypoint.sh; the bundle env must not
|
|
||||||
# leak it.
|
|
||||||
sc = self._render(with_egress=True)["services"]["sidecars"]
|
|
||||||
for line in sc["environment"]:
|
|
||||||
self.assertFalse(
|
|
||||||
line.startswith("HTTPS_PROXY=")
|
|
||||||
or line.startswith("HTTP_PROXY=")
|
|
||||||
or line.startswith("NO_PROXY="),
|
|
||||||
f"bundle env must not set {line!r}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_egress_token_env_present_when_routes_declared(self):
|
|
||||||
sc = self._render(with_egress=True)["services"]["sidecars"]
|
|
||||||
env_strings = sc["environment"]
|
|
||||||
self.assertIn("EGRESS_TOKEN_0", env_strings)
|
|
||||||
|
|
||||||
def test_egress_token_env_omitted_when_no_routes(self):
|
|
||||||
sc = self._render()["services"]["sidecars"]
|
|
||||||
env_strings = sc["environment"]
|
|
||||||
self.assertNotIn("EGRESS_TOKEN_0", env_strings)
|
|
||||||
|
|
||||||
def test_canary_env_registered_as_sensitive_in_sidecar(self):
|
|
||||||
sc = self._render(canary=True)["services"]["sidecars"]
|
|
||||||
env_strings = sc["environment"]
|
|
||||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", env_strings)
|
|
||||||
self.assertIn(
|
|
||||||
"BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET",
|
|
||||||
env_strings,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_canary_env_visible_to_agent(self):
|
|
||||||
agent = self._render(canary=True)["services"]["agent"]
|
|
||||||
env_strings = agent["environment"]
|
|
||||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", env_strings)
|
|
||||||
|
|
||||||
def test_supervise_env_present_when_active(self):
|
|
||||||
sc = self._render(supervise=True)["services"]["sidecars"]
|
|
||||||
env_strings = sc["environment"]
|
|
||||||
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
|
|
||||||
self.assertIn("SUPERVISE_DB_PATH=/run/supervise/bot-bottle.db", env_strings)
|
|
||||||
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings))
|
|
||||||
|
|
||||||
def test_volumes_always_includes_egress_ca(self):
|
|
||||||
sc = self._render()["services"]["sidecars"]
|
|
||||||
targets = {v["target"] for v in sc["volumes"]}
|
|
||||||
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
|
||||||
|
|
||||||
def test_volumes_union_full_matrix(self):
|
|
||||||
sc = self._render(with_git=True, with_egress=True, supervise=True)[
|
|
||||||
"services"]["sidecars"]
|
|
||||||
targets = {v["target"] for v in sc["volumes"]}
|
|
||||||
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
|
||||||
self.assertIn("/etc/egress", targets)
|
|
||||||
self.assertIn("/git-gate-entrypoint.sh", targets)
|
|
||||||
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
|
|
||||||
self.assertIn("/run/supervise/bot-bottle.db", targets)
|
|
||||||
|
|
||||||
def test_extra_hosts_omitted_for_git_upstreams(self):
|
|
||||||
sc = self._render(with_git=True)["services"]["sidecars"]
|
|
||||||
self.assertNotIn("extra_hosts", sc)
|
|
||||||
|
|
||||||
def test_agent_depends_on_bundle_only(self):
|
|
||||||
sc = self._render(with_git=True, with_egress=True, supervise=True)[
|
|
||||||
"services"]["agent"]
|
|
||||||
self.assertEqual(["sidecars"], sc["depends_on"])
|
|
||||||
|
|
||||||
def test_agent_proxy_url_resolves_via_bundle_alias(self):
|
|
||||||
# With egress active, the agent's HTTPS_PROXY points at
|
|
||||||
# `egress` shortname; bundle aliases `egress` to itself so
|
|
||||||
# the URL keeps working without an agent-side change.
|
|
||||||
spec = self._render(with_egress=True)
|
|
||||||
sc = spec["services"]["agent"]
|
|
||||||
proxy = next(e for e in sc["environment"] if e.startswith("HTTPS_PROXY="))
|
|
||||||
self.assertIn("egress", proxy)
|
|
||||||
self.assertIn("egress",
|
|
||||||
spec["services"]["sidecars"]["networks"]["internal"]["aliases"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestProjectNaming(unittest.TestCase):
|
class TestProjectNaming(unittest.TestCase):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.backend.docker.consolidated_compose import consolidated_agent_compose
|
from bot_bottle.backend.docker.consolidated_compose import consolidated_agent_compose
|
||||||
from tests.unit.test_compose import _plan
|
from tests.unit._docker_bottle_plan import _plan
|
||||||
|
|
||||||
_GW = "172.18.0.2"
|
_GW = "172.18.0.2"
|
||||||
_IP = "172.18.0.5"
|
_IP = "172.18.0.5"
|
||||||
@@ -19,8 +19,8 @@ class TestConsolidatedAgentCompose(unittest.TestCase):
|
|||||||
plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore[arg-type]
|
plan = type(plan)(**{**vars(plan), "use_runsc": True}) # type: ignore[arg-type]
|
||||||
return consolidated_agent_compose(plan, gateway_ip=_GW, source_ip=_IP, network=_NET)
|
return consolidated_agent_compose(plan, gateway_ip=_GW, source_ip=_IP, network=_NET)
|
||||||
|
|
||||||
def test_only_agent_service_no_sidecars(self) -> None:
|
def test_only_agent_service_no_companion_container(self) -> None:
|
||||||
# The whole point of consolidation: no per-bottle sidecar bundle.
|
# The whole point of consolidation: no per-bottle gateway.
|
||||||
self.assertEqual(["agent"], list(self._spec()["services"]))
|
self.assertEqual(["agent"], list(self._spec()["services"]))
|
||||||
|
|
||||||
def test_agent_pinned_on_external_gateway_network(self) -> None:
|
def test_agent_pinned_on_external_gateway_network(self) -> None:
|
||||||
@@ -35,7 +35,7 @@ class TestConsolidatedAgentCompose(unittest.TestCase):
|
|||||||
# git-http + supervise on the gateway must bypass the egress proxy.
|
# git-http + supervise on the gateway must bypass the egress proxy.
|
||||||
self.assertTrue(any(e.startswith("NO_PROXY=") and _GW in e for e in env))
|
self.assertTrue(any(e.startswith("NO_PROXY=") and _GW in e for e in env))
|
||||||
|
|
||||||
def test_no_sidecar_dependency(self) -> None:
|
def test_no_companion_container_dependency(self) -> None:
|
||||||
self.assertNotIn("depends_on", self._spec()["services"]["agent"])
|
self.assertNotIn("depends_on", self._spec()["services"]["agent"])
|
||||||
|
|
||||||
def test_runsc_runtime_when_enabled(self) -> None:
|
def test_runsc_runtime_when_enabled(self) -> 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:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from bot_bottle.egress import (
|
|||||||
egress_render_routes,
|
egress_render_routes,
|
||||||
egress_resolve_token_values,
|
egress_resolve_token_values,
|
||||||
egress_routes_for_bottle,
|
egress_routes_for_bottle,
|
||||||
egress_sidecar_env_entries,
|
egress_gateway_env_entries,
|
||||||
egress_token_env_map,
|
egress_token_env_map,
|
||||||
)
|
)
|
||||||
from bot_bottle.errors import MissingEnvVarError
|
from bot_bottle.errors import MissingEnvVarError
|
||||||
@@ -586,7 +586,7 @@ class TestCanaryGeneration(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestEgressEnvEntries(unittest.TestCase):
|
class TestEgressEnvEntries(unittest.TestCase):
|
||||||
def test_sidecar_entries_include_route_tokens_and_canary_scan_prefix(self):
|
def test_gateway_entries_include_route_tokens_and_canary_scan_prefix(self):
|
||||||
plan = EgressPlan(
|
plan = EgressPlan(
|
||||||
slug="s",
|
slug="s",
|
||||||
routes_path=Path("/tmp/r.yaml"),
|
routes_path=Path("/tmp/r.yaml"),
|
||||||
@@ -603,7 +603,7 @@ class TestEgressEnvEntries(unittest.TestCase):
|
|||||||
"CANON_ALPHA_SECRET=fake-canary-value",
|
"CANON_ALPHA_SECRET=fake-canary-value",
|
||||||
"BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET",
|
"BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET",
|
||||||
),
|
),
|
||||||
egress_sidecar_env_entries(plan),
|
egress_gateway_env_entries(plan),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_agent_entries_include_only_canary_bait(self):
|
def test_agent_entries_include_only_canary_bait(self):
|
||||||
@@ -630,7 +630,7 @@ class TestEgressEnvEntries(unittest.TestCase):
|
|||||||
canary="fake-canary-value",
|
canary="fake-canary-value",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual((), egress_sidecar_env_entries(plan))
|
self.assertEqual((), egress_gateway_env_entries(plan))
|
||||||
self.assertEqual((), egress_agent_env_entries(plan))
|
self.assertEqual((), egress_agent_env_entries(plan))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -951,7 +951,7 @@ class TestScanOutbound(unittest.TestCase):
|
|||||||
body='{"jsonrpc":"2.0","method":"initialize"}',
|
body='{"jsonrpc":"2.0","method":"initialize"}',
|
||||||
)
|
)
|
||||||
self.assertIsNone(scan_outbound(route, text, {
|
self.assertIsNone(scan_outbound(route, text, {
|
||||||
"EGRESS_TOKEN_0": "sidecar-owned-secret",
|
"EGRESS_TOKEN_0": "gateway-owned-secret",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def test_token_in_body_blocked(self):
|
def test_token_in_body_blocked(self):
|
||||||
@@ -1302,7 +1302,7 @@ class TestScanOutboundEnhanced(unittest.TestCase):
|
|||||||
self.assertEqual("warn", result.severity)
|
self.assertEqual("warn", result.severity)
|
||||||
|
|
||||||
def test_bot_bottle_sensitive_prefixes_env_var(self):
|
def test_bot_bottle_sensitive_prefixes_env_var(self):
|
||||||
# When the sidecar env contains BOT_BOTTLE_SENSITIVE_PREFIXES,
|
# When the gateway env contains BOT_BOTTLE_SENSITIVE_PREFIXES,
|
||||||
# scan_outbound should scan those additional prefixes.
|
# scan_outbound should scan those additional prefixes.
|
||||||
secret = "extra-sensitive-value-abc"
|
secret = "extra-sensitive-value-abc"
|
||||||
env = {
|
env = {
|
||||||
@@ -1324,7 +1324,7 @@ class TestScanOutboundEnhanced(unittest.TestCase):
|
|||||||
self.assertIsNotNone(result)
|
self.assertIsNotNone(result)
|
||||||
|
|
||||||
def test_canary_detected_via_random_secret_env_name(self):
|
def test_canary_detected_via_random_secret_env_name(self):
|
||||||
# The fake secret uses a randomized env name that the sidecar marks
|
# The fake secret uses a randomized env name that the gateway marks
|
||||||
# as sensitive through BOT_BOTTLE_SENSITIVE_PREFIXES.
|
# as sensitive through BOT_BOTTLE_SENSITIVE_PREFIXES.
|
||||||
canary = "canaryvalue12345abcdef"
|
canary = "canaryvalue12345abcdef"
|
||||||
env = {
|
env = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unit: LOG_FULL credential redaction in _log_request / _log_response (issue #257).
|
"""Unit: LOG_FULL credential redaction in _log_request / _log_response (issue #257).
|
||||||
|
|
||||||
egress_addon.py is sidecar-only code that depends on mitmproxy, which is
|
egress_addon.py is gateway-only code that depends on mitmproxy, which is
|
||||||
not installed on the host. This file pre-populates sys.modules with the
|
not installed on the host. This file pre-populates sys.modules with the
|
||||||
minimum mocks needed so EgressAddon can be imported and tested without the
|
minimum mocks needed so EgressAddon can be imported and tested without the
|
||||||
real mitmproxy package."""
|
real mitmproxy package."""
|
||||||
@@ -17,7 +17,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sidecar-import shims — must run before importing egress_addon
|
# Gateway-import shims — must run before importing egress_addon
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _ensure_shims() -> None:
|
def _ensure_shims() -> None:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unit: EgressAddon request/response decision flow (issue #286).
|
"""Unit: EgressAddon request/response decision flow (issue #286).
|
||||||
|
|
||||||
`egress_addon.py` is the sidecar-only mitmproxy adapter that wires the
|
`egress_addon.py` is the gateway-only mitmproxy adapter that wires the
|
||||||
host-importable decision logic in `egress_addon_core` into mitmproxy's
|
host-importable decision logic in `egress_addon_core` into mitmproxy's
|
||||||
request/response hooks. The core logic is exercised directly by
|
request/response hooks. The core logic is exercised directly by
|
||||||
`test_egress_addon_core.py`; the redaction logging by
|
`test_egress_addon_core.py`; the redaction logging by
|
||||||
@@ -13,7 +13,7 @@ from coverage.
|
|||||||
mitmproxy is not installed on the host, so we pre-populate `sys.modules`
|
mitmproxy is not installed on the host, so we pre-populate `sys.modules`
|
||||||
with the minimum stubs needed to import the adapter (a `mitmproxy.http`
|
with the minimum stubs needed to import the adapter (a `mitmproxy.http`
|
||||||
module exposing a `Response` with `.make`, plus the flat
|
module exposing a `Response` with `.make`, plus the flat
|
||||||
`egress_addon_core` name the sidecar uses)."""
|
`egress_addon_core` name the gateway uses)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ class _WebSocketData:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sidecar-import shims — must run before importing egress_addon
|
# Gateway-import shims — must run before importing egress_addon
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -303,9 +303,9 @@ class TestAuthInjection(unittest.TestCase):
|
|||||||
route = Route(host="api.example.com", auth_scheme="Bearer", token_env="EGRESS_TOKEN_0")
|
route = Route(host="api.example.com", auth_scheme="Bearer", token_env="EGRESS_TOKEN_0")
|
||||||
addon = _addon(Config(routes=(route,)))
|
addon = _addon(Config(routes=(route,)))
|
||||||
flow = _Flow(_Request(host="api.example.com", headers={"authorization": "Bearer agent-faked"}))
|
flow = _Flow(_Request(host="api.example.com", headers={"authorization": "Bearer agent-faked"}))
|
||||||
with patch.dict("os.environ", {"EGRESS_TOKEN_0": "real-sidecar-token"}):
|
with patch.dict("os.environ", {"EGRESS_TOKEN_0": "real-gateway-token"}):
|
||||||
_run_request(addon, flow)
|
_run_request(addon, flow)
|
||||||
self.assertEqual("Bearer real-sidecar-token", flow.request.headers.get("authorization"))
|
self.assertEqual("Bearer real-gateway-token", flow.request.headers.get("authorization"))
|
||||||
self.assertIsNone(flow.response)
|
self.assertIsNone(flow.response)
|
||||||
|
|
||||||
def test_auth_route_with_unset_env_blocks(self) -> None:
|
def test_auth_route_with_unset_env_blocks(self) -> None:
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ integration test)."""
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from tests.unit import use_bottle_root
|
from tests.unit import use_bottle_root
|
||||||
from bot_bottle.backend.egress_apply import EgressApplyError
|
from bot_bottle.backend.egress_apply import EgressApplyError
|
||||||
@@ -70,32 +68,16 @@ class TestApplyRoutesChange(unittest.TestCase):
|
|||||||
self.addCleanup(self._tmp.cleanup)
|
self.addCleanup(self._tmp.cleanup)
|
||||||
self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle"))
|
self.addCleanup(use_bottle_root(Path(self._tmp.name) / ".bot-bottle"))
|
||||||
|
|
||||||
def test_writes_live_routes_and_signals_reload(self):
|
def test_apply_routes_change_fails_closed_after_companion_removal(self):
|
||||||
calls: list[list[str]] = []
|
# The per-bottle companion container that live route-apply used to
|
||||||
|
# signal was removed in the companion-container removal (#385); apply now
|
||||||
def fake_run(argv: list[str], **kwargs: object) -> SimpleNamespace:
|
# fails closed until the gateway-side apply lands.
|
||||||
calls.append(list(argv))
|
with self.assertRaises(EgressApplyError) as cm:
|
||||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
applicator.apply_routes_change(
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.docker.egress_apply.subprocess.run",
|
|
||||||
side_effect=fake_run,
|
|
||||||
):
|
|
||||||
before, after = applicator.apply_routes_change(
|
|
||||||
"dev",
|
"dev",
|
||||||
"routes:\n - host: google.com\n",
|
"routes:\n - host: google.com\n",
|
||||||
)
|
)
|
||||||
|
self.assertIn("consolidated gateway", str(cm.exception))
|
||||||
self.assertEqual("", before)
|
|
||||||
self.assertEqual("routes:\n - host: google.com\n", after)
|
|
||||||
self.assertEqual(
|
|
||||||
"routes:\n - host: google.com\n",
|
|
||||||
(Path(self._tmp.name) / ".bot-bottle/state/dev/egress/routes.yaml").read_text(encoding="utf-8"),
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
["docker", "kill", "--signal", "HUP", "bot-bottle-sidecars-dev"],
|
|
||||||
calls[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -38,18 +38,6 @@ class TestOrphanEnumeration(unittest.TestCase):
|
|||||||
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
|
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
|
||||||
self.assertEqual([], fc_cleanup._orphan_vm_pids())
|
self.assertEqual([], fc_cleanup._orphan_vm_pids())
|
||||||
|
|
||||||
def test_sidecar_containers_sorted(self):
|
|
||||||
with patch.object(fc_cleanup.subprocess, "run",
|
|
||||||
return_value=_proc("bot-bottle-sidecars-b\nbot-bottle-sidecars-a\n")):
|
|
||||||
self.assertEqual(
|
|
||||||
["bot-bottle-sidecars-a", "bot-bottle-sidecars-b"],
|
|
||||||
fc_cleanup._sidecar_containers(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_sidecar_containers_empty_on_failure(self):
|
|
||||||
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
|
|
||||||
self.assertEqual([], fc_cleanup._sidecar_containers())
|
|
||||||
|
|
||||||
def test_run_dirs_empty_when_absent(self):
|
def test_run_dirs_empty_when_absent(self):
|
||||||
with patch.object(fc_cleanup.util, "cache_dir") as cache:
|
with patch.object(fc_cleanup.util, "cache_dir") as cache:
|
||||||
cache.return_value.__truediv__.return_value.is_dir.return_value = False
|
cache.return_value.__truediv__.return_value.is_dir.return_value = False
|
||||||
@@ -57,28 +45,23 @@ class TestOrphanEnumeration(unittest.TestCase):
|
|||||||
|
|
||||||
def test_prepare_cleanup_assembles_plan(self):
|
def test_prepare_cleanup_assembles_plan(self):
|
||||||
with patch.object(fc_cleanup, "_orphan_vm_pids", return_value=[7]), \
|
with patch.object(fc_cleanup, "_orphan_vm_pids", return_value=[7]), \
|
||||||
patch.object(fc_cleanup, "_sidecar_containers", return_value=["c1"]), \
|
|
||||||
patch.object(fc_cleanup, "_run_dirs", return_value=["/run/x"]):
|
patch.object(fc_cleanup, "_run_dirs", return_value=["/run/x"]):
|
||||||
plan = fc_cleanup.prepare_cleanup()
|
plan = fc_cleanup.prepare_cleanup()
|
||||||
self.assertEqual((7,), plan.vm_pids)
|
self.assertEqual((7,), plan.vm_pids)
|
||||||
self.assertEqual(("c1",), plan.containers)
|
|
||||||
self.assertEqual(("/run/x",), plan.run_dirs)
|
self.assertEqual(("/run/x",), plan.run_dirs)
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupRemoval(unittest.TestCase):
|
class TestCleanupRemoval(unittest.TestCase):
|
||||||
def test_cleanup_kills_removes_and_rmtrees(self):
|
def test_cleanup_kills_and_rmtrees(self):
|
||||||
plan = FirecrackerBottleCleanupPlan(
|
plan = FirecrackerBottleCleanupPlan(
|
||||||
vm_pids=(101,), containers=("bot-bottle-sidecars-x",),
|
vm_pids=(101,),
|
||||||
run_dirs=("/run/dev-x",),
|
run_dirs=("/run/dev-x",),
|
||||||
)
|
)
|
||||||
with patch.object(fc_cleanup.os, "kill") as kill, \
|
with patch.object(fc_cleanup.os, "kill") as kill, \
|
||||||
patch.object(fc_cleanup.subprocess, "run") as run, \
|
|
||||||
patch.object(fc_cleanup.shutil, "rmtree") as rmtree, \
|
patch.object(fc_cleanup.shutil, "rmtree") as rmtree, \
|
||||||
patch.object(fc_cleanup, "info"):
|
patch.object(fc_cleanup, "info"):
|
||||||
fc_cleanup.cleanup(plan)
|
fc_cleanup.cleanup(plan)
|
||||||
kill.assert_called_once()
|
kill.assert_called_once()
|
||||||
run.assert_called_once()
|
|
||||||
self.assertIn("bot-bottle-sidecars-x", run.call_args.args[0])
|
|
||||||
rmtree.assert_called_once_with("/run/dev-x", ignore_errors=True)
|
rmtree.assert_called_once_with("/run/dev-x", ignore_errors=True)
|
||||||
|
|
||||||
def test_cleanup_tolerates_dead_pid(self):
|
def test_cleanup_tolerates_dead_pid(self):
|
||||||
@@ -101,13 +84,12 @@ class TestCleanupPlan(unittest.TestCase):
|
|||||||
|
|
||||||
def test_print_lists_resources(self):
|
def test_print_lists_resources(self):
|
||||||
plan = FirecrackerBottleCleanupPlan(
|
plan = FirecrackerBottleCleanupPlan(
|
||||||
vm_pids=(5,), containers=("c",), run_dirs=("/r",),
|
vm_pids=(5,), run_dirs=("/r",),
|
||||||
)
|
)
|
||||||
with patch("bot_bottle.backend.firecracker.bottle_cleanup_plan.info") as info:
|
with patch("bot_bottle.backend.firecracker.bottle_cleanup_plan.info") as info:
|
||||||
plan.print()
|
plan.print()
|
||||||
joined = " ".join(c.args[0] for c in info.call_args_list)
|
joined = " ".join(c.args[0] for c in info.call_args_list)
|
||||||
self.assertIn("pid 5", joined)
|
self.assertIn("pid 5", joined)
|
||||||
self.assertIn("container: c", joined)
|
|
||||||
self.assertIn("run dir: /r", joined)
|
self.assertIn("run dir: /r", joined)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unit: sidecar bundle init supervisor (PRD 0024 chunk 1).
|
"""Unit: gateway data-plane init supervisor (PRD 0070; PRD 0024 bundle shape).
|
||||||
|
|
||||||
Tests both the helper functions in `bot_bottle.sidecar_init`
|
Tests both the helper functions in `bot_bottle.gateway_init`
|
||||||
and the supervisor's end-to-end signal / exit-code behavior. The
|
and the supervisor's end-to-end signal / exit-code behavior. The
|
||||||
end-to-end tests use real subprocesses (`/bin/sleep`,
|
end-to-end tests use real subprocesses (`/bin/sleep`,
|
||||||
`/bin/sh -c '...'`) — short-lived, no docker required — so they
|
`/bin/sh -c '...'`) — short-lived, no docker required — so they
|
||||||
@@ -18,7 +18,7 @@ import warnings
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.sidecar_init import (
|
from bot_bottle.gateway_init import (
|
||||||
_DaemonSpec,
|
_DaemonSpec,
|
||||||
_Supervisor,
|
_Supervisor,
|
||||||
_argv_for_daemon,
|
_argv_for_daemon,
|
||||||
@@ -80,18 +80,18 @@ class TestSelectedDaemons(unittest.TestCase):
|
|||||||
["egress", "git-gate", "supervise"])
|
["egress", "git-gate", "supervise"])
|
||||||
|
|
||||||
def test_empty_returns_all(self):
|
def test_empty_returns_all(self):
|
||||||
got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""},
|
got = _selected_daemons({"BOT_BOTTLE_GATEWAY_DAEMONS": ""},
|
||||||
all_daemons=self._DAEMONS)
|
all_daemons=self._DAEMONS)
|
||||||
self.assertEqual(3, len(got))
|
self.assertEqual(3, len(got))
|
||||||
|
|
||||||
def test_whitespace_only_returns_all(self):
|
def test_whitespace_only_returns_all(self):
|
||||||
got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": " "},
|
got = _selected_daemons({"BOT_BOTTLE_GATEWAY_DAEMONS": " "},
|
||||||
all_daemons=self._DAEMONS)
|
all_daemons=self._DAEMONS)
|
||||||
self.assertEqual(3, len(got))
|
self.assertEqual(3, len(got))
|
||||||
|
|
||||||
def test_explicit_subset(self):
|
def test_explicit_subset(self):
|
||||||
got = _selected_daemons(
|
got = _selected_daemons(
|
||||||
{"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-gate"},
|
{"BOT_BOTTLE_GATEWAY_DAEMONS": "egress,git-gate"},
|
||||||
all_daemons=self._DAEMONS,
|
all_daemons=self._DAEMONS,
|
||||||
)
|
)
|
||||||
self.assertEqual([d.name for d in got], ["egress", "git-gate"])
|
self.assertEqual([d.name for d in got], ["egress", "git-gate"])
|
||||||
@@ -100,7 +100,7 @@ class TestSelectedDaemons(unittest.TestCase):
|
|||||||
# Order in the env var doesn't matter; the result follows
|
# Order in the env var doesn't matter; the result follows
|
||||||
# the canonical _DAEMONS order so egress starts first.
|
# the canonical _DAEMONS order so egress starts first.
|
||||||
got = _selected_daemons(
|
got = _selected_daemons(
|
||||||
{"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,git-gate,egress"},
|
{"BOT_BOTTLE_GATEWAY_DAEMONS": "supervise,git-gate,egress"},
|
||||||
all_daemons=self._DAEMONS,
|
all_daemons=self._DAEMONS,
|
||||||
)
|
)
|
||||||
self.assertEqual([d.name for d in got],
|
self.assertEqual([d.name for d in got],
|
||||||
@@ -108,14 +108,14 @@ class TestSelectedDaemons(unittest.TestCase):
|
|||||||
|
|
||||||
def test_unknown_names_ignored(self):
|
def test_unknown_names_ignored(self):
|
||||||
got = _selected_daemons(
|
got = _selected_daemons(
|
||||||
{"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,bogus"},
|
{"BOT_BOTTLE_GATEWAY_DAEMONS": "egress,bogus"},
|
||||||
all_daemons=self._DAEMONS,
|
all_daemons=self._DAEMONS,
|
||||||
)
|
)
|
||||||
self.assertEqual([d.name for d in got], ["egress"])
|
self.assertEqual([d.name for d in got], ["egress"])
|
||||||
|
|
||||||
def test_whitespace_in_names_stripped(self):
|
def test_whitespace_in_names_stripped(self):
|
||||||
got = _selected_daemons(
|
got = _selected_daemons(
|
||||||
{"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , git-gate "},
|
{"BOT_BOTTLE_GATEWAY_DAEMONS": " egress , git-gate "},
|
||||||
all_daemons=self._DAEMONS,
|
all_daemons=self._DAEMONS,
|
||||||
)
|
)
|
||||||
self.assertEqual([d.name for d in got], ["egress", "git-gate"])
|
self.assertEqual([d.name for d in got], ["egress", "git-gate"])
|
||||||
@@ -441,7 +441,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
time.sleep(0.3) # let `trap` register
|
time.sleep(0.3) # let `trap` register
|
||||||
sup.request_shutdown(reason="test")
|
sup.request_shutdown(reason="test")
|
||||||
|
|
||||||
with patch("bot_bottle.sidecar_init._GRACE_SECONDS", 0.3):
|
with patch("bot_bottle.gateway_init._GRACE_SECONDS", 0.3):
|
||||||
rc = self._drive(sup, max_wait_s=4.0)
|
rc = self._drive(sup, max_wait_s=4.0)
|
||||||
|
|
||||||
# Process was SIGKILL'd → returncode -9 on POSIX.
|
# Process was SIGKILL'd → returncode -9 on POSIX.
|
||||||
@@ -464,7 +464,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMainEndToEnd(unittest.TestCase):
|
class TestMainEndToEnd(unittest.TestCase):
|
||||||
"""Run sidecar_init.py as a real subprocess to cover the
|
"""Run gateway_init.py as a real subprocess to cover the
|
||||||
signal-handler installation path. Skipped on platforms
|
signal-handler installation path. Skipped on platforms
|
||||||
without /bin/sleep + /bin/sh."""
|
without /bin/sleep + /bin/sh."""
|
||||||
|
|
||||||
@@ -477,20 +477,20 @@ class TestMainEndToEnd(unittest.TestCase):
|
|||||||
def _run(self, daemons_csv: str, send_signal: int | None,
|
def _run(self, daemons_csv: str, send_signal: int | None,
|
||||||
wait_before_signal: float = 0.4,
|
wait_before_signal: float = 0.4,
|
||||||
overall_timeout: float = 6.0) -> tuple[int, str]:
|
overall_timeout: float = 6.0) -> tuple[int, str]:
|
||||||
"""Spawn sidecar_init.main() in a child process with the
|
"""Spawn gateway_init.main() in a child process with the
|
||||||
DAEMONS list patched to harmless `sleep 30` commands.
|
DAEMONS list patched to harmless `sleep 30` commands.
|
||||||
Returns (returncode, captured stdout)."""
|
Returns (returncode, captured stdout)."""
|
||||||
|
|
||||||
helper = (
|
helper = (
|
||||||
"import os, runpy, sys\n"
|
"import os, runpy, sys\n"
|
||||||
"from bot_bottle import sidecar_init as si\n"
|
"from bot_bottle import gateway_init as si\n"
|
||||||
"si._DAEMONS = (\n"
|
"si._DAEMONS = (\n"
|
||||||
" si._DaemonSpec('alpha', ('/bin/sleep','30')),\n"
|
" si._DaemonSpec('alpha', ('/bin/sleep','30')),\n"
|
||||||
" si._DaemonSpec('beta', ('/bin/sleep','30')),\n"
|
" si._DaemonSpec('beta', ('/bin/sleep','30')),\n"
|
||||||
")\n"
|
")\n"
|
||||||
"sys.exit(si.main([]))\n"
|
"sys.exit(si.main([]))\n"
|
||||||
)
|
)
|
||||||
env = {**os.environ, "BOT_BOTTLE_SIDECAR_DAEMONS": daemons_csv}
|
env = {**os.environ, "BOT_BOTTLE_GATEWAY_DAEMONS": daemons_csv}
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-c", helper],
|
[sys.executable, "-c", helper],
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
@@ -504,7 +504,7 @@ class TestMainEndToEnd(unittest.TestCase):
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
out_b, _ = proc.communicate()
|
out_b, _ = proc.communicate()
|
||||||
self.fail("sidecar_init main() did not exit before timeout")
|
self.fail("gateway_init main() did not exit before timeout")
|
||||||
return proc.returncode, out_b.decode("utf-8", errors="replace")
|
return proc.returncode, out_b.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
def test_sigterm_clean_shutdown(self):
|
def test_sigterm_clean_shutdown(self):
|
||||||
@@ -218,9 +218,9 @@ class TestHookRender(unittest.TestCase):
|
|||||||
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
||||||
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
||||||
|
|
||||||
def test_inline_gitleaks_allow_python_imports_work_in_sidecar_layout(self):
|
def test_inline_gitleaks_allow_python_imports_work_in_gateway_layout(self):
|
||||||
hook = git_gate_render_hook()
|
hook = git_gate_render_hook()
|
||||||
# The sidecar image copies supervise.py flat under /app, while
|
# The gateway image copies supervise.py flat under /app, while
|
||||||
# host-side tests import it through the bot_bottle package.
|
# host-side tests import it through the bot_bottle package.
|
||||||
# Hooks execute from the bare repo directory, so the embedded
|
# Hooks execute from the bare repo directory, so the embedded
|
||||||
# Python must include /app and support both import layouts.
|
# Python must include /app and support both import layouts.
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ class TestGitHttpBackend(unittest.TestCase):
|
|||||||
|
|
||||||
def test_subprocess_calls_include_timeout(self):
|
def test_subprocess_calls_include_timeout(self):
|
||||||
"""Both subprocess.run calls (access-hook and git http-backend) must
|
"""Both subprocess.run calls (access-hook and git http-backend) must
|
||||||
pass timeout= so a hung upstream cannot wedge the sidecar."""
|
pass timeout= so a hung upstream cannot wedge the gateway."""
|
||||||
from http.server import ThreadingHTTPServer
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ class TestMacosContainerCleanup(unittest.TestCase):
|
|||||||
completed = cleanup.subprocess.CompletedProcess(
|
completed = cleanup.subprocess.CompletedProcess(
|
||||||
args=[],
|
args=[],
|
||||||
returncode=0,
|
returncode=0,
|
||||||
stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n",
|
stdout="bot-bottle-a\nbot-bottle-b\nother\n",
|
||||||
stderr="",
|
stderr="",
|
||||||
)
|
)
|
||||||
with patch.object(cleanup.subprocess, "run", return_value=completed):
|
with patch.object(cleanup.subprocess, "run", return_value=completed):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["bot-bottle-a", "bot-bottle-sidecars-a"],
|
["bot-bottle-a", "bot-bottle-b"],
|
||||||
cleanup._list_prefixed_containers(),
|
cleanup._list_prefixed_containers(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,27 +43,10 @@ class TestMacosContainerCleanup(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMacosContainerEnumerate(unittest.TestCase):
|
class TestMacosContainerEnumerate(unittest.TestCase):
|
||||||
def test_enumerate_active_reads_metadata(self):
|
def test_enumerate_active_is_empty_while_disabled(self):
|
||||||
completed = enum_mod.subprocess.CompletedProcess(
|
# The macOS backend is disabled during the companion-container removal cleanup
|
||||||
args=[],
|
# (#385); it launches nothing, so there is nothing to enumerate.
|
||||||
returncode=0,
|
self.assertEqual([], enum_mod.enumerate_active())
|
||||||
stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n",
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
|
|
||||||
class _Metadata:
|
|
||||||
agent_name = "impl"
|
|
||||||
started_at = "2026-06-10T00:00:00Z"
|
|
||||||
label = "Implement"
|
|
||||||
color = "blue"
|
|
||||||
|
|
||||||
with patch.object(enum_mod.subprocess, "run", return_value=completed), \
|
|
||||||
patch.object(enum_mod, "read_metadata", return_value=_Metadata()):
|
|
||||||
agents = enum_mod.enumerate_active()
|
|
||||||
self.assertEqual(1, len(agents))
|
|
||||||
self.assertEqual("macos-container", agents[0].backend_name)
|
|
||||||
self.assertEqual("a", agents[0].slug)
|
|
||||||
self.assertEqual("impl", agents[0].agent_name)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,369 +0,0 @@
|
|||||||
"""Unit: Apple Container launch argv construction."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import cast
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
|
||||||
from bot_bottle.backend import BottleSpec
|
|
||||||
from bot_bottle.backend.macos_container import launch
|
|
||||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
|
||||||
from bot_bottle.egress import EgressPlan
|
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
|
||||||
from bot_bottle.manifest import ManifestIndex
|
|
||||||
|
|
||||||
_MANIFEST = ManifestIndex.from_json_obj({
|
|
||||||
"bottles": {"dev": {}},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
}).load_for_agent("demo")
|
|
||||||
|
|
||||||
|
|
||||||
def _plan(
|
|
||||||
*,
|
|
||||||
stage_dir: Path,
|
|
||||||
git: bool = False,
|
|
||||||
supervise: bool = False,
|
|
||||||
agent_git_gate_url: str = "",
|
|
||||||
agent_supervise_url: str = "",
|
|
||||||
canary: bool = False,
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
routes_path = stage_dir / "routes.yaml"
|
|
||||||
routes_path.write_text("routes: []\n", encoding="utf-8")
|
|
||||||
ca_dir = stage_dir / "egress-ca"
|
|
||||||
ca_dir.mkdir(exist_ok=True)
|
|
||||||
ca_path = ca_dir / "mitmproxy-ca.pem"
|
|
||||||
ca_path.write_text("ca\n", encoding="utf-8")
|
|
||||||
egress_plan = SimpleNamespace(
|
|
||||||
mitmproxy_ca_host_path=ca_path,
|
|
||||||
routes_path=routes_path,
|
|
||||||
routes=("route",),
|
|
||||||
token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"},
|
|
||||||
canary="fake-canary-value" if canary else "",
|
|
||||||
canary_env="CANON_ALPHA_SECRET" if canary else "",
|
|
||||||
)
|
|
||||||
if git:
|
|
||||||
key_path = stage_dir / "origin-key"
|
|
||||||
key_path.write_text("key\n", encoding="utf-8")
|
|
||||||
known_hosts_path = stage_dir / "origin-known-hosts"
|
|
||||||
known_hosts_path.write_text("example.com ssh-ed25519 AAAA\n", encoding="utf-8")
|
|
||||||
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
|
||||||
entrypoint.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
||||||
hook = stage_dir / "git_gate_pre_receive.sh"
|
|
||||||
hook.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
||||||
access_hook = stage_dir / "git_gate_access_hook.sh"
|
|
||||||
access_hook.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
||||||
upstream = SimpleNamespace(
|
|
||||||
name="origin",
|
|
||||||
identity_file=str(key_path),
|
|
||||||
known_hosts_file=known_hosts_path,
|
|
||||||
)
|
|
||||||
git_gate_plan = SimpleNamespace(
|
|
||||||
upstreams=(upstream,),
|
|
||||||
entrypoint_script=entrypoint,
|
|
||||||
hook_script=hook,
|
|
||||||
access_hook_script=access_hook,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
git_gate_plan = SimpleNamespace(upstreams=())
|
|
||||||
supervise_plan = (
|
|
||||||
SimpleNamespace(
|
|
||||||
db_path=Path("/state/bot-bottle.db"),
|
|
||||||
)
|
|
||||||
if supervise else None
|
|
||||||
)
|
|
||||||
agent_provision = SimpleNamespace(
|
|
||||||
guest_env={"LITERAL": "value"},
|
|
||||||
provisioned_env={"CODEX_HOME": "/run/codex-home"},
|
|
||||||
)
|
|
||||||
return cast(MacosContainerBottlePlan, SimpleNamespace(
|
|
||||||
spec=SimpleNamespace(),
|
|
||||||
manifest=_MANIFEST,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
slug="dev-abc",
|
|
||||||
container_name="bot-bottle-dev-abc",
|
|
||||||
image="bot-bottle-agent:latest",
|
|
||||||
forwarded_env={"OAUTH_TOKEN": "host-value"},
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
agent_provision=agent_provision,
|
|
||||||
agent_git_gate_url=agent_git_gate_url,
|
|
||||||
agent_supervise_url=agent_supervise_url,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.stage_dir = Path(self._tmp.name)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir, supervise=True)
|
|
||||||
with patch.object(launch.os, "environ", {
|
|
||||||
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
|
|
||||||
}):
|
|
||||||
argv = launch._sidecar_run_argv(
|
|
||||||
plan,
|
|
||||||
"bot-bottle-sidecars-dev-abc",
|
|
||||||
"bot-bottle-net-dev-abc",
|
|
||||||
"bot-bottle-egress-dev-abc",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
[
|
|
||||||
"--network", "bot-bottle-egress-dev-abc",
|
|
||||||
"--network", "bot-bottle-net-dev-abc",
|
|
||||||
],
|
|
||||||
argv[argv.index("--network"):argv.index("--dns")],
|
|
||||||
)
|
|
||||||
self.assertIn("--dns", argv)
|
|
||||||
self.assertEqual("9.9.9.9", argv[argv.index("--dns") + 1])
|
|
||||||
self.assertIn(
|
|
||||||
"BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise",
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
self.assertIn("EGRESS_TOKEN_0", argv)
|
|
||||||
self.assertIn(
|
|
||||||
f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy",
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
f"type=bind,source={self.stage_dir},target=/etc/egress,readonly",
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"type=bind,source=/state,target=/run/supervise",
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_sidecar_argv_registers_canary_env_as_sensitive(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir, canary=True)
|
|
||||||
argv = launch._sidecar_run_argv(
|
|
||||||
plan,
|
|
||||||
"bot-bottle-sidecars-dev-abc",
|
|
||||||
"bot-bottle-net-dev-abc",
|
|
||||||
"bot-bottle-egress-dev-abc",
|
|
||||||
)
|
|
||||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", argv)
|
|
||||||
self.assertIn("BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET", argv)
|
|
||||||
|
|
||||||
def test_agent_argv_receives_canary_env(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir, canary=True)
|
|
||||||
argv = launch._agent_run_argv(
|
|
||||||
plan,
|
|
||||||
"bot-bottle-net-dev-abc",
|
|
||||||
"192.0.2.10",
|
|
||||||
)
|
|
||||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", argv)
|
|
||||||
|
|
||||||
def test_agent_env_points_proxy_at_sidecar_ip(self):
|
|
||||||
plan = _plan(
|
|
||||||
stage_dir=self.stage_dir,
|
|
||||||
agent_git_gate_url="http://192.168.128.2:9420",
|
|
||||||
agent_supervise_url="http://192.168.128.2:9100/",
|
|
||||||
)
|
|
||||||
env = launch._agent_env_entries(plan, "192.168.128.2")
|
|
||||||
self.assertIn("HTTPS_PROXY=http://192.168.128.2:9099", env)
|
|
||||||
self.assertIn("HTTP_PROXY=http://192.168.128.2:9099", env)
|
|
||||||
self.assertIn("https_proxy=http://192.168.128.2:9099", env)
|
|
||||||
self.assertIn("http_proxy=http://192.168.128.2:9099", env)
|
|
||||||
self.assertIn("NO_PROXY=localhost,127.0.0.1,192.168.128.2", env)
|
|
||||||
self.assertIn("NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt", env)
|
|
||||||
self.assertIn("SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", env)
|
|
||||||
self.assertIn("REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt", env)
|
|
||||||
self.assertIn("GIT_GATE_URL=http://192.168.128.2:9420", env)
|
|
||||||
self.assertIn("MCP_SUPERVISE_URL=http://192.168.128.2:9100/", env)
|
|
||||||
self.assertIn("LITERAL=value", env)
|
|
||||||
self.assertIn("OAUTH_TOKEN", env)
|
|
||||||
self.assertNotIn("CODEX_HOME", env)
|
|
||||||
|
|
||||||
def test_agent_run_uses_internal_network_only(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir)
|
|
||||||
argv = launch._agent_run_argv(
|
|
||||||
plan, "bot-bottle-net-dev-abc", "192.168.128.2",
|
|
||||||
)
|
|
||||||
self.assertIn("--network", argv)
|
|
||||||
self.assertEqual("bot-bottle-net-dev-abc", argv[argv.index("--network") + 1])
|
|
||||||
self.assertNotIn("bot-bottle-egress-dev-abc", argv)
|
|
||||||
self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:])
|
|
||||||
|
|
||||||
def test_git_gate_daemons_are_ready_gated(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir, git=True)
|
|
||||||
self.assertEqual(
|
|
||||||
("egress", "git-gate", "git-http"),
|
|
||||||
launch._sidecar_daemons(plan),
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"BOT_BOTTLE_GIT_GATE_READY_FILE=/run/git-gate/ready",
|
|
||||||
launch._sidecar_env_entries(plan),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir, git=True, supervise=True)
|
|
||||||
with patch.object(launch.dataclasses, "replace") as replace:
|
|
||||||
launch._stamp_agent_urls(plan, "192.168.128.2")
|
|
||||||
replace.assert_called_once_with(
|
|
||||||
plan,
|
|
||||||
agent_proxy_url="http://192.168.128.2:9099",
|
|
||||||
agent_git_gate_url="http://192.168.128.2:9420",
|
|
||||||
agent_supervise_url="http://192.168.128.2:9100/",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_macos_plan_uses_http_git_gate_rewrites(self):
|
|
||||||
base = _plan(
|
|
||||||
stage_dir=self.stage_dir,
|
|
||||||
git=True,
|
|
||||||
agent_git_gate_url="http://192.168.128.2:9420",
|
|
||||||
)
|
|
||||||
plan = MacosContainerBottlePlan(
|
|
||||||
spec=base.spec,
|
|
||||||
manifest=base.manifest,
|
|
||||||
stage_dir=base.stage_dir,
|
|
||||||
git_gate_plan=base.git_gate_plan,
|
|
||||||
egress_plan=base.egress_plan,
|
|
||||||
supervise_plan=base.supervise_plan,
|
|
||||||
agent_provision=base.agent_provision,
|
|
||||||
slug=base.slug,
|
|
||||||
forwarded_env=base.forwarded_env,
|
|
||||||
agent_git_gate_url=base.agent_git_gate_url,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"192.168.128.2:9420",
|
|
||||||
plan.git_gate_insteadof_host,
|
|
||||||
)
|
|
||||||
self.assertEqual("http", plan.git_gate_insteadof_scheme)
|
|
||||||
|
|
||||||
def test_stage_git_gate_copies_files_and_releases_ready_marker(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir, git=True)
|
|
||||||
with (
|
|
||||||
patch.object(launch.container_mod, "exec_container") as exec_container,
|
|
||||||
patch.object(launch.container_mod, "copy_into_container") as copy_in,
|
|
||||||
):
|
|
||||||
launch._stage_git_gate(plan, "sidecar")
|
|
||||||
|
|
||||||
exec_container.assert_any_call(
|
|
||||||
"sidecar",
|
|
||||||
[
|
|
||||||
"mkdir",
|
|
||||||
"-p",
|
|
||||||
"/etc/git-gate",
|
|
||||||
"/git-gate/creds",
|
|
||||||
"/git",
|
|
||||||
"/run/git-gate",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
copied = [call.args for call in copy_in.call_args_list]
|
|
||||||
self.assertIn(
|
|
||||||
(
|
|
||||||
"sidecar",
|
|
||||||
str(self.stage_dir / "git_gate_entrypoint.sh"),
|
|
||||||
"/git-gate-entrypoint.sh",
|
|
||||||
),
|
|
||||||
copied,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
(
|
|
||||||
"sidecar",
|
|
||||||
str(self.stage_dir / "origin-key"),
|
|
||||||
"/git-gate/creds/origin-key",
|
|
||||||
),
|
|
||||||
copied,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
(
|
|
||||||
"sidecar",
|
|
||||||
str(self.stage_dir / "origin-known-hosts"),
|
|
||||||
"/git-gate/creds/origin-known_hosts",
|
|
||||||
),
|
|
||||||
copied,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"touch /run/git-gate/ready",
|
|
||||||
exec_container.call_args_list[-1].args[1][-1],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan:
|
|
||||||
return MacosContainerBottlePlan(
|
|
||||||
spec=cast(BottleSpec, SimpleNamespace()),
|
|
||||||
manifest=_MANIFEST,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())),
|
|
||||||
egress_plan=cast(EgressPlan, SimpleNamespace(canary="")),
|
|
||||||
supervise_plan=None,
|
|
||||||
agent_provision=AgentProvisionPlan(
|
|
||||||
template="claude",
|
|
||||||
command="claude",
|
|
||||||
prompt_mode="append_file",
|
|
||||||
image="bot-bottle-agent:latest",
|
|
||||||
dockerfile="/repo/Dockerfile",
|
|
||||||
guest_home="/home/node",
|
|
||||||
instance_name="bot-bottle-dev-abc",
|
|
||||||
prompt_file=stage_dir / "prompt.txt",
|
|
||||||
guest_env={},
|
|
||||||
),
|
|
||||||
slug="dev-abc",
|
|
||||||
forwarded_env={},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.stage_dir = Path(self._tmp.name)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def test_build_images_uses_committed_image_when_present(self):
|
|
||||||
plan = _build_plan(self.stage_dir)
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_build(image: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
calls.append((image, context, dockerfile))
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
launch, "read_committed_image",
|
|
||||||
return_value="bot-bottle-committed-dev-abc:latest",
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "image_exists", return_value=True,
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "build_image", side_effect=fake_build,
|
|
||||||
), patch.object(launch, "info"):
|
|
||||||
updated = launch._build_images(plan)
|
|
||||||
|
|
||||||
self.assertEqual("bot-bottle-committed-dev-abc:latest", updated.image)
|
|
||||||
self.assertEqual(1, len(calls))
|
|
||||||
self.assertEqual(launch.SIDECAR_BUNDLE_IMAGE, calls[0][0])
|
|
||||||
|
|
||||||
def test_build_images_builds_agent_when_committed_image_missing(self):
|
|
||||||
plan = _build_plan(self.stage_dir)
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_build(image: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
calls.append((image, context, dockerfile))
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
launch, "read_committed_image",
|
|
||||||
return_value="bot-bottle-committed-dev-abc:latest",
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "image_exists", return_value=False,
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "build_image", side_effect=fake_build,
|
|
||||||
):
|
|
||||||
updated = launch._build_images(plan)
|
|
||||||
|
|
||||||
self.assertEqual("bot-bottle-agent:latest", updated.image)
|
|
||||||
self.assertEqual(2, len(calls))
|
|
||||||
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -88,14 +88,14 @@ resolver #2
|
|||||||
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
|
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
|
||||||
}):
|
}):
|
||||||
util.build_image(
|
util.build_image(
|
||||||
"bot-bottle-sidecars:latest",
|
"bot-bottle-gateway:latest",
|
||||||
"/repo",
|
"/repo",
|
||||||
dockerfile="Dockerfile.sidecars",
|
dockerfile="Dockerfile.gateway",
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[
|
[
|
||||||
"container", "build", "-t", "bot-bottle-sidecars:latest",
|
"container", "build", "-t", "bot-bottle-gateway:latest",
|
||||||
"--dns", "9.9.9.9", "-f", "/repo/Dockerfile.sidecars", "/repo",
|
"--dns", "9.9.9.9", "-f", "/repo/Dockerfile.gateway", "/repo",
|
||||||
],
|
],
|
||||||
run.call_args_list[-1].args[0],
|
run.call_args_list[-1].args[0],
|
||||||
)
|
)
|
||||||
@@ -267,7 +267,7 @@ resolver #2
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"192.168.128.2",
|
"192.168.128.2",
|
||||||
util.container_ipv4_on_network(
|
util.container_ipv4_on_network(
|
||||||
"bot-bottle-sidecars-demo",
|
"bot-bottle-demo",
|
||||||
"bot-bottle-net-demo",
|
"bot-bottle-net-demo",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock:
|
|||||||
|
|
||||||
class TestDockerGateway(unittest.TestCase):
|
class TestDockerGateway(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.sc = DockerGateway("bot-bottle-sidecars:latest")
|
self.sc = DockerGateway("bot-bottle-gateway:latest")
|
||||||
|
|
||||||
def test_default_name(self) -> None:
|
def test_default_name(self) -> None:
|
||||||
self.assertEqual(GATEWAY_NAME, self.sc.name)
|
self.assertEqual(GATEWAY_NAME, self.sc.name)
|
||||||
@@ -86,7 +86,7 @@ class TestDockerGateway(unittest.TestCase):
|
|||||||
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||||
self.assertEqual(1, len(runs))
|
self.assertEqual(1, len(runs))
|
||||||
self.assertIn(self.sc.name, runs[0])
|
self.assertIn(self.sc.name, runs[0])
|
||||||
self.assertIn("bot-bottle-sidecars:latest", runs[0])
|
self.assertIn("bot-bottle-gateway:latest", runs[0])
|
||||||
# Runs on the shared gateway network so agents can reach it by IP.
|
# Runs on the shared gateway network so agents can reach it by IP.
|
||||||
self.assertEqual(self.sc.network, runs[0][runs[0].index("--network") + 1])
|
self.assertEqual(self.sc.network, runs[0][runs[0].index("--network") + 1])
|
||||||
# Persists its CA on a named volume so agents keep trusting it.
|
# Persists its CA on a named volume so agents keep trusting it.
|
||||||
@@ -183,7 +183,7 @@ class TestDockerGatewayBuild(unittest.TestCase):
|
|||||||
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.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0]))
|
self.assertTrue(any(a.endswith("Dockerfile.gateway") for a in builds[0]))
|
||||||
self.assertNotIn("--no-cache", builds[0])
|
self.assertNotIn("--no-cache", builds[0])
|
||||||
|
|
||||||
def test_ensure_built_no_cache_env_forces_full_rebuild(self) -> None:
|
def test_ensure_built_no_cache_env_forces_full_rebuild(self) -> None:
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ from pathlib import Path
|
|||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
from bot_bottle.orchestrator.lifecycle import (
|
from bot_bottle.orchestrator.lifecycle import (
|
||||||
|
ORCHESTRATOR_IMAGE,
|
||||||
ORCHESTRATOR_NAME,
|
ORCHESTRATOR_NAME,
|
||||||
|
ORCHESTRATOR_SOURCE_HASH_LABEL,
|
||||||
OrchestratorService,
|
OrchestratorService,
|
||||||
OrchestratorStartError,
|
OrchestratorStartError,
|
||||||
|
_source_hash,
|
||||||
)
|
)
|
||||||
from tests.unit import use_bottle_root
|
from tests.unit import use_bottle_root
|
||||||
|
|
||||||
@@ -28,6 +31,10 @@ def _health(status: int) -> MagicMock:
|
|||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock:
|
||||||
|
return Mock(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
|
|
||||||
class TestOrchestratorService(unittest.TestCase):
|
class TestOrchestratorService(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
@@ -46,25 +53,67 @@ class TestOrchestratorService(unittest.TestCase):
|
|||||||
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.svc.is_healthy())
|
||||||
|
|
||||||
def test_ensure_running_always_recreates_orchestrator(self) -> None:
|
def test_ensure_running_noop_when_healthy_and_source_unchanged(self) -> None:
|
||||||
# Even when a control plane is already healthy, the orchestrator is
|
# A healthy control plane already running the *current* bind-mounted
|
||||||
# recreated so bind-mounted code changes take effect (its process
|
# source is left alone — recreating it on every launch would drop
|
||||||
# won't reload). The gateway is ensured too.
|
# every other active bottle's in-memory egress tokens (#381).
|
||||||
run = Mock(return_value=Mock(returncode=0, stderr=""))
|
current = _source_hash(self.svc._repo_root)
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
calls.append(argv)
|
||||||
|
if argv[:2] == ["docker", "ps"]:
|
||||||
|
return _proc(stdout=ORCHESTRATOR_NAME)
|
||||||
|
if argv[:2] == ["docker", "inspect"]:
|
||||||
|
return _proc(stdout=current)
|
||||||
|
return _proc()
|
||||||
|
|
||||||
with patch(_URLOPEN, return_value=_health(200)), \
|
with patch(_URLOPEN, return_value=_health(200)), \
|
||||||
patch(_GATEWAY) as gw_cls, patch(_RUN, run), patch(_SLEEP):
|
patch(_GATEWAY) as gw_cls, patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||||
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
||||||
gw_cls.return_value.ensure_running.assert_called() # gateway kept up
|
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"]]
|
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||||
self.assertEqual(1, len(runs)) # orchestrator recreated
|
rms = [c for c in calls if c[:3] == ["docker", "rm", "--force"] and ORCHESTRATOR_NAME in c]
|
||||||
|
self.assertEqual([], runs) # not recreated
|
||||||
|
self.assertEqual([], rms)
|
||||||
|
|
||||||
|
def test_ensure_running_recreates_when_source_changed(self) -> None:
|
||||||
|
# Healthy, but the running container's label doesn't match the
|
||||||
|
# current source hash (a real code change) — recreate so it takes
|
||||||
|
# effect, same as the gateway's image-staleness check.
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
calls.append(argv)
|
||||||
|
if argv[:2] == ["docker", "ps"]:
|
||||||
|
return _proc(stdout=ORCHESTRATOR_NAME)
|
||||||
|
if argv[:2] == ["docker", "inspect"]:
|
||||||
|
return _proc(stdout="stale-hash")
|
||||||
|
return _proc()
|
||||||
|
|
||||||
|
with patch(_URLOPEN, return_value=_health(200)), \
|
||||||
|
patch(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||||
|
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
||||||
|
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||||
|
self.assertEqual(1, len(runs))
|
||||||
self.assertIn(ORCHESTRATOR_NAME, runs[0])
|
self.assertIn(ORCHESTRATOR_NAME, runs[0])
|
||||||
|
# the fresh container is labeled with the current hash, not the stale one
|
||||||
|
current = _source_hash(self.svc._repo_root)
|
||||||
|
self.assertIn(f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current}", runs[0])
|
||||||
|
|
||||||
def test_ensure_running_starts_orchestrator_container_when_absent(self) -> None:
|
def test_ensure_running_starts_orchestrator_container_when_absent(self) -> None:
|
||||||
run = Mock(return_value=Mock(returncode=0, stderr=""))
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
calls.append(argv)
|
||||||
|
if argv[:2] == ["docker", "ps"]:
|
||||||
|
return _proc(stdout="") # not running
|
||||||
|
return _proc()
|
||||||
|
|
||||||
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(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||||
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
||||||
runs = [c.args[0] for c in run.call_args_list if c.args[0][:2] == ["docker", "run"]]
|
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||||
self.assertEqual(1, len(runs))
|
self.assertEqual(1, len(runs))
|
||||||
argv = runs[0]
|
argv = runs[0]
|
||||||
self.assertIn(ORCHESTRATOR_NAME, argv)
|
self.assertIn(ORCHESTRATOR_NAME, argv)
|
||||||
@@ -73,6 +122,45 @@ class TestOrchestratorService(unittest.TestCase):
|
|||||||
self.assertIn("bot_bottle.orchestrator", argv)
|
self.assertIn("bot_bottle.orchestrator", argv)
|
||||||
self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1])
|
self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1])
|
||||||
|
|
||||||
|
def test_ensure_running_builds_lean_orchestrator_image_when_missing(self) -> None:
|
||||||
|
# The control plane runs its own lean image (#384), distinct from the
|
||||||
|
# gateway data plane — built from Dockerfile.orchestrator when absent.
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
calls.append(argv)
|
||||||
|
if argv[:2] == ["docker", "ps"]:
|
||||||
|
return _proc(stdout="") # orchestrator not running
|
||||||
|
if argv[:3] == ["docker", "image", "inspect"]:
|
||||||
|
return _proc(returncode=1) # image absent -> build
|
||||||
|
return _proc()
|
||||||
|
|
||||||
|
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
||||||
|
patch(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||||
|
self.svc.ensure_running()
|
||||||
|
builds = [c for c in calls if c[:2] == ["docker", "build"]]
|
||||||
|
self.assertEqual(1, len(builds))
|
||||||
|
self.assertIn(ORCHESTRATOR_IMAGE, builds[0])
|
||||||
|
self.assertTrue(any(a.endswith("Dockerfile.orchestrator") for a in builds[0]))
|
||||||
|
# It is NOT the gateway image/dockerfile — the split is the point.
|
||||||
|
self.assertFalse(any("Dockerfile.gateway" in a for a in builds[0]))
|
||||||
|
|
||||||
|
def test_ensure_running_skips_orchestrator_image_build_when_present(self) -> None:
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake(argv: list[str]) -> Mock:
|
||||||
|
calls.append(argv)
|
||||||
|
if argv[:2] == ["docker", "ps"]:
|
||||||
|
return _proc(stdout="")
|
||||||
|
if argv[:3] == ["docker", "image", "inspect"]:
|
||||||
|
return _proc(returncode=0) # image present -> no build
|
||||||
|
return _proc()
|
||||||
|
|
||||||
|
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
||||||
|
patch(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||||
|
self.svc.ensure_running()
|
||||||
|
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "build"]])
|
||||||
|
|
||||||
def test_ensure_running_raises_on_timeout(self) -> None:
|
def test_ensure_running_raises_on_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(_GATEWAY), patch(_RUN, return_value=Mock(returncode=0, stderr="")), \
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def _plan(routes: tuple[EgressRoute, ...], *, slug: str = "demo", log: int = 0)
|
|||||||
class TestEgressPolicy(unittest.TestCase):
|
class TestEgressPolicy(unittest.TestCase):
|
||||||
def test_policy_round_trips_through_load_config(self) -> None:
|
def test_policy_round_trips_through_load_config(self) -> None:
|
||||||
# The policy the gateway serves must parse back to the same allow-list
|
# The policy the gateway serves must parse back to the same allow-list
|
||||||
# the per-bottle sidecar applied — moving onto the shared gateway must
|
# the per-bottle gateway applied — moving onto the shared gateway must
|
||||||
# not change a bottle's egress.
|
# not change a bottle's egress.
|
||||||
routes = (EgressRoute(host="api.example.com"), EgressRoute(host="pypi.org"))
|
routes = (EgressRoute(host="api.example.com"), EgressRoute(host="pypi.org"))
|
||||||
cfg = load_config(egress_policy(_plan(routes)))
|
cfg = load_config(egress_policy(_plan(routes)))
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
|
|||||||
bottle = fixture_with_git().bottles["dev"]
|
bottle = fixture_with_git().bottles["dev"]
|
||||||
out = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
|
out = git_gate_render_gitconfig(bottle.git, GIT_GATE_HOSTNAME)
|
||||||
# Both entries map to a [url ...] block keyed on the gate's
|
# Both entries map to a [url ...] block keyed on the gate's
|
||||||
# short network alias (`git-gate`) inside the sidecar bundle.
|
# short network alias (`git-gate`) inside the gateway.
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'[url "git://git-gate/bot-bottle.git"]',
|
'[url "git://git-gate/bot-bottle.git"]',
|
||||||
out,
|
out,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit: supervise sidecar MCP server (PRD 0013)."""
|
"""Unit: supervise daemon MCP server (PRD 0013)."""
|
||||||
|
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
|
|||||||
Reference in New Issue
Block a user