Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f72785536a | |||
| aed686d85d | |||
| 410c19aaaf | |||
| f0ba399f17 | |||
| 8b442b8718 | |||
| 5eb6c8d99b | |||
| 4f10b810d4 | |||
| d3c4fc0fd4 | |||
| 232dfdf37a | |||
| 9a0dd821ef | |||
| 5ad3449e3b | |||
| d8e3947bd3 | |||
| d0b7de119f | |||
| ea1fbeeaa0 | |||
| b601b663e2 | |||
| 2aec30e501 | |||
| b1850be5d1 | |||
| fd86e7fa99 | |||
| 3bbd839917 | |||
| 5c526860bc | |||
| 492669e620 | |||
| d32f36bb2b | |||
| 4f36b919b2 | |||
| ca91fc4d91 | |||
| 4a607ad098 | |||
| e24b62b6b9 | |||
| a5910696a5 | |||
| c69642e568 | |||
| dfc693e0b6 | |||
| 39d47b8108 | |||
| d0a0ce8d60 | |||
| 5c08701983 | |||
| e3e195f866 | |||
| e3d24b7e41 | |||
| f2891a1634 | |||
| 8d8a88aeeb | |||
| 18f190b7e3 | |||
| bbb8913382 | |||
| 59be808ab1 | |||
| eb63bd417d | |||
| 943049733e | |||
| c15eed4f2e | |||
| b1df380ae1 | |||
| 5f59df9e10 | |||
| 2641ab70fd | |||
| 265119d601 | |||
| 27fe03b612 | |||
| 9085d6f713 | |||
| 38bc555dbf | |||
| a208bcde08 | |||
| c0066d2cd2 | |||
| 7118480d0a | |||
| d4b27ebf1f | |||
| 914f01fa8f | |||
| 43c3d4408e | |||
| 0a26b8795a | |||
| c60e6b7e9f | |||
| 2d37965249 | |||
| 4873030550 | |||
| b93b14f5c2 | |||
| 957eb19368 | |||
| 5dfb9b0d75 | |||
| bb434b14d7 | |||
| d79d5b295a | |||
| e1610121c0 | |||
| 1614172423 | |||
| 4edd7803e8 | |||
| 81a2f15046 | |||
| 75b122398d | |||
| 2e738c3338 | |||
| 9369fb7de5 | |||
| 9afdeff619 | |||
| e45df03bd9 | |||
| ec55dfde0c | |||
| 56d879f0b3 | |||
| 09393b354b | |||
| 77948ef56c | |||
| c10d1cb6e0 | |||
| 96b84eb84d | |||
| 910267b8a8 | |||
| 03eacd9f57 | |||
| c839cee319 | |||
| ea75fab3b4 | |||
| 13a8bdd7ca | |||
| 3318bdce28 | |||
| b2f61053ad | |||
| 485d101430 | |||
| e97366dad5 | |||
| 8e7f5c9572 | |||
| 69db629e16 | |||
| 96f75599a1 | |||
| 3062fc9230 | |||
| 404369a3e0 | |||
| 327e756eef | |||
| 444924d95d | |||
| 118d81533b | |||
| 353b1ad984 | |||
| 40ad2364ab | |||
| 9e5d00c6fa | |||
| 0c158c5718 | |||
| fe25ad7623 | |||
| 6570e9f044 | |||
| 39c823d3c0 | |||
| 8a44537f7e | |||
| 72ee1f77da | |||
| 00418a2834 | |||
| 2cbb178f88 | |||
| ed9a19bb38 | |||
| c5ae93c8ba | |||
| 186b905dff | |||
| 3519b924cb | |||
| c77f578fb4 | |||
| bc52c3525c | |||
| b70f3228ca | |||
| f7b3d6aaca | |||
| b05f245299 | |||
| 8d4e7f2582 | |||
| a0e17d56de | |||
| 934c1617b9 | |||
| bd964c0278 | |||
| 93756f2a8b | |||
| d9843fa41e | |||
| ae6c8b13d9 | |||
| 13a8d66c9b | |||
| c46d392b44 | |||
| 2a473eb404 | |||
| 4d89ffce8b | |||
| 822cff48ad | |||
| ce440fabc4 |
@@ -22,10 +22,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: canaries are stdlib unittest on the image's
|
||||||
uses: actions/setup-python@v5
|
# system Python 3.12 (older act_runner mishandles setup-python's PATH).
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Run canaries
|
- name: Run canaries
|
||||||
run: python3 -m unittest discover -t . -s tests/canaries -v
|
run: python3 -m unittest discover -t . -s tests/canaries -v
|
||||||
|
|||||||
@@ -13,15 +13,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||||
uses: actions/setup-python@v4
|
# and older act_runner engines mishandle setup-python's PATH. Install
|
||||||
with:
|
# into the ephemeral job container's system Python — the pylint/pyright
|
||||||
python-version: "3.12"
|
# console scripts land on /usr/local/bin (on PATH) so the steps below
|
||||||
|
# still resolve. --break-system-packages is safe: the container is
|
||||||
|
# disposable.
|
||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: |
|
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -37,11 +37,8 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the inline script is stdlib-only on the
|
||||||
uses: actions/setup-python@v5
|
# image's system Python 3.12 (older act_runner mishandles its PATH).
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Configure git
|
- name: Configure git
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
|
|||||||
+14
-17
@@ -34,13 +34,13 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||||
uses: actions/setup-python@v5
|
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||||
with:
|
# lands in one interpreter, `python3` resolves to another). Install
|
||||||
python-version: "3.12"
|
# straight into the ephemeral job container's system Python —
|
||||||
|
# --break-system-packages is safe because the container is disposable.
|
||||||
- name: Install dev requirements
|
- name: Install dev requirements
|
||||||
run: python3 -m pip install -r requirements-dev.txt
|
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
||||||
@@ -54,11 +54,8 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python (see the note in the `unit` job); the
|
||||||
uses: actions/setup-python@v5
|
# container's system Python 3.12 runs the stdlib test suite directly.
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Show environment
|
- name: Show environment
|
||||||
run: |
|
run: |
|
||||||
python3 --version
|
python3 --version
|
||||||
@@ -88,13 +85,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||||
uses: actions/setup-python@v5
|
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||||
with:
|
# lands in one interpreter, `python3` resolves to another). Install
|
||||||
python-version: "3.12"
|
# straight into the ephemeral job container's system Python —
|
||||||
|
# --break-system-packages is safe because the container is disposable.
|
||||||
- name: Install dev requirements
|
- name: Install dev requirements
|
||||||
run: python3 -m pip install -r requirements-dev.txt
|
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Combined coverage report (unit + integration)
|
- name: Combined coverage report (unit + integration)
|
||||||
run: PYTHON=python3 bash scripts/coverage.sh critical
|
run: PYTHON=python3 bash scripts/coverage.sh critical
|
||||||
|
|||||||
@@ -20,21 +20,18 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Python
|
# No actions/setup-python: the runner image ships Python 3.12 and older
|
||||||
uses: actions/setup-python@v4
|
# act_runner engines mishandle setup-python's PATH. Install into the
|
||||||
with:
|
# ephemeral job container's system Python (--break-system-packages is
|
||||||
python-version: '3.12'
|
# safe because the container is disposable).
|
||||||
|
|
||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: |
|
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run coverage and extract percentage
|
- name: Run coverage and extract percentage
|
||||||
id: coverage
|
id: coverage
|
||||||
run: |
|
run: |
|
||||||
python -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
python3 -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
||||||
PERCENT=$(python -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
PERCENT=$(python3 -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||||
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||||
echo "Coverage: $PERCENT%"
|
echo "Coverage: $PERCENT%"
|
||||||
|
|
||||||
@@ -45,7 +42,7 @@ jobs:
|
|||||||
# the single source of truth in scripts/critical-modules.txt; every
|
# the single source of truth in scripts/critical-modules.txt; every
|
||||||
# core module is unit-tested, so the unit-only run is accurate for it.
|
# core module is unit-tested, so the unit-only run is accurate for it.
|
||||||
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
|
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
|
||||||
PERCENT=$(python -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
PERCENT=$(python3 -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||||
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||||
echo "Core coverage: $PERCENT%"
|
echo "Core coverage: $PERCENT%"
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Gateway data-plane image (PRD 0024 bundle shape; PRD 0070 gateway).
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# /app/gateway_init.py spawns all daemons, forwards SIGTERM, and
|
||||||
|
# propagates per-daemon stdout/stderr to the container log with a
|
||||||
|
# `[name]` prefix. See PRD 0024 for the rationale.
|
||||||
|
#
|
||||||
|
# Layout:
|
||||||
|
#
|
||||||
|
# /usr/bin/gitleaks gitleaks binary
|
||||||
|
# /app/egress_addon.py mitmproxy addon entry point
|
||||||
|
# /app/egress-entrypoint.sh mitmdump launcher
|
||||||
|
# /usr/local/lib/python*/bot_bottle/ installed package (all daemons + shared modules)
|
||||||
|
# /app/egress_addon.py one-line shim: re-exports addons from package
|
||||||
|
# (mitmdump -s requires a file path, not a module)
|
||||||
|
# /etc/egress/routes.yaml bind-mounted at run time
|
||||||
|
# /etc/git-gate/pre-receive docker-cp'd at start time
|
||||||
|
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||||
|
# /git-gate/creds/* docker-cp'd at start time
|
||||||
|
# /git/* bare repos, populated at runtime
|
||||||
|
# /run/supervise/bot-bottle.db bind-mounted at run time
|
||||||
|
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
||||||
|
#
|
||||||
|
# Exposed ports inside the container:
|
||||||
|
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
||||||
|
# 9418 git-gate (git-daemon)
|
||||||
|
# 9420 git-gate smart HTTP (VM-backend agent-facing transport)
|
||||||
|
# 9100 supervise (MCP HTTP)
|
||||||
|
|
||||||
|
# Based on `python:3.12-slim` (Debian trixie) rather than the
|
||||||
|
# `mitmproxy/mitmproxy` image (Debian bookworm) so the whole stack —
|
||||||
|
# gateway here, and the firecracker infra image that builds FROM this —
|
||||||
|
# lands on trixie, whose buildah (1.39) can build agent Dockerfiles that
|
||||||
|
# use heredocs. mitmproxy is pip-installed to the same effect as the
|
||||||
|
# upstream image. (bookworm's buildah is 1.28, which can't parse
|
||||||
|
# `RUN ... <<EOF`; see the infra image + PR discussion.)
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Runtime system deps:
|
||||||
|
# git supplies the `git daemon` subcommand (no separate package)
|
||||||
|
# plus the core `git` binary the pre-receive hook invokes.
|
||||||
|
# openssh-client supplies the upstream SSH transport the
|
||||||
|
# pre-receive hook uses to forward accepted refs.
|
||||||
|
# ca-certificates is needed for mitmdump upstream TLS.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
git openssh-client ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# mitmdump (the egress data plane). The upstream mitmproxy image baked
|
||||||
|
# this in; on the plain python base we pip-install the same pinned
|
||||||
|
# version. Its CA dir is set explicitly via `--set confdir=` in
|
||||||
|
# egress-entrypoint.sh, so it doesn't depend on a `mitmproxy` home user.
|
||||||
|
RUN pip install --no-cache-dir mitmproxy==11.1.3
|
||||||
|
|
||||||
|
# gitleaks (the pre-receive hook's secret scanner). Installed from its
|
||||||
|
# official release, pinned by version + SHA256 and verified — rather than
|
||||||
|
# using a third-party image as a build stage (supply-chain surface, and it
|
||||||
|
# would pin us to that image's cadence). python (already present) does the
|
||||||
|
# download so we add no curl/wget. trixie apt also ships gitleaks, but an
|
||||||
|
# older 8.16; the pinned download keeps the verified 8.30.1.
|
||||||
|
#
|
||||||
|
# Arch-aware: the asset + SHA are picked from the build's target
|
||||||
|
# architecture so an arm64 host (Apple Silicon) gets the arm64 binary
|
||||||
|
# rather than an x86_64 one that dies with "Exec format error" the first
|
||||||
|
# time the pre-receive hook runs it. TARGETARCH is auto-populated by
|
||||||
|
# BuildKit; the dpkg fallback keeps it correct under a legacy builder.
|
||||||
|
ARG GITLEAKS_VERSION=8.30.1
|
||||||
|
ARG GITLEAKS_SHA256_AMD64=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
||||||
|
ARG GITLEAKS_SHA256_ARM64=e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}" \
|
||||||
|
&& case "$arch" in \
|
||||||
|
amd64) asset="linux_x64"; sha="${GITLEAKS_SHA256_AMD64}" ;; \
|
||||||
|
arm64) asset="linux_arm64"; sha="${GITLEAKS_SHA256_ARM64}" ;; \
|
||||||
|
*) echo "unsupported gitleaks target arch: $arch" >&2; exit 1 ;; \
|
||||||
|
esac \
|
||||||
|
&& url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${asset}.tar.gz" \
|
||||||
|
&& python3 -c "import sys,urllib.request; urllib.request.urlretrieve(sys.argv[1], '/tmp/gitleaks.tar.gz')" "$url" \
|
||||||
|
&& echo "${sha} /tmp/gitleaks.tar.gz" | sha256sum -c - \
|
||||||
|
&& tar -xzf /tmp/gitleaks.tar.gz -C /usr/bin gitleaks \
|
||||||
|
&& rm /tmp/gitleaks.tar.gz
|
||||||
|
|
||||||
|
# Install bot_bottle as a proper package so entry-point scripts can use
|
||||||
|
# `from bot_bottle.X import Y` absolute imports. A rename or a missing
|
||||||
|
# module is caught at pip-install time — not at container runtime.
|
||||||
|
COPY pyproject.toml /src/
|
||||||
|
COPY bot_bottle/ /src/bot_bottle/
|
||||||
|
RUN pip install --no-cache-dir /src/
|
||||||
|
|
||||||
|
# mitmdump -s requires a file path, not a module. Write a one-line shim that
|
||||||
|
# re-exports `addons` from the installed package; mitmdump finds it there.
|
||||||
|
RUN printf 'from bot_bottle.egress_addon import addons\n' > /app/egress_addon.py
|
||||||
|
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||||
|
RUN chmod +x /app/egress-entrypoint.sh
|
||||||
|
|
||||||
|
# Pre-create runtime directories the compose renderer + start
|
||||||
|
# step expect to exist. `docker cp` does not create intermediate
|
||||||
|
# dirs, and bind mounts won't either if the parent is missing.
|
||||||
|
RUN mkdir -p \
|
||||||
|
/etc/egress \
|
||||||
|
/etc/git-gate \
|
||||||
|
/git-gate/creds \
|
||||||
|
/git \
|
||||||
|
/run/supervise \
|
||||||
|
/home/mitmproxy/.mitmproxy
|
||||||
|
|
||||||
|
# Documentation only — the compose renderer publishes whichever
|
||||||
|
# subset the bottle uses.
|
||||||
|
EXPOSE 8888 9099 9418 9420 9100
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# PID 1 is the supervisor. It owns signal handling and exit-code
|
||||||
|
# propagation; no `exec` chain in the entrypoint itself.
|
||||||
|
ENTRYPOINT ["python3", "-m", "bot_bottle.gateway_init"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Firecracker single infra-VM image (PRD 0070 Stage B).
|
||||||
|
#
|
||||||
|
# The per-host infra VM runs the orchestrator control plane, the gateway
|
||||||
|
# data plane, AND builds agent images (buildah) — all in one microVM (see
|
||||||
|
# backend/firecracker/infra_vm.py). It composes:
|
||||||
|
# * FROM the gateway image (mitmproxy / git / gitleaks / supervise + the
|
||||||
|
# flat daemon modules) — now trixie-based, so buildah 1.39 is available;
|
||||||
|
# * `COPY --from` the orchestrator image's content (the single definition
|
||||||
|
# of the control-plane payload — see Dockerfile.orchestrator), so this
|
||||||
|
# VM and the docker backend share one orchestrator definition; and
|
||||||
|
# * buildah, installed HERE only (the docker orchestrator/gateway images
|
||||||
|
# never carry it).
|
||||||
|
#
|
||||||
|
# multi-`FROM` can't union two bases (that's multi-stage, not multiple
|
||||||
|
# inheritance), so the orchestrator content is pulled in via `COPY --from`
|
||||||
|
# rather than a second base. Both images share the trixie `python:3.12-slim`
|
||||||
|
# base, so the copy is clean (same python; future installed deps copy too).
|
||||||
|
#
|
||||||
|
# The docker backend keeps orchestrator + gateway as separate images; this
|
||||||
|
# combined image exists only for the Firecracker single-VM cut. Splitting a
|
||||||
|
# service back into its own VM later is a routing change, not a repackaging
|
||||||
|
# (PRD 0070's "secret concentration"; a disposable builder can boot from
|
||||||
|
# this same image on its own TAP).
|
||||||
|
FROM bot-bottle-gateway:latest
|
||||||
|
|
||||||
|
# --- in-VM agent-image builder (PRD 0069 Stage 3) -------------------
|
||||||
|
# The Firecracker backend builds users' agent Dockerfiles *inside this VM*
|
||||||
|
# with buildah (rootless, daemonless) instead of on the host — no host
|
||||||
|
# Docker daemon, no root-equivalent `docker` group. `crun` is the OCI
|
||||||
|
# runtime; `netavark` + `aardvark-dns` are the network backend for `FROM`
|
||||||
|
# pulls + `RUN` egress. Requires the trixie base (buildah 1.39: bookworm's
|
||||||
|
# 1.28 can't parse Dockerfile heredocs that agent images use).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
buildah crun netavark aardvark-dns \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
# vfs + chroot: buildah works as root in the bare microVM (no
|
||||||
|
# fuse-overlayfs / overlay module / subuid maps). Matches image_builder.
|
||||||
|
ENV STORAGE_DRIVER=vfs \
|
||||||
|
BUILDAH_ISOLATION=chroot
|
||||||
|
|
||||||
|
# The orchestrator content, pulled from its single definition. The gateway
|
||||||
|
# image already has the flat daemon modules under /app; this adds the full
|
||||||
|
# `bot_bottle` package so `python3 -m bot_bottle.orchestrator` resolves.
|
||||||
|
COPY --from=bot-bottle-orchestrator:latest /app/bot_bottle /app/bot_bottle
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Orchestrator control-plane image (PRD 0070, #384).
|
||||||
|
#
|
||||||
|
# This is the **single definition of the orchestrator's content** — the
|
||||||
|
# `bot_bottle` package baked onto a Python runtime — referenced by BOTH:
|
||||||
|
# * the docker backend, which runs this image directly as the lean
|
||||||
|
# control-plane container; and
|
||||||
|
# * the firecracker infra image (Dockerfile.infra), which `COPY --from`s
|
||||||
|
# this image's `/app/bot_bottle` so the single infra VM runs the same
|
||||||
|
# control plane. Keeping it in one place means future orchestrator deps
|
||||||
|
# (e.g. iroh) are added here once, not duplicated per backend.
|
||||||
|
#
|
||||||
|
# It stays deliberately lean: the control plane is **stdlib-only** today, so
|
||||||
|
# no third-party payload — none of the gateway's mitmproxy/git/gitleaks
|
||||||
|
# (that's Dockerfile.gateway) and no buildah (that's the firecracker
|
||||||
|
# builder, and lives only in Dockerfile.infra). Keeping the secret-dense
|
||||||
|
# control plane on a minimal dependency surface is the point (PRD 0070's
|
||||||
|
# "secret concentration").
|
||||||
|
#
|
||||||
|
# Shares the trixie `python:3.12-slim` base with the gateway image, so when
|
||||||
|
# the orchestrator grows real deps they can be `COPY --from`'d into the
|
||||||
|
# infra image cleanly (same base/python — installed packages copy safely).
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# The orchestrator content. Baked so the image is self-contained (runs from
|
||||||
|
# a built image, no runtime bind-mount); the docker backend may still
|
||||||
|
# bind-mount /app for dev live-reload, which simply overlays this copy.
|
||||||
|
# `.dockerignore` keeps .git/docs/*.md out of the context. (Future deps like
|
||||||
|
# iroh go here too — a shared requirements installed on this same base.)
|
||||||
|
COPY bot_bottle /app/bot_bottle
|
||||||
|
|
||||||
|
# Documentation only; lifecycle.py overrides the entrypoint to
|
||||||
|
# `python3 -m bot_bottle.orchestrator` with the runtime flags.
|
||||||
|
ENTRYPOINT ["python3", "-m", "bot_bottle.orchestrator"]
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# Per-bottle sidecar bundle image (PRD 0024).
|
|
||||||
#
|
|
||||||
# Collapses the prior per-sidecar images (egress, git-gate,
|
|
||||||
# supervise) into one. A small stdlib-Python init supervisor at
|
|
||||||
# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
|
|
||||||
# propagates per-daemon stdout/stderr to the container log with a
|
|
||||||
# `[name]` prefix. See PRD 0024 for the rationale.
|
|
||||||
#
|
|
||||||
# Layout:
|
|
||||||
#
|
|
||||||
# /usr/bin/gitleaks gitleaks binary
|
|
||||||
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
|
||||||
# /app/egress-entrypoint.sh mitmdump launcher
|
|
||||||
# /app/supervise_server.py + .py supervise MCP server
|
|
||||||
# /app/sidecar_init.py PID 1 supervisor
|
|
||||||
# /etc/egress/routes.yaml bind-mounted at run time
|
|
||||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
|
||||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
|
||||||
# /git-gate/creds/* docker-cp'd at start time
|
|
||||||
# /git/* bare repos, populated at runtime
|
|
||||||
# /run/supervise/bot-bottle.db bind-mounted at run time
|
|
||||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
|
||||||
#
|
|
||||||
# Exposed ports inside the container:
|
|
||||||
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
|
||||||
# 9418 git-gate (git-daemon)
|
|
||||||
# 9420 git-gate smart HTTP (VM-backend agent-facing transport)
|
|
||||||
# 9100 supervise (MCP HTTP)
|
|
||||||
|
|
||||||
# Stage 1: gitleaks binary. The upstream gitleaks image is alpine
|
|
||||||
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
|
|
||||||
# with Dockerfile.git-gate's prior base (now deleted at chunk 3).
|
|
||||||
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src
|
|
||||||
|
|
||||||
# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with
|
|
||||||
# Python + mitmdump pre-installed — heavier than the others, so
|
|
||||||
# this stage starts there and pulls the standalone binaries in.
|
|
||||||
FROM mitmproxy/mitmproxy:11.1.3
|
|
||||||
|
|
||||||
# Run as root inside the bundle. The bundle is the isolation
|
|
||||||
# boundary; per-daemon user separation inside it is not load-bearing
|
|
||||||
# and complicates the supervisor's spawn path.
|
|
||||||
USER root
|
|
||||||
|
|
||||||
# Runtime system deps:
|
|
||||||
# git supplies the `git daemon` subcommand (no separate package)
|
|
||||||
# plus the core `git` binary the pre-receive hook invokes.
|
|
||||||
# openssh-client supplies the upstream SSH transport the
|
|
||||||
# pre-receive hook uses to forward accepted refs.
|
|
||||||
# ca-certificates is needed for mitmdump upstream TLS (the
|
|
||||||
# base image already has it; listed for explicitness).
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
git openssh-client ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Pull the standalone binaries into the final image.
|
|
||||||
COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
|
||||||
|
|
||||||
# Project Python: addon + server modules + the init supervisor.
|
|
||||||
# Kept flat under /app/ so mitmdump's loader resolves them as
|
|
||||||
# top-level siblings (absolute imports), matching the prior
|
|
||||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
|
||||||
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
|
||||||
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
|
|
||||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
|
||||||
COPY bot_bottle/policy_resolver.py /app/policy_resolver.py
|
|
||||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
|
||||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
|
||||||
COPY bot_bottle/paths.py /app/paths.py
|
|
||||||
COPY bot_bottle/migrations.py /app/migrations.py
|
|
||||||
COPY bot_bottle/db_store.py /app/db_store.py
|
|
||||||
COPY bot_bottle/supervise_types.py /app/supervise_types.py
|
|
||||||
COPY bot_bottle/queue_store.py /app/queue_store.py
|
|
||||||
COPY bot_bottle/audit_store.py /app/audit_store.py
|
|
||||||
COPY bot_bottle/store_manager.py /app/store_manager.py
|
|
||||||
COPY bot_bottle/supervise.py /app/supervise.py
|
|
||||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
|
||||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
|
||||||
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
|
||||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
|
||||||
RUN chmod +x /app/egress-entrypoint.sh
|
|
||||||
|
|
||||||
# Pre-create runtime directories the compose renderer + start
|
|
||||||
# step expect to exist. `docker cp` does not create intermediate
|
|
||||||
# dirs, and bind mounts won't either if the parent is missing.
|
|
||||||
RUN mkdir -p \
|
|
||||||
/etc/egress \
|
|
||||||
/etc/git-gate \
|
|
||||||
/git-gate/creds \
|
|
||||||
/git \
|
|
||||||
/run/supervise \
|
|
||||||
/home/mitmproxy/.mitmproxy
|
|
||||||
|
|
||||||
# Documentation only — the compose renderer publishes whichever
|
|
||||||
# subset the bottle uses.
|
|
||||||
EXPOSE 8888 9099 9418 9420 9100
|
|
||||||
|
|
||||||
# WORKDIR matches Dockerfile.supervise's prior layout so the
|
|
||||||
# in-app same-dir import in supervise_server.py stays deterministic.
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# PID 1 is the supervisor. It owns signal handling and exit-code
|
|
||||||
# propagation; no `exec` chain in the entrypoint itself.
|
|
||||||
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
|
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
# 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
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
|
|||||||
# forward_host_credentials is enabled. Pipelock must pass these through
|
# forward_host_credentials is enabled. Pipelock must pass these through
|
||||||
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
# (no TLS MITM) or its header DLP blocks the injected JWT.
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
|
||||||
|
|
||||||
|
# Host that egress injects the host Claude bearer on when Claude
|
||||||
|
# forward_host_credentials is enabled.
|
||||||
|
CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)
|
||||||
PromptMode = Literal[
|
PromptMode = Literal[
|
||||||
"append_file",
|
"append_file",
|
||||||
"read_prompt_file",
|
"read_prompt_file",
|
||||||
@@ -61,6 +65,13 @@ class AgentProviderRuntime:
|
|||||||
prompt_mode: PromptMode
|
prompt_mode: PromptMode
|
||||||
bypass_args: tuple[str, ...]
|
bypass_args: tuple[str, ...]
|
||||||
resume_args: tuple[str, ...]
|
resume_args: tuple[str, ...]
|
||||||
|
# argv run inside a throwaway container of a freshly built agent
|
||||||
|
# image, right after `build_image()`, to catch a build that
|
||||||
|
# exited 0 but produced a broken CLI (e.g. an npm
|
||||||
|
# optionalDependencies fetch for a platform-native binary that
|
||||||
|
# silently no-ops on a transient failure). Empty tuple skips the
|
||||||
|
# check — not every provider has opted in yet.
|
||||||
|
smoke_test: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -204,9 +215,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
|
||||||
@@ -259,6 +270,7 @@ class AgentProvider(ABC):
|
|||||||
gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git")
|
gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git")
|
||||||
content = git_gate_render_gitconfig(
|
content = git_gate_render_gitconfig(
|
||||||
manifest_bottle.git, gate_host, scheme=gate_scheme,
|
manifest_bottle.git, gate_host, scheme=gate_scheme,
|
||||||
|
identity_token=getattr(plan, "identity_token", ""),
|
||||||
)
|
)
|
||||||
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
|
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
|
|||||||
@@ -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 Any, Generic, Sequence, TypeVar
|
from typing import TYPE_CHECKING, 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,6 +54,9 @@ 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:
|
||||||
@@ -199,7 +202,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-
|
||||||
@@ -251,7 +254,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.
|
||||||
|
|
||||||
@@ -454,7 +457,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,
|
||||||
@@ -502,7 +505,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.
|
||||||
|
|
||||||
@@ -511,6 +514,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
del plan
|
del plan
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def ensure_orchestrator(self) -> str:
|
||||||
|
"""Bring up this backend's per-host orchestrator + shared gateway
|
||||||
|
(idempotent) and return the host-reachable control-plane URL.
|
||||||
|
|
||||||
|
This is the backend-agnostic bring-up entry point: `launch` calls
|
||||||
|
it as part of starting a bottle, and operator tools (`supervise`)
|
||||||
|
call it to start the control plane on demand when none is running
|
||||||
|
yet. Docker starts the orchestrator + gateway containers;
|
||||||
|
firecracker boots the infra VM. Backends with no orchestrator
|
||||||
|
(macos-container) die with a pointer — the default here."""
|
||||||
|
die(f"backend {self.name!r} has no orchestrator control plane")
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def prepare_cleanup(self) -> CleanupT:
|
def prepare_cleanup(self) -> CleanupT:
|
||||||
"""Enumerate orphaned resources from previous bottles. No side
|
"""Enumerate orphaned resources from previous bottles. No side
|
||||||
@@ -524,7 +539,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
|
||||||
@@ -572,28 +587,63 @@ 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."""
|
||||||
|
|
||||||
|
|
||||||
# Import concrete backend classes AFTER the base types are defined, so
|
# _backends is None until the first call to _get_backends(), at which
|
||||||
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
# point all three concrete backend classes are imported and instantiated.
|
||||||
# via `from . import ...` without hitting a partially-initialized module.
|
# Keeping the imports out of module scope means that importing any
|
||||||
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
# backend sub-module (e.g. `backend.docker.util`) no longer drags the
|
||||||
from .firecracker import FirecrackerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
# firecracker and macos-container implementations into memory.
|
||||||
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
#
|
||||||
|
# Tests may replace _backends with a {name: fake} dict via patch.object;
|
||||||
# Freezer is imported after the backend classes for the same reason:
|
# _get_backends() returns the current module-level value as-is when it
|
||||||
# Freezer.commit_slug constructs ActiveAgent, which must be fully
|
# is not None, so test fakes take effect without triggering real imports.
|
||||||
# defined first.
|
_backends: dict[str, BottleBackend[Any, Any]] | None = None
|
||||||
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
|
|
||||||
|
|
||||||
|
|
||||||
# The dict is heterogeneous: each value is a BottleBackend specialized
|
def _get_backends() -> dict[str, BottleBackend[Any, Any]]:
|
||||||
# over its own plan type. Concrete plan types are erased here because
|
"""Return the registry of all backend instances, loading lazily on first call."""
|
||||||
# the registry is selected at runtime and the CLI only needs the
|
global _backends # pylint: disable=global-statement
|
||||||
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
if _backends is None:
|
||||||
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
from .docker import DockerBottleBackend
|
||||||
"docker": DockerBottleBackend(),
|
from .firecracker import FirecrackerBottleBackend
|
||||||
"firecracker": FirecrackerBottleBackend(),
|
from .macos_container import MacosContainerBottleBackend
|
||||||
"macos-container": MacosContainerBottleBackend(),
|
_backends = {
|
||||||
}
|
"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(
|
||||||
@@ -611,10 +661,11 @@ 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()
|
||||||
if resolved not in _BACKENDS:
|
backends = _get_backends()
|
||||||
known = ", ".join(sorted(_BACKENDS))
|
if resolved not in 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:
|
||||||
@@ -624,16 +675,17 @@ 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 `_BACKENDS`. Used by
|
"""Sorted tuple of all backend keys in `_get_backends()`. Used by
|
||||||
argparse (`--backend` choices) and the dashboard's backend
|
argparse (`--backend` choices) and the dashboard's backend
|
||||||
picker."""
|
picker."""
|
||||||
return tuple(sorted(_BACKENDS))
|
return tuple(sorted(_get_backends()))
|
||||||
|
|
||||||
|
|
||||||
def has_backend(name: str) -> bool:
|
def has_backend(name: str) -> bool:
|
||||||
@@ -645,9 +697,10 @@ 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."""
|
||||||
if name not in _BACKENDS:
|
backends = _get_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]:
|
||||||
@@ -663,10 +716,11 @@ 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] = []
|
||||||
for name in known_backend_names():
|
backends = _get_backends()
|
||||||
if not has_backend(name):
|
for name in sorted(backends):
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -105,12 +105,21 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
|
def ensure_orchestrator(self) -> str:
|
||||||
|
from ...orchestrator.lifecycle import OrchestratorService
|
||||||
|
return OrchestratorService().ensure_running()
|
||||||
|
|
||||||
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:
|
||||||
return ""
|
return ""
|
||||||
|
# Consolidated: the supervise daemon lives on the shared gateway, so
|
||||||
|
# the agent registers the gateway address (NO_PROXY bypasses egress),
|
||||||
|
# not the per-bottle `supervise` alias.
|
||||||
|
if plan.agent_supervise_url:
|
||||||
|
return plan.agent_supervise_url
|
||||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
||||||
|
|
||||||
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
||||||
|
|||||||
@@ -28,11 +28,34 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
# accidental log of the plan dataclass.
|
# accidental log of the plan dataclass.
|
||||||
forwarded_env: dict[str, str] = field(repr=False)
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
|
# Consolidated mode (PRD 0070): the agent reaches git-gate over HTTP at the
|
||||||
|
# shared gateway's address (`http://<gateway_ip>:9420`), set at launch.
|
||||||
|
# Empty → single-tenant defaults (the `git-gate` alias over git://).
|
||||||
|
agent_git_gate_url: str = ""
|
||||||
|
# Likewise the supervise MCP endpoint at the gateway (`http://<gw>:9100/`);
|
||||||
|
# empty → the single-tenant `supervise` alias.
|
||||||
|
agent_supervise_url: str = ""
|
||||||
|
# Per-bottle identity token the agent presents on every attributed request
|
||||||
|
# (egress proxy credentials, git-gate/supervise headers); set by launch
|
||||||
|
# from the orchestrator registration. Empty pre-registration.
|
||||||
|
identity_token: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def container_name(self) -> str:
|
def container_name(self) -> str:
|
||||||
return self.agent_provision.instance_name
|
return self.agent_provision.instance_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def git_gate_insteadof_host(self) -> str:
|
||||||
|
if self.agent_git_gate_url.startswith("http://"):
|
||||||
|
return self.agent_git_gate_url.removeprefix("http://").rstrip("/")
|
||||||
|
return super().git_gate_insteadof_host
|
||||||
|
|
||||||
|
@property
|
||||||
|
def git_gate_insteadof_scheme(self) -> str:
|
||||||
|
if self.agent_git_gate_url.startswith("http://"):
|
||||||
|
return "http"
|
||||||
|
return super().git_gate_insteadof_scheme
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def image(self) -> str:
|
def image(self) -> str:
|
||||||
return self.agent_provision.image
|
return self.agent_provision.image
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Agent-only compose for the consolidated docker backend (PRD 0070).
|
||||||
|
|
||||||
|
The per-bottle model rendered a compose project with the agent *and* a
|
||||||
|
gateway on two per-bottle networks. In the consolidated model the
|
||||||
|
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
|
||||||
|
pinned source IP the orchestrator allocated, and pointed at the gateway's
|
||||||
|
address for egress (and, around the proxy, for git-http / supervise).
|
||||||
|
|
||||||
|
Pure: it takes the launch-time `LaunchContext` values (gateway address,
|
||||||
|
source IP, network) and the prepared plan, and returns a compose dict — no
|
||||||
|
docker, so it's testable in isolation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ...egress import egress_agent_env_entries
|
||||||
|
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
|
from .bottle_plan import DockerBottlePlan
|
||||||
|
from .egress import EGRESS_PORT
|
||||||
|
|
||||||
|
|
||||||
|
def consolidated_agent_compose(
|
||||||
|
plan: DockerBottlePlan,
|
||||||
|
*,
|
||||||
|
gateway_ip: str,
|
||||||
|
source_ip: str,
|
||||||
|
network: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""A compose spec with only the agent service, on the external gateway
|
||||||
|
network at `source_ip`, proxying egress through `gateway_ip`."""
|
||||||
|
# Deliver the identity token as egress proxy credentials — the gateway
|
||||||
|
# reads Proxy-Authorization, validates the (source_ip, token) pair, and
|
||||||
|
# strips it before upstream. git-http/supervise get it via their own
|
||||||
|
# headers (git config extraHeader / MCP header).
|
||||||
|
token = getattr(plan, "identity_token", "")
|
||||||
|
cred = f"bottle:{token}@" if token else ""
|
||||||
|
proxy_url = f"http://{cred}{gateway_ip}:{EGRESS_PORT}"
|
||||||
|
# git-http + supervise live on the gateway too and must NOT go through the
|
||||||
|
# egress proxy — the agent reaches them directly by the gateway address.
|
||||||
|
no_proxy = f"localhost,127.0.0.1,{gateway_ip}"
|
||||||
|
env: list[str] = [
|
||||||
|
f"HTTPS_PROXY={proxy_url}",
|
||||||
|
f"HTTP_PROXY={proxy_url}",
|
||||||
|
f"https_proxy={proxy_url}",
|
||||||
|
f"http_proxy={proxy_url}",
|
||||||
|
f"NO_PROXY={no_proxy}",
|
||||||
|
f"no_proxy={no_proxy}",
|
||||||
|
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
||||||
|
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
||||||
|
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
||||||
|
]
|
||||||
|
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
||||||
|
env.append(f"{name}={value}")
|
||||||
|
# Forwarded vars: bare name → inherits from the compose-up process env so
|
||||||
|
# the secret value never lands on argv or in the compose file.
|
||||||
|
for name in sorted(plan.forwarded_env.keys()):
|
||||||
|
env.append(name)
|
||||||
|
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||||
|
|
||||||
|
service: dict[str, Any] = {
|
||||||
|
"image": plan.image,
|
||||||
|
"container_name": plan.container_name,
|
||||||
|
"command": ["sleep", "infinity"],
|
||||||
|
# Pinned address on the shared gateway network — the orchestrator
|
||||||
|
# registered this IP, and the gateway attributes the bottle by it.
|
||||||
|
"networks": {network: {"ipv4_address": source_ip}},
|
||||||
|
"environment": env,
|
||||||
|
}
|
||||||
|
if plan.use_runsc:
|
||||||
|
service["runtime"] = "runsc"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": f"bot-bottle-{plan.slug}",
|
||||||
|
"services": {"agent": service},
|
||||||
|
# The gateway network is created + owned by the orchestrator; compose
|
||||||
|
# attaches to it (external) and must not create or destroy it.
|
||||||
|
"networks": {network: {"external": True}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["consolidated_agent_compose"]
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Consolidated bottle launch sequence for the docker backend (PRD 0070).
|
||||||
|
|
||||||
|
Composes the orchestrator primitives into the register/teardown sequence that
|
||||||
|
replaces the per-bottle gateway:
|
||||||
|
|
||||||
|
1. ensure the orchestrator control plane + shared gateway are up;
|
||||||
|
2. allocate the bottle a pinned source IP on the gateway network (the
|
||||||
|
attribution key), skipping the gateway's own address + live bottles;
|
||||||
|
3. register it (egress policy blob + slug metadata) → bottle id + identity
|
||||||
|
token;
|
||||||
|
4. provision its git-gate repos/creds into the running gateway.
|
||||||
|
|
||||||
|
It returns a `LaunchContext` with everything the agent container needs to
|
||||||
|
attach — network, pinned IP, the gateway's address (its proxy target), the
|
||||||
|
orchestrator URL, and the identity token. The agent `docker run` itself is
|
||||||
|
the backend's job (it owns provider provisioning); this owns the
|
||||||
|
orchestrator-facing wiring so that sequence stays testable in isolation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from ...docker_cmd import run_docker
|
||||||
|
from ...egress import EgressPlan
|
||||||
|
from ...git_gate import GitGatePlan
|
||||||
|
from ...orchestrator.client import OrchestratorClient
|
||||||
|
from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK
|
||||||
|
from ...orchestrator.lifecycle import OrchestratorService
|
||||||
|
from ...orchestrator.registration import registration_inputs
|
||||||
|
from .gateway_net import next_free_ip
|
||||||
|
from .gateway_provision import (
|
||||||
|
DockerGatewayTransport,
|
||||||
|
deprovision_git_gate,
|
||||||
|
provision_git_gate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsolidatedLaunchError(RuntimeError):
|
||||||
|
"""The consolidated register/provision sequence could not complete."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LaunchContext:
|
||||||
|
"""What the agent container needs to join the shared gateway."""
|
||||||
|
|
||||||
|
bottle_id: str
|
||||||
|
identity_token: str
|
||||||
|
source_ip: str # the agent's pinned address (attribution key)
|
||||||
|
network: str # the shared gateway network to attach to
|
||||||
|
gateway_ip: str # the gateway's address — the agent's proxy target
|
||||||
|
orchestrator_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def _network_cidr(network: str) -> str:
|
||||||
|
"""The gateway network's IPv4 subnet, or raise."""
|
||||||
|
proc = run_docker([
|
||||||
|
"docker", "network", "inspect",
|
||||||
|
"--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", network,
|
||||||
|
])
|
||||||
|
cidr = proc.stdout.strip()
|
||||||
|
if proc.returncode != 0 or not cidr:
|
||||||
|
raise ConsolidatedLaunchError(
|
||||||
|
f"gateway network {network} has no subnet: {proc.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return cidr
|
||||||
|
|
||||||
|
|
||||||
|
def _container_ip(name: str, network: str) -> str:
|
||||||
|
"""A container's IPv4 address on `network`, or raise."""
|
||||||
|
proc = run_docker([
|
||||||
|
"docker", "inspect", "--format",
|
||||||
|
f'{{{{(index .NetworkSettings.Networks "{network}").IPAddress}}}}', name,
|
||||||
|
])
|
||||||
|
ip = proc.stdout.strip()
|
||||||
|
if proc.returncode != 0 or not ip:
|
||||||
|
raise ConsolidatedLaunchError(
|
||||||
|
f"gateway {name} has no address on {network}: {proc.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def _network_container_ips(network: str) -> list[str]:
|
||||||
|
"""Every address currently assigned on the gateway network — the ground
|
||||||
|
truth for "in use": the gateway + orchestrator infrastructure containers
|
||||||
|
and every live agent. Read from the network so a new bottle can't collide
|
||||||
|
with anything actually attached (a registry-only view would miss the
|
||||||
|
orchestrator/gateway containers)."""
|
||||||
|
proc = run_docker([
|
||||||
|
"docker", "network", "inspect", "--format",
|
||||||
|
"{{range .Containers}}{{.IPv4Address}} {{end}}", network,
|
||||||
|
])
|
||||||
|
ips: list[str] = []
|
||||||
|
for entry in proc.stdout.split():
|
||||||
|
# entries look like "172.20.0.2/16" — keep the address.
|
||||||
|
ips.append(entry.split("/", 1)[0])
|
||||||
|
return ips
|
||||||
|
|
||||||
|
|
||||||
|
def launch_consolidated(
|
||||||
|
egress_plan: EgressPlan,
|
||||||
|
git_gate_plan: GitGatePlan,
|
||||||
|
*,
|
||||||
|
image_ref: str = "",
|
||||||
|
tokens: dict[str, str] | None = None,
|
||||||
|
service: OrchestratorService | None = None,
|
||||||
|
gateway_name: str = GATEWAY_NAME,
|
||||||
|
network: str = GATEWAY_NETWORK,
|
||||||
|
) -> LaunchContext:
|
||||||
|
"""Ensure the orchestrator + gateway are up, allocate + register the
|
||||||
|
bottle, and provision its git-gate state. Returns the agent's attach
|
||||||
|
context. Raises `ConsolidatedLaunchError` (or the primitives' own errors)
|
||||||
|
if any step fails — the caller tears down on failure."""
|
||||||
|
service = service or OrchestratorService()
|
||||||
|
url = service.ensure_running()
|
||||||
|
client = OrchestratorClient(url)
|
||||||
|
|
||||||
|
cidr = _network_cidr(network)
|
||||||
|
gateway_ip = _container_ip(gateway_name, network)
|
||||||
|
source_ip = next_free_ip(cidr, _network_container_ips(network))
|
||||||
|
|
||||||
|
inputs = registration_inputs(egress_plan)
|
||||||
|
reg = client.register_bottle(
|
||||||
|
source_ip, image_ref=image_ref, policy=inputs.policy,
|
||||||
|
metadata=inputs.metadata, tokens=tokens,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
provision_git_gate(
|
||||||
|
DockerGatewayTransport(gateway_name), reg.bottle_id, git_gate_plan)
|
||||||
|
except Exception:
|
||||||
|
# Roll the registration back so a provisioning failure leaves no orphan.
|
||||||
|
client.teardown_bottle(reg.bottle_id)
|
||||||
|
raise
|
||||||
|
return LaunchContext(
|
||||||
|
bottle_id=reg.bottle_id,
|
||||||
|
identity_token=reg.identity_token,
|
||||||
|
source_ip=source_ip,
|
||||||
|
network=network,
|
||||||
|
gateway_ip=gateway_ip,
|
||||||
|
orchestrator_url=url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_consolidated(
|
||||||
|
bottle_id: str, *, orchestrator_url: str, gateway_name: str = GATEWAY_NAME,
|
||||||
|
) -> None:
|
||||||
|
"""Deregister the bottle and remove its git-gate state from the gateway.
|
||||||
|
Both steps are idempotent so this is safe from a cleanup trap."""
|
||||||
|
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||||
|
deprovision_git_gate(DockerGatewayTransport(gateway_name), bottle_id)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LaunchContext",
|
||||||
|
"launch_consolidated",
|
||||||
|
"teardown_consolidated",
|
||||||
|
"ConsolidatedLaunchError",
|
||||||
|
]
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Provision one bottle's git-gate state into the running shared gateway
|
||||||
|
(PRD 0070, docker slice).
|
||||||
|
|
||||||
|
The consolidated gateway serves every bottle's repos under `/git/<bottle_id>/`
|
||||||
|
with per-repo credentials under `/git-gate/creds/<bottle_id>/`. When a bottle
|
||||||
|
is registered the launcher must place *its* deploy keys + known_hosts into
|
||||||
|
that per-bottle creds dir and init its bare repos there — so this copies the
|
||||||
|
credential files into the live gateway container and runs the (namespaced,
|
||||||
|
init-only) provisioning script produced by `git_gate_render_provision`.
|
||||||
|
|
||||||
|
Isolating each bottle's creds dir + repo root by id is what keeps one
|
||||||
|
bottle's push credentials out of another's repos on the shared gateway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from ...docker_cmd import run_docker
|
||||||
|
from ...git_gate import GitGatePlan, git_gate_render_provision
|
||||||
|
|
||||||
|
# bottle ids index the gateway's per-bottle repo + creds dirs; they land in
|
||||||
|
# exec/cp path arguments, so validate before any path is built (a traversal
|
||||||
|
# id like "../etc" must never reach the gateway). Registry ids are token_hex —
|
||||||
|
# this is defense in depth at the transport boundary.
|
||||||
|
_SAFE_BOTTLE_ID = re.compile(r"[A-Za-z0-9_-]+")
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayProvisionError(RuntimeError):
|
||||||
|
"""A git-gate provisioning step against the running gateway failed."""
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayTransport(Protocol):
|
||||||
|
"""How the launcher stages files + runs commands in the running gateway.
|
||||||
|
Backend-neutral so the same provisioning logic serves the docker gateway
|
||||||
|
(exec/cp over the docker socket) and the firecracker gateway VM (over
|
||||||
|
SSH)."""
|
||||||
|
|
||||||
|
def exec(self, argv: list[str]) -> None:
|
||||||
|
"""Run `argv` in the gateway, raising `GatewayProvisionError` on
|
||||||
|
failure."""
|
||||||
|
|
||||||
|
def cp_into(self, src: str, dest: str) -> None:
|
||||||
|
"""Copy host file `src` to `dest` in the gateway, raising on
|
||||||
|
failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class DockerGatewayTransport:
|
||||||
|
"""`GatewayTransport` for the docker gateway container (exec/cp)."""
|
||||||
|
|
||||||
|
def __init__(self, gateway: str) -> None:
|
||||||
|
self.gateway = gateway
|
||||||
|
|
||||||
|
def exec(self, argv: list[str]) -> None:
|
||||||
|
proc = run_docker(["docker", "exec", self.gateway, *argv])
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise GatewayProvisionError(
|
||||||
|
f"gateway exec {argv!r} failed: {proc.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def cp_into(self, src: str, dest: str) -> None:
|
||||||
|
proc = run_docker(["docker", "cp", src, f"{self.gateway}:{dest}"])
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise GatewayProvisionError(
|
||||||
|
f"gateway cp {src} -> {dest} failed: {proc.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_safe(bottle_id: str) -> None:
|
||||||
|
if not _SAFE_BOTTLE_ID.fullmatch(bottle_id):
|
||||||
|
raise GatewayProvisionError(f"unsafe bottle id {bottle_id!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_dir(bottle_id: str) -> str:
|
||||||
|
return f"/git-gate/creds/{bottle_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def provision_git_gate(
|
||||||
|
transport: GatewayTransport, bottle_id: str, plan: GitGatePlan,
|
||||||
|
) -> None:
|
||||||
|
"""Place `bottle_id`'s git-gate credentials into the running gateway and
|
||||||
|
init its bare repos under `/git/<bottle_id>/`.
|
||||||
|
|
||||||
|
Copies each upstream's identity key (and known_hosts, when present) into
|
||||||
|
`/git-gate/creds/<bottle_id>/`, then runs the namespaced provisioning
|
||||||
|
script. No-op for a bottle with no git upstreams."""
|
||||||
|
_require_safe(bottle_id)
|
||||||
|
if not plan.upstreams:
|
||||||
|
return
|
||||||
|
# The pre-receive + access hooks are bottle-agnostic and shared by every
|
||||||
|
# bottle's repos; install them into the gateway (idempotent — same content
|
||||||
|
# each time). The per-bottle model cp'd these into each bundle at start.
|
||||||
|
transport.exec(["mkdir", "-p", "/etc/git-gate"])
|
||||||
|
transport.cp_into(str(plan.hook_script), "/etc/git-gate/pre-receive")
|
||||||
|
transport.cp_into(str(plan.access_hook_script), "/etc/git-gate/access-hook")
|
||||||
|
# The access-hook is exec'd directly (not via `sh`), so it needs the x bit.
|
||||||
|
# Set it here rather than trusting the copy to carry the staged 0o700:
|
||||||
|
# `docker cp` preserves source mode, but the Apple `container cp` does not,
|
||||||
|
# landing the hook 0o644 → EACCES when the git-http handler tries to exec it.
|
||||||
|
# chmod on the gateway side is backend-neutral and fixes every transport.
|
||||||
|
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
|
||||||
|
creds = _creds_dir(bottle_id)
|
||||||
|
transport.exec(["mkdir", "-p", creds])
|
||||||
|
for u in plan.upstreams:
|
||||||
|
if u.identity_file:
|
||||||
|
transport.cp_into(u.identity_file, f"{creds}/{u.name}-key")
|
||||||
|
known_hosts = str(u.known_hosts_file)
|
||||||
|
if known_hosts and known_hosts != ".":
|
||||||
|
transport.cp_into(known_hosts, f"{creds}/{u.name}-known_hosts")
|
||||||
|
# Init the bare repos + per-repo credential config for this namespace.
|
||||||
|
script = git_gate_render_provision(bottle_id, plan.upstreams)
|
||||||
|
transport.exec(["sh", "-c", script])
|
||||||
|
|
||||||
|
|
||||||
|
def deprovision_git_gate(transport: GatewayTransport, bottle_id: str) -> None:
|
||||||
|
"""Remove a bottle's repos + creds from the gateway on teardown. Idempotent
|
||||||
|
— an already-absent namespace is a clean no-op (best effort; a stray dir
|
||||||
|
can't leak, since attribution is by source IP and the bottle is gone)."""
|
||||||
|
_require_safe(bottle_id)
|
||||||
|
try:
|
||||||
|
transport.exec([
|
||||||
|
"rm", "-rf", f"/git/{bottle_id}", _creds_dir(bottle_id),
|
||||||
|
])
|
||||||
|
except GatewayProvisionError:
|
||||||
|
pass # best-effort teardown; absent namespace is success
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"provision_git_gate", "deprovision_git_gate",
|
||||||
|
"GatewayProvisionError", "GatewayTransport", "DockerGatewayTransport",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -36,13 +36,13 @@ from contextlib import ExitStack, contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
|
from ...agent_provider import runtime_for
|
||||||
from ...egress import egress_resolve_token_values
|
from ...egress import egress_resolve_token_values
|
||||||
from ...git_gate import (
|
from ...git_gate import (
|
||||||
provision_git_gate_dynamic_keys,
|
provision_git_gate_dynamic_keys,
|
||||||
revoke_git_gate_provisioned_keys,
|
revoke_git_gate_provisioned_keys,
|
||||||
)
|
)
|
||||||
from ...log import info, warn
|
from ...log import info, warn
|
||||||
from . import network as network_mod
|
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
from .bottle import DockerBottle
|
from .bottle import DockerBottle
|
||||||
from .bottle_plan import DockerBottlePlan
|
from .bottle_plan import DockerBottlePlan
|
||||||
@@ -53,7 +53,6 @@ from ...bottle_state import (
|
|||||||
read_committed_image,
|
read_committed_image,
|
||||||
)
|
)
|
||||||
from .compose import (
|
from .compose import (
|
||||||
bottle_plan_to_compose,
|
|
||||||
compose_down,
|
compose_down,
|
||||||
compose_dump_logs,
|
compose_dump_logs,
|
||||||
compose_file_path,
|
compose_file_path,
|
||||||
@@ -62,7 +61,9 @@ from .compose import (
|
|||||||
compose_up,
|
compose_up,
|
||||||
write_compose_file,
|
write_compose_file,
|
||||||
)
|
)
|
||||||
from .egress import egress_tls_init
|
from .consolidated_compose import consolidated_agent_compose
|
||||||
|
from .consolidated_launch import launch_consolidated, teardown_consolidated
|
||||||
|
from ...orchestrator.gateway import DockerGateway
|
||||||
|
|
||||||
|
|
||||||
# Where the repo root lives, for `docker build` context. Computed once.
|
# Where the repo root lives, for `docker build` context. Computed once.
|
||||||
@@ -97,8 +98,7 @@ def launch(
|
|||||||
try:
|
try:
|
||||||
# Step 1: agent image. Use a committed snapshot when one exists
|
# Step 1: agent image. Use a committed snapshot when one exists
|
||||||
# and is present in the local daemon; otherwise build from the
|
# and is present in the local daemon; otherwise build from the
|
||||||
# Dockerfile. Sidecar images get built lazily by `docker compose
|
# Dockerfile. (The gateway image is built by the orchestrator.)
|
||||||
# up` via the renderer's `build:` directives.
|
|
||||||
committed = read_committed_image(plan.slug)
|
committed = read_committed_image(plan.slug)
|
||||||
if committed and docker_mod.image_exists(committed):
|
if committed and docker_mod.image_exists(committed):
|
||||||
info(f"using committed image {committed!r}")
|
info(f"using committed image {committed!r}")
|
||||||
@@ -111,83 +111,87 @@ def launch(
|
|||||||
plan.image, _REPO_DIR,
|
plan.image, _REPO_DIR,
|
||||||
dockerfile=plan.dockerfile_path,
|
dockerfile=plan.dockerfile_path,
|
||||||
)
|
)
|
||||||
|
docker_mod.verify_agent_image(
|
||||||
|
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||||
|
)
|
||||||
|
|
||||||
internal_network = network_mod.network_name_for_slug(plan.slug)
|
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any, before
|
||||||
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
# provisioning the bottle's repos into the shared gateway.
|
||||||
|
|
||||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
|
||||||
egress_state_dir(plan.slug),
|
|
||||||
)
|
|
||||||
|
|
||||||
git_gate_plan = plan.git_gate_plan
|
git_gate_plan = plan.git_gate_plan
|
||||||
if git_gate_plan.upstreams:
|
if git_gate_plan.upstreams:
|
||||||
git_gate_plan = provision_git_gate_dynamic_keys(
|
git_gate_plan = provision_git_gate_dynamic_keys(
|
||||||
plan.manifest.bottle,
|
plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
|
||||||
git_gate_plan,
|
|
||||||
git_gate_state_dir(plan.slug),
|
|
||||||
)
|
|
||||||
git_gate_plan = dataclasses.replace(
|
|
||||||
git_gate_plan,
|
|
||||||
internal_network=internal_network,
|
|
||||||
egress_network=egress_network,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Step 3: register on the orchestrator + provision this bottle's
|
||||||
|
# git-gate state into the shared gateway; get the agent's attach
|
||||||
|
# context (pinned source IP, gateway address, shared network). The
|
||||||
|
# per-bottle egress auth tokens are resolved from the host env now and
|
||||||
|
# handed to the orchestrator (in memory) for the gateway to inject —
|
||||||
|
# the agent never sees them.
|
||||||
|
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
|
||||||
|
token_values = egress_resolve_token_values(
|
||||||
|
plan.egress_plan.token_env_map, effective_env,
|
||||||
|
)
|
||||||
|
ctx = launch_consolidated(
|
||||||
|
plan.egress_plan, git_gate_plan, image_ref=plan.image, tokens=token_values,
|
||||||
|
)
|
||||||
|
stack.callback(
|
||||||
|
teardown_consolidated, ctx.bottle_id, orchestrator_url=ctx.orchestrator_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: install the SHARED gateway CA into the agent (replaces the
|
||||||
|
# per-bottle CA) — read it out of the running gateway.
|
||||||
|
ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
|
||||||
|
ca_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ca_file = ca_dir / "gateway-ca.pem"
|
||||||
|
ca_file.write_text(DockerGateway(network=ctx.network).ca_cert_pem())
|
||||||
egress_plan = dataclasses.replace(
|
egress_plan = dataclasses.replace(
|
||||||
plan.egress_plan,
|
plan.egress_plan,
|
||||||
internal_network=internal_network,
|
mitmproxy_ca_host_path=ca_file,
|
||||||
egress_network=egress_network,
|
mitmproxy_ca_cert_only_host_path=ca_file,
|
||||||
mitmproxy_ca_host_path=egress_ca_host,
|
)
|
||||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
# Point the agent's git-gate insteadOf rewrites at the shared gateway's
|
||||||
|
# HTTP git endpoint (9420) instead of the dead per-bottle `git-gate`
|
||||||
|
# alias. git-http + supervise on the gateway bypass the egress proxy
|
||||||
|
# (NO_PROXY includes the gateway address).
|
||||||
|
git_gate_url = (
|
||||||
|
f"http://{ctx.gateway_ip}:9420" if git_gate_plan.upstreams else ""
|
||||||
|
)
|
||||||
|
supervise_url = (
|
||||||
|
f"http://{ctx.gateway_ip}:9100/" if plan.supervise_plan is not None else ""
|
||||||
)
|
)
|
||||||
supervise_plan = plan.supervise_plan
|
|
||||||
if supervise_plan is not None:
|
|
||||||
supervise_plan = dataclasses.replace(
|
|
||||||
supervise_plan,
|
|
||||||
internal_network=internal_network,
|
|
||||||
)
|
|
||||||
plan = dataclasses.replace(
|
plan = dataclasses.replace(
|
||||||
plan,
|
plan,
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
egress_plan=egress_plan,
|
egress_plan=egress_plan,
|
||||||
supervise_plan=supervise_plan,
|
agent_git_gate_url=git_gate_url,
|
||||||
|
agent_supervise_url=supervise_url,
|
||||||
|
identity_token=ctx.identity_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 6: render + write the compose file. metadata.json
|
# Step 5: render + up the agent-only compose, pinned on the shared
|
||||||
# was written at prepare time and already carries
|
# gateway network and proxied through the gateway's address.
|
||||||
# compose_project; nothing to update here.
|
|
||||||
state_dir = bottle_state_dir(plan.slug)
|
state_dir = bottle_state_dir(plan.slug)
|
||||||
spec = bottle_plan_to_compose(plan)
|
spec = consolidated_agent_compose(
|
||||||
|
plan, gateway_ip=ctx.gateway_ip, source_ip=ctx.source_ip, network=ctx.network,
|
||||||
|
)
|
||||||
compose_file = write_compose_file(spec, compose_file_path(state_dir))
|
compose_file = write_compose_file(spec, compose_file_path(state_dir))
|
||||||
project = compose_project_name(plan.slug)
|
project = compose_project_name(plan.slug)
|
||||||
|
# Forwarded vars (OAuth token, host interpolations) flow through the
|
||||||
# Step 7: compose up. Token values + the OAuth placeholder
|
# subprocess env as bare names so values never land in the file.
|
||||||
# flow through subprocess env; the compose file holds only
|
compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env}
|
||||||
# bare names for the secret-carrying entries.
|
|
||||||
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
|
|
||||||
token_values = egress_resolve_token_values(
|
|
||||||
plan.egress_plan.token_env_map, effective_env,
|
|
||||||
)
|
|
||||||
compose_env: dict[str, str] = {
|
|
||||||
**os.environ,
|
|
||||||
**plan.forwarded_env,
|
|
||||||
**token_values,
|
|
||||||
}
|
|
||||||
info(
|
info(
|
||||||
f"docker compose up -d (project {project}, "
|
f"docker compose up -d (project {project}, agent on shared "
|
||||||
f"{len(spec['services'])} services)"
|
f"gateway {ctx.gateway_ip}, ip {ctx.source_ip})"
|
||||||
)
|
)
|
||||||
compose_up(project, compose_file, env=compose_env)
|
compose_up(project, compose_file, env=compose_env)
|
||||||
|
|
||||||
# Register teardown in reverse order: log dump first, then
|
|
||||||
# `compose down`. Networks come down last via callbacks
|
|
||||||
# registered in step 2.
|
|
||||||
stack.callback(compose_down, project, compose_file)
|
stack.callback(compose_down, project, compose_file)
|
||||||
stack.callback(
|
stack.callback(
|
||||||
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
compose_dump_logs, project, compose_file, compose_log_path(state_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 8: provision. Create the bottle first so provisioners
|
# Step 6: provision (CA install now uses the gateway CA) + yield.
|
||||||
# can use bottle.exec / bottle.cp_in; set the prompt path
|
|
||||||
# returned by provision_prompt after the fact.
|
|
||||||
bottle = DockerBottle(
|
bottle = DockerBottle(
|
||||||
plan.container_name,
|
plan.container_name,
|
||||||
teardown,
|
teardown,
|
||||||
@@ -200,10 +204,6 @@ def launch(
|
|||||||
agent_workdir=plan.workspace_plan.workdir,
|
agent_workdir=plan.workspace_plan.workdir,
|
||||||
)
|
)
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
bottle.prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
# Step 9: yield. exec_agent continues to use `docker exec -it`
|
|
||||||
# — the agent runs `sleep infinity` per the renderer's
|
|
||||||
# service spec.
|
|
||||||
yield bottle
|
yield bottle
|
||||||
finally:
|
finally:
|
||||||
teardown()
|
teardown()
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
@@ -4,11 +4,13 @@ existence, and building images."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Iterable, Iterator
|
from typing import 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
|
||||||
|
|
||||||
@@ -30,12 +32,7 @@ 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 = subprocess.run(
|
r = run_docker(["docker", "info", "--format", "{{json .Runtimes}}"])
|
||||||
["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
|
||||||
|
|
||||||
|
|
||||||
@@ -49,20 +46,15 @@ def require_docker() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def image_exists(ref: str) -> bool:
|
def image_exists(ref: str) -> bool:
|
||||||
return _silent_run(["docker", "image", "inspect", ref]) == 0
|
return run_docker(["docker", "image", "inspect", ref]).returncode == 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 = subprocess.run(
|
result = run_docker(["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"])
|
||||||
["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"],
|
return result.returncode == 0 and bool(result.stdout.strip())
|
||||||
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:
|
||||||
@@ -70,12 +62,7 @@ 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):
|
||||||
subprocess.run(
|
run_docker(["docker", "rm", "-f", name])
|
||||||
["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:
|
||||||
@@ -88,6 +75,29 @@ def docker_exec_root(container: str, argv: list[str]) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def docker_exec(container: str, argv: list[str], *, user: str = "") -> None:
|
||||||
|
"""Run `docker exec` in the named container, dying with the
|
||||||
|
command's own stderr on failure. Pass `user=\"0\"` to run as root."""
|
||||||
|
cmd = ["docker", "exec"]
|
||||||
|
if user:
|
||||||
|
cmd += ["-u", user]
|
||||||
|
cmd += [container, *argv]
|
||||||
|
result = run_docker(cmd)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(
|
||||||
|
f"docker exec in {container} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def docker_cp(src: str, dest: str) -> None:
|
||||||
|
"""Run `docker cp`, dying with the command's own stderr on failure."""
|
||||||
|
result = run_docker(["docker", "cp", src, dest])
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"docker cp {src} -> {dest} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||||
|
|
||||||
|
|
||||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
@@ -108,15 +118,40 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|||||||
|
|
||||||
`dockerfile` is an optional path (relative to `context`, or
|
`dockerfile` is an optional path (relative to `context`, or
|
||||||
absolute) for callers that need to build from a non-default
|
absolute) for callers that need to build from a non-default
|
||||||
Dockerfile in the same context — e.g. `Dockerfile.git-gate`."""
|
Dockerfile in the same context — e.g. `Dockerfile.git-gate`.
|
||||||
|
|
||||||
|
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
|
||||||
|
`--no-cache`. The npm/curl installers some provider Dockerfiles
|
||||||
|
shell out to can silently no-op on a transient network failure —
|
||||||
|
e.g. an `optionalDependencies` fetch for a platform-native binary —
|
||||||
|
and Docker will then cache that broken layer indefinitely."""
|
||||||
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
|
||||||
args = ["docker", "build", "-t", ref]
|
args = ["docker", "build", "-t", ref]
|
||||||
|
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
||||||
|
args.append("--no-cache")
|
||||||
if dockerfile:
|
if dockerfile:
|
||||||
args.extend(["-f", dockerfile])
|
args.extend(["-f", dockerfile])
|
||||||
args.append(context)
|
args.append(context)
|
||||||
subprocess.run(args, check=True)
|
subprocess.run(args, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
|
||||||
|
"""Run `argv` inside a throwaway container of a freshly built agent
|
||||||
|
image and die loudly if it fails, instead of shipping an image
|
||||||
|
whose CLI only breaks at first real use. No-op when the provider
|
||||||
|
hasn't declared a smoke test (`AgentProviderRuntime.smoke_test`)."""
|
||||||
|
if not argv:
|
||||||
|
return
|
||||||
|
result = run_docker(["docker", "run", "--rm", "--entrypoint", argv[0], image, *argv[1:]])
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout or "").strip()
|
||||||
|
die(
|
||||||
|
f"agent image {image!r} failed its post-build smoke test "
|
||||||
|
f"({' '.join(argv)}): {detail}\n"
|
||||||
|
f"Try rebuilding from scratch: bot-bottle start --no-cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# def build_image_with_cwd(
|
# def build_image_with_cwd(
|
||||||
# derived: str,
|
# derived: str,
|
||||||
# base: str,
|
# base: str,
|
||||||
@@ -155,22 +190,10 @@ 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 = subprocess.run(
|
result = run_docker(["docker", "commit", container_name, image_tag])
|
||||||
["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
|
|
||||||
|
|||||||
@@ -110,3 +110,7 @@ class FirecrackerBottleBackend(
|
|||||||
|
|
||||||
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
|
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
|
||||||
return plan.agent_supervise_url
|
return plan.agent_supervise_url
|
||||||
|
|
||||||
|
def ensure_orchestrator(self) -> str:
|
||||||
|
from . import infra_vm
|
||||||
|
return infra_vm.ensure_running().control_plane_url
|
||||||
|
|||||||
@@ -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).
|
||||||
"""
|
"""
|
||||||
@@ -105,10 +105,9 @@ class FirecrackerBottle(Bottle):
|
|||||||
# root-owned and unreadable by node, which breaks Node's
|
# root-owned and unreadable by node, which breaks Node's
|
||||||
# process.cwd(), the shell-snapshot machinery, and `/doctor`.
|
# process.cwd(), the shell-snapshot machinery, and `/doctor`.
|
||||||
# Use `env --chdir` rather than a `sh -c 'cd … && exec "$@"'`
|
# Use `env --chdir` rather than a `sh -c 'cd … && exec "$@"'`
|
||||||
# wrapper: ssh space-joins everything after the host into one
|
# wrapper: it keeps the guest command a flat argv that `agent_argv`
|
||||||
# string for the guest shell, so a quoted script + $@ would be
|
# can quote token-by-token for the ssh→guest-shell round trip,
|
||||||
# re-split and mangled (exec'ing the $0 placeholder). All-simple
|
# avoiding a fragile nested-quoting `"$@"` script.
|
||||||
# words survive that join.
|
|
||||||
workdir = self.agent_workdir or _HOME_FOR["node"]
|
workdir = self.agent_workdir or _HOME_FOR["node"]
|
||||||
remote = ["runuser", "-u", "node", "--",
|
remote = ["runuser", "-u", "node", "--",
|
||||||
"env", f"--chdir={workdir}",
|
"env", f"--chdir={workdir}",
|
||||||
@@ -117,7 +116,15 @@ class FirecrackerBottle(Bottle):
|
|||||||
return remote
|
return remote
|
||||||
|
|
||||||
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
|
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
|
||||||
return [*self._ssh(tty=tty), "--", *self._agent_remote_argv(argv)]
|
# ssh space-joins everything after the host into one line the guest
|
||||||
|
# shell re-parses, so pre-quote each remote token for that shell.
|
||||||
|
# Simple words are unchanged (existing behaviour); an arg containing
|
||||||
|
# spaces — e.g. codex's `read_prompt_file` positional "Read and follow
|
||||||
|
# the instructions in <path>." — is quoted so it survives as ONE
|
||||||
|
# argument instead of being re-split (which made codex parse "and" as
|
||||||
|
# a subcommand).
|
||||||
|
remote = self._agent_remote_argv(argv)
|
||||||
|
return [*self._ssh(tty=tty), "--", *(shlex.quote(t) for t in remote)]
|
||||||
|
|
||||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||||
agent_argv = self.agent_argv(argv, tty=tty)
|
agent_argv = self.agent_argv(argv, tty=tty)
|
||||||
|
|||||||
@@ -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,15 +13,19 @@ 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 = ""
|
||||||
agent_supervise_url: str = ""
|
agent_supervise_url: str = ""
|
||||||
|
# Per-bottle identity token the agent presents on every attributed request
|
||||||
|
# (egress proxy credentials, git-gate/supervise headers); set by launch
|
||||||
|
# from the orchestrator registration. Empty pre-registration.
|
||||||
|
identity_token: str = ""
|
||||||
|
|
||||||
@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)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Consolidated bottle launch sequence for the Firecracker backend
|
||||||
|
(PRD 0070, Stage B).
|
||||||
|
|
||||||
|
The shared gateway + orchestrator control plane run in a single persistent
|
||||||
|
per-host **infra VM** (`infra_vm.py`), not Docker containers. Agent VMs reach
|
||||||
|
the gateway's egress / supervise / git-http ports at the infra VM via a
|
||||||
|
PREROUTING DNAT on their own host-side TAP IP (see
|
||||||
|
`scripts/firecracker-netpool.sh`), and the host CLI reaches the control plane
|
||||||
|
over HTTP at the infra VM's guest IP.
|
||||||
|
|
||||||
|
Attribution is by the agent VM's guest IP, unspoofable by construction: the
|
||||||
|
/31 point-to-point TAP + the `bot_bottle_fc` nft table ensure only the
|
||||||
|
expected VM can source-IP that address.
|
||||||
|
|
||||||
|
Sequence:
|
||||||
|
1. ensure the infra VM (control plane + gateway) is up (a singleton — a
|
||||||
|
prior launcher may already have booted it);
|
||||||
|
2. register the bottle by its guest IP (attribution key) → bottle id +
|
||||||
|
identity token;
|
||||||
|
3. provision its git-gate repos/creds into the gateway VM (over SSH);
|
||||||
|
4. fetch the shared gateway CA for the provisioner to install in the rootfs.
|
||||||
|
|
||||||
|
The TAP slot allocation, rootfs build, and VM boot are the caller's job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from ...egress import EgressPlan
|
||||||
|
from ...git_gate import GitGatePlan
|
||||||
|
from ...orchestrator.client import OrchestratorClient
|
||||||
|
from ...orchestrator.lifecycle import (
|
||||||
|
OrchestratorStartError, # re-exported so callers can catch it
|
||||||
|
)
|
||||||
|
from ...orchestrator.registration import registration_inputs
|
||||||
|
from ..docker.gateway_provision import deprovision_git_gate, provision_git_gate
|
||||||
|
from . import infra_vm
|
||||||
|
|
||||||
|
|
||||||
|
class ConsolidatedLaunchError(RuntimeError):
|
||||||
|
"""The consolidated register/provision sequence could not complete."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LaunchContext:
|
||||||
|
"""What the Firecracker launch needs from the consolidated sequence."""
|
||||||
|
|
||||||
|
bottle_id: str
|
||||||
|
identity_token: str
|
||||||
|
source_ip: str # the VM's guest IP — the attribution key
|
||||||
|
gateway_ca_pem: str # the shared gateway CA the provisioner installs
|
||||||
|
orchestrator_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def launch_consolidated(
|
||||||
|
egress_plan: EgressPlan,
|
||||||
|
git_gate_plan: GitGatePlan,
|
||||||
|
*,
|
||||||
|
guest_ip: str,
|
||||||
|
image_ref: str = "",
|
||||||
|
tokens: dict[str, str] | None = None,
|
||||||
|
) -> LaunchContext:
|
||||||
|
"""Ensure the infra VM is up, register the bottle by its guest IP, and
|
||||||
|
provision its git-gate state into the gateway VM. Returns the context the
|
||||||
|
agent-VM launch needs. Raises on failure — the caller tears down."""
|
||||||
|
infra = infra_vm.ensure_running()
|
||||||
|
url = infra.control_plane_url
|
||||||
|
client = OrchestratorClient(url)
|
||||||
|
|
||||||
|
inputs = registration_inputs(egress_plan)
|
||||||
|
reg = client.register_bottle(
|
||||||
|
guest_ip, image_ref=image_ref, policy=inputs.policy,
|
||||||
|
metadata=inputs.metadata, tokens=tokens,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
provision_git_gate(
|
||||||
|
infra_vm.gateway_transport(), reg.bottle_id, git_gate_plan)
|
||||||
|
except Exception:
|
||||||
|
client.teardown_bottle(reg.bottle_id)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# The shared gateway CA every agent on this host trusts for TLS
|
||||||
|
# interception — fetched from the infra VM over SSH.
|
||||||
|
return LaunchContext(
|
||||||
|
bottle_id=reg.bottle_id,
|
||||||
|
identity_token=reg.identity_token,
|
||||||
|
source_ip=guest_ip,
|
||||||
|
gateway_ca_pem=infra.gateway_ca_pem(),
|
||||||
|
orchestrator_url=url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_consolidated(bottle_id: str, *, orchestrator_url: str) -> None:
|
||||||
|
"""Deregister the bottle and remove its git-gate state from the gateway
|
||||||
|
VM. Both steps are idempotent so this is safe from a cleanup trap. Does
|
||||||
|
NOT stop the infra VM — it's a persistent per-host singleton shared by
|
||||||
|
every bottle."""
|
||||||
|
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||||
|
deprovision_git_gate(infra_vm.gateway_transport(), bottle_id)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LaunchContext",
|
||||||
|
"launch_consolidated",
|
||||||
|
"teardown_consolidated",
|
||||||
|
"ConsolidatedLaunchError",
|
||||||
|
"OrchestratorStartError",
|
||||||
|
]
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -78,20 +78,32 @@ def _config(
|
|||||||
vcpus: int,
|
vcpus: int,
|
||||||
mem_mib: int,
|
mem_mib: int,
|
||||||
guest_mac: str,
|
guest_mac: str,
|
||||||
|
data_drive: Path | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
drives: list[dict[str, object]] = [
|
||||||
|
{
|
||||||
|
"drive_id": "rootfs",
|
||||||
|
"path_on_host": str(rootfs),
|
||||||
|
"is_root_device": True,
|
||||||
|
"is_read_only": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
# A second virtio-block device (guest /dev/vdb) — the infra VM's
|
||||||
|
# persistent registry "volume", a host-side ext4 file that outlives the
|
||||||
|
# ephemeral rootfs across VM restarts.
|
||||||
|
if data_drive is not None:
|
||||||
|
drives.append({
|
||||||
|
"drive_id": "data",
|
||||||
|
"path_on_host": str(data_drive),
|
||||||
|
"is_root_device": False,
|
||||||
|
"is_read_only": False,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
"boot-source": {
|
"boot-source": {
|
||||||
"kernel_image_path": str(util.kernel_path()),
|
"kernel_image_path": str(util.kernel_path()),
|
||||||
"boot_args": _boot_args(guest_ip, host_ip, pubkey),
|
"boot_args": _boot_args(guest_ip, host_ip, pubkey),
|
||||||
},
|
},
|
||||||
"drives": [
|
"drives": drives,
|
||||||
{
|
|
||||||
"drive_id": "rootfs",
|
|
||||||
"path_on_host": str(rootfs),
|
|
||||||
"is_root_device": True,
|
|
||||||
"is_read_only": False,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"network-interfaces": [
|
"network-interfaces": [
|
||||||
{
|
{
|
||||||
"iface_id": "eth0",
|
"iface_id": "eth0",
|
||||||
@@ -118,9 +130,16 @@ def boot(
|
|||||||
vcpus: int = 2,
|
vcpus: int = 2,
|
||||||
mem_mib: int = 2048,
|
mem_mib: int = 2048,
|
||||||
guest_mac: str = "06:00:AC:10:00:02",
|
guest_mac: str = "06:00:AC:10:00:02",
|
||||||
|
detached: bool = False,
|
||||||
|
data_drive: Path | None = None,
|
||||||
) -> VmHandle:
|
) -> VmHandle:
|
||||||
"""Write the config and launch the VMM. Returns once the process is
|
"""Write the config and launch the VMM. Returns once the process is
|
||||||
spawned; callers wait for SSH readiness separately."""
|
spawned; callers wait for SSH readiness separately.
|
||||||
|
|
||||||
|
`detached` starts the VMM in its own session (`start_new_session`) so it
|
||||||
|
survives the launcher exiting — used for the persistent per-host infra
|
||||||
|
VM, which must outlive the short-lived `start` process (agent VMs stay
|
||||||
|
attached and are torn down with the launcher)."""
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
config_path = run_dir / "config.json"
|
config_path = run_dir / "config.json"
|
||||||
console_log = run_dir / "console.log"
|
console_log = run_dir / "console.log"
|
||||||
@@ -128,6 +147,7 @@ def boot(
|
|||||||
_config(
|
_config(
|
||||||
rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip,
|
rootfs=rootfs, tap=tap, guest_ip=guest_ip, host_ip=host_ip,
|
||||||
pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac,
|
pubkey=pubkey, vcpus=vcpus, mem_mib=mem_mib, guest_mac=guest_mac,
|
||||||
|
data_drive=data_drive,
|
||||||
),
|
),
|
||||||
indent=2,
|
indent=2,
|
||||||
))
|
))
|
||||||
@@ -137,6 +157,7 @@ def boot(
|
|||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
["firecracker", "--no-api", "--config-file", str(config_path)],
|
["firecracker", "--no-api", "--config-file", str(config_path)],
|
||||||
stdout=log_fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
stdout=log_fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||||
|
start_new_session=detached,
|
||||||
)
|
)
|
||||||
return VmHandle(process=process, guest_ip=guest_ip, console_log=console_log)
|
return VmHandle(process=process, guest_ip=guest_ip, console_log=console_log)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""FirecrackerFreezer — snapshot a running microVM to a Docker image.
|
"""FirecrackerFreezer — snapshot a running microVM to a rootfs tar.
|
||||||
|
|
||||||
The VM is live and can't be block-copied safely, so — like the macOS
|
The VM is live and can't be block-copied safely, so — like the macOS
|
||||||
backend — we stream the guest root filesystem out over the control
|
backend — we stream the guest root filesystem out over the control
|
||||||
channel (SSH here) and rebuild an image from it. The bottle keeps
|
channel (SSH here). Unlike the other backends this needs no Docker: the
|
||||||
running after the snapshot.
|
tar *is* the resumable artifact. `resume` extracts it and rebuilds a
|
||||||
|
fresh per-bottle ext4 with `mke2fs -d` (see `util.build_committed_rootfs_dir`
|
||||||
|
and `launch._build_agent_base`). The bottle keeps running after the
|
||||||
|
snapshot.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,9 +14,9 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...bottle_state import committed_rootfs_path
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
from .. import ActiveAgent
|
from .. import ActiveAgent
|
||||||
from ..freeze import Freezer
|
from ..freeze import Freezer
|
||||||
@@ -30,14 +33,13 @@ class FirecrackerFreezer(Freezer):
|
|||||||
if not private_key.is_file() or not guest_ip:
|
if not private_key.is_file() or not guest_ip:
|
||||||
die(f"cannot freeze {agent.slug}: run dir {run_dir} is missing the "
|
die(f"cannot freeze {agent.slug}: run dir {run_dir} is missing the "
|
||||||
f"SSH key or VM config (is the bottle still running?)")
|
f"SSH key or VM config (is the bottle still running?)")
|
||||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
tar_path = committed_rootfs_path(agent.slug)
|
||||||
_commit_via_ssh(private_key, guest_ip, image_tag)
|
_commit_rootfs_via_ssh(private_key, guest_ip, tar_path)
|
||||||
info(f"committed {agent.slug} -> {image_tag!r}")
|
info(f"committed {agent.slug} -> {tar_path}")
|
||||||
return image_tag
|
return str(tar_path)
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||||
info(f"to export for migration: docker image save {image_ref} "
|
info(f"to export for migration: cp {image_ref} {slug}.tar")
|
||||||
f"-o {slug}.tar")
|
|
||||||
|
|
||||||
|
|
||||||
def _guest_ip_from_config(config_path: Path) -> str:
|
def _guest_ip_from_config(config_path: Path) -> str:
|
||||||
@@ -53,24 +55,36 @@ def _guest_ip_from_config(config_path: Path) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _commit_via_ssh(private_key: Path, guest_ip: str, image_tag: str) -> None:
|
def _commit_rootfs_via_ssh(private_key: Path, guest_ip: str, tar_path: Path) -> None:
|
||||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-fc-commit.") as tmp:
|
"""Stream the guest rootfs out over SSH into `tar_path`. Excludes the
|
||||||
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
virtual/live mounts (proc/sys/dev/run) — resume recreates those empty
|
||||||
ssh = util.ssh_base_argv(private_key, guest_ip)
|
mount points. Written to a `.partial` sibling and renamed on success so
|
||||||
with open(rootfs_tar, "wb") as tar_out:
|
a failed freeze never leaves a truncated artifact in its place."""
|
||||||
result = subprocess.run(
|
tar_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
[*ssh, "--", "tar", "--create", "--one-file-system",
|
partial = tar_path.with_name(tar_path.name + ".partial")
|
||||||
"--exclude=./proc", "--exclude=./sys", "--exclude=./dev",
|
ssh = util.ssh_base_argv(private_key, guest_ip)
|
||||||
"--exclude=./run", "--file=-", "--directory=/", "."],
|
# The snapshot can contain the bottle's private workspace, so keep it
|
||||||
stdout=tar_out, stderr=subprocess.PIPE, check=False,
|
# owner-only (0600) for the whole stream. The `os.open` mode only applies
|
||||||
)
|
# on *creation*, so unlink any leftover partial (a prior interrupted run
|
||||||
if result.returncode != 0:
|
# could have left it world-readable, or something could swap in a symlink
|
||||||
die(f"ssh tar for {guest_ip} failed: "
|
# at this predictable name) and exclusively recreate it — O_EXCL|O_NOFOLLOW
|
||||||
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}")
|
# — then fchmod immediately so umask can't loosen it. Re-assert after the
|
||||||
with open(os.path.join(tmp, "Dockerfile"), "w", encoding="utf-8") as f:
|
# rename too (os.replace carries the source mode, but be explicit).
|
||||||
f.write("FROM scratch\nADD rootfs.tar /\nUSER node\nWORKDIR /home/node\n")
|
partial.unlink(missing_ok=True)
|
||||||
build = subprocess.run(
|
fd = os.open(
|
||||||
["docker", "build", "-t", image_tag, tmp], check=False,
|
partial, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600
|
||||||
|
)
|
||||||
|
os.fchmod(fd, 0o600)
|
||||||
|
with os.fdopen(fd, "wb") as tar_out:
|
||||||
|
result = subprocess.run(
|
||||||
|
[*ssh, "--", "tar", "--create", "--one-file-system",
|
||||||
|
"--exclude=./proc", "--exclude=./sys", "--exclude=./dev",
|
||||||
|
"--exclude=./run", "--file=-", "--directory=/", "."],
|
||||||
|
stdout=tar_out, stderr=subprocess.PIPE, check=False,
|
||||||
)
|
)
|
||||||
if build.returncode != 0:
|
if result.returncode != 0:
|
||||||
die(f"docker build for {image_tag!r} failed")
|
partial.unlink(missing_ok=True)
|
||||||
|
die(f"ssh tar for {guest_ip} failed: "
|
||||||
|
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}")
|
||||||
|
os.replace(partial, tar_path)
|
||||||
|
os.chmod(tar_path, 0o600)
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Docker-free agent-image builds for the Firecracker backend (PRD 0069 Stage 3).
|
||||||
|
|
||||||
|
Agent Dockerfiles build **inside the persistent per-host infra VM**
|
||||||
|
(`infra_vm.py`), which carries buildah (rootless, daemonless): no host Docker
|
||||||
|
daemon, no root-equivalent `docker` group. The build runs over SSH against the
|
||||||
|
infra VM and its rootfs streams back to the host, where the existing
|
||||||
|
`mke2fs -d` path (`util.build_rootfs_ext4`) turns it into a bootable ext4.
|
||||||
|
|
||||||
|
Building in the infra VM — rather than a throwaway builder VM — means there is
|
||||||
|
one buildah image (`bot-bottle-infra`) and no contention for the orchestrator
|
||||||
|
TAP. Tradeoff: an untrusted Dockerfile's `RUN` steps share the VM with the
|
||||||
|
control plane + gateway (buildah `--isolation chroot` isn't a hard boundary) —
|
||||||
|
the accepted single-VM blast-radius tradeoff, re-splittable into a disposable
|
||||||
|
builder (booted from this same image on its own TAP) later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from ...log import die, info
|
||||||
|
from . import infra_vm, util
|
||||||
|
|
||||||
|
# vfs + chroot: buildah works as root in the microVM (no fuse-overlayfs /
|
||||||
|
# overlay module / subuid maps). `--isolation` is a build/run-only flag;
|
||||||
|
# `from`/`mount` take just the store.
|
||||||
|
_BUILD_FLAGS = "--isolation chroot --storage-driver vfs"
|
||||||
|
_STORE_FLAG = "--storage-driver vfs"
|
||||||
|
|
||||||
|
_BUILD_TIMEOUT_SECONDS = 900.0
|
||||||
|
|
||||||
|
|
||||||
|
def _dockerfile_hash(dockerfile: Path) -> str:
|
||||||
|
"""Cache key: the Dockerfile's content. The shipped agent Dockerfiles
|
||||||
|
COPY nothing from the build context (see .dockerignore), so their content
|
||||||
|
fully determines the image; a Dockerfile that adds COPY will want the
|
||||||
|
context folded in here too."""
|
||||||
|
return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def build_agent_rootfs_dir(
|
||||||
|
dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (),
|
||||||
|
) -> Path:
|
||||||
|
"""Build `dockerfile` in the infra VM (buildah, no host docker), export its
|
||||||
|
rootfs, inject the guest boot bits, and return the cached base dir — the
|
||||||
|
same shape `util.build_rootfs_ext4` consumes. Cached by Dockerfile content,
|
||||||
|
so a repeat launch skips the rebuild.
|
||||||
|
|
||||||
|
`smoke_test` (the provider's declared argv, e.g. `("claude","--version")`)
|
||||||
|
is run in the freshly built image before export, catching an npm
|
||||||
|
silent-failure image at build time rather than at first agent use."""
|
||||||
|
digest = _dockerfile_hash(dockerfile)
|
||||||
|
base = util.cache_dir() / "rootfs" / f"agent-{digest}"
|
||||||
|
if (base / ".bb-ready").is_file():
|
||||||
|
info(f"using cached agent rootfs {base.name}")
|
||||||
|
return base
|
||||||
|
|
||||||
|
# Serialize builds: the infra VM's buildah store + this cache dir are
|
||||||
|
# shared, so concurrent `start`s must not build into them at once. The
|
||||||
|
# lock covers the cache lookup + build + atomic publish; the ready
|
||||||
|
# fast-path above takes no lock.
|
||||||
|
with _build_lock():
|
||||||
|
if (base / ".bb-ready").is_file(): # another build finished while we waited
|
||||||
|
info(f"using cached agent rootfs {base.name}")
|
||||||
|
return base
|
||||||
|
# Build into a temp dir and publish by atomic rename, so a partial
|
||||||
|
# build is never visible as `agent-<digest>`.
|
||||||
|
staging = util.cache_dir() / "rootfs" / f".building-{digest}"
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
staging.mkdir(parents=True)
|
||||||
|
info(f"building agent image {image_tag!r} in the infra VM")
|
||||||
|
_build_in_infra(dockerfile, staging, smoke_test, digest)
|
||||||
|
util.inject_guest_boot(staging)
|
||||||
|
(staging / ".bb-ready").write_text("ok\n")
|
||||||
|
shutil.rmtree(base, ignore_errors=True)
|
||||||
|
os.rename(staging, base)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _build_lock() -> Generator[None, None, None]:
|
||||||
|
"""Host-level exclusive lock serializing agent-image builds (shared infra
|
||||||
|
buildah store + cache dir). flock auto-releases on a crash."""
|
||||||
|
lock_path = util.cache_dir() / "rootfs" / ".build.lock"
|
||||||
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handle = open(lock_path, "w", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_in_infra(
|
||||||
|
dockerfile: Path, base: Path, smoke_test: tuple[str, ...], digest: str,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure the infra VM is up, `buildah build` the Dockerfile in it, smoke
|
||||||
|
test the image, and stream its rootfs into `base`. The infra VM persists;
|
||||||
|
only the per-build container/image/context are cleaned up."""
|
||||||
|
infra = infra_vm.ensure_running()
|
||||||
|
key, ip = infra.private_key, infra.guest_ip
|
||||||
|
tag = f"bot-bottle-agent-build-{digest}"
|
||||||
|
ctx = f"/tmp/agent-build-{digest}"
|
||||||
|
smoke_ctr, export_ctr = f"{tag}-smoke", f"{tag}-export"
|
||||||
|
|
||||||
|
def _cleanup() -> None:
|
||||||
|
# Remove only THIS build's working containers/image/context — never
|
||||||
|
# `buildah rm -a`, which would nuke a concurrent build's container.
|
||||||
|
_ssh(key, ip,
|
||||||
|
f"buildah rm {smoke_ctr} {export_ctr} >/dev/null 2>&1; "
|
||||||
|
f"buildah rmi {_STORE_FLAG} {tag} >/dev/null 2>&1; rm -rf {ctx}",
|
||||||
|
timeout=60)
|
||||||
|
|
||||||
|
_cleanup() # clear leftovers from a crashed prior build of this digest
|
||||||
|
try:
|
||||||
|
prep = _ssh(key, ip, f"mkdir -p {ctx}/ctx")
|
||||||
|
if prep.returncode != 0:
|
||||||
|
die(f"preparing build dir in the infra VM failed: {prep.stderr.strip()}")
|
||||||
|
_send_dockerfile(key, ip, dockerfile, ctx)
|
||||||
|
_buildah_build(key, ip, ctx, tag)
|
||||||
|
_smoke_test(key, ip, tag, smoke_ctr, smoke_test)
|
||||||
|
_stream_rootfs(key, ip, tag, export_ctr, base)
|
||||||
|
finally:
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh(private_key: Path, guest_ip: str, script: str,
|
||||||
|
*, timeout: float = 60.0) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
util.ssh_base_argv(private_key, guest_ip) + [script],
|
||||||
|
capture_output=True, text=True, timeout=timeout, check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_streamed(private_key: Path, guest_ip: str, script: str,
|
||||||
|
*, timeout: float) -> int:
|
||||||
|
"""Run an SSH command letting the remote's stdout/stderr flow straight to
|
||||||
|
ours (no capture), for long chatty steps where live progress beats a
|
||||||
|
silent wait. Returns the exit code."""
|
||||||
|
proc = subprocess.run(
|
||||||
|
util.ssh_base_argv(private_key, guest_ip) + [script],
|
||||||
|
timeout=timeout, check=False,
|
||||||
|
)
|
||||||
|
return proc.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def _send_dockerfile(private_key: Path, guest_ip: str, dockerfile: Path, ctx: str) -> None:
|
||||||
|
proc = subprocess.run(
|
||||||
|
util.ssh_base_argv(private_key, guest_ip) + [f"cat > {ctx}/Dockerfile"],
|
||||||
|
input=dockerfile.read_bytes(), capture_output=True, timeout=30, check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
die(f"sending Dockerfile to the infra VM failed: "
|
||||||
|
f"{proc.stderr.decode(errors='replace').strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def _buildah_build(private_key: Path, guest_ip: str, ctx: str, tag: str) -> None:
|
||||||
|
# Stream buildah's step-by-step output straight to our stderr (like the
|
||||||
|
# docker backend's `docker build`), so a long first build (base pull +
|
||||||
|
# apt/npm installs) shows live progress instead of a silent wait. The
|
||||||
|
# remote stderr is where buildah writes its `STEP i/n` lines.
|
||||||
|
info(f"buildah build {tag} in the infra VM (streaming output)")
|
||||||
|
rc = _ssh_streamed(
|
||||||
|
private_key, guest_ip,
|
||||||
|
f"buildah build {_BUILD_FLAGS} -t {tag} -f {ctx}/Dockerfile {ctx}/ctx",
|
||||||
|
timeout=_BUILD_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
die(f"buildah build in the infra VM failed (exit {rc}); "
|
||||||
|
"see the build output above.")
|
||||||
|
|
||||||
|
|
||||||
|
def _smoke_test(private_key: Path, guest_ip: str, tag: str, ctr: str,
|
||||||
|
argv: tuple[str, ...]) -> None:
|
||||||
|
"""Run the provider's smoke argv inside the freshly built image
|
||||||
|
(`buildah run`, which uses the image's own PATH), failing the build
|
||||||
|
loudly if the CLI is a broken stub. No-op without a declared test. Uses a
|
||||||
|
named working container (`ctr`) so cleanup is scoped to this build."""
|
||||||
|
if not argv:
|
||||||
|
return
|
||||||
|
cmd = (
|
||||||
|
f"set -e; buildah from {_STORE_FLAG} --name {ctr} {tag} >/dev/null; "
|
||||||
|
f"buildah run {_BUILD_FLAGS} {ctr} -- {' '.join(argv)}; rc=$?; "
|
||||||
|
f"buildah rm {ctr} >/dev/null 2>&1 || true; exit $rc"
|
||||||
|
)
|
||||||
|
result = _ssh(private_key, guest_ip, cmd, timeout=120)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stdout + result.stderr).strip().splitlines()[-10:]
|
||||||
|
die(f"agent image failed its post-build smoke test "
|
||||||
|
f"({' '.join(argv)}):\n" + "\n".join(detail))
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_rootfs(private_key: Path, guest_ip: str, tag: str, ctr: str, base: Path) -> None:
|
||||||
|
"""`buildah mount` the built image in the infra VM and pipe its rootfs tar
|
||||||
|
straight into `base` on the host (extracted as the non-root host user, so
|
||||||
|
uid 0 isn't preserved — the guest init restores /root ownership). Uses a
|
||||||
|
named working container so cleanup is scoped to this build."""
|
||||||
|
export = (
|
||||||
|
f"set -e; buildah from {_STORE_FLAG} --name {ctr} {tag} >/dev/null; "
|
||||||
|
f"mnt=$(buildah mount {_STORE_FLAG} {ctr}); "
|
||||||
|
f"tar -C \"$mnt\" -cf - ."
|
||||||
|
)
|
||||||
|
ssh_proc = subprocess.Popen(
|
||||||
|
util.ssh_base_argv(private_key, guest_ip) + [export],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
assert ssh_proc.stdout is not None
|
||||||
|
untar = subprocess.run(
|
||||||
|
["tar", "-x", "-C", str(base)], stdin=ssh_proc.stdout, check=False,
|
||||||
|
)
|
||||||
|
ssh_proc.stdout.close()
|
||||||
|
ssh_err = (ssh_proc.stderr.read().decode(errors="replace")
|
||||||
|
if ssh_proc.stderr else "")
|
||||||
|
rc = ssh_proc.wait()
|
||||||
|
if rc != 0 or untar.returncode != 0:
|
||||||
|
die(f"exporting the built rootfs from the infra VM failed: "
|
||||||
|
f"{ssh_err.strip() or '<no stderr>'}")
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Prebuilt infra-VM rootfs, pulled as an artifact (PRD 0069 Stage 2).
|
||||||
|
|
||||||
|
The Firecracker infra VM boots a fixed rootfs (orchestrator control plane +
|
||||||
|
gateway + buildah, control-plane init as PID 1) that does not vary per launch —
|
||||||
|
the per-boot bits (authorized_keys, guest IP) ride the kernel cmdline, so one
|
||||||
|
rootfs boots on any host. Instead of building that rootfs on the launch host
|
||||||
|
with Docker, we build it **off-host** and publish it as a versioned, ready-to-
|
||||||
|
boot ext4 (gzip-compressed) to a Gitea **generic package**; the launch host
|
||||||
|
downloads + verifies + boots it. No Docker, no image tooling on the launch
|
||||||
|
host — just an HTTP fetch and gunzip.
|
||||||
|
|
||||||
|
publish (off-host, see publish_infra.py):
|
||||||
|
docker build -> rootfs dir -> mke2fs -> gzip -> PUT generic package
|
||||||
|
pull (this module, launch host):
|
||||||
|
GET .../rootfs.ext4.gz (+ .sha256) -> verify -> gunzip -> boot
|
||||||
|
|
||||||
|
The artifact **version** is a content hash of everything baked into the rootfs
|
||||||
|
(the shipped bot_bottle package, the three Dockerfiles, and the init), so a
|
||||||
|
launch host always pulls the artifact matching its code and a content change
|
||||||
|
can't silently boot a stale rootfs. A checksum mismatch fails closed.
|
||||||
|
|
||||||
|
Set `BOT_BOTTLE_INFRA_BUILD=local` to skip the pull and build the rootfs
|
||||||
|
locally with Docker (dev iteration on the Dockerfiles) — see `infra_vm`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import die, info
|
||||||
|
from . import util
|
||||||
|
|
||||||
|
# Bump if the on-disk artifact *format* changes (compression, layout) so a new
|
||||||
|
# scheme can't collide with a cached/published artifact of the old one.
|
||||||
|
_ARTIFACT_FORMAT = "1"
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
_DOCKERFILES = ("Dockerfile.orchestrator", "Dockerfile.gateway", "Dockerfile.infra")
|
||||||
|
|
||||||
|
_DEFAULT_BASE = "https://gitea.dideric.is"
|
||||||
|
_DEFAULT_OWNER = "didericis"
|
||||||
|
_PACKAGE = "bot-bottle-firecracker-infra"
|
||||||
|
|
||||||
|
# Streaming copy chunk for the (hundreds-of-MB) download.
|
||||||
|
_CHUNK = 1 << 20
|
||||||
|
|
||||||
|
|
||||||
|
def local_build_requested() -> bool:
|
||||||
|
"""True when the operator opted into the dev Docker-build path instead of
|
||||||
|
pulling the published artifact (`BOT_BOTTLE_INFRA_BUILD=local`)."""
|
||||||
|
return os.environ.get("BOT_BOTTLE_INFRA_BUILD", "").strip().lower() == "local"
|
||||||
|
|
||||||
|
|
||||||
|
def infra_artifact_version(init_script: str, *, repo_root: Path = _REPO_ROOT) -> str:
|
||||||
|
"""Content hash (16 hex) of everything baked into the infra rootfs: the
|
||||||
|
whole shipped `bot_bottle` package, the three fixed Dockerfiles, and the
|
||||||
|
guest init. Deterministic across the publish host and the launch host when
|
||||||
|
both run the same checkout, so the tag the launch host pulls is exactly the
|
||||||
|
tag publish produced.
|
||||||
|
|
||||||
|
The package is `COPY bot_bottle /app/bot_bottle`'d wholesale into the image,
|
||||||
|
so hash *every* regular file under it — not just `*.py`. Non-Python inputs
|
||||||
|
(e.g. `egress_entrypoint.sh`, `netpool.defaults.env`) are baked in too, and
|
||||||
|
a change to one must bump the version or a launch host could boot a stale
|
||||||
|
rootfs whose code differs from its checkout. `__pycache__`/`.pyc` are the
|
||||||
|
only exclusions — build artifacts, never copied."""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
h.update(f"format={_ARTIFACT_FORMAT}\n".encode())
|
||||||
|
pkg = repo_root / "bot_bottle"
|
||||||
|
for path in sorted(pkg.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
if "__pycache__" in path.parts or path.suffix == ".pyc":
|
||||||
|
continue
|
||||||
|
h.update(str(path.relative_to(repo_root)).encode())
|
||||||
|
h.update(b"\0")
|
||||||
|
h.update(path.read_bytes())
|
||||||
|
for name in _DOCKERFILES:
|
||||||
|
h.update(name.encode())
|
||||||
|
h.update(b"\0")
|
||||||
|
h.update((repo_root / name).read_bytes())
|
||||||
|
h.update(b"init\0")
|
||||||
|
h.update(init_script.encode())
|
||||||
|
return h.hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> tuple[str, str, str]:
|
||||||
|
"""(base_url, owner, token) for the generic-package endpoint. Base + owner
|
||||||
|
are overridable for other deployments / mirrors; the token comes solely from
|
||||||
|
`BOT_BOTTLE_INFRA_ARTIFACT_TOKEN` (a dedicated package-scoped token, kept
|
||||||
|
separate from the general-purpose Gitea token) and is optional — a public
|
||||||
|
package needs none to pull."""
|
||||||
|
base = os.environ.get("BOT_BOTTLE_INFRA_ARTIFACT_BASE", _DEFAULT_BASE).rstrip("/")
|
||||||
|
owner = os.environ.get("BOT_BOTTLE_INFRA_ARTIFACT_OWNER", _DEFAULT_OWNER)
|
||||||
|
token = os.environ.get("BOT_BOTTLE_INFRA_ARTIFACT_TOKEN", "")
|
||||||
|
return base, owner, token
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_url(version: str, filename: str) -> str:
|
||||||
|
"""The generic-package download URL for one file of this version's
|
||||||
|
artifact (`rootfs.ext4.gz` / `rootfs.ext4.gz.sha256`)."""
|
||||||
|
base, owner, _ = _config()
|
||||||
|
return f"{base}/api/packages/{owner}/generic/{_PACKAGE}/{version}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
_GZ_NAME = "rootfs.ext4.gz"
|
||||||
|
_SHA_NAME = "rootfs.ext4.gz.sha256"
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_root(version: str) -> Path:
|
||||||
|
return util.cache_dir() / "infra-artifact" / version
|
||||||
|
|
||||||
|
|
||||||
|
def _open(url: str) -> urllib.request.Request:
|
||||||
|
_, _, token = _config()
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url: str, dest: Path) -> None:
|
||||||
|
"""Stream `url` to `dest` (atomic via a `.part` sibling)."""
|
||||||
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(_open(url)) as resp, open(tmp, "wb") as out:
|
||||||
|
shutil.copyfileobj(resp, out, _CHUNK)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
if e.code == 404:
|
||||||
|
die(
|
||||||
|
f"infra artifact not published for this code version.\n"
|
||||||
|
f" missing: {url}\n"
|
||||||
|
f" publish it from a build host (Docker):\n"
|
||||||
|
f" python3 -m bot_bottle.backend.firecracker.publish_infra\n"
|
||||||
|
f" or build the rootfs locally: BOT_BOTTLE_INFRA_BUILD=local"
|
||||||
|
)
|
||||||
|
die(f"downloading infra artifact failed (HTTP {e.code}): {url}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
die(f"infra artifact registry unreachable: {url} ({e.reason})")
|
||||||
|
tmp.replace(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
for chunk in iter(lambda: fh.read(_CHUNK), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_artifact_gz(version: str) -> Path:
|
||||||
|
"""The verified, cached `rootfs.ext4.gz` for `version` — downloading it (and
|
||||||
|
its `.sha256`) once, then reusing it. Fail-closed on a checksum mismatch:
|
||||||
|
the partial is removed and we die rather than boot an unverified rootfs."""
|
||||||
|
root = _cache_root(version)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
gz = root / _GZ_NAME
|
||||||
|
ok = root / ".verified"
|
||||||
|
if gz.is_file() and ok.is_file():
|
||||||
|
return gz
|
||||||
|
|
||||||
|
info(f"pulling infra rootfs artifact {_PACKAGE}/{version}")
|
||||||
|
_download(artifact_url(version, _GZ_NAME), gz)
|
||||||
|
sha = root / _SHA_NAME
|
||||||
|
_download(artifact_url(version, _SHA_NAME), sha)
|
||||||
|
|
||||||
|
expected = sha.read_text().split()[0].strip().lower()
|
||||||
|
actual = _sha256_file(gz)
|
||||||
|
if actual != expected:
|
||||||
|
gz.unlink(missing_ok=True)
|
||||||
|
sha.unlink(missing_ok=True)
|
||||||
|
die(
|
||||||
|
f"infra artifact checksum mismatch for {version}:\n"
|
||||||
|
f" expected {expected}\n"
|
||||||
|
f" actual {actual}\n"
|
||||||
|
f" refusing to boot an unverified rootfs."
|
||||||
|
)
|
||||||
|
ok.write_text("ok\n")
|
||||||
|
return gz
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_ext4(version: str, dest: Path) -> None:
|
||||||
|
"""Ensure the verified artifact is cached, then gunzip it to `dest` — a
|
||||||
|
fresh, writable per-boot rootfs (the VM mutates it; the cached `.gz` stays
|
||||||
|
pristine). Atomic via a `.part` sibling."""
|
||||||
|
gz = ensure_artifact_gz(version)
|
||||||
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||||
|
info(f"expanding infra rootfs -> {dest}")
|
||||||
|
with gzip.open(gz, "rb") as src, open(tmp, "wb") as out:
|
||||||
|
shutil.copyfileobj(src, out, _CHUNK)
|
||||||
|
tmp.replace(dest)
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
"""The per-host infra VM for the Firecracker backend (PRD 0070 Stage B).
|
||||||
|
|
||||||
|
A single persistent microVM that runs the orchestrator **control plane** (and,
|
||||||
|
in a following step, the gateway **data plane**) — the trusted per-host service
|
||||||
|
the docker backend runs as containers. It boots on the NAT'd orchestrator link
|
||||||
|
(`netpool.orch_slot()`): the host CLI reaches its control plane over HTTP at the
|
||||||
|
guest IP, and agent VMs reach its gateway ports over VM-to-VM routing.
|
||||||
|
|
||||||
|
Build-from-source (the default while the design churns): the rootfs is exported
|
||||||
|
from the locally built orchestrator image, which bakes the stdlib-only
|
||||||
|
control-plane source. A pull-from-registry mode (Gitea's OCI registry) becomes
|
||||||
|
the default later.
|
||||||
|
|
||||||
|
SSH is left enabled for debugging; the control plane is the load-bearing
|
||||||
|
surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import signal
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from ...log import die, info
|
||||||
|
from ..docker import util as docker_mod
|
||||||
|
from ..docker.gateway_provision import GatewayProvisionError
|
||||||
|
from . import firecracker_vm, infra_artifact, netpool, util
|
||||||
|
|
||||||
|
# The single infra-VM image: gateway data plane + baked control-plane source
|
||||||
|
# (Dockerfile.infra FROM the gateway image). Built from source by default;
|
||||||
|
# a pull-from-registry mode lands later.
|
||||||
|
_INFRA_IMAGE = "bot-bottle-infra:latest"
|
||||||
|
_GATEWAY_IMAGE = "bot-bottle-gateway:latest"
|
||||||
|
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
CONTROL_PLANE_PORT = 8099
|
||||||
|
# Gateway data-plane ports (agent-facing): egress proxy, supervise MCP,
|
||||||
|
# git-http. Reached by agent VMs over VM-to-VM routing (added next).
|
||||||
|
EGRESS_PORT = 9099
|
||||||
|
SUPERVISE_PORT = 9100
|
||||||
|
GIT_HTTP_PORT = 9420
|
||||||
|
# mitmproxy writes its CA here a beat after start; agents install it to trust
|
||||||
|
# the gateway's TLS interception.
|
||||||
|
_GATEWAY_CA_PATH = "/home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem"
|
||||||
|
|
||||||
|
# The infra VM makes direct upstream connections (gateway egress, and buildah
|
||||||
|
# during builds), and the kernel `ip=` cmdline sets no resolver. Public for
|
||||||
|
# now; routing DNS through a filtered path is a later refinement.
|
||||||
|
_INFRA_RESOLVER = "1.1.1.1"
|
||||||
|
|
||||||
|
_HEALTH_TIMEOUT_SECONDS = 45.0
|
||||||
|
_HEALTH_POLL_SECONDS = 0.5
|
||||||
|
_CA_TIMEOUT_SECONDS = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InfraVm:
|
||||||
|
"""A handle to the per-host infra VM: its guest IP and the stable SSH key
|
||||||
|
used to fetch the gateway CA / provision git-gate. `vm` is the live VMM
|
||||||
|
handle when this process booted it, and None when adopting a singleton a
|
||||||
|
prior launcher started (teardown then goes through the PID file)."""
|
||||||
|
|
||||||
|
guest_ip: str
|
||||||
|
private_key: Path
|
||||||
|
vm: firecracker_vm.VmHandle | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def control_plane_url(self) -> str:
|
||||||
|
return f"http://{self.guest_ip}:{CONTROL_PLANE_PORT}"
|
||||||
|
|
||||||
|
def terminate(self) -> None:
|
||||||
|
"""Stop the infra VM — via the live handle if we booted it, else the
|
||||||
|
PID file (adopting-process case)."""
|
||||||
|
if self.vm is not None:
|
||||||
|
self.vm.terminate()
|
||||||
|
else:
|
||||||
|
_kill_pidfile()
|
||||||
|
_pid_file().unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def gateway_ca_pem(self, *, timeout: float = _CA_TIMEOUT_SECONDS) -> str:
|
||||||
|
"""The gateway's mitmproxy CA (PEM) that agents install to trust its
|
||||||
|
TLS interception. Generated a moment after boot, so this polls over
|
||||||
|
SSH until it appears (mirrors DockerGateway.ca_cert_pem)."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
proc = subprocess.run(
|
||||||
|
util.ssh_base_argv(self.private_key, self.guest_ip)
|
||||||
|
+ [f"cat {_GATEWAY_CA_PATH}"],
|
||||||
|
capture_output=True, text=True, timeout=15, check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0 and "BEGIN CERTIFICATE" in proc.stdout:
|
||||||
|
return proc.stdout
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
die(f"gateway CA not available after {timeout:g}s: "
|
||||||
|
f"{proc.stderr.strip() or 'empty'}")
|
||||||
|
time.sleep(_HEALTH_POLL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_built() -> None:
|
||||||
|
"""Ensure the infra rootfs is available before boot.
|
||||||
|
|
||||||
|
Default (docker-free, PRD 0069 Stage 2): download + verify the prebuilt
|
||||||
|
rootfs artifact matching this code version (see `infra_artifact`); the
|
||||||
|
launch host needs no Docker. `BOT_BOTTLE_INFRA_BUILD=local` instead builds
|
||||||
|
the three fixed images from source with host Docker — the infra image
|
||||||
|
`COPY --from`s the orchestrator image and is `FROM` the gateway image, so
|
||||||
|
both must exist first — for iterating on the Dockerfiles."""
|
||||||
|
if infra_artifact.local_build_requested():
|
||||||
|
build_infra_images_with_docker()
|
||||||
|
return
|
||||||
|
infra_artifact.ensure_artifact_gz(
|
||||||
|
infra_artifact.infra_artifact_version(_infra_init()))
|
||||||
|
|
||||||
|
|
||||||
|
def build_infra_images_with_docker() -> None:
|
||||||
|
"""Build the three fixed images from source with host Docker: orchestrator,
|
||||||
|
gateway, then the combined infra image (`COPY --from` orchestrator, `FROM`
|
||||||
|
gateway). The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local`
|
||||||
|
mode; `publish_infra` uses it off-host to produce the published artifact."""
|
||||||
|
docker_mod.build_image(
|
||||||
|
_ORCHESTRATOR_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator")
|
||||||
|
docker_mod.build_image(
|
||||||
|
_GATEWAY_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.gateway")
|
||||||
|
docker_mod.build_image(
|
||||||
|
_INFRA_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.infra")
|
||||||
|
|
||||||
|
|
||||||
|
def build_infra_rootfs_dir() -> Path:
|
||||||
|
"""The infra VM's base rootfs: the infra image prepared with the
|
||||||
|
control-plane + gateway init as PID 1. The init's content is folded into
|
||||||
|
the cache key so an init change rebuilds the rootfs (the base image digest
|
||||||
|
alone wouldn't catch it)."""
|
||||||
|
init = _infra_init()
|
||||||
|
tag = hashlib.sha256(init.encode()).hexdigest()[:8]
|
||||||
|
return util.build_base_rootfs_dir(
|
||||||
|
_INFRA_IMAGE, variant=f"-infra-{tag}", init_script=init,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_running() -> InfraVm:
|
||||||
|
"""Idempotent per-host singleton. Adopt the infra VM if its control plane
|
||||||
|
is already healthy (a prior launcher booted it — it outlives short-lived
|
||||||
|
`start` processes); otherwise clear any stale VM and boot a fresh one.
|
||||||
|
Returns a handle usable for CA fetch / git-gate provisioning.
|
||||||
|
|
||||||
|
Concurrency-safe: the cold stop/build/boot path is serialized by a host
|
||||||
|
flock, so two simultaneous first launches don't both boot on the same
|
||||||
|
rootfs/PID. The healthy fast-path takes no lock."""
|
||||||
|
slot = netpool.orch_slot()
|
||||||
|
url = f"http://{slot.guest_ip}:{CONTROL_PLANE_PORT}"
|
||||||
|
key = _infra_dir() / "id_ed25519"
|
||||||
|
if key.exists() and _health_ok(url):
|
||||||
|
info(f"adopting running infra VM at {url}")
|
||||||
|
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
|
||||||
|
|
||||||
|
with _singleton_lock():
|
||||||
|
# Re-check under the lock: another launcher may have booted it while
|
||||||
|
# we waited for the lock (double-checked, so we adopt not re-boot).
|
||||||
|
if key.exists() and _health_ok(url):
|
||||||
|
info(f"adopting running infra VM at {url}")
|
||||||
|
return InfraVm(guest_ip=slot.guest_ip, private_key=key)
|
||||||
|
stop() # clear a stale/hung VM holding the link before booting fresh
|
||||||
|
ensure_built()
|
||||||
|
infra = boot()
|
||||||
|
wait_for_health(infra)
|
||||||
|
return infra
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _singleton_lock() -> Generator[None, None, None]:
|
||||||
|
"""Host-level exclusive lock serializing the infra VM's cold create path
|
||||||
|
(`stop`/`ensure_built`/`boot`). flock auto-releases if the launcher
|
||||||
|
crashes, so the lock is never leaked."""
|
||||||
|
lock_path = _infra_dir() / "singleton.lock"
|
||||||
|
handle = open(lock_path, "w", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def stop() -> None:
|
||||||
|
"""Stop the infra VM singleton (idempotent — absent is success)."""
|
||||||
|
_kill_pidfile()
|
||||||
|
_pid_file().unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def boot() -> InfraVm:
|
||||||
|
"""Boot the infra VM (detached, so it outlives the launcher) on the
|
||||||
|
orchestrator link, recording its PID. Prefer `ensure_running`."""
|
||||||
|
slot = netpool.orch_slot()
|
||||||
|
if not netpool.tap_present(slot.iface):
|
||||||
|
die(f"orchestrator link {slot.iface} not present.\n"
|
||||||
|
f" ./cli.py backend setup --backend=firecracker")
|
||||||
|
|
||||||
|
run_dir = _infra_dir()
|
||||||
|
rootfs = run_dir / "rootfs.ext4"
|
||||||
|
if infra_artifact.local_build_requested():
|
||||||
|
util.build_rootfs_ext4(build_infra_rootfs_dir(), rootfs, slack_mib=8192)
|
||||||
|
else:
|
||||||
|
# Prebuilt artifact already carries the buildah build slack; expand it
|
||||||
|
# to a fresh writable rootfs for this boot.
|
||||||
|
infra_artifact.materialize_ext4(
|
||||||
|
infra_artifact.infra_artifact_version(_infra_init()), rootfs)
|
||||||
|
private_key, pubkey = _stable_keypair()
|
||||||
|
|
||||||
|
info(f"booting infra VM on {slot.iface} (guest {slot.guest_ip})")
|
||||||
|
vm = firecracker_vm.boot(
|
||||||
|
name="bot-bottle-infra", rootfs=rootfs, tap=slot.iface,
|
||||||
|
guest_ip=slot.guest_ip, host_ip=slot.host_ip, pubkey=pubkey,
|
||||||
|
run_dir=run_dir, mem_mib=4096, detached=True,
|
||||||
|
data_drive=_ensure_registry_volume(),
|
||||||
|
)
|
||||||
|
_pid_file().write_text(str(vm.process.pid))
|
||||||
|
return InfraVm(guest_ip=slot.guest_ip, private_key=private_key, vm=vm)
|
||||||
|
|
||||||
|
|
||||||
|
def _infra_dir() -> Path:
|
||||||
|
d = util.cache_dir() / "infra"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _pid_file() -> Path:
|
||||||
|
return _infra_dir() / "vm.pid"
|
||||||
|
|
||||||
|
|
||||||
|
# The registry "volume": a host-side ext4 file attached to the infra VM as a
|
||||||
|
# second virtio-block device (guest /dev/vdb), mounted at the control plane's
|
||||||
|
# DB dir. It outlives the ephemeral rootfs, so the bottle registry survives an
|
||||||
|
# infra-VM restart — the firecracker analogue of a docker volume. It is a
|
||||||
|
# plain ext4 file: `sudo mount -o loop <path>` on the host (with the VM
|
||||||
|
# stopped) to inspect bot-bottle.db directly.
|
||||||
|
_REGISTRY_SIZE = "512M"
|
||||||
|
|
||||||
|
|
||||||
|
def registry_volume_path() -> Path:
|
||||||
|
return _infra_dir() / "registry.ext4"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_registry_volume() -> Path:
|
||||||
|
"""Create the empty ext4 registry volume on first use; reuse it after."""
|
||||||
|
vol = registry_volume_path()
|
||||||
|
if vol.exists():
|
||||||
|
return vol
|
||||||
|
info(f"creating infra registry volume {vol} ({_REGISTRY_SIZE})")
|
||||||
|
proc = subprocess.run(
|
||||||
|
["mke2fs", "-q", "-t", "ext4", "-F", str(vol), _REGISTRY_SIZE],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
vol.unlink(missing_ok=True)
|
||||||
|
die(f"creating registry volume failed: {proc.stderr.strip()}")
|
||||||
|
return vol
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_keypair() -> tuple[Path, str]:
|
||||||
|
"""The infra VM's SSH keypair — generated once and reused, so any later
|
||||||
|
launcher can SSH in (fetch CA / provision) even though a different process
|
||||||
|
booted the VM. The pubkey is re-injected on every boot via the cmdline."""
|
||||||
|
d = _infra_dir()
|
||||||
|
key, pub = d / "id_ed25519", d / "id_ed25519.pub"
|
||||||
|
if key.exists() and pub.exists():
|
||||||
|
return key, pub.read_text().strip()
|
||||||
|
key.unlink(missing_ok=True)
|
||||||
|
pub.unlink(missing_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["ssh-keygen", "-t", "ed25519", "-N", "", "-q", "-f", str(key),
|
||||||
|
"-C", "bot-bottle-infra"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return key, pub.read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_pidfile() -> None:
|
||||||
|
"""SIGTERM (then SIGKILL) the recorded infra VMM, if it's still ours.
|
||||||
|
Guards against a recycled PID by checking the process is firecracker."""
|
||||||
|
try:
|
||||||
|
pid = int(_pid_file().read_text().strip())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
comm = Path(f"/proc/{pid}/comm").read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
return # already gone
|
||||||
|
if comm != "firecracker":
|
||||||
|
return # PID recycled by an unrelated process
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
for _ in range(50):
|
||||||
|
if not Path(f"/proc/{pid}").exists():
|
||||||
|
return
|
||||||
|
time.sleep(0.1)
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _health_ok(url: str) -> bool:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{url}/health", timeout=1.0) as resp:
|
||||||
|
return resp.status == 200
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SshGatewayTransport:
|
||||||
|
"""`GatewayTransport` for the gateway running in the infra VM — the docker
|
||||||
|
exec/cp equivalents over SSH (dropbear + the stable infra key)."""
|
||||||
|
|
||||||
|
def __init__(self, private_key: Path, guest_ip: str) -> None:
|
||||||
|
self._key = private_key
|
||||||
|
self._ip = guest_ip
|
||||||
|
|
||||||
|
def exec(self, argv: list[str]) -> None:
|
||||||
|
proc = subprocess.run(
|
||||||
|
util.ssh_base_argv(self._key, self._ip) + [shlex.join(argv)],
|
||||||
|
capture_output=True, text=True, timeout=60, check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise GatewayProvisionError(
|
||||||
|
f"infra gateway exec {argv!r} failed: {proc.stderr.strip()}")
|
||||||
|
|
||||||
|
def cp_into(self, src: str, dest: str) -> None:
|
||||||
|
# Preserve the source mode (docker cp does): the access-hook is staged
|
||||||
|
# 0700 and git-http execs it directly — a plain `cat >` would land it
|
||||||
|
# 0644 and the exec fails with EACCES; keys stay 0600.
|
||||||
|
mode = stat.S_IMODE(os.stat(src).st_mode)
|
||||||
|
q = shlex.quote(dest)
|
||||||
|
proc = subprocess.run(
|
||||||
|
util.ssh_base_argv(self._key, self._ip)
|
||||||
|
+ [f"cat > {q} && chmod {mode:o} {q}"],
|
||||||
|
input=Path(src).read_bytes(), capture_output=True, timeout=30, check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise GatewayProvisionError(
|
||||||
|
f"infra gateway cp {src} -> {dest} failed: "
|
||||||
|
f"{proc.stderr.decode(errors='replace').strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_transport() -> SshGatewayTransport:
|
||||||
|
"""git-gate provisioning transport for the gateway in the infra VM, built
|
||||||
|
from the stable key + the orchestrator link's guest IP. Needs no live VM
|
||||||
|
handle, so teardown can use it too."""
|
||||||
|
return SshGatewayTransport(
|
||||||
|
_infra_dir() / "id_ed25519", netpool.orch_slot().guest_ip)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_health(
|
||||||
|
infra: InfraVm, *, timeout: float = _HEALTH_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
"""Poll the control plane's /health until it answers 200 or the deadline
|
||||||
|
passes. Dies (with the console tail) if the VMM exits early."""
|
||||||
|
url = f"{infra.control_plane_url}/health"
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if infra.vm is not None and not infra.vm.is_alive():
|
||||||
|
die(f"infra VM exited during boot (rc={infra.vm.process.returncode}).\n"
|
||||||
|
f"{firecracker_vm._console_tail(infra.vm.console_log)}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=1.0) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
info(f"infra control plane healthy at {infra.control_plane_url}")
|
||||||
|
return
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
|
pass
|
||||||
|
time.sleep(_HEALTH_POLL_SECONDS)
|
||||||
|
tail = (firecracker_vm._console_tail(infra.vm.console_log)
|
||||||
|
if infra.vm is not None else "")
|
||||||
|
die(f"infra control plane at {url} did not become healthy within "
|
||||||
|
f"{timeout:.0f}s.\n{tail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _infra_init() -> str:
|
||||||
|
"""PID-1 init for the infra VM: mount the pseudo-filesystems, wire a
|
||||||
|
resolver, start dropbear (debug SSH), then launch the control plane and
|
||||||
|
the gateway data plane (multi-tenant against the local control plane)."""
|
||||||
|
return f"""#!/bin/sh
|
||||||
|
# bot-bottle Firecracker infra VM init (PID 1).
|
||||||
|
mount -t proc proc /proc 2>/dev/null
|
||||||
|
mount -t sysfs sys /sys 2>/dev/null
|
||||||
|
mount -t devtmpfs dev /dev 2>/dev/null
|
||||||
|
mkdir -p /dev/pts && mount -t devpts devpts /dev/pts 2>/dev/null
|
||||||
|
mount -o remount,rw / 2>/dev/null
|
||||||
|
|
||||||
|
# Export a real PATH: a bare-init shell resolves its own execs via a
|
||||||
|
# built-in default path, but that isn't in the *environment*, so
|
||||||
|
# gateway_init's subprocess daemons (spawned as `python3 ...`) would
|
||||||
|
# inherit no PATH and fail to find python3. Export it for all children.
|
||||||
|
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
|
# Direct upstream resolver (control-plane / gateway egress + buildah).
|
||||||
|
printf 'nameserver {_INFRA_RESOLVER}\\n' > /etc/resolv.conf 2>/dev/null
|
||||||
|
|
||||||
|
# Debug SSH: install the per-boot pubkey from the kernel cmdline.
|
||||||
|
KEY=$(sed -n 's/.*bb_pubkey=\\([^ ]*\\).*/\\1/p' /proc/cmdline | base64 -d 2>/dev/null)
|
||||||
|
if [ -n "$KEY" ]; then
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
printf '%s\\n' "$KEY" > /root/.ssh/authorized_keys
|
||||||
|
chmod 700 /root/.ssh && chmod 600 /root/.ssh/authorized_keys
|
||||||
|
fi
|
||||||
|
chown -R 0:0 /root 2>/dev/null || true
|
||||||
|
mkdir -p /etc/dropbear /run /var/lib/bot-bottle
|
||||||
|
|
||||||
|
# Persistent registry volume (second virtio-block device, /dev/vdb) mounted
|
||||||
|
# at the control plane's DB dir, so bot-bottle.db survives infra-VM restarts.
|
||||||
|
mount -t ext4 /dev/vdb /var/lib/bot-bottle 2>/dev/null || true
|
||||||
|
|
||||||
|
/bb-dropbear -R -E -p 22 &
|
||||||
|
|
||||||
|
# Control plane. Source is baked at /app; the package is stdlib-only.
|
||||||
|
cd /app
|
||||||
|
BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
|
||||||
|
--host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub &
|
||||||
|
|
||||||
|
# Gateway data plane, multi-tenant: each request resolves source-IP ->
|
||||||
|
# policy against the local control plane. The VM backend reaches git over
|
||||||
|
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
|
||||||
|
# entrypoint the consolidated model doesn't use) is left out.
|
||||||
|
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
|
||||||
|
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
|
||||||
|
SUPERVISE_DB_PATH=/var/lib/bot-bottle/db/bot-bottle.db \\
|
||||||
|
python3 /app/gateway_init.py &
|
||||||
|
|
||||||
|
# Reap as PID 1; children are backgrounded, so `wait` blocks.
|
||||||
|
while : ; do wait ; done
|
||||||
|
"""
|
||||||
@@ -1,75 +1,64 @@
|
|||||||
"""Launch flow for the Firecracker backend.
|
"""Launch flow for the Firecracker backend (PRD 0070, consolidated).
|
||||||
|
|
||||||
Per bottle:
|
Per bottle:
|
||||||
1. mint the egress CA, build the agent image (docker), export it to a
|
1. build the agent rootfs in a builder VM (buildah, no host docker), or
|
||||||
cached ext4 rootfs;
|
resume a frozen bottle from its committed rootfs tar; cache the ext4;
|
||||||
2. claim a free TAP pool slot (rootless flock);
|
2. ensure the per-host orchestrator + shared gateway are up;
|
||||||
3. bring up the Docker sidecar bundle, publishing egress / git-gate /
|
3. claim a free TAP pool slot (rootless flock);
|
||||||
supervise on the slot's host-side TAP IP at fixed ports;
|
4. register the bottle on the orchestrator by the VM's guest IP (the
|
||||||
4. boot the microVM on that TAP; wait for SSH;
|
attribution key) and provision its git-gate state into the gateway;
|
||||||
5. provision (CA, prompt, skills, workspace, git, supervise) over SSH.
|
5. boot the microVM on that TAP; wait for SSH;
|
||||||
|
6. provision (shared gateway CA, prompt, skills, workspace, git, supervise)
|
||||||
|
over SSH.
|
||||||
|
|
||||||
|
The per-bottle Docker sidecar bundle is gone. The shared gateway handles
|
||||||
|
egress / git-gate / supervise for every VM; Docker's PREROUTING DNAT routes
|
||||||
|
the VMs' traffic to it, and the nft table's `ct status dnat accept` rule
|
||||||
|
in the forward chain lets it pass. The VM still sends to `host_tap_ip:PORT`
|
||||||
|
— the address its world is, by nft design, limited to.
|
||||||
|
|
||||||
Isolation is enforced by the operator-provisioned nft table (checked
|
Isolation is enforced by the operator-provisioned nft table (checked
|
||||||
fail-closed in preflight): a VM reaches only its sidecar (DNAT'd from
|
fail-closed in preflight): a VM reaches only the sidecar (DNAT'd from
|
||||||
the host TAP IP) and nothing else. The agent's HTTPS_PROXY therefore
|
the host TAP IP) and nothing else.
|
||||||
points at `http://<host_tap_ip>:9099`, its only route to the world.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
|
from ...agent_provider import runtime_for
|
||||||
from ...bottle_state import (
|
from ...bottle_state import (
|
||||||
|
committed_rootfs_path,
|
||||||
egress_state_dir,
|
egress_state_dir,
|
||||||
git_gate_state_dir,
|
git_gate_state_dir,
|
||||||
read_committed_image,
|
read_committed_image,
|
||||||
)
|
)
|
||||||
from ...egress import (
|
from ...egress import (
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
|
||||||
egress_agent_env_entries,
|
egress_agent_env_entries,
|
||||||
egress_resolve_token_values,
|
egress_resolve_token_values,
|
||||||
egress_sidecar_env_entries,
|
|
||||||
)
|
)
|
||||||
from ...git_gate import (
|
from ...git_gate import (
|
||||||
provision_git_gate_dynamic_keys,
|
provision_git_gate_dynamic_keys,
|
||||||
revoke_git_gate_provisioned_keys,
|
revoke_git_gate_provisioned_keys,
|
||||||
)
|
)
|
||||||
from ...log import die, info, warn
|
from ...log import info, warn
|
||||||
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
from ...supervise import SUPERVISE_PORT
|
||||||
from ...util import expand_tilde
|
from ..docker.egress import EGRESS_PORT
|
||||||
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 ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
from . import firecracker_vm, isolation_probe, netpool, util
|
from . import firecracker_vm, image_builder, isolation_probe, netpool, util
|
||||||
from .bottle import FirecrackerBottle
|
from .bottle import FirecrackerBottle
|
||||||
from .bottle_plan import FirecrackerBottlePlan
|
from .bottle_plan import FirecrackerBottlePlan
|
||||||
|
from .consolidated_launch import (
|
||||||
|
launch_consolidated,
|
||||||
|
teardown_consolidated,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
_GIT_HTTP_PORT = 9420
|
_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
|
||||||
@@ -78,6 +67,8 @@ def launch(
|
|||||||
*,
|
*,
|
||||||
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
|
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
|
||||||
) -> Generator[FirecrackerBottle, None, None]:
|
) -> Generator[FirecrackerBottle, None, None]:
|
||||||
|
"""Build, launch, and provision a Firecracker bottle via the consolidated
|
||||||
|
orchestrator. Teardown on exit."""
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
bottle_for_revoke = plan.manifest.bottle
|
bottle_for_revoke = plan.manifest.bottle
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||||
@@ -94,31 +85,87 @@ def launch(
|
|||||||
raise teardown_exc
|
raise teardown_exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plan = _mint_certs(plan)
|
# Step 1: agent rootfs. Built from the Dockerfile inside a Firecracker
|
||||||
plan = _build_agent_image(plan)
|
# builder VM (buildah, no host docker); a committed snapshot is reused
|
||||||
|
# when present. Returns the base dir the per-bottle ext4 is made from.
|
||||||
|
plan, agent_base = _build_agent_base(plan)
|
||||||
|
|
||||||
# Claim a TAP slot; the flock is held until teardown closes it.
|
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any.
|
||||||
|
git_gate_plan = plan.git_gate_plan
|
||||||
|
if git_gate_plan.upstreams:
|
||||||
|
git_gate_plan = provision_git_gate_dynamic_keys(
|
||||||
|
plan.manifest.bottle, git_gate_plan, git_gate_state_dir(plan.slug),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: claim a TAP slot; the flock is held until teardown.
|
||||||
slot, lock = netpool.allocate(plan.slug)
|
slot, lock = netpool.allocate(plan.slug)
|
||||||
stack.callback(lock.close)
|
stack.callback(lock.close)
|
||||||
info(f"firecracker slot {slot.iface}: host={slot.host_ip} "
|
info(f"firecracker slot {slot.iface}: host={slot.host_ip} "
|
||||||
f"guest={slot.guest_ip}")
|
f"guest={slot.guest_ip}")
|
||||||
|
|
||||||
plan = _provision_git_gate_keys(plan)
|
# Step 4: register on the orchestrator + provision this bottle's
|
||||||
|
# git-gate state into the shared gateway. The per-bottle egress tokens
|
||||||
|
# are resolved from the host env now and handed to the orchestrator
|
||||||
|
# (in memory) for the gateway to inject — the agent never sees them.
|
||||||
|
# Attribution is by the VM's guest IP (unspoofable via /31 TAP + nft).
|
||||||
|
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
|
||||||
|
token_values = egress_resolve_token_values(
|
||||||
|
plan.egress_plan.token_env_map, effective_env,
|
||||||
|
)
|
||||||
|
ctx = launch_consolidated(
|
||||||
|
plan.egress_plan, git_gate_plan,
|
||||||
|
guest_ip=slot.guest_ip,
|
||||||
|
image_ref=plan.image,
|
||||||
|
tokens=token_values,
|
||||||
|
)
|
||||||
|
stack.callback(
|
||||||
|
teardown_consolidated, ctx.bottle_id,
|
||||||
|
orchestrator_url=ctx.orchestrator_url,
|
||||||
|
)
|
||||||
|
|
||||||
sidecar_name = sidecar_container_name(plan.slug)
|
# Step 5: install the SHARED gateway CA (replaces the per-bottle CA).
|
||||||
_force_remove_container(sidecar_name)
|
# Write it to a stable host path so the provisioner can copy it over SSH.
|
||||||
_start_sidecar_bundle(plan, sidecar_name, slot.host_ip)
|
ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
|
||||||
stack.callback(_force_remove_container, sidecar_name)
|
ca_dir.mkdir(parents=True, exist_ok=True)
|
||||||
_stage_git_gate(plan, sidecar_name)
|
ca_file = ca_dir / "gateway-ca.pem"
|
||||||
|
ca_file.write_text(ctx.gateway_ca_pem)
|
||||||
|
egress_plan = dataclasses.replace(
|
||||||
|
plan.egress_plan,
|
||||||
|
mitmproxy_ca_host_path=ca_file,
|
||||||
|
mitmproxy_ca_cert_only_host_path=ca_file,
|
||||||
|
)
|
||||||
|
# Point the agent's git-gate insteadOf rewrites and supervise MCP URL
|
||||||
|
# at the shared gateway (reached at the slot's host TAP IP — the VM
|
||||||
|
# sends there and Docker DNAT routes to the gateway container).
|
||||||
|
git_gate_url = (
|
||||||
|
f"http://{slot.host_ip}:{_GIT_HTTP_PORT}" if git_gate_plan.upstreams else ""
|
||||||
|
)
|
||||||
|
supervise_url = (
|
||||||
|
f"http://{slot.host_ip}:{SUPERVISE_PORT}/"
|
||||||
|
if plan.supervise_plan is not None else ""
|
||||||
|
)
|
||||||
|
plan = dataclasses.replace(
|
||||||
|
plan,
|
||||||
|
git_gate_plan=git_gate_plan,
|
||||||
|
egress_plan=egress_plan,
|
||||||
|
identity_token=ctx.identity_token,
|
||||||
|
# Deliver the identity token as egress proxy credentials — clients
|
||||||
|
# honor `HTTPS_PROXY=http://id:token@gw` without app changes; the
|
||||||
|
# gateway reads Proxy-Authorization, validates the (source_ip,
|
||||||
|
# token) pair, and strips it before upstream.
|
||||||
|
agent_proxy_url=(
|
||||||
|
f"http://bottle:{ctx.identity_token}"
|
||||||
|
f"@{slot.host_ip}:{EGRESS_PORT}"
|
||||||
|
),
|
||||||
|
agent_git_gate_url=git_gate_url,
|
||||||
|
agent_supervise_url=supervise_url,
|
||||||
|
)
|
||||||
|
|
||||||
plan = _stamp_agent_urls(plan, slot.host_ip)
|
# Step 6: build the per-bottle rootfs + SSH key, then boot.
|
||||||
|
|
||||||
# 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 = util.cache_dir() / "run" / plan.slug
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
rootfs = run_dir / "rootfs.ext4"
|
rootfs = run_dir / "rootfs.ext4"
|
||||||
util.build_rootfs_ext4(base_dir, rootfs)
|
util.build_rootfs_ext4(agent_base, rootfs)
|
||||||
private_key, pubkey = util.generate_keypair(run_dir)
|
private_key, pubkey = util.generate_keypair(run_dir)
|
||||||
|
|
||||||
vm = firecracker_vm.boot(
|
vm = firecracker_vm.boot(
|
||||||
@@ -133,8 +180,8 @@ def launch(
|
|||||||
stack.callback(vm.terminate)
|
stack.callback(vm.terminate)
|
||||||
firecracker_vm.wait_for_ssh(vm, private_key)
|
firecracker_vm.wait_for_ssh(vm, private_key)
|
||||||
|
|
||||||
# Authoritative fail-closed egress-boundary check, before the
|
# Authoritative fail-closed egress-boundary check, before the agent
|
||||||
# agent runs: prove the VM cannot reach the host directly.
|
# runs: prove the VM cannot reach the host directly.
|
||||||
isolation_probe.verify_isolation(private_key, slot.guest_ip)
|
isolation_probe.verify_isolation(private_key, slot.guest_ip)
|
||||||
|
|
||||||
bottle = FirecrackerBottle(
|
bottle = FirecrackerBottle(
|
||||||
@@ -159,169 +206,24 @@ def launch(
|
|||||||
teardown()
|
teardown()
|
||||||
|
|
||||||
|
|
||||||
def _mint_certs(plan: FirecrackerBottlePlan) -> FirecrackerBottlePlan:
|
def _build_agent_base(
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
plan: FirecrackerBottlePlan,
|
||||||
) -> tuple[tuple[str, str, bool], ...]:
|
) -> tuple[FirecrackerBottlePlan, Path]:
|
||||||
mounts: list[tuple[str, str, bool]] = []
|
"""Produce the agent's base rootfs dir. Primary path: build the Dockerfile
|
||||||
ep = plan.egress_plan
|
inside a Firecracker builder VM (buildah, no host docker), smoke-testing
|
||||||
mounts.append((str(ep.mitmproxy_ca_host_path.parent),
|
the image before export. A committed snapshot (freeze/migrate) is resumed
|
||||||
str(Path(EGRESS_CA_IN_CONTAINER).parent), False))
|
directly from the rootfs tar the freezer wrote — no host docker either."""
|
||||||
if ep.routes:
|
committed = read_committed_image(plan.slug)
|
||||||
mounts.append((str(ep.routes_path.parent),
|
committed_tar = committed_rootfs_path(plan.slug)
|
||||||
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True))
|
if committed and committed_tar.is_file():
|
||||||
sp = plan.supervise_plan
|
info(f"resuming from committed rootfs {committed_tar}")
|
||||||
if sp is not None:
|
return plan, util.build_committed_rootfs_dir(committed_tar)
|
||||||
mounts.append((str(sp.db_path.parent),
|
base = image_builder.build_agent_rootfs_dir(
|
||||||
str(Path(DB_PATH_IN_CONTAINER).parent), False))
|
Path(plan.dockerfile_path),
|
||||||
return tuple(mounts)
|
image_tag=plan.image,
|
||||||
|
smoke_test=runtime_for(plan.agent_provider_template).smoke_test,
|
||||||
|
)
|
||||||
def _stage_git_gate(plan: FirecrackerBottlePlan, sidecar_name: str) -> None:
|
return plan, base
|
||||||
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 -------------------------------------------------
|
# --- agent guest env -------------------------------------------------
|
||||||
@@ -330,7 +232,8 @@ 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
|
"""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
|
baked process env (it just runs init), so the proxy/CA/git/supervise
|
||||||
wiring is applied per-invocation."""
|
wiring is applied per-invocation."""
|
||||||
proxy_url = f"http://{host_ip}:{EGRESS_PORT}"
|
# Carries the identity token as proxy credentials (set in `launch`).
|
||||||
|
proxy_url = plan.agent_proxy_url or f"http://{host_ip}:{EGRESS_PORT}"
|
||||||
no_proxy = f"localhost,127.0.0.1,{host_ip}"
|
no_proxy = f"localhost,127.0.0.1,{host_ip}"
|
||||||
env: dict[str, str] = {
|
env: dict[str, str] = {
|
||||||
"HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url,
|
"HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url,
|
||||||
@@ -355,50 +258,3 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str
|
|||||||
if value is not None:
|
if value is not None:
|
||||||
env[name] = value
|
env[name] = value
|
||||||
return env
|
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>'}")
|
|
||||||
|
|||||||
@@ -15,3 +15,11 @@ BOT_BOTTLE_FC_POOL_SIZE=8
|
|||||||
BOT_BOTTLE_FC_IP_BASE=10.243.0.0
|
BOT_BOTTLE_FC_IP_BASE=10.243.0.0
|
||||||
BOT_BOTTLE_FC_IFACE_PREFIX=bbfc
|
BOT_BOTTLE_FC_IFACE_PREFIX=bbfc
|
||||||
BOT_BOTTLE_FC_NFT_TABLE=bot_bottle_fc
|
BOT_BOTTLE_FC_NFT_TABLE=bot_bottle_fc
|
||||||
|
# The orchestrator/gateway VM's own TAP — a dedicated link OUTSIDE the
|
||||||
|
# bbfc* agent pool. Unlike agent VMs (which reach only their gateway),
|
||||||
|
# the orchestrator is trusted infra that needs real NAT'd internet
|
||||||
|
# egress: to FROM-pull + apt/npm during in-VM agent-image builds
|
||||||
|
# (buildah) and to forward agent egress upstream (Stage B gateway). Its
|
||||||
|
# /31 is the top of the IP_BASE /16 (host x.y.255.0, guest x.y.255.1),
|
||||||
|
# clear of the pool near the bottom of the block.
|
||||||
|
BOT_BOTTLE_FC_ORCH_IFACE=bborch0
|
||||||
|
|||||||
@@ -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*
|
||||||
@@ -79,6 +79,12 @@ def _cfg(key: str) -> str:
|
|||||||
IFACE_PREFIX = _cfg("BOT_BOTTLE_FC_IFACE_PREFIX")
|
IFACE_PREFIX = _cfg("BOT_BOTTLE_FC_IFACE_PREFIX")
|
||||||
NFT_TABLE = _cfg("BOT_BOTTLE_FC_NFT_TABLE")
|
NFT_TABLE = _cfg("BOT_BOTTLE_FC_NFT_TABLE")
|
||||||
|
|
||||||
|
# The orchestrator/gateway VM's dedicated TAP — outside the bbfc* agent
|
||||||
|
# pool and, unlike it, NAT'd to the internet (see `orch_slot`). The
|
||||||
|
# orchestrator is trusted infra: it builds agent images in-VM (buildah
|
||||||
|
# needs to FROM-pull + apt/npm) and forwards agent egress upstream.
|
||||||
|
ORCH_IFACE = _cfg("BOT_BOTTLE_FC_ORCH_IFACE")
|
||||||
|
|
||||||
|
|
||||||
def pool_size() -> int:
|
def pool_size() -> int:
|
||||||
return int(_cfg("BOT_BOTTLE_FC_POOL_SIZE"))
|
return int(_cfg("BOT_BOTTLE_FC_POOL_SIZE"))
|
||||||
@@ -88,10 +94,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)
|
||||||
@@ -123,6 +129,25 @@ def all_slots() -> list[Slot]:
|
|||||||
return [slot(i) for i in range(pool_size())]
|
return [slot(i) for i in range(pool_size())]
|
||||||
|
|
||||||
|
|
||||||
|
def orch_slot() -> Slot:
|
||||||
|
"""The orchestrator/gateway VM's dedicated link — its own TAP
|
||||||
|
(`ORCH_IFACE`) on a /31 at the TOP of the IP_BASE /16 (host
|
||||||
|
x.y.255.0, guest x.y.255.1), well clear of the agent pool near the
|
||||||
|
bottom of the block. Unlike a pool `Slot`, this link is NAT'd out to
|
||||||
|
the internet by the setup (the orchestrator is trusted infra), so it
|
||||||
|
is deliberately *not* one of the isolated `bbfc*` slots.
|
||||||
|
|
||||||
|
`index` is -1 (sentinel: not a pool index)."""
|
||||||
|
base16 = int(ipaddress.IPv4Address(ip_base())) & 0xFFFF0000
|
||||||
|
host = base16 + 0xFF00
|
||||||
|
return Slot(
|
||||||
|
index=-1,
|
||||||
|
iface=ORCH_IFACE,
|
||||||
|
host_ip=str(ipaddress.IPv4Address(host)),
|
||||||
|
guest_ip=str(ipaddress.IPv4Address(host + 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- fail-closed verification ---------------------------------------
|
# --- fail-closed verification ---------------------------------------
|
||||||
|
|
||||||
def _run_ok(argv: list[str]) -> bool:
|
def _run_ok(argv: list[str]) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Build the infra rootfs and publish it as a Gitea generic package.
|
||||||
|
|
||||||
|
The off-host (build / CI) half of PRD 0069 Stage 2: this DOES use Docker, but
|
||||||
|
never on the launch host. It runs the same pipeline the launch host used to run
|
||||||
|
locally — `docker build` the three fixed images, export to a rootfs dir, inject
|
||||||
|
the guest boot, `mke2fs` to an ext4 with the buildah build slack — then gzips
|
||||||
|
the ext4 and PUTs it (plus a `.sha256`) to
|
||||||
|
`…/api/packages/<owner>/generic/bot-bottle-firecracker-infra/<version>/`.
|
||||||
|
|
||||||
|
The `<version>` is `infra_artifact.infra_artifact_version(...)`, the content
|
||||||
|
hash of the rootfs inputs, so a launch host at the same code checkout resolves
|
||||||
|
the exact artifact this produced.
|
||||||
|
|
||||||
|
python3 -m bot_bottle.backend.firecracker.publish_infra [--dry-run] [--force]
|
||||||
|
|
||||||
|
Auth: a token with `write:package` on the target owner, from
|
||||||
|
`BOT_BOTTLE_INFRA_ARTIFACT_TOKEN`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import infra_artifact, infra_vm, util
|
||||||
|
|
||||||
|
_CHUNK = 1 << 20
|
||||||
|
|
||||||
|
# A human-readable description shipped alongside the artifact — generic packages
|
||||||
|
# have no description field, so this file *is* the description on the package
|
||||||
|
# page. Uploaded on every publish so it never goes stale.
|
||||||
|
_ABOUT_NAME = "about.txt"
|
||||||
|
_ABOUT_TEXT = (
|
||||||
|
"bot-bottle infra rootfs for the Firecracker backend (PRD 0069 Stage 2, "
|
||||||
|
"#348): the per-host infra VM (orchestrator control plane + gateway + "
|
||||||
|
"buildah). Prebuilt off-host, gzip ext4; the launch host downloads + "
|
||||||
|
"sha256-verifies + boots it, no host Docker. The version tag is a content "
|
||||||
|
"hash of the rootfs inputs. Files: rootfs.ext4.gz + rootfs.ext4.gz.sha256.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gzip(src: Path, dest: Path) -> None:
|
||||||
|
with open(src, "rb") as fh, gzip.open(dest, "wb") as out:
|
||||||
|
shutil.copyfileobj(fh, out, _CHUNK)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
for chunk in iter(lambda: fh.read(_CHUNK), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _put(url: str, body: "bytes | Path", token: str) -> None:
|
||||||
|
"""PUT `body` (raw bytes, or a Path streamed from disk) to `url`. The rootfs
|
||||||
|
is hundreds of MB, so it is passed as a Path and streamed — `urlopen` reads
|
||||||
|
the open file in blocks rather than materializing it in memory (with an
|
||||||
|
explicit Content-Length, which Gitea requires and which also stops urllib
|
||||||
|
from `len()`-ing a non-bytes body)."""
|
||||||
|
handle = None
|
||||||
|
if isinstance(body, Path):
|
||||||
|
length = body.stat().st_size
|
||||||
|
handle = open(body, "rb")
|
||||||
|
data: object = handle
|
||||||
|
else:
|
||||||
|
length = len(body)
|
||||||
|
data = body
|
||||||
|
req = urllib.request.Request(url, data=data, method="PUT") # type: ignore[arg-type]
|
||||||
|
req.add_header("Content-Length", str(length))
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
req.add_header("Content-Type", "application/octet-stream")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
print(f" uploaded {url} (HTTP {resp.status})")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 409:
|
||||||
|
raise SystemExit(
|
||||||
|
f"artifact already published at {url} (HTTP 409); "
|
||||||
|
f"bump the code version or pass --force to overwrite"
|
||||||
|
)
|
||||||
|
raise SystemExit(f"upload failed (HTTP {e.code}): {url}\n{e.read().decode(errors='replace')}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise SystemExit(f"registry unreachable: {url} ({e.reason})")
|
||||||
|
finally:
|
||||||
|
if handle is not None:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _delete(url: str, token: str) -> None:
|
||||||
|
req = urllib.request.Request(url, method="DELETE")
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req):
|
||||||
|
pass
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code != 404:
|
||||||
|
raise SystemExit(f"could not overwrite existing artifact (HTTP {e.code}): {url}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise SystemExit(f"registry unreachable: {url} ({e.reason})")
|
||||||
|
|
||||||
|
|
||||||
|
def build_artifact(out_dir: Path) -> tuple[str, Path, Path]:
|
||||||
|
"""Build the infra rootfs ext4, gzip it, and write the checksum. Returns
|
||||||
|
`(version, gz_path, sha_path)`. Uses host Docker (off-host / CI)."""
|
||||||
|
version = infra_artifact.infra_artifact_version(infra_vm._infra_init())
|
||||||
|
print(f"building infra rootfs artifact {version} (docker)")
|
||||||
|
infra_vm.build_infra_images_with_docker()
|
||||||
|
base = infra_vm.build_infra_rootfs_dir()
|
||||||
|
|
||||||
|
ext4 = out_dir / "rootfs.ext4"
|
||||||
|
util.build_rootfs_ext4(base, ext4, slack_mib=8192)
|
||||||
|
gz = out_dir / "rootfs.ext4.gz"
|
||||||
|
print("compressing rootfs")
|
||||||
|
_gzip(ext4, gz)
|
||||||
|
ext4.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
sha = out_dir / "rootfs.ext4.gz.sha256"
|
||||||
|
digest = _sha256(gz)
|
||||||
|
sha.write_text(f"{digest} rootfs.ext4.gz\n")
|
||||||
|
print(f" {gz.name}: {gz.stat().st_size / 1e6:.0f} MB sha256={digest}")
|
||||||
|
return version, gz, sha
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="publish_infra", description="Build + publish the infra rootfs artifact.")
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="build the artifact but do not upload")
|
||||||
|
parser.add_argument("--force", action="store_true",
|
||||||
|
help="overwrite an already-published artifact of this version")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
_, _, token = infra_artifact._config()
|
||||||
|
if not args.dry_run and not token:
|
||||||
|
raise SystemExit(
|
||||||
|
"no publish token: set BOT_BOTTLE_INFRA_ARTIFACT_TOKEN to a token "
|
||||||
|
"with write:package")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bb-publish-infra.") as tmp:
|
||||||
|
version, gz, sha = build_artifact(Path(tmp))
|
||||||
|
gz_url = infra_artifact.artifact_url(version, gz.name)
|
||||||
|
sha_url = infra_artifact.artifact_url(version, sha.name)
|
||||||
|
about_url = infra_artifact.artifact_url(version, _ABOUT_NAME)
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"dry-run: would upload -> {gz_url}")
|
||||||
|
return 0
|
||||||
|
if args.force:
|
||||||
|
_delete(gz_url, token)
|
||||||
|
_delete(sha_url, token)
|
||||||
|
_delete(about_url, token)
|
||||||
|
_put(gz_url, gz, token) # streamed from disk (hundreds of MB)
|
||||||
|
_put(sha_url, sha.read_bytes(), token) # tiny, in-memory is fine
|
||||||
|
_put(about_url, _ABOUT_TEXT.encode(), token) # package description
|
||||||
|
print(f"published infra rootfs {version}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -14,6 +14,7 @@ and `./cli.py backend setup --backend=firecracker`.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
@@ -159,15 +160,22 @@ def docker_image_id(ref: str) -> str:
|
|||||||
return result.stdout.strip().replace("sha256:", "")[:16]
|
return result.stdout.strip().replace("sha256:", "")[:16]
|
||||||
|
|
||||||
|
|
||||||
def build_base_rootfs_dir(image_ref: str) -> Path:
|
def build_base_rootfs_dir(
|
||||||
"""Export the agent image's filesystem and inject the guest init +
|
image_ref: str, *, variant: str = "", init_script: str | None = None,
|
||||||
static dropbear. Cached by image digest — the per-bottle bits
|
) -> Path:
|
||||||
|
"""Export the image's filesystem and inject the guest init + static
|
||||||
|
dropbear. Cached by image digest — the per-bottle bits
|
||||||
(authorized_keys, IP) are passed at boot via the kernel cmdline, so
|
(authorized_keys, IP) are passed at boot via the kernel cmdline, so
|
||||||
this tree carries nothing bottle-specific and is safely shared.
|
this tree carries nothing bottle-specific and is safely shared.
|
||||||
|
|
||||||
|
`variant` suffixes the cache key so the same image can be prepared
|
||||||
|
with a different `init_script` (e.g. the infra VM boots the same
|
||||||
|
orchestrator image as the builder but runs the control plane as
|
||||||
|
PID 1, not the SSH-only agent init) without a cache collision.
|
||||||
|
|
||||||
Returns the prepared directory (read as the `mke2fs -d` source)."""
|
Returns the prepared directory (read as the `mke2fs -d` source)."""
|
||||||
digest = docker_image_id(image_ref)
|
digest = docker_image_id(image_ref)
|
||||||
base = cache_dir() / "rootfs" / digest
|
base = cache_dir() / "rootfs" / f"{digest}{variant}"
|
||||||
ready = base / ".bb-ready"
|
ready = base / ".bb-ready"
|
||||||
if ready.is_file():
|
if ready.is_file():
|
||||||
return base
|
return base
|
||||||
@@ -200,18 +208,85 @@ def build_base_rootfs_dir(image_ref: str) -> Path:
|
|||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
_inject_guest_boot(base)
|
inject_guest_boot(base, init_script=init_script)
|
||||||
ready.write_text("ok\n")
|
ready.write_text("ok\n")
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def _inject_guest_boot(rootfs: Path) -> None:
|
def build_committed_rootfs_dir(tar_path: Path) -> Path:
|
||||||
"""Drop the static dropbear and the PID-1 init into the rootfs."""
|
"""Prepare a base rootfs dir from a frozen-bottle snapshot tar (the
|
||||||
shutil.copy2(dropbear_path(), rootfs / "bb-dropbear")
|
freeze/resume path — no Docker). Extracts the snapshot, recreates the
|
||||||
os.chmod(rootfs / "bb-dropbear", 0o755)
|
virtual mount points the freezer excluded, and injects the guest init +
|
||||||
init = rootfs / "bb-init"
|
static dropbear, mirroring `build_base_rootfs_dir` but sourced from a tar
|
||||||
init.write_text(_GUEST_INIT)
|
we control rather than a Docker image.
|
||||||
os.chmod(init, 0o755)
|
|
||||||
|
Cached under the rootfs cache, keyed by the tar's size+mtime so a
|
||||||
|
re-freeze re-extracts but repeated resumes of the same snapshot don't.
|
||||||
|
Returns the prepared directory (read as the `mke2fs -d` source)."""
|
||||||
|
st = tar_path.stat()
|
||||||
|
fingerprint = hashlib.sha256(
|
||||||
|
f"{tar_path}:{st.st_size}:{st.st_mtime_ns}".encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
base = cache_dir() / "rootfs" / f"committed-{fingerprint}"
|
||||||
|
ready = base / ".bb-ready"
|
||||||
|
if ready.is_file():
|
||||||
|
return base
|
||||||
|
|
||||||
|
if base.exists():
|
||||||
|
shutil.rmtree(base, ignore_errors=True)
|
||||||
|
base.mkdir(parents=True)
|
||||||
|
|
||||||
|
info(f"extracting committed rootfs {tar_path} -> {base}")
|
||||||
|
result = subprocess.run(
|
||||||
|
["tar", "-x", "-f", str(tar_path), "-C", str(base)],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
die(f"extracting committed rootfs {tar_path} failed: "
|
||||||
|
f"{result.stderr.strip() or '<no stderr>'}")
|
||||||
|
|
||||||
|
# The freezer excludes the live/virtual filesystems from the snapshot;
|
||||||
|
# recreate them as empty mount points so the guest init can mount
|
||||||
|
# proc/sys/dev and dropbear has a writable /run.
|
||||||
|
for mount_point in ("proc", "sys", "dev", "run"):
|
||||||
|
(base / mount_point).mkdir(mode=0o755, exist_ok=True)
|
||||||
|
|
||||||
|
inject_guest_boot(base)
|
||||||
|
ready.write_text("ok\n")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def inject_guest_boot(rootfs: Path, init_script: str | None = None) -> None:
|
||||||
|
"""Drop the static dropbear and the PID-1 init into the rootfs.
|
||||||
|
`init_script` defaults to the SSH-only agent init; the infra VM
|
||||||
|
passes its own (control plane + gateway) init.
|
||||||
|
|
||||||
|
A committed snapshot is guest-controlled, so `bb-dropbear`/`bb-init`
|
||||||
|
may already exist as symlinks aimed at a host file (e.g. bb-init ->
|
||||||
|
~/.bashrc). Replace whatever is there and create the files with
|
||||||
|
O_EXCL|O_NOFOLLOW so the write always lands a fresh regular file in
|
||||||
|
the staging tree and never follows a planted symlink out of it."""
|
||||||
|
_write_staged_file(rootfs / "bb-dropbear", dropbear_path().read_bytes())
|
||||||
|
_write_staged_file(rootfs / "bb-init", (init_script or _GUEST_INIT).encode())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_staged_file(path: Path, data: bytes) -> None:
|
||||||
|
"""Write `data` to `path` (mode 0755) as a fresh regular file inside a
|
||||||
|
staging rootfs, replacing any pre-existing entry without following a
|
||||||
|
symlink at `path`. Fails closed on anything unexpected there."""
|
||||||
|
if path.is_symlink() or path.exists():
|
||||||
|
if path.is_dir() and not path.is_symlink():
|
||||||
|
shutil.rmtree(path)
|
||||||
|
else:
|
||||||
|
path.unlink()
|
||||||
|
fd = os.open(
|
||||||
|
path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o755
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.write(fd, data)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(path, 0o755)
|
||||||
|
|
||||||
|
|
||||||
def build_rootfs_ext4(base_dir: Path, out_path: Path, *, slack_mib: int = 1024) -> None:
|
def build_rootfs_ext4(base_dir: Path, out_path: Path, *, slack_mib: int = 1024) -> None:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ class MacosContainerBottleBackend(
|
|||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||||
yield bottle
|
yield bottle
|
||||||
|
|
||||||
|
def ensure_orchestrator(self) -> str:
|
||||||
|
"""Bring up the per-host infra container (control plane + gateway) and
|
||||||
|
return its control-plane URL — the on-demand entry point operator tools
|
||||||
|
(`supervise`) call when no control plane is running yet. Mirrors
|
||||||
|
firecracker's infra-VM bring-up."""
|
||||||
|
from .infra import MacosInfraService
|
||||||
|
return MacosInfraService().ensure_running().control_plane_url
|
||||||
|
|
||||||
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
||||||
return _cleanup.prepare_cleanup()
|
return _cleanup.prepare_cleanup()
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class MacosContainerBottle(Bottle):
|
|||||||
terminal_title: str = "",
|
terminal_title: str = "",
|
||||||
terminal_color: str = "",
|
terminal_color: str = "",
|
||||||
agent_workdir: str = "/home/node",
|
agent_workdir: str = "/home/node",
|
||||||
|
exec_env: dict[str, str] | None = None,
|
||||||
):
|
):
|
||||||
self.name = container
|
self.name = container
|
||||||
self._teardown = teardown
|
self._teardown = teardown
|
||||||
@@ -62,6 +63,15 @@ class MacosContainerBottle(Bottle):
|
|||||||
self.terminal_color = terminal_color
|
self.terminal_color = terminal_color
|
||||||
self.agent_provider_template = agent_provider_template
|
self.agent_provider_template = agent_provider_template
|
||||||
self.agent_workdir = agent_workdir
|
self.agent_workdir = agent_workdir
|
||||||
|
# Env applied to the agent process at `container exec` time, on top of
|
||||||
|
# what the container was run with. This is how the identity token
|
||||||
|
# reaches the agent (PRD 0070): registration mints it *after* the
|
||||||
|
# container exists — its source IP is the registration key and Apple
|
||||||
|
# Container assigns that by DHCP — so it cannot be in the run-time env
|
||||||
|
# the way docker's compose spec does it. `container exec --env` wins
|
||||||
|
# over the run-time value, so the token-bearing proxy URL set here
|
||||||
|
# supersedes the token-less one baked in at launch.
|
||||||
|
self._exec_env = dict(exec_env or {})
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
|
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
|
||||||
@@ -74,6 +84,12 @@ class MacosContainerBottle(Bottle):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
container_exec = ["container", "exec"]
|
container_exec = ["container", "exec"]
|
||||||
|
# Bare env names, same rule as the terminal hints below: the value
|
||||||
|
# stays in the child env `exec_agent` builds and never reaches argv —
|
||||||
|
# the proxy URL here carries the identity token, which `ps` would
|
||||||
|
# otherwise expose to every process on the host.
|
||||||
|
for name in sorted(self._exec_env):
|
||||||
|
container_exec.extend(["--env", name])
|
||||||
if tty:
|
if tty:
|
||||||
container_exec.extend(["--interactive", "--tty"])
|
container_exec.extend(["--interactive", "--tty"])
|
||||||
# Forward terminal capability hints so TUIs can enable modified-key
|
# Forward terminal capability hints so TUIs can enable modified-key
|
||||||
@@ -94,21 +110,33 @@ class MacosContainerBottle(Bottle):
|
|||||||
|
|
||||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||||
agent_argv = self.agent_argv(argv, tty=tty)
|
agent_argv = self.agent_argv(argv, tty=tty)
|
||||||
|
# The values behind the bare `--env` names in `agent_argv`. `sh -lc`
|
||||||
|
# below is in this process tree, so the child env reaches `container
|
||||||
|
# exec` either way.
|
||||||
|
env = {**os.environ, **self._exec_env} if self._exec_env else None
|
||||||
script = (
|
script = (
|
||||||
exec_shell_script(agent_argv, self.terminal_title, self.terminal_color)
|
exec_shell_script(agent_argv, self.terminal_title, self.terminal_color)
|
||||||
if tty else None
|
if tty else None
|
||||||
)
|
)
|
||||||
if script is None:
|
if script is None:
|
||||||
return subprocess.run(agent_argv, check=False).returncode
|
return subprocess.run(agent_argv, env=env, check=False).returncode
|
||||||
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
return subprocess.run(["sh", "-lc", script], env=env, check=False).returncode
|
||||||
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||||
|
# Carry the same exec env the agent gets: provisioning steps run
|
||||||
|
# through here, and a provider whose provision step fetches anything
|
||||||
|
# would egress without the identity token and be denied by /resolve.
|
||||||
|
# Bare `--env NAME` again, so the token stays off argv.
|
||||||
|
argv = ["container", "exec", "--user", user, "--interactive"]
|
||||||
|
for name in sorted(self._exec_env):
|
||||||
|
argv.extend(["--env", name])
|
||||||
|
argv.extend([self.name, "sh", "-s"])
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["container", "exec", "--user", user, "--interactive",
|
argv,
|
||||||
self.name, "sh", "-s"],
|
|
||||||
input=script,
|
input=script,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
env={**os.environ, **self._exec_env} if self._exec_env else None,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
return ExecResult(
|
return ExecResult(
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ from .. import BottlePlan
|
|||||||
class MacosContainerBottlePlan(BottlePlan):
|
class MacosContainerBottlePlan(BottlePlan):
|
||||||
slug: str
|
slug: str
|
||||||
forwarded_env: dict[str, str] = field(repr=False)
|
forwarded_env: dict[str, str] = field(repr=False)
|
||||||
agent_proxy_url: str = ""
|
|
||||||
agent_git_gate_url: str = ""
|
agent_git_gate_url: str = ""
|
||||||
agent_supervise_url: str = ""
|
agent_supervise_url: str = ""
|
||||||
|
# Read by provision-time consumers (git extraHeader, supervise MCP header)
|
||||||
|
# via getattr(plan, "identity_token", ""); stamped in launch after the
|
||||||
|
# bottle is registered. See launch.py's stamp for why it lives here and not
|
||||||
|
# only in the exec-time proxy env.
|
||||||
|
identity_token: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def container_name(self) -> str:
|
def container_name(self) -> str:
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Consolidated bottle launch sequence for the macOS backend (PRD 0070).
|
||||||
|
|
||||||
|
The docker backend allocates a free address, pins the agent to it with
|
||||||
|
`--ip`, registers it, *then* starts the agent — registration precedes launch
|
||||||
|
because the pinned address is known up front.
|
||||||
|
|
||||||
|
**Apple Container 1.0.0 has no `--ip`.** The `--network` flag takes only
|
||||||
|
`<name>[,mac=…][,mtu=…]`; the address is assigned by vmnet's DHCP and is
|
||||||
|
knowable only once the container is running. So the macOS order inverts:
|
||||||
|
|
||||||
|
ensure_gateway() -> caller starts the agent -> register_agent(source_ip)
|
||||||
|
|
||||||
|
That is why this module exposes two functions where docker has one — the
|
||||||
|
caller has to start the agent in between. `ensure_gateway` runs first because
|
||||||
|
the agent's proxy env needs the gateway's address at `container run` time; the
|
||||||
|
agent's *own* address (the attribution key) only exists afterwards.
|
||||||
|
|
||||||
|
The control plane and the gateway are one **infra container** here (see
|
||||||
|
`infra`), so `gateway_ip` and the control-plane host are the same address.
|
||||||
|
|
||||||
|
The consequence for the identity token: it is minted by registration, i.e.
|
||||||
|
*after* the agent container exists, so it cannot be baked into the run-time
|
||||||
|
env the way docker's compose spec does. It is delivered at `container exec`
|
||||||
|
time instead — see `bottle.MacosContainerBottle`.
|
||||||
|
|
||||||
|
That delivery is load-bearing, not a nicety: `/resolve` requires a matching
|
||||||
|
`(source_ip, identity_token)` pair and fail-closes with no source-IP-only
|
||||||
|
fallback (#366). So egress that does not carry the token is denied — which is
|
||||||
|
the safe direction, and is why the agent's init process is a bare `sleep` and
|
||||||
|
every real command arrives through `container exec`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from ...egress import EgressPlan
|
||||||
|
from ...git_gate import GitGatePlan
|
||||||
|
from ...orchestrator.client import OrchestratorClient
|
||||||
|
from ...orchestrator.registration import registration_inputs
|
||||||
|
from ..docker.gateway_provision import deprovision_git_gate, provision_git_gate
|
||||||
|
from .gateway import GATEWAY_NETWORK
|
||||||
|
from .gateway_provision import AppleGatewayTransport
|
||||||
|
from .infra import MacosInfraService, OrchestratorStartError
|
||||||
|
|
||||||
|
|
||||||
|
class ConsolidatedLaunchError(RuntimeError):
|
||||||
|
"""The consolidated register/provision sequence could not complete."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GatewayEndpoint:
|
||||||
|
"""What the agent `container run` needs to reach the shared gateway (the
|
||||||
|
infra container). `gateway_ip` is that container's host-only address, the
|
||||||
|
same host the control-plane URL points at."""
|
||||||
|
|
||||||
|
orchestrator_url: str
|
||||||
|
gateway_ip: str # the gateway's address — the agent's proxy target
|
||||||
|
gateway_ca_pem: str # the shared CA the provisioner installs
|
||||||
|
network: str # the shared host-only network to attach to
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LaunchContext:
|
||||||
|
"""What the running agent needs once it has been registered."""
|
||||||
|
|
||||||
|
bottle_id: str
|
||||||
|
identity_token: str
|
||||||
|
source_ip: str # the agent's DHCP-assigned address (attribution key)
|
||||||
|
gateway_ip: str
|
||||||
|
network: str
|
||||||
|
orchestrator_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_gateway(
|
||||||
|
*, service: MacosInfraService | None = None,
|
||||||
|
) -> GatewayEndpoint:
|
||||||
|
"""Ensure the per-host infra container (control plane + gateway) is up and
|
||||||
|
report how to reach it. Idempotent — one singleton, so N bottle launches
|
||||||
|
share it. Call before starting the agent container: the agent's proxy env
|
||||||
|
needs `gateway_ip` at run time."""
|
||||||
|
service = service or MacosInfraService()
|
||||||
|
infra = service.ensure_running()
|
||||||
|
return GatewayEndpoint(
|
||||||
|
orchestrator_url=infra.control_plane_url,
|
||||||
|
gateway_ip=infra.gateway_ip,
|
||||||
|
gateway_ca_pem=service.ca_cert_pem(),
|
||||||
|
network=service.network,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_agent(
|
||||||
|
egress_plan: EgressPlan,
|
||||||
|
git_gate_plan: GitGatePlan,
|
||||||
|
*,
|
||||||
|
source_ip: str,
|
||||||
|
endpoint: GatewayEndpoint,
|
||||||
|
image_ref: str = "",
|
||||||
|
tokens: dict[str, str] | None = None,
|
||||||
|
) -> LaunchContext:
|
||||||
|
"""Register the (already running) agent by its address and provision its
|
||||||
|
git-gate state into the gateway. `source_ip` must be read from the live
|
||||||
|
container — it is the attribution key the gateway resolves policy by.
|
||||||
|
Raises on failure; the caller tears down."""
|
||||||
|
client = OrchestratorClient(endpoint.orchestrator_url)
|
||||||
|
inputs = registration_inputs(egress_plan)
|
||||||
|
reg = client.register_bottle(
|
||||||
|
source_ip, image_ref=image_ref, policy=inputs.policy,
|
||||||
|
metadata=inputs.metadata, tokens=tokens,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
provision_git_gate(AppleGatewayTransport(), reg.bottle_id, git_gate_plan)
|
||||||
|
except Exception:
|
||||||
|
# Roll the registration back so a provisioning failure leaves no orphan.
|
||||||
|
client.teardown_bottle(reg.bottle_id)
|
||||||
|
raise
|
||||||
|
return LaunchContext(
|
||||||
|
bottle_id=reg.bottle_id,
|
||||||
|
identity_token=reg.identity_token,
|
||||||
|
source_ip=source_ip,
|
||||||
|
gateway_ip=endpoint.gateway_ip,
|
||||||
|
network=endpoint.network,
|
||||||
|
orchestrator_url=endpoint.orchestrator_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_consolidated(bottle_id: str, *, orchestrator_url: str) -> None:
|
||||||
|
"""Deregister the bottle and remove its git-gate state from the gateway.
|
||||||
|
Both steps are idempotent so this is safe from a cleanup trap. Does NOT
|
||||||
|
stop the gateway — it's a persistent per-host singleton."""
|
||||||
|
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||||
|
deprovision_git_gate(AppleGatewayTransport(), bottle_id)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GatewayEndpoint",
|
||||||
|
"LaunchContext",
|
||||||
|
"ensure_gateway",
|
||||||
|
"register_agent",
|
||||||
|
"teardown_consolidated",
|
||||||
|
"ConsolidatedLaunchError",
|
||||||
|
"OrchestratorStartError",
|
||||||
|
"GATEWAY_NETWORK",
|
||||||
|
]
|
||||||
@@ -1,36 +1,26 @@
|
|||||||
"""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). 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 — same posture as the docker backend.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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); 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 'container kill failed'}"
|
|
||||||
)
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not reload egress bundle {container}: "
|
|
||||||
f"{last_error or 'container kill failed'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
applicator = MacOSContainerEgressApplicator()
|
applicator = MacOSContainerEgressApplicator()
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import subprocess
|
|||||||
|
|
||||||
from ...bottle_state import read_metadata
|
from ...bottle_state import read_metadata
|
||||||
from .. import ActiveAgent
|
from .. import ActiveAgent
|
||||||
|
from .infra import INFRA_NAME
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
_PREFIX = "bot-bottle-"
|
||||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
# The shared per-host infra container carries the same prefix as agent
|
||||||
|
# containers but is infrastructure, not a bottle — one control plane + gateway
|
||||||
|
# serves every agent, so listing it as an agent would invent one per host.
|
||||||
|
_INFRA_NAMES = frozenset({INFRA_NAME})
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
def enumerate_active() -> list[ActiveAgent]:
|
||||||
@@ -22,9 +26,7 @@ def enumerate_active() -> list[ActiveAgent]:
|
|||||||
return []
|
return []
|
||||||
out: list[ActiveAgent] = []
|
out: list[ActiveAgent] = []
|
||||||
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
||||||
if not name.startswith(_PREFIX):
|
if not name.startswith(_PREFIX) or name in _INFRA_NAMES:
|
||||||
continue
|
|
||||||
if name.startswith(_SIDECAR_PREFIX):
|
|
||||||
continue
|
continue
|
||||||
slug = name[len(_PREFIX):]
|
slug = name[len(_PREFIX):]
|
||||||
metadata = read_metadata(slug)
|
metadata = read_metadata(slug)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Shared network/image constants for the macOS consolidated infra container.
|
||||||
|
|
||||||
|
The gateway data plane no longer runs as its own Apple container — it shares a
|
||||||
|
single per-host **infra container** with the control plane (see `infra`),
|
||||||
|
because two Apple-Container guests writing one `bot-bottle.db` over virtiofs
|
||||||
|
would race incoherent `fcntl` locks. This module holds the pieces both the
|
||||||
|
infra service and the launch/provision glue need: the network names, the
|
||||||
|
gateway image, and the network-creation helper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from ...orchestrator.gateway import GatewayError
|
||||||
|
from . import util as container_mod
|
||||||
|
|
||||||
|
# The shared host-only network the infra container and every agent bottle sit
|
||||||
|
# on. The agent's address here is the attribution key. Distinct from the docker
|
||||||
|
# names so both backends can coexist on one host.
|
||||||
|
GATEWAY_NETWORK = "bot-bottle-mac-gateway"
|
||||||
|
# The NAT network that gives the infra container (and only it) a route out.
|
||||||
|
GATEWAY_EGRESS_NETWORK = "bot-bottle-mac-egress"
|
||||||
|
|
||||||
|
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||||
|
|
||||||
|
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_networks(
|
||||||
|
network: str = GATEWAY_NETWORK, egress_network: str = GATEWAY_EGRESS_NETWORK,
|
||||||
|
) -> None:
|
||||||
|
"""Create the shared host-only network + the NAT egress network. Idempotent
|
||||||
|
— `create_network` tolerates 'already exists'."""
|
||||||
|
container_mod.create_network(egress_network)
|
||||||
|
container_mod.create_network(network, internal=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GATEWAY_NETWORK",
|
||||||
|
"GATEWAY_EGRESS_NETWORK",
|
||||||
|
"GATEWAY_IMAGE",
|
||||||
|
"GatewayError",
|
||||||
|
"DEFAULT_CA_TIMEOUT_SECONDS",
|
||||||
|
"ensure_networks",
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""`GatewayTransport` for the Apple infra container (PRD 0070).
|
||||||
|
|
||||||
|
The provisioning *logic* (per-bottle creds dirs, namespaced repo init) is
|
||||||
|
backend-neutral and lives in `backend.docker.gateway_provision`; this is only
|
||||||
|
the transport — how files and commands reach the running gateway. Docker uses
|
||||||
|
`docker exec`/`docker cp` and Firecracker uses SSH; Apple uses the `container`
|
||||||
|
CLI's equivalents against the infra container that hosts the gateway daemons.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..docker.gateway_provision import GatewayProvisionError
|
||||||
|
from . import util as container_mod
|
||||||
|
from .infra import INFRA_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class AppleGatewayTransport:
|
||||||
|
"""`GatewayTransport` for the gateway daemons in the Apple infra container."""
|
||||||
|
|
||||||
|
def __init__(self, gateway: str = INFRA_NAME) -> None:
|
||||||
|
self.gateway = gateway
|
||||||
|
|
||||||
|
def exec(self, argv: list[str]) -> None:
|
||||||
|
result = container_mod.run_container_argv(
|
||||||
|
["container", "exec", self.gateway, *argv]
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise GatewayProvisionError(
|
||||||
|
f"gateway exec {argv!r} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def cp_into(self, src: str, dest: str) -> None:
|
||||||
|
result = container_mod.run_container_argv(
|
||||||
|
["container", "cp", src, f"{self.gateway}:{dest}"]
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise GatewayProvisionError(
|
||||||
|
f"gateway cp {src} -> {dest} failed: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AppleGatewayTransport", "GatewayProvisionError"]
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""The per-host infra container for the macOS backend (PRD 0070).
|
||||||
|
|
||||||
|
A single persistent Apple container that runs BOTH the orchestrator control
|
||||||
|
plane and the gateway data plane — the macOS analogue of the Firecracker infra
|
||||||
|
VM (`backend/firecracker/infra_vm.py`), not the docker backend's two separate
|
||||||
|
containers.
|
||||||
|
|
||||||
|
Why one container, not two: Apple Containers are lightweight VMs, each with its
|
||||||
|
own kernel. The docker backend runs the orchestrator and gateway as two
|
||||||
|
containers safely because they share the host kernel, so their concurrent
|
||||||
|
writes to the one `bot-bottle.db` (the orchestrator's registry + the gateway
|
||||||
|
supervise daemon's queue) are serialized by coherent `fcntl` locks. Across two
|
||||||
|
*guest* kernels sharing a virtiofs-mounted DB those locks are not coherent, and
|
||||||
|
concurrent writers can corrupt the file. Firecracker solved this by putting
|
||||||
|
both services in one guest with the DB on a device only that guest mounts; this
|
||||||
|
does the same with Apple primitives.
|
||||||
|
|
||||||
|
Two consequences fall out of the single container, both simplifications:
|
||||||
|
|
||||||
|
- **No DNS dance.** The control plane and the gateway daemons reach each other
|
||||||
|
over `127.0.0.1`, so nothing depends on Apple's (absent) container DNS and
|
||||||
|
there is no orchestrator-before-gateway ordering to get right.
|
||||||
|
- **The DB is never host-shared.** It lives on a container-only volume, so no
|
||||||
|
host process opens the live file. The host CLI reaches registry + supervise
|
||||||
|
state through the control-plane HTTP surface (`cli/supervise.py` already uses
|
||||||
|
`OrchestratorClient`), exactly as it does for firecracker.
|
||||||
|
|
||||||
|
The control-plane source is bind-mounted (like the docker orchestrator), so a
|
||||||
|
code change takes effect on the next launch without an image rebuild; the
|
||||||
|
gateway daemons are baked in the gateway image and rebuild through its own
|
||||||
|
digest check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ... import log
|
||||||
|
from ...orchestrator.gateway import GATEWAY_CA_CERT
|
||||||
|
from ...orchestrator.lifecycle import (
|
||||||
|
DEFAULT_PORT,
|
||||||
|
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||||
|
OrchestratorStartError,
|
||||||
|
source_hash,
|
||||||
|
)
|
||||||
|
from ...paths import (
|
||||||
|
CONTROL_PLANE_TOKEN_ENV,
|
||||||
|
HOST_DB_FILENAME,
|
||||||
|
host_control_plane_token,
|
||||||
|
)
|
||||||
|
from . import util as container_mod
|
||||||
|
from .gateway import (
|
||||||
|
DEFAULT_CA_TIMEOUT_SECONDS,
|
||||||
|
GATEWAY_EGRESS_NETWORK,
|
||||||
|
GATEWAY_IMAGE,
|
||||||
|
GATEWAY_NETWORK,
|
||||||
|
GatewayError,
|
||||||
|
ensure_networks,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The one per-host infra container: control plane + gateway data plane.
|
||||||
|
INFRA_NAME = "bot-bottle-mac-infra"
|
||||||
|
INFRA_LABEL = "bot-bottle-mac-infra=1"
|
||||||
|
# Container-only volume holding bot-bottle.db. No host bind-mount, so the DB is
|
||||||
|
# written by exactly one kernel (this container's). Survives recreation.
|
||||||
|
INFRA_DB_VOLUME = "bot-bottle-mac-db"
|
||||||
|
|
||||||
|
# BOT_BOTTLE_ROOT inside the container; host_db_path() resolves the DB to
|
||||||
|
# <root>/db/<filename> and the supervise daemon writes the same file.
|
||||||
|
_DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
|
||||||
|
_DB_PATH_IN_CONTAINER = f"{_DB_ROOT_IN_CONTAINER}/db/{HOST_DB_FILENAME}"
|
||||||
|
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
_HEALTH_POLL_SECONDS = 0.25
|
||||||
|
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
||||||
|
_CA_POLL_SECONDS = 0.5
|
||||||
|
|
||||||
|
# The gateway subset the consolidated model runs (no per-bottle git:// daemon).
|
||||||
|
_GATEWAY_DAEMONS = "egress,git-http,supervise"
|
||||||
|
|
||||||
|
|
||||||
|
def _init_script(port: int) -> str:
|
||||||
|
"""PID-1 init: start the control plane and the gateway daemons, both in
|
||||||
|
this container, reaching each other over loopback. Backgrounded so `wait`
|
||||||
|
reaps as PID 1. No `set -e` — a transient daemon failure must not kill the
|
||||||
|
whole container (gateway_init applies the same 'stay up' policy)."""
|
||||||
|
return (
|
||||||
|
"export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n"
|
||||||
|
f"mkdir -p $(dirname {_DB_PATH_IN_CONTAINER})\n"
|
||||||
|
# Control plane, from the bind-mounted source (stdlib-only package).
|
||||||
|
f"( cd {_SRC_IN_CONTAINER} && BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER} "
|
||||||
|
f"python3 -m bot_bottle.orchestrator --host 0.0.0.0 --port {port} "
|
||||||
|
"--broker stub ) &\n"
|
||||||
|
# Gateway data plane, multi-tenant against the local control plane.
|
||||||
|
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
|
||||||
|
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
|
||||||
|
f"SUPERVISE_DB_PATH={_DB_PATH_IN_CONTAINER} python3 /app/gateway_init.py ) &\n"
|
||||||
|
"while : ; do wait ; done\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InfraEndpoint:
|
||||||
|
"""How to reach the running infra container. The control plane and the
|
||||||
|
gateway are the same container, so one address serves both."""
|
||||||
|
|
||||||
|
control_plane_url: str # http://<infra ip>:8099 — host CLI + registration
|
||||||
|
gateway_ip: str # same container; agents' proxy / git-http / MCP target
|
||||||
|
|
||||||
|
|
||||||
|
class MacosInfraService:
|
||||||
|
"""Manages the single per-host infra container. Callers use
|
||||||
|
`ensure_running()` (returns the endpoint) and `ca_cert_pem()`."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
port: int = DEFAULT_PORT,
|
||||||
|
network: str = GATEWAY_NETWORK,
|
||||||
|
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
||||||
|
image: str = GATEWAY_IMAGE,
|
||||||
|
repo_root: Path = _REPO_ROOT,
|
||||||
|
name: str = INFRA_NAME,
|
||||||
|
db_volume: str = INFRA_DB_VOLUME,
|
||||||
|
) -> None:
|
||||||
|
self.port = port
|
||||||
|
self.network = network
|
||||||
|
self.egress_network = egress_network
|
||||||
|
self.image = image
|
||||||
|
self._repo_root = repo_root
|
||||||
|
self._name = name
|
||||||
|
self._db_volume = db_volume
|
||||||
|
|
||||||
|
def _resolve_url(self) -> str:
|
||||||
|
"""The control-plane URL, or "" while the container has no address."""
|
||||||
|
ip = container_mod.try_container_ipv4_on_network(self._name, self.network)
|
||||||
|
return f"http://{ip}:{self.port}" if ip else ""
|
||||||
|
|
||||||
|
def is_healthy(
|
||||||
|
self, url: str, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS,
|
||||||
|
) -> bool:
|
||||||
|
if not url:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{url}/health", timeout=timeout) as resp:
|
||||||
|
return resp.status == 200
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _source_current(self, current_hash: str) -> bool:
|
||||||
|
"""True iff the running infra container was created from the current
|
||||||
|
bind-mounted control-plane source. The control-plane process loads that
|
||||||
|
code at startup and won't reload it, so a stale container keeps serving
|
||||||
|
OLD code."""
|
||||||
|
if not container_mod.container_is_running(self._name):
|
||||||
|
return False
|
||||||
|
env = container_mod.container_env(self._name)
|
||||||
|
if not env:
|
||||||
|
return True # can't compare → don't churn a working container
|
||||||
|
return env.get("BOT_BOTTLE_SOURCE_HASH") == current_hash
|
||||||
|
|
||||||
|
def _running_healthy_endpoint(self, current_hash: str) -> InfraEndpoint | None:
|
||||||
|
"""The endpoint if the running container is BOTH source-current and
|
||||||
|
answering /health, else None (→ recreate). Health, not just the source
|
||||||
|
label, is what lets a wedged-but-current container self-heal instead of
|
||||||
|
being polled to death forever."""
|
||||||
|
if not self._source_current(current_hash):
|
||||||
|
return None
|
||||||
|
url = self._resolve_url()
|
||||||
|
if url and self.is_healthy(url):
|
||||||
|
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def ensure_built(self) -> None:
|
||||||
|
"""Ensure the gateway data-plane image exists. The control-plane source
|
||||||
|
is bind-mounted, not baked, so only the gateway image needs building."""
|
||||||
|
container_mod.build_image(
|
||||||
|
self.image, str(self._repo_root), dockerfile="Dockerfile.gateway",
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_running(
|
||||||
|
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||||
|
) -> InfraEndpoint:
|
||||||
|
"""Ensure the single infra container is up; return how to reach it.
|
||||||
|
Idempotent per-host singleton — a healthy container on current source
|
||||||
|
is left untouched, so N launches share the one control plane + gateway.
|
||||||
|
Raises `OrchestratorStartError` on startup timeout."""
|
||||||
|
current_hash = source_hash(self._repo_root)
|
||||||
|
endpoint = self._running_healthy_endpoint(current_hash)
|
||||||
|
if endpoint is not None:
|
||||||
|
return endpoint
|
||||||
|
self.ensure_built()
|
||||||
|
log.info("starting infra container", context={"name": self._name})
|
||||||
|
self._run_container(current_hash)
|
||||||
|
return self._wait_healthy(startup_timeout)
|
||||||
|
|
||||||
|
def _run_container(self, current_hash: str) -> None:
|
||||||
|
ensure_networks(self.network, self.egress_network)
|
||||||
|
container_mod.force_remove_container(self._name)
|
||||||
|
argv = [
|
||||||
|
"container", "run", "--detach",
|
||||||
|
"--name", self._name,
|
||||||
|
"--label", "bot-bottle.backend=macos-container",
|
||||||
|
"--label", INFRA_LABEL,
|
||||||
|
# NAT network FIRST so the gateway's egress has a default route;
|
||||||
|
# the host-only network is where agents (and the host CLI) reach it.
|
||||||
|
"--network", self.egress_network,
|
||||||
|
"--network", self.network,
|
||||||
|
"--dns", container_mod.dns_server(),
|
||||||
|
# Container-only DB volume: one kernel writes bot-bottle.db, never
|
||||||
|
# shared with the host or another guest.
|
||||||
|
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}",
|
||||||
|
# Bind-mount the control-plane source (read-only); a code change
|
||||||
|
# takes effect on relaunch with no image rebuild.
|
||||||
|
"--mount",
|
||||||
|
container_mod.bind_mount_spec(
|
||||||
|
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
|
||||||
|
# Baked onto the container so `_source_current` can detect a real
|
||||||
|
# control-plane code change and recreate.
|
||||||
|
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
|
||||||
|
# The control-plane secret, for BOTH the control plane (to require
|
||||||
|
# it) and the gateway's PolicyResolver (to present it) — they share
|
||||||
|
# this one container. Bare `--env NAME` inherits the value from the
|
||||||
|
# run process below, so the secret never lands on argv or in
|
||||||
|
# `container inspect`'s command line. The agent runs in a SEPARATE
|
||||||
|
# container that is never given this var, which is the whole point.
|
||||||
|
"--env", CONTROL_PLANE_TOKEN_ENV,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
self.image,
|
||||||
|
"-c", _init_script(self.port),
|
||||||
|
]
|
||||||
|
run_env = {**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()}
|
||||||
|
result = container_mod.run_container_argv(argv, env=run_env)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise OrchestratorStartError(
|
||||||
|
f"infra container failed to start: "
|
||||||
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wait_healthy(self, startup_timeout: float) -> InfraEndpoint:
|
||||||
|
deadline = time.monotonic() + startup_timeout
|
||||||
|
while True:
|
||||||
|
url = self._resolve_url()
|
||||||
|
if url and self.is_healthy(url):
|
||||||
|
log.info("infra container healthy", context={"url": url})
|
||||||
|
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise OrchestratorStartError(
|
||||||
|
f"infra container did not become healthy within "
|
||||||
|
f"{startup_timeout:g}s"
|
||||||
|
)
|
||||||
|
time.sleep(_HEALTH_POLL_SECONDS)
|
||||||
|
|
||||||
|
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
||||||
|
"""The gateway's mitmproxy CA (PEM) agents install to trust its TLS
|
||||||
|
interception. Read out of the container (the CA lives on a
|
||||||
|
container-internal path, not a host mount); polls because mitmproxy
|
||||||
|
writes it a beat after start."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
result = container_mod.run_container_argv(
|
||||||
|
["container", "exec", self._name, "cat", GATEWAY_CA_CERT])
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise GatewayError(
|
||||||
|
f"gateway CA not available in {self._name} after {timeout:g}s: "
|
||||||
|
f"{(result.stderr or '').strip() or 'empty'}"
|
||||||
|
)
|
||||||
|
time.sleep(_CA_POLL_SECONDS)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Remove the infra container (idempotent). The DB volume persists."""
|
||||||
|
container_mod.force_remove_container(self._name)
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_of(url: str) -> str:
|
||||||
|
"""The host from an http://host:port URL."""
|
||||||
|
return url.split("://", 1)[-1].rsplit(":", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def probe_control_plane_url(port: int = DEFAULT_PORT) -> str:
|
||||||
|
"""The running infra container's control-plane URL, or "" if it isn't up.
|
||||||
|
Used by host-side control-plane discovery (`discover_orchestrator_url`);
|
||||||
|
safe to call on any host — returns "" when the container or the `container`
|
||||||
|
CLI isn't present."""
|
||||||
|
ip = container_mod.try_container_ipv4_on_network(INFRA_NAME, GATEWAY_NETWORK)
|
||||||
|
return f"http://{ip}:{port}" if ip else ""
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MacosInfraService",
|
||||||
|
"InfraEndpoint",
|
||||||
|
"OrchestratorStartError",
|
||||||
|
"GatewayError",
|
||||||
|
"INFRA_NAME",
|
||||||
|
"INFRA_DB_VOLUME",
|
||||||
|
]
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
"""Launch flow for the macOS Apple Container backend.
|
"""Launch flow for the macOS Apple Container backend (PRD 0070).
|
||||||
|
|
||||||
This backend keeps the explicit proxy-env enforcement model for v1:
|
The agent container attaches to the **shared host-only gateway network** and
|
||||||
the agent container is attached only to a host-only Apple Container
|
proxies egress through the one per-host gateway, replacing the per-bottle
|
||||||
network, while the sidecar bundle is attached to a NAT network first
|
companion container removed in #385.
|
||||||
and the host-only network second. The sidecar's host-only IP is
|
|
||||||
discovered from `container inspect` and stamped into the agent's
|
The order differs from docker's, forced by Apple Container 1.0.0 having no
|
||||||
HTTP_PROXY / HTTPS_PROXY env vars.
|
`--ip` (see `consolidated_launch`): the agent is started *before* it is
|
||||||
|
registered, because its DHCP-assigned address — the attribution key — does not
|
||||||
|
exist until then.
|
||||||
|
|
||||||
|
gateway up -> run agent -> read its IP -> register it -> provision
|
||||||
|
|
||||||
|
Two things follow from that inversion:
|
||||||
|
|
||||||
|
- The **identity token** is minted by registration and so cannot be in the
|
||||||
|
agent's run-time env; it rides the proxy URL applied at `container exec`
|
||||||
|
time (`bottle.MacosContainerBottle`). `/resolve` requires it (#366), so
|
||||||
|
egress without it is denied — hence the bare `sleep` init: every real agent
|
||||||
|
command goes through exec and therefore carries the token.
|
||||||
|
- The agent is run with `--cap-drop CAP_NET_RAW`. Apple Container grants
|
||||||
|
NET_RAW by default, which would let an agent open a raw socket and forge a
|
||||||
|
neighbour's source address on the shared segment. NET_ADMIN is already
|
||||||
|
absent (the agent cannot change its own address or route), so dropping
|
||||||
|
NET_RAW is what closes the source-address half of PRD 0070's invariant:
|
||||||
|
"a packet's source address, as seen by the orchestrator, provably identifies
|
||||||
|
the originating bottle." The identity token is the other half — an attacker
|
||||||
|
would need to forge the address *and* steal the token — but the invariant is
|
||||||
|
a stated precondition of consolidation, so it is enforced on its own terms
|
||||||
|
rather than left to the token.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,52 +45,30 @@ from ...bottle_state import (
|
|||||||
read_committed_image,
|
read_committed_image,
|
||||||
)
|
)
|
||||||
from ...egress import (
|
from ...egress import (
|
||||||
EGRESS_ROUTES_IN_CONTAINER,
|
|
||||||
egress_agent_env_entries,
|
egress_agent_env_entries,
|
||||||
egress_resolve_token_values,
|
egress_resolve_token_values,
|
||||||
egress_sidecar_env_entries,
|
|
||||||
)
|
)
|
||||||
from ...git_gate import (
|
from ...git_gate import (
|
||||||
provision_git_gate_dynamic_keys,
|
provision_git_gate_dynamic_keys,
|
||||||
revoke_git_gate_provisioned_keys,
|
revoke_git_gate_provisioned_keys,
|
||||||
)
|
)
|
||||||
|
from ...git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
from ...supervise import SUPERVISE_PORT
|
||||||
from ...util import expand_tilde
|
from ..docker.egress import EGRESS_PORT
|
||||||
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 ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||||
from . import util as container_mod
|
from . import util as container_mod
|
||||||
from .bottle import MacosContainerBottle
|
from .bottle import MacosContainerBottle
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
from .bottle_plan import MacosContainerBottlePlan
|
||||||
|
from .consolidated_launch import (
|
||||||
|
GatewayEndpoint,
|
||||||
|
ensure_gateway,
|
||||||
|
register_agent,
|
||||||
|
teardown_consolidated,
|
||||||
|
)
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
_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
|
||||||
@@ -77,7 +77,8 @@ def launch(
|
|||||||
*,
|
*,
|
||||||
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."""
|
"""Build, run, register, provision, and yield an Apple Container bottle on
|
||||||
|
the shared per-host gateway."""
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
bottle_for_revoke = plan.manifest.bottle
|
bottle_for_revoke = plan.manifest.bottle
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||||
@@ -94,30 +95,66 @@ def launch(
|
|||||||
raise teardown_exc
|
raise teardown_exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plan = _mint_certs(plan)
|
|
||||||
plan = _build_images(plan)
|
plan = _build_images(plan)
|
||||||
|
|
||||||
internal_network = internal_network_name(plan.slug)
|
# Step 1: the per-host singletons. Must precede the agent run — its
|
||||||
egress_network = egress_network_name(plan.slug)
|
# proxy env needs the gateway's address at `container run` time.
|
||||||
_create_networks(internal_network, egress_network, stack)
|
endpoint = ensure_gateway()
|
||||||
|
|
||||||
|
# Step 2: mint this bottle's deploy keys, then point it at the SHARED
|
||||||
|
# gateway's CA + git-http/supervise ports.
|
||||||
plan = _provision_git_gate_keys(plan)
|
plan = _provision_git_gate_keys(plan)
|
||||||
|
plan = _install_gateway_ca(plan, endpoint)
|
||||||
|
plan = _stamp_agent_urls(plan, endpoint)
|
||||||
|
|
||||||
sidecar_name = sidecar_container_name(plan.slug)
|
# Step 3: run the agent. It has no identity token yet — registration
|
||||||
container_mod.force_remove_container(sidecar_name)
|
# needs the address this run assigns.
|
||||||
_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)
|
container_mod.force_remove_container(plan.container_name)
|
||||||
_start_agent(plan, internal_network, sidecar_ip)
|
_start_agent(plan, endpoint)
|
||||||
stack.callback(container_mod.force_remove_container, plan.container_name)
|
stack.callback(container_mod.force_remove_container, plan.container_name)
|
||||||
|
|
||||||
|
# Step 4: read the assigned address and register by it. This is the
|
||||||
|
# attribution key; `--cap-drop CAP_NET_RAW` at run is what makes it
|
||||||
|
# unforgeable. Poll: `container run --detach` can return before vmnet's
|
||||||
|
# DHCP has assigned the address.
|
||||||
|
source_ip = container_mod.wait_container_ipv4_on_network(
|
||||||
|
plan.container_name, endpoint.network,
|
||||||
|
)
|
||||||
|
if not source_ip:
|
||||||
|
die(
|
||||||
|
f"agent {plan.container_name} never got an address on "
|
||||||
|
f"{endpoint.network}"
|
||||||
|
)
|
||||||
|
effective_env = {**os.environ, **plan.agent_provision.provisioned_env}
|
||||||
|
token_values = egress_resolve_token_values(
|
||||||
|
plan.egress_plan.token_env_map, effective_env,
|
||||||
|
)
|
||||||
|
ctx = register_agent(
|
||||||
|
plan.egress_plan,
|
||||||
|
plan.git_gate_plan,
|
||||||
|
source_ip=source_ip,
|
||||||
|
endpoint=endpoint,
|
||||||
|
image_ref=plan.image,
|
||||||
|
tokens=token_values,
|
||||||
|
)
|
||||||
|
stack.callback(
|
||||||
|
teardown_consolidated, ctx.bottle_id,
|
||||||
|
orchestrator_url=ctx.orchestrator_url,
|
||||||
|
)
|
||||||
|
info(
|
||||||
|
f"agent {plan.container_name} registered "
|
||||||
|
f"(gateway {endpoint.gateway_ip}, ip {source_ip})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stamp the token onto the plan so provision-time consumers can read it,
|
||||||
|
# not only the exec-time egress proxy. git-gate's gitconfig extraHeader
|
||||||
|
# and the supervise MCP --header both reach the gateway on NO_PROXY (they
|
||||||
|
# bypass the egress proxy that carries the token), so without this the
|
||||||
|
# gateway's /resolve fail-closes and every git fetch/push and supervise
|
||||||
|
# call from the bottle is denied. Registration already produced the
|
||||||
|
# token above, so — unlike the run-time env — the plan CAN carry it.
|
||||||
|
plan = dataclasses.replace(plan, identity_token=ctx.identity_token)
|
||||||
|
|
||||||
bottle = MacosContainerBottle(
|
bottle = MacosContainerBottle(
|
||||||
plan.container_name,
|
plan.container_name,
|
||||||
teardown,
|
teardown,
|
||||||
@@ -125,9 +162,13 @@ def launch(
|
|||||||
agent_command=plan.agent_command,
|
agent_command=plan.agent_command,
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
agent_prompt_mode=plan.agent_prompt_mode,
|
||||||
agent_provider_template=plan.agent_provider_template,
|
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_title=(
|
||||||
|
f"{plan.spec.label} ({plan.spec.agent_name})"
|
||||||
|
if plan.spec.label else plan.spec.agent_name
|
||||||
|
),
|
||||||
terminal_color=plan.spec.color,
|
terminal_color=plan.spec.color,
|
||||||
agent_workdir=plan.workspace_plan.workdir,
|
agent_workdir=plan.workspace_plan.workdir,
|
||||||
|
exec_env=_identity_proxy_env(endpoint, ctx.identity_token),
|
||||||
)
|
)
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
bottle.prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
@@ -136,116 +177,24 @@ def launch(
|
|||||||
teardown()
|
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(
|
|
||||||
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:
|
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||||
container_mod.build_image(
|
"""Build the agent image. The gateway's own image is built by
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
`ensure_gateway` — it belongs to the shared singleton, not to a bottle."""
|
||||||
_REPO_DIR,
|
|
||||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
)
|
|
||||||
committed = read_committed_image(plan.slug)
|
committed = read_committed_image(plan.slug)
|
||||||
if committed and container_mod.image_exists(committed):
|
if committed and container_mod.image_exists(committed):
|
||||||
info(f"using committed image {committed!r}")
|
info(f"using committed image {committed!r}")
|
||||||
return dataclasses.replace(
|
return dataclasses.replace(
|
||||||
plan,
|
plan,
|
||||||
agent_provision=dataclasses.replace(
|
agent_provision=dataclasses.replace(
|
||||||
plan.agent_provision,
|
plan.agent_provision, image=committed,
|
||||||
image=committed,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
container_mod.build_image(
|
container_mod.build_image(
|
||||||
plan.image,
|
plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path,
|
||||||
_REPO_DIR,
|
|
||||||
dockerfile=plan.dockerfile_path,
|
|
||||||
)
|
)
|
||||||
return plan
|
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(
|
def _provision_git_gate_keys(
|
||||||
plan: MacosContainerBottlePlan,
|
plan: MacosContainerBottlePlan,
|
||||||
) -> MacosContainerBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
@@ -259,177 +208,120 @@ def _provision_git_gate_keys(
|
|||||||
return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
|
return dataclasses.replace(plan, git_gate_plan=git_gate_plan)
|
||||||
|
|
||||||
|
|
||||||
def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None:
|
def _install_gateway_ca(
|
||||||
gp = plan.git_gate_plan
|
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint,
|
||||||
if not gp.upstreams:
|
) -> MacosContainerBottlePlan:
|
||||||
return
|
"""Stage the SHARED gateway's CA for the provisioner to install, replacing
|
||||||
|
the per-bottle CA the companion container used to mint. Every bottle on
|
||||||
|
this host trusts this one CA."""
|
||||||
|
ca_dir = egress_state_dir(plan.slug) / "gateway-ca"
|
||||||
|
ca_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ca_file = ca_dir / "gateway-ca.pem"
|
||||||
|
ca_file.write_text(endpoint.gateway_ca_pem)
|
||||||
|
egress_plan = dataclasses.replace(
|
||||||
|
plan.egress_plan,
|
||||||
|
mitmproxy_ca_host_path=ca_file,
|
||||||
|
mitmproxy_ca_cert_only_host_path=ca_file,
|
||||||
|
)
|
||||||
|
return dataclasses.replace(plan, egress_plan=egress_plan)
|
||||||
|
|
||||||
container_mod.exec_container(
|
|
||||||
sidecar_name,
|
def _stamp_agent_urls(
|
||||||
[
|
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint,
|
||||||
"mkdir",
|
) -> MacosContainerBottlePlan:
|
||||||
"-p",
|
"""Point the agent's git-gate insteadOf rewrites + supervise MCP at the
|
||||||
str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
|
shared gateway's ports. Both bypass the egress proxy (NO_PROXY covers the
|
||||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
gateway address)."""
|
||||||
"/git",
|
git_gate_url = (
|
||||||
str(Path(_GIT_GATE_READY_FILE).parent),
|
f"http://{endpoint.gateway_ip}:{_GIT_HTTP_PORT}"
|
||||||
],
|
if plan.git_gate_plan.upstreams else ""
|
||||||
|
)
|
||||||
|
supervise_url = (
|
||||||
|
f"http://{endpoint.gateway_ip}:{SUPERVISE_PORT}/"
|
||||||
|
if plan.supervise_plan is not None else ""
|
||||||
|
)
|
||||||
|
return dataclasses.replace(
|
||||||
|
plan,
|
||||||
|
agent_git_gate_url=git_gate_url,
|
||||||
|
agent_supervise_url=supervise_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
for host_path, container_path in _git_gate_files(plan):
|
|
||||||
container_mod.copy_into_container(
|
def _proxy_url(gateway_ip: str, identity_token: str = "") -> str:
|
||||||
sidecar_name, host_path, container_path,
|
"""The agent's egress proxy URL. The identity token rides as proxy
|
||||||
|
credentials — the gateway reads Proxy-Authorization, resolves the
|
||||||
|
(source_ip, token) pair against the control plane, and strips it before
|
||||||
|
upstream. Without a valid pair `/resolve` denies the request (#366)."""
|
||||||
|
cred = f"bottle:{identity_token}@" if identity_token else ""
|
||||||
|
return f"http://{cred}{gateway_ip}:{EGRESS_PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
def _no_proxy(gateway_ip: str) -> str:
|
||||||
|
# git-http + supervise live on the gateway and must NOT go through the
|
||||||
|
# egress proxy — the agent reaches them directly by its address.
|
||||||
|
return f"localhost,127.0.0.1,{gateway_ip}"
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_proxy_env(
|
||||||
|
endpoint: GatewayEndpoint, identity_token: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""The token-bearing proxy env applied at `container exec`. It supersedes
|
||||||
|
the token-less run-time value (exec `--env` wins), which is the only way to
|
||||||
|
get the token in: it does not exist until after the container runs."""
|
||||||
|
if not identity_token:
|
||||||
|
return {}
|
||||||
|
url = _proxy_url(endpoint.gateway_ip, identity_token)
|
||||||
|
return {
|
||||||
|
"HTTPS_PROXY": url, "HTTP_PROXY": url,
|
||||||
|
"https_proxy": url, "http_proxy": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _start_agent(plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint) -> None:
|
||||||
|
argv = _agent_run_argv(plan, endpoint)
|
||||||
|
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>'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
def _agent_run_argv(
|
||||||
plan: MacosContainerBottlePlan,
|
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint,
|
||||||
internal_network: str,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
argv = [
|
argv = [
|
||||||
"container", "run",
|
"container", "run",
|
||||||
"--name", plan.container_name,
|
"--name", plan.container_name,
|
||||||
"--detach",
|
"--detach",
|
||||||
"--network", internal_network,
|
"--label", "bot-bottle.backend=macos-container",
|
||||||
|
"--network", endpoint.network,
|
||||||
|
# The attribution invariant: without NET_RAW the agent cannot open a
|
||||||
|
# raw socket, so it cannot source-IP-spoof its neighbours on the shared
|
||||||
|
# segment. NET_ADMIN is not granted by default, so its address and
|
||||||
|
# route are already fixed. See the module docstring.
|
||||||
|
"--cap-drop", "CAP_NET_RAW",
|
||||||
]
|
]
|
||||||
for entry in _agent_env_entries(plan, sidecar_ip):
|
for entry in _agent_env_entries(plan, endpoint):
|
||||||
argv += ["--env", entry]
|
argv += ["--env", entry]
|
||||||
|
# The init process is a no-op: every agent command arrives via
|
||||||
|
# `container exec`, which is also how the identity token gets in.
|
||||||
argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS]
|
argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS]
|
||||||
return argv
|
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(
|
def _agent_env_entries(
|
||||||
plan: MacosContainerBottlePlan,
|
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint,
|
||||||
sidecar_ip: str,
|
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
# Token-less at run time — the token does not exist yet (see
|
||||||
no_proxy = _agent_no_proxy(plan, sidecar_ip)
|
# `_identity_proxy_env`). Anything egressing before the exec-time override
|
||||||
|
# is denied by `/resolve`, which is the safe direction.
|
||||||
|
proxy_url = _proxy_url(endpoint.gateway_ip)
|
||||||
|
no_proxy = _no_proxy(endpoint.gateway_ip)
|
||||||
env = [
|
env = [
|
||||||
f"HTTPS_PROXY={proxy_url}",
|
f"HTTPS_PROXY={proxy_url}",
|
||||||
f"HTTP_PROXY={proxy_url}",
|
f"HTTP_PROXY={proxy_url}",
|
||||||
@@ -447,12 +339,12 @@ def _agent_env_entries(
|
|||||||
env.append(f"MCP_SUPERVISE_URL={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()):
|
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
||||||
env.append(f"{name}={value}")
|
env.append(f"{name}={value}")
|
||||||
|
# Forwarded vars: bare name → inherits from the `container run` process env
|
||||||
|
# so the secret value never lands on argv.
|
||||||
for name in sorted(plan.forwarded_env.keys()):
|
for name in sorted(plan.forwarded_env.keys()):
|
||||||
env.append(name)
|
env.append(name)
|
||||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||||
return tuple(env)
|
return tuple(env)
|
||||||
|
|
||||||
|
|
||||||
def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str:
|
__all__ = ["launch"]
|
||||||
hosts = ["localhost", "127.0.0.1", sidecar_ip]
|
|
||||||
return ",".join(hosts)
|
|
||||||
|
|||||||
@@ -60,13 +60,21 @@ def dns_server() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||||
"""Build an OCI image with Apple's BuildKit-backed `container build`."""
|
"""Build an OCI image with Apple's BuildKit-backed `container build`.
|
||||||
|
|
||||||
|
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
|
||||||
|
`--no-cache`. The npm/curl installers some provider Dockerfiles
|
||||||
|
shell out to can silently no-op on a transient network failure —
|
||||||
|
e.g. an `optionalDependencies` fetch for a platform-native binary —
|
||||||
|
and the builder will then cache that broken layer indefinitely."""
|
||||||
info(
|
info(
|
||||||
f"building image {ref} from {context} with Apple Container "
|
f"building image {ref} from {context} with Apple Container "
|
||||||
"(layer cache keeps repeat builds fast)"
|
"(layer cache keeps repeat builds fast)"
|
||||||
)
|
)
|
||||||
_ensure_builder_dns()
|
_ensure_builder_dns()
|
||||||
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
||||||
|
if os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
||||||
|
args.append("--no-cache")
|
||||||
if dockerfile:
|
if dockerfile:
|
||||||
# `container build` resolves -f relative to the current working
|
# `container build` resolves -f relative to the current working
|
||||||
# directory, not the build context. Anchor a relative Dockerfile to
|
# directory, not the build context. Anchor a relative Dockerfile to
|
||||||
@@ -78,6 +86,28 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|||||||
subprocess.run(args, check=True)
|
subprocess.run(args, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_agent_image(image: str, argv: tuple[str, ...]) -> None:
|
||||||
|
"""Run `argv` inside a throwaway container of a freshly built agent
|
||||||
|
image and die loudly if it fails, instead of shipping an image
|
||||||
|
whose CLI only breaks at first real use. No-op when the provider
|
||||||
|
hasn't declared a smoke test (`AgentProviderRuntime.smoke_test`)."""
|
||||||
|
if not argv:
|
||||||
|
return
|
||||||
|
result = subprocess.run(
|
||||||
|
[_CONTAINER, "run", "--rm", "--entrypoint", argv[0], image, *argv[1:]],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout or "").strip()
|
||||||
|
die(
|
||||||
|
f"agent image {image!r} failed its post-build smoke test "
|
||||||
|
f"({' '.join(argv)}): {detail}\n"
|
||||||
|
f"Try rebuilding from scratch: bot-bottle start --no-cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def commit_container(container_name: str, image_tag: str) -> None:
|
def commit_container(container_name: str, image_tag: str) -> None:
|
||||||
"""Snapshot a running Apple Container as a local image.
|
"""Snapshot a running Apple Container as a local image.
|
||||||
|
|
||||||
@@ -407,22 +437,145 @@ def inspect_container(name: str) -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
def container_ipv4_on_network(name: str, network: str) -> str:
|
def container_ipv4_on_network(name: str, network: str) -> str:
|
||||||
data = inspect_container(name)
|
"""The container's IPv4 address on `network`. Fatal if absent — callers
|
||||||
status = data.get("status")
|
that can tolerate "not yet" want `try_container_ipv4_on_network`."""
|
||||||
|
ip = try_container_ipv4_on_network(name, network)
|
||||||
|
if not ip:
|
||||||
|
die(f"container {name} has no IPv4 address on {network}")
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def run_container_argv(
|
||||||
|
argv: list[str], *, env: dict[str, str] | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Run a `container` command, returning the result for the caller to
|
||||||
|
interpret. Unlike the `die`-on-failure helpers above, this lets callers
|
||||||
|
that raise their own typed errors (the gateway / orchestrator lifecycle)
|
||||||
|
keep control of the failure path.
|
||||||
|
|
||||||
|
`env` sets the child process environment — used to hand a secret to a bare
|
||||||
|
`--env NAME` flag (Apple's "just key → inherit from host" form) so the
|
||||||
|
value is inherited from this process, never written onto argv or into
|
||||||
|
`container inspect`'s recorded command line."""
|
||||||
|
return subprocess.run(
|
||||||
|
argv, capture_output=True, text=True, check=False, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_mount_spec(source: str, target: str, *, readonly: bool = False) -> str:
|
||||||
|
"""A `container run --mount` bind spec. One definition so the gateway and
|
||||||
|
orchestrator emit an identical string — a divergence here would silently
|
||||||
|
break one backend's mounts while the other kept working."""
|
||||||
|
spec = f"type=bind,source={source},target={target}"
|
||||||
|
if readonly:
|
||||||
|
spec += ",readonly"
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_digest(value: str) -> str:
|
||||||
|
return value.split(":", 1)[1] if ":" in value else value
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_first(argv: list[str]) -> dict[str, object]:
|
||||||
|
"""Run an inspect command and return its first JSON object, or {} on any
|
||||||
|
failure (non-zero exit, malformed JSON, unexpected shape). {} is the shared
|
||||||
|
'don't know' signal all the non-fatal inspect readers below build on — a
|
||||||
|
caller comparing against it treats it as 'leave the working container
|
||||||
|
alone', never as a mismatch."""
|
||||||
|
result = run_container_argv(argv)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(result.stdout or "[]")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
if isinstance(data, list):
|
||||||
|
data = data[0] if data else {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptor_digest(node: object) -> str:
|
||||||
|
"""The normalized digest under a `{... "descriptor": {"digest": ...}}`
|
||||||
|
node, or "". Both the image and container inspect shapes nest the image's
|
||||||
|
identity this way, so the digest readers stay symmetric — a difference
|
||||||
|
between them is what would spuriously recreate a container."""
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return ""
|
||||||
|
descriptor = node.get("descriptor")
|
||||||
|
if isinstance(descriptor, dict) and descriptor.get("digest"):
|
||||||
|
return _normalize_digest(str(descriptor["digest"]))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def image_digest(ref: str) -> str:
|
||||||
|
"""The digest of image `ref`, or "" if it can't be read. Reads exactly the
|
||||||
|
field `container_image_digest` reads (`configuration.descriptor.digest`) so
|
||||||
|
the two are comparable; "" means 'don't know' → callers don't churn."""
|
||||||
|
data = _inspect_first([_CONTAINER, "image", "inspect", ref])
|
||||||
|
return _descriptor_digest(data.get("configuration"))
|
||||||
|
|
||||||
|
|
||||||
|
def container_image_digest(name: str) -> str:
|
||||||
|
"""The digest of the image container `name` was created from, or "" if it
|
||||||
|
can't be read. Compare with `image_digest(ref)` to tell whether a running
|
||||||
|
container predates an image rebuild."""
|
||||||
|
config = _inspect_first([_CONTAINER, "inspect", name]).get("configuration")
|
||||||
|
image = config.get("image") if isinstance(config, dict) else None
|
||||||
|
return _descriptor_digest(image)
|
||||||
|
|
||||||
|
|
||||||
|
def container_env(name: str) -> dict[str, str]:
|
||||||
|
"""The env container `name` was started with, or {} if unreadable. Lets a
|
||||||
|
caller tell whether a running container's baked-in configuration still
|
||||||
|
matches what it would pass today."""
|
||||||
|
config = _inspect_first([_CONTAINER, "inspect", name]).get("configuration")
|
||||||
|
init = config.get("initProcess") if isinstance(config, dict) else None
|
||||||
|
entries = init.get("environment") if isinstance(init, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return {}
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, str) and "=" in entry:
|
||||||
|
key, value = entry.split("=", 1)
|
||||||
|
env[key] = value
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def try_container_ipv4_on_network(name: str, network: str) -> str:
|
||||||
|
"""`container_ipv4_on_network` without the fatal exit: "" when the address
|
||||||
|
isn't readable yet. For pollers — a container is created before it has an
|
||||||
|
address, so "not yet" is an expected state there, not an error."""
|
||||||
|
status = _inspect_first([_CONTAINER, "inspect", name]).get("status")
|
||||||
networks = status.get("networks") if isinstance(status, dict) else None
|
networks = status.get("networks") if isinstance(status, dict) else None
|
||||||
if not isinstance(networks, list):
|
if not isinstance(networks, list):
|
||||||
die(f"container inspect {name} did not include status.networks")
|
return ""
|
||||||
for entry in networks:
|
for entry in networks:
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict) or entry.get("network") != network:
|
||||||
continue
|
|
||||||
if entry.get("network") != network:
|
|
||||||
continue
|
continue
|
||||||
raw = entry.get("ipv4Address")
|
raw = entry.get("ipv4Address")
|
||||||
if not isinstance(raw, str) or not raw:
|
if isinstance(raw, str) and raw:
|
||||||
die(f"container {name} has no IPv4 address on {network}")
|
return raw.split("/", 1)[0]
|
||||||
return raw.split("/", 1)[0]
|
return ""
|
||||||
die(f"container {name} is not attached to network {network}")
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
|
def wait_container_ipv4_on_network(
|
||||||
|
name: str, network: str, *, timeout: float = 15.0, poll: float = 0.25,
|
||||||
|
) -> str:
|
||||||
|
"""Poll for the container's DHCP-assigned address on `network`, returning
|
||||||
|
it once available or "" on timeout.
|
||||||
|
|
||||||
|
Apple Container has no `--ip`: `container run --detach` can return before
|
||||||
|
vmnet's DHCP has populated `status.networks[].ipv4Address`, so a bare read
|
||||||
|
right after start races the assignment. Callers that need the address (the
|
||||||
|
attribution key, the gateway's proxy target) poll through here instead of
|
||||||
|
the fatal `container_ipv4_on_network`."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
ip = try_container_ipv4_on_network(name, network)
|
||||||
|
if ip:
|
||||||
|
return ip
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
return ""
|
||||||
|
time.sleep(poll)
|
||||||
|
|
||||||
|
|
||||||
def image_id(ref: str) -> str:
|
def image_id(ref: str) -> str:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import socket
|
||||||
import string
|
import string
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -43,17 +44,18 @@ from .paths import bot_bottle_root
|
|||||||
_STATE_SUBDIR = "state"
|
_STATE_SUBDIR = "state"
|
||||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||||
_COMMITTED_IMAGE_NAME = "committed-image"
|
_COMMITTED_IMAGE_NAME = "committed-image"
|
||||||
|
_COMMITTED_ROOTFS_NAME = "committed-rootfs.tar"
|
||||||
_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.
|
||||||
@@ -87,6 +89,14 @@ def bottle_identity(agent_name: str) -> str:
|
|||||||
return f"{slug}-{suffix}"
|
return f"{slug}-{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def globalize_slug(slug: str) -> str:
|
||||||
|
"""Return a globally-unique slug qualified with the current hostname.
|
||||||
|
|
||||||
|
Assumes slug is a value returned from mint_slug. Use wherever a slug
|
||||||
|
must be unique across hosts (e.g. deploy-key titles)."""
|
||||||
|
return f"{socket.gethostname()}-{slug}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BottleMetadata:
|
class BottleMetadata:
|
||||||
"""Persistent record of how a bottle was launched, written at
|
"""Persistent record of how a bottle was launched, written at
|
||||||
@@ -191,6 +201,15 @@ def committed_image_path(identity: str) -> Path:
|
|||||||
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
|
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def committed_rootfs_path(identity: str) -> Path:
|
||||||
|
"""Where the Firecracker freezer stores a snapshot of the bottle's
|
||||||
|
guest rootfs (a plain tar). This is the resumable/migratable artifact
|
||||||
|
the Firecracker backend boots from — no Docker image involved. The
|
||||||
|
matching `committed-image` state file records that a snapshot exists
|
||||||
|
(and its path); `resume` boots from this tar when both are present."""
|
||||||
|
return bottle_state_dir(identity) / _COMMITTED_ROOTFS_NAME
|
||||||
|
|
||||||
|
|
||||||
def write_committed_image(identity: str, image_tag: str) -> Path:
|
def write_committed_image(identity: str, image_tag: str) -> Path:
|
||||||
"""Persist the committed image tag for `identity`. The next
|
"""Persist the committed image tag for `identity`. The next
|
||||||
`cli.py resume <identity>` will boot from this image instead of
|
`cli.py resume <identity>` will boot from this image instead of
|
||||||
@@ -222,7 +241,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 +279,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 +289,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
|
||||||
@@ -340,10 +359,12 @@ __all__ = [
|
|||||||
"BottleMetadata",
|
"BottleMetadata",
|
||||||
"agent_state_dir",
|
"agent_state_dir",
|
||||||
"bottle_identity",
|
"bottle_identity",
|
||||||
|
"globalize_slug",
|
||||||
"bottle_state_dir",
|
"bottle_state_dir",
|
||||||
"cleanup_state",
|
"cleanup_state",
|
||||||
"clear_preserve_marker",
|
"clear_preserve_marker",
|
||||||
"committed_image_path",
|
"committed_image_path",
|
||||||
|
"committed_rootfs_path",
|
||||||
"egress_state_dir",
|
"egress_state_dir",
|
||||||
"git_gate_state_dir",
|
"git_gate_state_dir",
|
||||||
"is_preserved",
|
"is_preserved",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-cache",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"rebuild agent/sidecar images from scratch, bypassing the "
|
||||||
|
"build layer cache. Use when an image looks broken after a "
|
||||||
|
"dependency bump — e.g. an installer's optionalDependencies "
|
||||||
|
"fetch silently no-op'd on a transient failure and got baked "
|
||||||
|
"into a cached layer."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
choices=known_backend_names(),
|
choices=known_backend_names(),
|
||||||
@@ -97,6 +108,11 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||||
|
if args.no_cache or os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
|
||||||
|
# Read by build_image() in each backend's util module — set here
|
||||||
|
# so both the interactive and --headless paths pick it up without
|
||||||
|
# threading a no_cache field through every backend's plan dataclass.
|
||||||
|
os.environ["BOT_BOTTLE_NO_CACHE"] = "1"
|
||||||
|
|
||||||
manifest = ManifestIndex.resolve(USER_CWD)
|
manifest = ManifestIndex.resolve(USER_CWD)
|
||||||
backend_name: str | None = args.backend
|
backend_name: str | None = args.backend
|
||||||
|
|||||||
+84
-87
@@ -20,32 +20,19 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..paths import bot_bottle_root
|
from ..paths import bot_bottle_root
|
||||||
from ..bottle_state import read_metadata
|
|
||||||
from ..backend.docker.egress_apply import (
|
|
||||||
EgressApplyError,
|
|
||||||
applicator as _docker_applicator,
|
|
||||||
)
|
|
||||||
from ..backend.macos_container.egress_apply import (
|
|
||||||
applicator as _macos_applicator,
|
|
||||||
)
|
|
||||||
from ..log import Die, error, info
|
from ..log import Die, error, info
|
||||||
|
from ..orchestrator.client import (
|
||||||
|
OrchestratorClient,
|
||||||
|
OrchestratorClientError,
|
||||||
|
discover_orchestrator_url,
|
||||||
|
)
|
||||||
|
|
||||||
from ..supervise import (
|
from ..supervise import (
|
||||||
COMPONENT_FOR_TOOL,
|
|
||||||
AuditEntry,
|
|
||||||
Proposal,
|
Proposal,
|
||||||
Response,
|
|
||||||
STATUS_APPROVED,
|
|
||||||
STATUS_MODIFIED,
|
|
||||||
STATUS_REJECTED,
|
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
TOOL_EGRESS_TOKEN_ALLOW,
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
list_all_pending_proposals,
|
|
||||||
render_diff,
|
|
||||||
write_audit_entry,
|
|
||||||
write_response,
|
|
||||||
)
|
)
|
||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
|
|
||||||
@@ -60,30 +47,61 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class QueuedProposal:
|
class QueuedProposal:
|
||||||
"""A pending proposal from the supervise queue."""
|
"""A pending proposal from the supervise queue.
|
||||||
|
|
||||||
|
`label` is the operator-facing bottle name (the human slug the
|
||||||
|
orchestrator resolved from the registry); `proposal.bottle_slug` is the
|
||||||
|
opaque bottle_id every operator action is keyed by. Display uses `label`;
|
||||||
|
respond calls use `proposal.bottle_slug`."""
|
||||||
|
|
||||||
proposal: Proposal
|
proposal: Proposal
|
||||||
|
label: str = ""
|
||||||
|
|
||||||
|
|
||||||
# Errors any remediation engine may raise. Caught by the TUI key
|
# A failed operator action (orchestrator unreachable, bottle torn down,
|
||||||
# handlers and surfaced in the status line so a failed apply keeps
|
# 409) is caught by the TUI key handlers and surfaced in the status line so
|
||||||
# the proposal pending rather than crashing curses.
|
# the proposal stays pending rather than crashing curses.
|
||||||
ApplyError = (EgressApplyError,)
|
ApplyError = (OrchestratorClientError,)
|
||||||
|
|
||||||
|
|
||||||
def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
|
# The one per-host orchestrator, discovered lazily on first use. Every
|
||||||
meta = read_metadata(slug)
|
# operator action — list, approve, reject — goes through its HTTP control
|
||||||
backend = meta.backend if meta is not None else ""
|
# plane (the orchestrator owns the single DB + live policy); there is no
|
||||||
if backend == "macos-container":
|
# direct-DB path and no backend branching here.
|
||||||
return _macos_applicator.apply_routes_change(slug, content)
|
_client_instance: OrchestratorClient | None = None
|
||||||
return _docker_applicator.apply_routes_change(slug, content)
|
|
||||||
|
|
||||||
|
def _resolve_orchestrator_url() -> str:
|
||||||
|
"""URL of the running orchestrator control plane, starting one on demand.
|
||||||
|
|
||||||
|
Supervise is often the first thing an operator runs — before any bottle
|
||||||
|
has booted the control plane. So when discovery finds nothing, bring up
|
||||||
|
the selected backend's orchestrator + gateway (idempotent) rather than
|
||||||
|
failing with "launch a bottle first"."""
|
||||||
|
try:
|
||||||
|
return discover_orchestrator_url()
|
||||||
|
except OrchestratorClientError:
|
||||||
|
from ..backend import get_bottle_backend
|
||||||
|
backend = get_bottle_backend()
|
||||||
|
info(f"no orchestrator control plane running; starting one ({backend.name})…")
|
||||||
|
return backend.ensure_orchestrator()
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> OrchestratorClient:
|
||||||
|
global _client_instance # noqa: PLW0603 — CLI-session singleton
|
||||||
|
if _client_instance is None:
|
||||||
|
_client_instance = OrchestratorClient(_resolve_orchestrator_url())
|
||||||
|
return _client_instance
|
||||||
|
|
||||||
|
|
||||||
def discover_pending() -> list[QueuedProposal]:
|
def discover_pending() -> list[QueuedProposal]:
|
||||||
"""Collect pending proposals across bottles."""
|
"""Collect pending proposals across bottles from the orchestrator."""
|
||||||
out = [
|
out = [
|
||||||
QueuedProposal(proposal=proposal)
|
QueuedProposal(
|
||||||
for proposal in list_all_pending_proposals()
|
proposal=Proposal.from_dict(d),
|
||||||
|
label=str(d.get("bottle_label") or d.get("bottle_slug") or ""),
|
||||||
|
)
|
||||||
|
for d in _client().supervise_pending()
|
||||||
]
|
]
|
||||||
out.sort(key=lambda q: q.proposal.arrival_timestamp)
|
out.sort(key=lambda q: q.proposal.arrival_timestamp)
|
||||||
return out
|
return out
|
||||||
@@ -91,8 +109,8 @@ def discover_pending() -> list[QueuedProposal]:
|
|||||||
|
|
||||||
def _approval_status(qp: QueuedProposal, verb: str) -> str:
|
def _approval_status(qp: QueuedProposal, verb: str) -> str:
|
||||||
"""Status-line text after a successful approval."""
|
"""Status-line text after a successful approval."""
|
||||||
base = f"{verb} {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
|
base = f"{verb} {qp.proposal.tool} for [{qp.label}]"
|
||||||
return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}"
|
return f"{base}; resume: ./cli.py resume {qp.label}"
|
||||||
|
|
||||||
|
|
||||||
def _detail_lines(
|
def _detail_lines(
|
||||||
@@ -103,7 +121,7 @@ def _detail_lines(
|
|||||||
"""Return the detail-view body as (text, curses-attr) tuples."""
|
"""Return the detail-view body as (text, curses-attr) tuples."""
|
||||||
p = qp.proposal
|
p = qp.proposal
|
||||||
out: list[tuple[str, int]] = [
|
out: list[tuple[str, int]] = [
|
||||||
(f"bottle: {p.bottle_slug}", 0),
|
(f"bottle: {qp.label}", 0),
|
||||||
(f"tool: {p.tool}", 0),
|
(f"tool: {p.tool}", 0),
|
||||||
(f"id: {p.id}", 0),
|
(f"id: {p.id}", 0),
|
||||||
(f"arrived: {p.arrival_timestamp}", 0),
|
(f"arrived: {p.arrival_timestamp}", 0),
|
||||||
@@ -136,39 +154,27 @@ def approve(
|
|||||||
notes: str = "",
|
notes: str = "",
|
||||||
final_file: str | None = None,
|
final_file: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply the proposal, write the waiting response, and audit it."""
|
"""Approve (or, with `final_file`, modify-then-approve) via the
|
||||||
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
|
orchestrator: it applies the route change to the bottle's live policy,
|
||||||
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
|
writes the response that unblocks the agent, and audits it — one atomic
|
||||||
|
server-side op. Raises `OrchestratorClientError` on failure."""
|
||||||
diff_before, diff_after = "", ""
|
_client().supervise_respond(
|
||||||
if qp.proposal.tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
qp.proposal.id,
|
||||||
diff_before, diff_after = apply_routes_change(
|
bottle_slug=qp.proposal.bottle_slug,
|
||||||
qp.proposal.bottle_slug,
|
decision="modify" if final_file is not None else "approve",
|
||||||
file_to_apply,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = Response(
|
|
||||||
proposal_id=qp.proposal.id,
|
|
||||||
status=status,
|
|
||||||
notes=notes,
|
notes=notes,
|
||||||
final_file=final_file,
|
final_file=final_file,
|
||||||
)
|
)
|
||||||
write_response(qp.proposal.bottle_slug, response)
|
|
||||||
_write_audit(
|
|
||||||
qp, action=status, notes=notes,
|
|
||||||
diff_before=diff_before, diff_after=diff_after,
|
|
||||||
)
|
|
||||||
|
|
||||||
def reject(qp: QueuedProposal, *, reason: str) -> None:
|
def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||||
"""Write a rejection response and an audit entry."""
|
"""Reject via the orchestrator (writes the response + audit)."""
|
||||||
response = Response(
|
_client().supervise_respond(
|
||||||
proposal_id=qp.proposal.id,
|
qp.proposal.id,
|
||||||
status=STATUS_REJECTED,
|
bottle_slug=qp.proposal.bottle_slug,
|
||||||
|
decision="reject",
|
||||||
notes=reason,
|
notes=reason,
|
||||||
final_file=None,
|
|
||||||
)
|
)
|
||||||
write_response(qp.proposal.bottle_slug, response)
|
|
||||||
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
|
||||||
|
|
||||||
|
|
||||||
def _approve_from_tui(
|
def _approve_from_tui(
|
||||||
@@ -188,29 +194,6 @@ def _approve_from_tui(
|
|||||||
return _approval_status(qp, verb)
|
return _approval_status(qp, verb)
|
||||||
|
|
||||||
|
|
||||||
def _write_audit(
|
|
||||||
qp: QueuedProposal,
|
|
||||||
*,
|
|
||||||
action: str,
|
|
||||||
notes: str,
|
|
||||||
diff_before: str,
|
|
||||||
diff_after: str,
|
|
||||||
) -> None:
|
|
||||||
"""Audit log for egress tool."""
|
|
||||||
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
|
|
||||||
if component is None:
|
|
||||||
return
|
|
||||||
write_audit_entry(AuditEntry(
|
|
||||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
||||||
bottle_slug=qp.proposal.bottle_slug,
|
|
||||||
component=component,
|
|
||||||
operator_action=action,
|
|
||||||
operator_notes=notes,
|
|
||||||
justification=qp.proposal.justification,
|
|
||||||
diff=render_diff(diff_before, diff_after, label=component),
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
# --- $EDITOR integration --------------------------------------------------
|
# --- $EDITOR integration --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -245,6 +228,20 @@ def cmd_supervise(argv: list[str]) -> int:
|
|||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
# Establish the orchestrator connection up front so a missing control
|
||||||
|
# plane is a clean one-line error, not a curses crash mid-loop. This also
|
||||||
|
# starts the orchestrator on demand when none is running (see `_client`).
|
||||||
|
try:
|
||||||
|
_client()
|
||||||
|
except OrchestratorClientError as e:
|
||||||
|
error(str(e))
|
||||||
|
return 1
|
||||||
|
except Die as e:
|
||||||
|
# Backend has no orchestrator to start (e.g. macos-container).
|
||||||
|
if e.message:
|
||||||
|
error(e.message)
|
||||||
|
return e.code if isinstance(e.code, int) else 1
|
||||||
|
|
||||||
if args.once:
|
if args.once:
|
||||||
return _list_once()
|
return _list_once()
|
||||||
try:
|
try:
|
||||||
@@ -299,7 +296,7 @@ def _list_once() -> int:
|
|||||||
for qp in pending:
|
for qp in pending:
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f"{qp.proposal.arrival_timestamp} "
|
f"{qp.proposal.arrival_timestamp} "
|
||||||
f"[{qp.proposal.bottle_slug}] "
|
f"[{qp.label}] "
|
||||||
f"{qp.proposal.tool} "
|
f"{qp.proposal.tool} "
|
||||||
f"{qp.proposal.id}\n"
|
f"{qp.proposal.id}\n"
|
||||||
)
|
)
|
||||||
@@ -396,7 +393,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragm
|
|||||||
reason = _prompt(stdscr, "reject reason: ")
|
reason = _prompt(stdscr, "reject reason: ")
|
||||||
if reason:
|
if reason:
|
||||||
reject(qp, reason=reason)
|
reject(qp, reason=reason)
|
||||||
status_line = f"rejected {qp.proposal.tool} for [{qp.proposal.bottle_slug}]"
|
status_line = f"rejected {qp.proposal.tool} for [{qp.label}]"
|
||||||
else:
|
else:
|
||||||
status_line = "reject aborted (empty reason)"
|
status_line = "reject aborted (empty reason)"
|
||||||
|
|
||||||
@@ -435,7 +432,7 @@ def _render(
|
|||||||
cursor = "> " if i == selected else " "
|
cursor = "> " if i == selected else " "
|
||||||
line = (
|
line = (
|
||||||
f"{cursor}{ts_short} "
|
f"{cursor}{ts_short} "
|
||||||
f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]}"
|
f"[{qp.label}] {p.tool:<18} {p.id[:8]}"
|
||||||
)
|
)
|
||||||
attr = curses.A_REVERSE if i == selected else curses.A_NORMAL
|
attr = curses.A_REVERSE if i == selected else curses.A_NORMAL
|
||||||
stdscr.addnstr(row, 0, line, w - 1, attr)
|
stdscr.addnstr(row, 0, line, w - 1, attr)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Shared wire-protocol constants for gateway-bundled modules.
|
||||||
|
|
||||||
|
Single source of truth for values that appear across the egress addon,
|
||||||
|
git-http backend, supervise server, and git-gate renderer. Importing
|
||||||
|
from this module instead of duplicating the literals means a rename is
|
||||||
|
a one-line change and is caught by the type checker at the import site."""
|
||||||
|
|
||||||
|
# App-layer identity token header. Delivered as proxy credentials
|
||||||
|
# (HTTPS_PROXY=http://<bottle_id>:<token>@gw) by launch; the egress
|
||||||
|
# addon reads and strips it, the supervise server and git-http backend
|
||||||
|
# read it for attribution, and none of them forward it upstream.
|
||||||
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
|
|
||||||
|
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
||||||
|
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
|
||||||
|
# git_http_backend, and the git http-backend CGI subprocess.
|
||||||
|
GIT_GATE_TIMEOUT_SECS = 15
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -23,8 +23,9 @@ from ...agent_provider import (
|
|||||||
provider_startup_args,
|
provider_startup_args,
|
||||||
)
|
)
|
||||||
from ...backend.docker import util as docker_mod
|
from ...backend.docker import util as docker_mod
|
||||||
from ...egress import EgressRoute
|
from ...egress import CLAUDE_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
|
from .claude_auth import claude_host_access_token
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -32,6 +33,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
_SUPERVISE_MCP_NAME = "supervise"
|
_SUPERVISE_MCP_NAME = "supervise"
|
||||||
|
# App-layer identity token header (mirrors egress_addon / git_http_backend).
|
||||||
|
_IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
|
|
||||||
|
|
||||||
def _skills_dir(guest_home: str) -> str:
|
def _skills_dir(guest_home: str) -> str:
|
||||||
@@ -91,6 +94,7 @@ _RUNTIME = AgentProviderRuntime(
|
|||||||
prompt_mode="append_file",
|
prompt_mode="append_file",
|
||||||
bypass_args=("--dangerously-skip-permissions",),
|
bypass_args=("--dangerously-skip-permissions",),
|
||||||
resume_args=("--continue",),
|
resume_args=("--continue",),
|
||||||
|
smoke_test=("claude", "--version"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +119,6 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
color: str = "",
|
color: str = "",
|
||||||
provider_settings: dict[str, object] | None = None,
|
provider_settings: dict[str, object] | None = None,
|
||||||
) -> AgentProvisionPlan:
|
) -> AgentProvisionPlan:
|
||||||
del forward_host_credentials, host_env
|
|
||||||
resolved_guest_env = dict(guest_env or {})
|
resolved_guest_env = dict(guest_env or {})
|
||||||
startup_args = provider_startup_args(provider_settings)
|
startup_args = provider_startup_args(provider_settings)
|
||||||
guest_home = self.guest_home
|
guest_home = self.guest_home
|
||||||
@@ -177,13 +180,24 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
claude_settings,
|
claude_settings,
|
||||||
f"{guest_home}/.claude/settings.json",
|
f"{guest_home}/.claude/settings.json",
|
||||||
))
|
))
|
||||||
|
provisioned_env: dict[str, str] = {}
|
||||||
|
if forward_host_credentials:
|
||||||
|
_host_env = host_env or dict(os.environ)
|
||||||
|
provisioned_env[CLAUDE_HOST_CREDENTIAL_TOKEN_REF] = (
|
||||||
|
claude_host_access_token(_host_env)
|
||||||
|
)
|
||||||
|
|
||||||
|
cred_token_ref = (
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials
|
||||||
|
else auth_token
|
||||||
|
)
|
||||||
egress_routes = (EgressRoute(
|
egress_routes = (EgressRoute(
|
||||||
host="api.anthropic.com",
|
host="api.anthropic.com",
|
||||||
auth_scheme="Bearer" if auth_token else "",
|
auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "",
|
||||||
token_ref=auth_token,
|
token_ref=cred_token_ref,
|
||||||
),)
|
),)
|
||||||
hidden_env_names: frozenset[str] = frozenset()
|
hidden_env_names: frozenset[str] = frozenset()
|
||||||
if auth_token:
|
if auth_token or forward_host_credentials:
|
||||||
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
|
||||||
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||||||
|
|
||||||
@@ -205,6 +219,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
files=tuple(files),
|
files=tuple(files),
|
||||||
egress_routes=egress_routes,
|
egress_routes=egress_routes,
|
||||||
hidden_env_names=hidden_env_names,
|
hidden_env_names=hidden_env_names,
|
||||||
|
provisioned_env=provisioned_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
@@ -293,16 +308,22 @@ 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."""
|
||||||
if plan.supervise_plan is None:
|
if plan.supervise_plan is None:
|
||||||
return
|
return
|
||||||
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
|
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
|
||||||
|
# Deliver the identity token as an MCP request header — the supervise
|
||||||
|
# daemon requires it (mandatory (source_ip, token) attribution).
|
||||||
|
token = getattr(plan, "identity_token", "")
|
||||||
|
header = (
|
||||||
|
f" --header {shlex.quote(f'{_IDENTITY_HEADER}: {token}')}" if token else ""
|
||||||
|
)
|
||||||
r = bottle.exec(
|
r = bottle.exec(
|
||||||
f"claude mcp add --scope user --transport http "
|
f"claude mcp add --scope user --transport http "
|
||||||
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
f"{_SUPERVISE_MCP_NAME} {supervise_url}{header}",
|
||||||
user="node",
|
user="node",
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Host Claude auth helpers.
|
||||||
|
|
||||||
|
Reads the host's Claude Code credentials and returns only the access
|
||||||
|
token needed by egress. Does not expose refresh tokens or raw payloads.
|
||||||
|
|
||||||
|
Credential storage by platform:
|
||||||
|
Linux — ~/.claude/.credentials.json
|
||||||
|
macOS — macOS Keychain, service "Claude Code-credentials"
|
||||||
|
(file path is tried first; Keychain is the fallback)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...log import die
|
||||||
|
|
||||||
|
|
||||||
|
_KEYCHAIN_SERVICE = "Claude Code-credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def claude_auth_path(host_env: dict[str, str] | None = None) -> Path:
|
||||||
|
env = os.environ if host_env is None else host_env
|
||||||
|
home = env.get("HOME")
|
||||||
|
if home:
|
||||||
|
return Path(home) / ".claude" / ".credentials.json"
|
||||||
|
return Path.home() / ".claude" / ".credentials.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_keychain() -> dict[str, object] | None:
|
||||||
|
"""Try the macOS Keychain. Returns parsed JSON dict or None."""
|
||||||
|
if sys.platform != "darwin":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = json.loads(result.stdout.strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return raw if isinstance(raw, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def claude_host_access_token(
|
||||||
|
host_env: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
path = claude_auth_path(host_env)
|
||||||
|
raw: dict[str, object] | None = None
|
||||||
|
|
||||||
|
if path.is_file():
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
die(f"claude host credentials: could not read valid JSON at {path}: {e}")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
die(f"claude host credentials: {path} must contain a JSON object")
|
||||||
|
else:
|
||||||
|
raw = _read_keychain()
|
||||||
|
if raw is None:
|
||||||
|
die(
|
||||||
|
f"claude host credentials: auth file missing at {path} and "
|
||||||
|
f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. "
|
||||||
|
"Run `claude login` on the host or disable "
|
||||||
|
"agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth = raw.get("claudeAiOauth")
|
||||||
|
if not isinstance(oauth, dict):
|
||||||
|
die(
|
||||||
|
"claude host credentials: claudeAiOauth is missing from credentials. "
|
||||||
|
"Run `claude login` on the host or disable "
|
||||||
|
"agent_provider.forward_host_credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = oauth.get("accessToken")
|
||||||
|
if not isinstance(access_token, str) or not access_token:
|
||||||
|
die(
|
||||||
|
"claude host credentials: claudeAiOauth.accessToken is missing or empty. "
|
||||||
|
"Run `claude login` on the host and restart the bottle."
|
||||||
|
)
|
||||||
|
|
||||||
|
# expiresAt is in milliseconds
|
||||||
|
expires_at = oauth.get("expiresAt")
|
||||||
|
if isinstance(expires_at, (int, float)):
|
||||||
|
check_now = now or datetime.now(timezone.utc)
|
||||||
|
exp_dt = datetime.fromtimestamp(float(expires_at) / 1000.0, timezone.utc)
|
||||||
|
if exp_dt <= check_now:
|
||||||
|
die(
|
||||||
|
"claude host credentials: host Claude access token is expired. "
|
||||||
|
"Run `claude login` on the host and restart the bottle."
|
||||||
|
)
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"claude_auth_path",
|
||||||
|
"claude_host_access_token",
|
||||||
|
]
|
||||||
@@ -4,11 +4,12 @@ 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
|
||||||
|
|
||||||
|
import base64
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -26,7 +27,7 @@ from ...agent_provider import (
|
|||||||
)
|
)
|
||||||
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
|
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
|
||||||
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||||
from ...log import die, info, warn
|
from ...log import die, info
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -34,6 +35,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
_SUPERVISE_MCP_NAME = "supervise"
|
_SUPERVISE_MCP_NAME = "supervise"
|
||||||
|
# App-layer identity token header (mirrors egress_addon / git_http_backend).
|
||||||
|
_IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
_CODEX_CLI = "/home/node/.codex/packages/standalone/current/bin/codex"
|
_CODEX_CLI = "/home/node/.codex/packages/standalone/current/bin/codex"
|
||||||
_CODEX_CLI_PATH = (
|
_CODEX_CLI_PATH = (
|
||||||
"/home/node/.local/bin:"
|
"/home/node/.local/bin:"
|
||||||
@@ -42,6 +45,41 @@ _CODEX_CLI_PATH = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _toml_basic_string(value: str) -> str:
|
||||||
|
"""Quote `value` as a TOML basic (double-quoted) string."""
|
||||||
|
escaped = (
|
||||||
|
value.replace("\\", "\\\\")
|
||||||
|
.replace('"', '\\"')
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\t", "\\t")
|
||||||
|
)
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _supervise_mcp_config_toml(supervise_url: str, token: str) -> str:
|
||||||
|
"""Render the `[mcp_servers.supervise]` streamable-HTTP entry for
|
||||||
|
Codex's `config.toml`.
|
||||||
|
|
||||||
|
The Codex CLI has no `mcp add --header` flag; a static request
|
||||||
|
header on an HTTP MCP server is only expressible via the
|
||||||
|
`http_headers` config key (see `RawMcpServerConfig` /
|
||||||
|
`McpServerTransportConfig::StreamableHttp`). We deliver the
|
||||||
|
mandatory identity token (source_ip, token attribution) that way.
|
||||||
|
Only Codex-supported streamable-HTTP keys (`url`, `http_headers`)
|
||||||
|
are emitted."""
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
f"[mcp_servers.{_SUPERVISE_MCP_NAME}]",
|
||||||
|
f"url = {_toml_basic_string(supervise_url)}",
|
||||||
|
]
|
||||||
|
if token:
|
||||||
|
key = _toml_basic_string(_IDENTITY_HEADER)
|
||||||
|
val = _toml_basic_string(token)
|
||||||
|
lines.append(f"http_headers = {{ {key} = {val} }}")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _skills_dir(guest_home: str) -> str:
|
def _skills_dir(guest_home: str) -> str:
|
||||||
# Codex agents still read skills from the claude-code convention
|
# Codex agents still read skills from the claude-code convention
|
||||||
# (~/.claude/skills/) — the bot-bottle-codex image follows the
|
# (~/.claude/skills/) — the bot-bottle-codex image follows the
|
||||||
@@ -61,6 +99,7 @@ _RUNTIME = AgentProviderRuntime(
|
|||||||
prompt_mode="read_prompt_file",
|
prompt_mode="read_prompt_file",
|
||||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||||
resume_args=("resume", "--last"),
|
resume_args=("resume", "--last"),
|
||||||
|
smoke_test=(_CODEX_CLI, "--version"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -265,25 +304,39 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
bottle: "Bottle",
|
bottle: "Bottle",
|
||||||
supervise_url: str,
|
supervise_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run `codex mcp add` inside the agent guest to register the
|
"""Register the supervise daemon as a streamable-HTTP MCP
|
||||||
supervise sidecar in Codex's user config (~/.codex/config.toml).
|
server in Codex's user config (`~/.codex/config.toml`).
|
||||||
|
|
||||||
Mirrors the Claude provider's `claude mcp add` flow — failure
|
We write the `[mcp_servers.supervise]` entry directly rather
|
||||||
is logged but not fatal."""
|
than shelling out to `codex mcp add`: the CLI's `add` has no
|
||||||
|
way to attach a static request header, and the identity token
|
||||||
|
(mandatory (source_ip, token) attribution) MUST ride on the
|
||||||
|
MCP request as `http_headers`. Failure is FATAL when supervise
|
||||||
|
is enabled — a silently-unregistered server leaves the agent
|
||||||
|
with no supervise access and, under mandatory attribution, no
|
||||||
|
way to recover from inside the bottle."""
|
||||||
if plan.supervise_plan is None:
|
if plan.supervise_plan is None:
|
||||||
return
|
return
|
||||||
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||||
r = bottle.exec(
|
token = getattr(plan, "identity_token", "")
|
||||||
f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url "
|
block = _supervise_mcp_config_toml(supervise_url, token)
|
||||||
f"{shlex.quote(supervise_url)}",
|
auth_dir = plan.agent_provision.guest_env.get("CODEX_HOME") \
|
||||||
user="node",
|
or f"{plan.guest_home}/.codex"
|
||||||
|
config_path = f"{auth_dir}/config.toml"
|
||||||
|
# Append via base64 so the TOML payload never has to survive a
|
||||||
|
# shell-quoting round trip. node owns the config file, so append
|
||||||
|
# as node to preserve ownership/mode.
|
||||||
|
payload = base64.b64encode(block.encode()).decode()
|
||||||
|
script = (
|
||||||
|
f"printf %s {shlex.quote(payload)} | base64 -d "
|
||||||
|
f">> {shlex.quote(config_path)}"
|
||||||
)
|
)
|
||||||
|
r = bottle.exec(script, user="node")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
warn(
|
die(
|
||||||
f"`codex mcp add supervise` failed (exit {r.returncode}): "
|
"agent provider provisioning: could not register supervise "
|
||||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
f"MCP server in {config_path}: "
|
||||||
f"register manually with: "
|
f"{(r.stderr or r.stdout or '').strip()}"
|
||||||
f"codex mcp add supervise --url {shlex.quote(supervise_url)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def headless_prompt(self, prompt: str) -> list[str]:
|
def headless_prompt(self, prompt: str) -> list[str]:
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
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
|
Available in the gateway via the installed `bot_bottle` package
|
||||||
`egress_addon_core.py` — both this file and the package source use
|
(see `Dockerfile.gateway`).
|
||||||
the same try/except import shim pattern.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,10 +19,7 @@ from math import log2
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from urllib.parse import quote as url_quote
|
from urllib.parse import quote as url_quote
|
||||||
|
|
||||||
try:
|
from .egress_addon_core import ScanResult
|
||||||
from egress_addon_core import ScanResult # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from .egress_addon_core import ScanResult
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -14,13 +14,20 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
def run_docker(argv: list[str]) -> subprocess.CompletedProcess[str]:
|
def run_docker(
|
||||||
|
argv: list[str], *, env: dict[str, str] | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
"""Run a `docker` command, capturing stdout/stderr as text. Never raises
|
"""Run a `docker` command, capturing stdout/stderr as text. Never raises
|
||||||
on a non-zero exit — callers inspect `returncode` / `stderr` so they can
|
on a non-zero exit — callers inspect `returncode` / `stderr` so they can
|
||||||
stay fail-closed or tolerate idempotent no-ops (e.g. removing an
|
stay fail-closed or tolerate idempotent no-ops (e.g. removing an
|
||||||
already-absent container)."""
|
already-absent container).
|
||||||
|
|
||||||
|
`env` sets the child process environment — used to hand a secret to a bare
|
||||||
|
`--env NAME` flag (docker inherits its value from this process) so the
|
||||||
|
value never lands on argv or in `docker inspect`'s recorded command line."""
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
|
argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||||
|
check=False, env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
||||||
"""
|
"""
|
||||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|||||||
from .manifest import ManifestBottle
|
from .manifest import ManifestBottle
|
||||||
|
|
||||||
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||||
|
CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"
|
||||||
|
|
||||||
EGRESS_HOSTNAME = "egress"
|
EGRESS_HOSTNAME = "egress"
|
||||||
|
|
||||||
@@ -63,8 +64,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 +88,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 +387,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(
|
||||||
@@ -400,6 +401,7 @@ class Egress(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"EGRESS_HOSTNAME",
|
"EGRESS_HOSTNAME",
|
||||||
"EGRESS_ROUTES_FILENAME",
|
"EGRESS_ROUTES_FILENAME",
|
||||||
@@ -412,6 +414,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",
|
||||||
]
|
]
|
||||||
|
|||||||
+225
-140
@@ -6,15 +6,18 @@ egress container."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
import typing
|
||||||
|
|
||||||
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
||||||
|
|
||||||
from egress_addon_core import ( # type: ignore[import-not-found] # pylint: disable=import-error
|
from bot_bottle.constants import IDENTITY_HEADER
|
||||||
|
from bot_bottle.dlp_detectors import redact_tokens, strip_crlf
|
||||||
|
from bot_bottle.egress_addon_core import (
|
||||||
LOG_BLOCKS,
|
LOG_BLOCKS,
|
||||||
LOG_FULL,
|
LOG_FULL,
|
||||||
DEFAULT_OUTBOUND_ON_MATCH,
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
@@ -30,7 +33,6 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
|
|||||||
decide_git_fetch,
|
decide_git_fetch,
|
||||||
is_git_fetch_request,
|
is_git_fetch_request,
|
||||||
is_git_push_request,
|
is_git_push_request,
|
||||||
load_config,
|
|
||||||
match_route,
|
match_route,
|
||||||
resolve_client_context,
|
resolve_client_context,
|
||||||
outbound_scan_headers,
|
outbound_scan_headers,
|
||||||
@@ -38,39 +40,40 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
|
|||||||
scan_inbound,
|
scan_inbound,
|
||||||
scan_outbound,
|
scan_outbound,
|
||||||
)
|
)
|
||||||
|
from bot_bottle import supervise as _sv
|
||||||
|
from bot_bottle.policy_resolver import PolicyResolver
|
||||||
|
|
||||||
try:
|
|
||||||
from dlp_detectors import redact_tokens, strip_crlf # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from bot_bottle.dlp_detectors import ( # type: ignore[import-not-found]
|
|
||||||
redact_tokens,
|
|
||||||
strip_crlf,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import supervise as _sv # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from bot_bottle import supervise as _sv # type: ignore[import-not-found]
|
|
||||||
|
|
||||||
try:
|
|
||||||
from policy_resolver import PolicyResolver # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from bot_bottle.policy_resolver import PolicyResolver
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
|
||||||
|
|
||||||
INTROSPECT_HOST = "_egress.local"
|
INTROSPECT_HOST = "_egress.local"
|
||||||
|
|
||||||
# Consolidated (multi-tenant) mode: when this points at the per-host
|
# The per-host orchestrator control plane the addon resolves every request's
|
||||||
# orchestrator's control plane, the addon resolves each client's Config by
|
# Config against, by source IP (PRD 0070). Mandatory: the consolidated gateway
|
||||||
# source IP per request instead of using a single static routes file. Unset
|
# is the only topology now — there is no static per-bottle routes file to fall
|
||||||
# → legacy per-bottle single-tenant mode (unchanged).
|
# back to — so an unset value is a fatal misconfiguration (see __init__).
|
||||||
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
||||||
|
|
||||||
# App-layer identity token (defense-in-depth over the source-IP invariant);
|
# Per-flow key under which `request()` stashes the resolved (Config, supervise
|
||||||
# the agent injects it, the addon strips it so it never leaks upstream.
|
# slug, env) so the later `response()` and `websocket_message()` hooks scan
|
||||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
# against the *calling bottle's* policy — the same one the request was decided
|
||||||
|
# on — without a second `/resolve` per response or per WebSocket frame. A hook
|
||||||
|
# on a flow that never resolved (no stash) fails closed to deny-all, so it's a
|
||||||
|
# safe no-op rather than an unscanned pass.
|
||||||
|
_FLOW_CTX_KEY = "bot_bottle_egress_ctx"
|
||||||
|
|
||||||
|
|
||||||
|
def _token_from_proxy_auth(header: str) -> str:
|
||||||
|
"""Extract the identity token (the password) from a `Proxy-Authorization:
|
||||||
|
Basic base64(<bottle_id>:<token>)` header. Empty on any malformed value —
|
||||||
|
the mandatory `/resolve` then fail-closes on the empty token."""
|
||||||
|
scheme, _, encoded = header.partition(" ")
|
||||||
|
if scheme.lower() != "basic" or not encoded:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
|
||||||
|
except (binascii.Error, ValueError, UnicodeDecodeError):
|
||||||
|
return ""
|
||||||
|
_, _, password = decoded.partition(":")
|
||||||
|
return password
|
||||||
|
|
||||||
# Seconds the egress proxy holds a token-blocked request open waiting for the
|
# Seconds the egress proxy holds a token-blocked request open waiting for the
|
||||||
# operator's supervisor decision (PRD 0062), overridable via env.
|
# operator's supervisor decision (PRD 0062), overridable via env.
|
||||||
@@ -88,33 +91,47 @@ _TOKEN_ALLOW_JUSTIFICATION = (
|
|||||||
|
|
||||||
|
|
||||||
class EgressAddon:
|
class EgressAddon:
|
||||||
# Class default so addons built via __new__ (e.g. in tests) default to
|
# Bare annotations (no class value): __init__ sets a live PolicyResolver for
|
||||||
# single-tenant; __init__ sets the instance attribute for real runs.
|
# real runs, and every host-side test builds an addon via __new__ and sets a
|
||||||
_resolver: "PolicyResolver | None" = None
|
# fake resolver. Egress is resolver-only now — the per-request policy always
|
||||||
|
# comes from the orchestrator's /resolve (PRD 0070); there is no static
|
||||||
|
# per-bottle routes file, SIGHUP reload, or single-tenant fallback.
|
||||||
|
_resolver: "PolicyResolver"
|
||||||
|
# Class default so __new__-built addons have it (real runs get a fresh
|
||||||
|
# per-instance dict in __init__; only http_connect mutates it, which the
|
||||||
|
# request-flow tests don't exercise).
|
||||||
|
_conn_tokens: "dict[str, str]" = {}
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
# Resolver-only: the gateway is always multi-tenant, resolving each
|
||||||
self.config: Config = Config(routes=())
|
# request's policy by source IP against the orchestrator control plane
|
||||||
# Consolidated mode: resolve per-client Config from the orchestrator.
|
# (PRD 0070). The URL is mandatory — without a policy source the gateway
|
||||||
# Absent → single-tenant (static routes file); behaviour unchanged.
|
# must not come up (fail-closed), rather than silently allowing nothing.
|
||||||
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
|
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
|
||||||
self._resolver = PolicyResolver(orch_url) if orch_url else None
|
if not orch_url:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{ORCHESTRATOR_URL_ENV} is required: the egress gateway "
|
||||||
|
"resolves every request's policy from the orchestrator and has "
|
||||||
|
"no static routes file to fall back to."
|
||||||
|
)
|
||||||
|
self._resolver = PolicyResolver(orch_url)
|
||||||
# Tokens the operator has approved this session (PRD 0062), keyed by
|
# Tokens the operator has approved this session (PRD 0062), keyed by
|
||||||
# bottle so the shared gateway keeps each bottle's safelist separate —
|
# bottle so the shared gateway keeps each bottle's safelist separate —
|
||||||
# a global set would let bottle A's approved secret pass bottle B's DLP
|
# a global set would let bottle A's approved secret pass bottle B's DLP
|
||||||
# scan. In-memory only (a restart re-prompts); mutated only from the
|
# scan. In-memory only (a restart re-prompts); mutated only from the
|
||||||
# asyncio loop that runs the addon hooks, so no lock is needed.
|
# asyncio loop that runs the addon hooks, so no lock is needed.
|
||||||
self._safe_tokens: dict[str, set[str]] = {}
|
self._safe_tokens: dict[str, set[str]] = {}
|
||||||
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
|
# Per-client-connection identity token captured from the CONNECT's
|
||||||
|
# `Proxy-Authorization` (HTTPS tunnels don't repeat it on the bumped
|
||||||
|
# inner requests). Keyed by client_conn.id; cleared on disconnect.
|
||||||
|
self._conn_tokens: dict[str, str] = {}
|
||||||
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
||||||
self._reload(initial=True)
|
|
||||||
self._install_sighup()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _supervise_available(slug: str) -> bool:
|
def _supervise_available(slug: str) -> bool:
|
||||||
"""Supervise is reachable for this request iff we resolved a bottle to
|
"""Supervise is reachable for this request iff we resolved a bottle to
|
||||||
attribute its proposals to (single-tenant env slug, or a source-IP
|
attribute its proposals to (the source-IP-attributed bottle id). Empty
|
||||||
-attributed bottle id). Empty → fail closed (no queue to write to)."""
|
→ fail closed (no queue to write to)."""
|
||||||
return bool(slug)
|
return bool(slug)
|
||||||
|
|
||||||
def _safe_tokens_for(self, slug: str) -> set[str]:
|
def _safe_tokens_for(self, slug: str) -> set[str]:
|
||||||
@@ -123,40 +140,15 @@ class EgressAddon:
|
|||||||
bottle's approved token into another's scan."""
|
bottle's approved token into another's scan."""
|
||||||
return self._safe_tokens.setdefault(slug, set())
|
return self._safe_tokens.setdefault(slug, set())
|
||||||
|
|
||||||
def _reload(self, *, initial: bool = False) -> None:
|
def _serve_introspection(
|
||||||
try:
|
self, flow: http.HTTPFlow, path: str, config: Config,
|
||||||
text = Path(self.routes_path).read_text(encoding="utf-8")
|
) -> None:
|
||||||
new_config = load_config(text)
|
"""Serve the calling bottle's own allowlist. `config` is this flow's
|
||||||
except (OSError, ValueError) as e:
|
resolved policy (the same one every hook uses), so the agent sees the
|
||||||
tag = "boot" if initial else "SIGHUP"
|
routes that actually apply to it."""
|
||||||
sys.stderr.write(
|
|
||||||
f"egress: {tag} load failed: {e}\n"
|
|
||||||
)
|
|
||||||
if initial:
|
|
||||||
self.config = Config(routes=())
|
|
||||||
return
|
|
||||||
self.config = new_config
|
|
||||||
log_label = ("off", "blocks", "full")[self.config.log]
|
|
||||||
sys.stderr.write(
|
|
||||||
f"egress: loaded {len(self.config.routes)} route(s): "
|
|
||||||
f"{', '.join(r.host for r in self.config.routes)}"
|
|
||||||
f" [log={log_label}]\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _install_sighup(self) -> None:
|
|
||||||
if not hasattr(signal, "SIGHUP"):
|
|
||||||
return
|
|
||||||
|
|
||||||
def handler(signum: int, frame: object) -> None:
|
|
||||||
del signum, frame
|
|
||||||
self._reload()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGHUP, handler)
|
|
||||||
|
|
||||||
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
|
||||||
if path == "/allowlist":
|
if path == "/allowlist":
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{"routes": [route_to_yaml_dict(r) for r in self.config.routes]},
|
{"routes": [route_to_yaml_dict(r) for r in config.routes]},
|
||||||
indent=2,
|
indent=2,
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
flow.response = http.Response.make(
|
flow.response = http.Response.make(
|
||||||
@@ -170,11 +162,21 @@ class EgressAddon:
|
|||||||
{"Content-Type": "text/plain; charset=utf-8"},
|
{"Content-Type": "text/plain; charset=utf-8"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _flow_log(self, flow: http.HTTPFlow) -> int:
|
||||||
|
"""This flow's log level, from the policy `request()` resolved and
|
||||||
|
stashed. The block/redact log gates were a single global in the static-
|
||||||
|
config world; they are per bottle now, so they read it from the flow."""
|
||||||
|
return self._flow_ctx(flow)[0].log
|
||||||
|
|
||||||
def _req_ctx(self, flow: http.HTTPFlow) -> dict[str, object]:
|
def _req_ctx(self, flow: http.HTTPFlow) -> dict[str, object]:
|
||||||
|
# Redact with this flow's resolved env overlay (process env + the
|
||||||
|
# bottle's /resolve tokens), so the ctx scrubs the calling bottle's
|
||||||
|
# provisioned secrets, not just os.environ's.
|
||||||
|
env = self._flow_ctx(flow)[2]
|
||||||
return {
|
return {
|
||||||
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
"host": redact_tokens(flow.request.pretty_host, env=env),
|
||||||
"method": flow.request.method,
|
"method": flow.request.method,
|
||||||
"path": redact_tokens(flow.request.path, env=os.environ),
|
"path": redact_tokens(flow.request.path, env=env),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _block(
|
def _block(
|
||||||
@@ -183,7 +185,7 @@ class EgressAddon:
|
|||||||
reason: str,
|
reason: str,
|
||||||
ctx: dict[str, object] | None = None,
|
ctx: dict[str, object] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.config.log >= LOG_BLOCKS:
|
if self._flow_log(flow) >= LOG_BLOCKS:
|
||||||
entry: dict[str, object] = {"event": "egress_block", "reason": reason}
|
entry: dict[str, object] = {"event": "egress_block", "reason": reason}
|
||||||
if ctx:
|
if ctx:
|
||||||
entry.update(ctx)
|
entry.update(ctx)
|
||||||
@@ -194,31 +196,39 @@ class EgressAddon:
|
|||||||
{"Content-Type": "text/plain; charset=utf-8"},
|
{"Content-Type": "text/plain; charset=utf-8"},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _log_request(self, flow: http.HTTPFlow) -> None:
|
def _log_request(
|
||||||
|
self, flow: http.HTTPFlow, env: "typing.Mapping[str, str]",
|
||||||
|
) -> None:
|
||||||
|
# `env` is the per-flow resolved overlay (process env + this bottle's
|
||||||
|
# /resolve tokens), so the log redaction scrubs the calling bottle's
|
||||||
|
# provisioned secrets — not just the process-level ones in os.environ.
|
||||||
headers = {
|
headers = {
|
||||||
k: redact_tokens(v, env=os.environ)
|
k: redact_tokens(v, env=env)
|
||||||
for k, v in flow.request.headers.items()
|
for k, v in flow.request.headers.items()
|
||||||
if k.lower() != "authorization"
|
if k.lower() != "authorization"
|
||||||
}
|
}
|
||||||
body = redact_tokens(flow.request.get_text(strict=False) or "", env=os.environ)
|
body = redact_tokens(flow.request.get_text(strict=False) or "", env=env)
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"event": "egress_request",
|
"event": "egress_request",
|
||||||
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
"host": redact_tokens(flow.request.pretty_host, env=env),
|
||||||
"method": flow.request.method,
|
"method": flow.request.method,
|
||||||
"path": redact_tokens(flow.request.path, env=os.environ),
|
"path": redact_tokens(flow.request.path, env=env),
|
||||||
"headers": headers,
|
"headers": headers,
|
||||||
"body": body,
|
"body": body,
|
||||||
})
|
})
|
||||||
+ "\n"
|
+ "\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _log_response(self, flow: http.HTTPFlow) -> None:
|
def _log_response(
|
||||||
|
self, flow: http.HTTPFlow, env: "typing.Mapping[str, str]",
|
||||||
|
) -> None:
|
||||||
|
# Per-flow env overlay (see _log_request): redact this bottle's tokens.
|
||||||
headers = {
|
headers = {
|
||||||
k: redact_tokens(v, env=os.environ)
|
k: redact_tokens(v, env=env)
|
||||||
for k, v in flow.response.headers.items()
|
for k, v in flow.response.headers.items()
|
||||||
}
|
}
|
||||||
body = redact_tokens(flow.response.get_text(strict=False) or "", env=os.environ)
|
body = redact_tokens(flow.response.get_text(strict=False) or "", env=env)
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"event": "egress_response",
|
"event": "egress_response",
|
||||||
@@ -230,37 +240,104 @@ class EgressAddon:
|
|||||||
+ "\n"
|
+ "\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_flow(self, flow: http.HTTPFlow) -> "tuple[Config, str]":
|
def _resolve_flow(
|
||||||
"""The `(Config, supervise slug)` to apply to this request. Single-tenant
|
self, flow: http.HTTPFlow,
|
||||||
→ the static `self.config` and the env slug. Consolidated → the calling
|
) -> "tuple[Config, str, typing.Mapping[str, str]]":
|
||||||
bottle's Config and its bottle id, resolved by source IP in one
|
"""The calling bottle's `(Config, supervise slug, env)`, resolved by
|
||||||
round-trip (fail-closed to deny-all + empty slug if unattributed). The
|
source IP in one round-trip against the orchestrator — fail-closed to
|
||||||
identity token, if the agent injected one, is read then stripped so it
|
deny-all + empty slug if unattributed. `env` is the process env overlaid
|
||||||
never leaks upstream. The slug keys both the proposal queue and the
|
with the bottle's `/resolve` tokens, so upstream-auth injection (and DLP)
|
||||||
per-bottle safelist, so an approval only ever affects its own bottle."""
|
use *this* bottle's credentials. The identity token, if the agent
|
||||||
if self._resolver is None:
|
injected one, is read then stripped so it never leaks upstream."""
|
||||||
return self.config, self._supervise_slug
|
|
||||||
conn = flow.client_conn
|
conn = flow.client_conn
|
||||||
client_ip = conn.peername[0] if conn and conn.peername else ""
|
client_ip = conn.peername[0] if conn and conn.peername else ""
|
||||||
token = flow.request.headers.get(IDENTITY_HEADER, "")
|
token = self._request_token(flow)
|
||||||
|
config, slug, tokens = resolve_client_context(self._resolver, client_ip, token)
|
||||||
|
env = {**os.environ, **tokens} if tokens else os.environ
|
||||||
|
return config, slug, env
|
||||||
|
|
||||||
|
def _stash_flow_ctx(
|
||||||
|
self,
|
||||||
|
flow: http.HTTPFlow,
|
||||||
|
config: Config,
|
||||||
|
slug: str,
|
||||||
|
env: "typing.Mapping[str, str]",
|
||||||
|
) -> None:
|
||||||
|
"""Remember the per-flow context `request()` resolved, so the later
|
||||||
|
`response()` / `websocket_message()` hooks reuse it — scanning against
|
||||||
|
the same bottle's policy the request was decided on, with one `/resolve`
|
||||||
|
per flow rather than one per frame."""
|
||||||
|
meta = getattr(flow, "metadata", None)
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
meta[_FLOW_CTX_KEY] = (config, slug, env)
|
||||||
|
|
||||||
|
def _flow_ctx(
|
||||||
|
self, flow: http.HTTPFlow,
|
||||||
|
) -> "tuple[Config, str, typing.Mapping[str, str]]":
|
||||||
|
"""The `(Config, supervise slug, env)` `request()` resolved for this
|
||||||
|
flow, so a later hook scans against the calling bottle's policy. Falls
|
||||||
|
back to deny-all (empty routes, empty slug) for a flow that never passed
|
||||||
|
through `request()` (or a flow object without metadata) — fail-closed, so
|
||||||
|
a DLP hook on such a flow is a safe no-op rather than an unscanned pass."""
|
||||||
|
meta = getattr(flow, "metadata", None)
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
ctx = meta.get(_FLOW_CTX_KEY)
|
||||||
|
if ctx is not None:
|
||||||
|
return ctx
|
||||||
|
return Config(routes=()), "", os.environ
|
||||||
|
|
||||||
|
def _request_token(self, flow: http.HTTPFlow) -> str:
|
||||||
|
"""The per-bottle identity token for this request, from the proxy
|
||||||
|
credentials — the delivery mechanism (`HTTPS_PROXY=http://id:token@gw`)
|
||||||
|
that clients honor without app changes. Plain-HTTP requests carry
|
||||||
|
`Proxy-Authorization` directly; HTTPS bumped requests inherit the token
|
||||||
|
captured from their tunnel's CONNECT. Read then stripped so it never
|
||||||
|
leaks upstream (also strips the legacy header, if present)."""
|
||||||
|
token = _token_from_proxy_auth(
|
||||||
|
flow.request.headers.get("Proxy-Authorization", ""))
|
||||||
|
flow.request.headers.pop("Proxy-Authorization", None)
|
||||||
flow.request.headers.pop(IDENTITY_HEADER, None)
|
flow.request.headers.pop(IDENTITY_HEADER, None)
|
||||||
return resolve_client_context(self._resolver, client_ip, token)
|
conn = flow.client_conn
|
||||||
|
if not token and conn is not None:
|
||||||
|
token = self._conn_tokens.get(getattr(conn, "id", ""), "")
|
||||||
|
return token
|
||||||
|
|
||||||
|
def http_connect(self, flow: http.HTTPFlow) -> None:
|
||||||
|
"""Capture the identity token from an HTTPS tunnel's CONNECT (the inner
|
||||||
|
bumped requests won't carry `Proxy-Authorization`), keyed by client
|
||||||
|
connection, and strip it so it never reaches upstream."""
|
||||||
|
token = _token_from_proxy_auth(
|
||||||
|
flow.request.headers.get("Proxy-Authorization", ""))
|
||||||
|
flow.request.headers.pop("Proxy-Authorization", None)
|
||||||
|
conn = flow.client_conn
|
||||||
|
if conn is not None and getattr(conn, "id", ""):
|
||||||
|
self._conn_tokens[conn.id] = token
|
||||||
|
|
||||||
|
def client_disconnected(self, client: typing.Any) -> None:
|
||||||
|
"""Drop the per-connection token when the client goes away."""
|
||||||
|
self._conn_tokens.pop(getattr(client, "id", ""), None)
|
||||||
|
|
||||||
async def request(self, flow: http.HTTPFlow) -> None:
|
async def request(self, flow: http.HTTPFlow) -> None:
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
|
||||||
if flow.request.pretty_host == INTROSPECT_HOST:
|
config, slug, env = self._resolve_flow(flow)
|
||||||
self._serve_introspection(flow, request_path)
|
# Stash for the response / websocket hooks so their DLP scans reuse this
|
||||||
return
|
# bottle's resolved policy (one /resolve per flow — see _flow_ctx).
|
||||||
|
self._stash_flow_ctx(flow, config, slug, env)
|
||||||
|
|
||||||
config, slug = self._resolve_flow(flow)
|
# Introspection ("_egress.local/allowlist") reports the calling bottle's
|
||||||
|
# own resolved routes — served after resolution so it reflects this
|
||||||
|
# bottle's policy, not a stale global.
|
||||||
|
if flow.request.pretty_host == INTROSPECT_HOST:
|
||||||
|
self._serve_introspection(flow, request_path, config)
|
||||||
|
return
|
||||||
|
|
||||||
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
||||||
# agent tried to smuggle in any header, path, query param, or body.
|
# agent tried to smuggle in any header, path, query param, or body.
|
||||||
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
||||||
route = match_route(config.routes, flow.request.pretty_host)
|
route = match_route(config.routes, flow.request.pretty_host)
|
||||||
if route is not None:
|
if route is not None:
|
||||||
if not await self._handle_outbound_dlp(flow, route, slug):
|
if not await self._handle_outbound_dlp(flow, route, slug, env):
|
||||||
return
|
return
|
||||||
# The redact policy may have rewritten the request line; recompute
|
# The redact policy may have rewritten the request line; recompute
|
||||||
# the path/query the git checks below rely on.
|
# the path/query the git checks below rely on.
|
||||||
@@ -299,7 +376,7 @@ class EgressAddon:
|
|||||||
config.routes,
|
config.routes,
|
||||||
flow.request.pretty_host,
|
flow.request.pretty_host,
|
||||||
request_path,
|
request_path,
|
||||||
os.environ,
|
env,
|
||||||
request_method=flow.request.method,
|
request_method=flow.request.method,
|
||||||
request_headers=req_headers,
|
request_headers=req_headers,
|
||||||
)
|
)
|
||||||
@@ -312,7 +389,7 @@ class EgressAddon:
|
|||||||
flow.request.headers["authorization"] = decision.inject_authorization
|
flow.request.headers["authorization"] = decision.inject_authorization
|
||||||
|
|
||||||
if config.log >= LOG_FULL:
|
if config.log >= LOG_FULL:
|
||||||
self._log_request(flow)
|
self._log_request(flow, env)
|
||||||
|
|
||||||
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
||||||
ctx = self._req_ctx(flow)
|
ctx = self._req_ctx(flow)
|
||||||
@@ -325,10 +402,12 @@ class EgressAddon:
|
|||||||
flow: http.HTTPFlow,
|
flow: http.HTTPFlow,
|
||||||
route: Route,
|
route: Route,
|
||||||
slug: str,
|
slug: str,
|
||||||
|
env: "typing.Mapping[str, str]",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Scan the outbound request and apply the route's on-match policy
|
"""Scan the outbound request and apply the route's on-match policy
|
||||||
(PRD 0062). Returns True if the request may be forwarded, False if a
|
(PRD 0062). `env` is the per-bottle env overlay (process env + this
|
||||||
403 response has been written to `flow`.
|
bottle's tokens) used for DLP detection. Returns True if the request may
|
||||||
|
be forwarded, False if a 403 response has been written to `flow`.
|
||||||
|
|
||||||
Loops so the supervise policy can re-scan after each approval — a
|
Loops so the supervise policy can re-scan after each approval — a
|
||||||
second, un-approved token in the same request is still caught."""
|
second, un-approved token in the same request is still caught."""
|
||||||
@@ -345,7 +424,7 @@ class EgressAddon:
|
|||||||
flow.request.pretty_host, request_path, query, headers, "",
|
flow.request.pretty_host, request_path, query, headers, "",
|
||||||
)
|
)
|
||||||
result = scan_outbound(
|
result = scan_outbound(
|
||||||
route, scan_text, os.environ,
|
route, scan_text, env,
|
||||||
safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text,
|
safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text,
|
||||||
)
|
)
|
||||||
if result is None or result.severity != "block":
|
if result is None or result.severity != "block":
|
||||||
@@ -356,8 +435,8 @@ class EgressAddon:
|
|||||||
# redact scrubs every detection (tokens and structural CRLF) and
|
# redact scrubs every detection (tokens and structural CRLF) and
|
||||||
# forwards; it fails closed only if a match survives the scrub.
|
# forwards; it fails closed only if a match survives the scrub.
|
||||||
if policy == ON_MATCH_REDACT:
|
if policy == ON_MATCH_REDACT:
|
||||||
if self._redact_outbound(flow, route):
|
if self._redact_outbound(flow, route, env):
|
||||||
if self.config.log >= LOG_BLOCKS:
|
if self._flow_log(flow) >= LOG_BLOCKS:
|
||||||
sys.stderr.write(json.dumps({
|
sys.stderr.write(json.dumps({
|
||||||
"event": "egress_redacted",
|
"event": "egress_redacted",
|
||||||
"reason": f"egress DLP: {result.reason}",
|
"reason": f"egress DLP: {result.reason}",
|
||||||
@@ -384,29 +463,31 @@ class EgressAddon:
|
|||||||
if not self._supervise_available(slug):
|
if not self._supervise_available(slug):
|
||||||
self._block_dlp(flow, result)
|
self._block_dlp(flow, result)
|
||||||
return False
|
return False
|
||||||
approved = await self._supervise_token_block(flow, request_path, result, slug)
|
approved = await self._supervise_token_block(flow, request_path, result, slug, env)
|
||||||
if not approved:
|
if not approved:
|
||||||
return False # _supervise_token_block wrote the 403 response
|
return False # _supervise_token_block wrote the 403 response
|
||||||
# loop: the approved value is now in safe_tokens; re-scan.
|
# loop: the approved value is now in safe_tokens; re-scan.
|
||||||
|
|
||||||
def _redact_outbound(self, flow: http.HTTPFlow, route: Route) -> bool:
|
def _redact_outbound(
|
||||||
|
self, flow: http.HTTPFlow, route: Route, env: "typing.Mapping[str, str]",
|
||||||
|
) -> bool:
|
||||||
"""Scrub detected tokens (and CRLF injection sequences) from the mutable
|
"""Scrub detected tokens (and CRLF injection sequences) from the mutable
|
||||||
request surfaces (body, headers, path/query) and re-scan. Returns True
|
request surfaces (body, headers, path/query) and re-scan. `env` is the
|
||||||
if the request is now clean; False if a block-severity match remains on
|
per-bottle env overlay. Returns True if the request is now clean; False
|
||||||
a surface redaction cannot rewrite (the hostname) so the caller fails
|
if a block-severity match remains on a surface redaction cannot rewrite
|
||||||
closed."""
|
(the hostname) so the caller fails closed."""
|
||||||
body = flow.request.get_text(strict=False)
|
body = flow.request.get_text(strict=False)
|
||||||
if body:
|
if body:
|
||||||
redacted_body = redact_tokens(body, env=os.environ)
|
redacted_body = redact_tokens(body, env=env)
|
||||||
if redacted_body != body:
|
if redacted_body != body:
|
||||||
flow.request.text = redacted_body
|
flow.request.text = redacted_body
|
||||||
for name, value in list(flow.request.headers.items()):
|
for name, value in list(flow.request.headers.items()):
|
||||||
if name.lower() == "host":
|
if name.lower() == "host":
|
||||||
continue # routing-critical; never a legitimate token
|
continue # routing-critical; never a legitimate token
|
||||||
redacted = strip_crlf(redact_tokens(value, env=os.environ))
|
redacted = strip_crlf(redact_tokens(value, env=env))
|
||||||
if redacted != value:
|
if redacted != value:
|
||||||
flow.request.headers[name] = redacted
|
flow.request.headers[name] = redacted
|
||||||
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=os.environ))
|
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=env))
|
||||||
if redacted_path != flow.request.path:
|
if redacted_path != flow.request.path:
|
||||||
flow.request.path = redacted_path
|
flow.request.path = redacted_path
|
||||||
|
|
||||||
@@ -419,7 +500,7 @@ class EgressAddon:
|
|||||||
crlf_text = build_outbound_scan_text(
|
crlf_text = build_outbound_scan_text(
|
||||||
flow.request.pretty_host, request_path, query, headers, "",
|
flow.request.pretty_host, request_path, query, headers, "",
|
||||||
)
|
)
|
||||||
result = scan_outbound(route, scan_text, os.environ, crlf_text=crlf_text)
|
result = scan_outbound(route, scan_text, env, crlf_text=crlf_text)
|
||||||
return result is None or result.severity != "block"
|
return result is None or result.severity != "block"
|
||||||
|
|
||||||
async def _supervise_token_block(
|
async def _supervise_token_block(
|
||||||
@@ -428,20 +509,22 @@ class EgressAddon:
|
|||||||
request_path: str,
|
request_path: str,
|
||||||
result: ScanResult,
|
result: ScanResult,
|
||||||
slug: str,
|
slug: str,
|
||||||
|
env: "typing.Mapping[str, str]",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Route a token DLP block to the operator's supervisor queue and wait.
|
"""Route a token DLP block to the operator's supervisor queue and wait.
|
||||||
|
|
||||||
`slug` attributes the proposal to the calling bottle (its own queue +
|
`slug` attributes the proposal to the calling bottle (its own queue +
|
||||||
safelist) — in the shared gateway this is what keeps one bottle's
|
safelist) — in the shared gateway this is what keeps one bottle's
|
||||||
approval from unblocking another's request. Returns True if the operator
|
approval from unblocking another's request. `env` is the per-bottle env
|
||||||
approved (the matched value is added to that bottle's safelist and the
|
overlay used to redact secrets from the proposal text. Returns True if
|
||||||
caller re-scans); False if the request must be blocked (a 403 response
|
the operator approved (the matched value is added to that bottle's
|
||||||
has been written to `flow`)."""
|
safelist and the caller re-scans); False if the request must be blocked
|
||||||
|
(a 403 response has been written to `flow`)."""
|
||||||
host = flow.request.pretty_host
|
host = flow.request.pretty_host
|
||||||
payload = build_token_allow_payload(
|
payload = build_token_allow_payload(
|
||||||
redact_tokens(host, env=os.environ),
|
redact_tokens(host, env=env),
|
||||||
flow.request.method,
|
flow.request.method,
|
||||||
redact_tokens(request_path, env=os.environ),
|
redact_tokens(request_path, env=env),
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
proposal = _sv.Proposal.new(
|
proposal = _sv.Proposal.new(
|
||||||
@@ -475,7 +558,7 @@ class EgressAddon:
|
|||||||
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
||||||
):
|
):
|
||||||
self._safe_tokens_for(slug).add(result.matched)
|
self._safe_tokens_for(slug).add(result.matched)
|
||||||
if self.config.log >= LOG_BLOCKS:
|
if self._flow_log(flow) >= LOG_BLOCKS:
|
||||||
sys.stderr.write(json.dumps({
|
sys.stderr.write(json.dumps({
|
||||||
"event": "egress_token_allowed",
|
"event": "egress_token_allowed",
|
||||||
"reason": f"egress DLP: {result.reason}",
|
"reason": f"egress DLP: {result.reason}",
|
||||||
@@ -515,14 +598,16 @@ class EgressAddon:
|
|||||||
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
|
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
|
||||||
|
|
||||||
def response(self, flow: http.HTTPFlow) -> None:
|
def response(self, flow: http.HTTPFlow) -> None:
|
||||||
"""DLP inbound scan on response headers and body."""
|
"""DLP inbound scan on response headers and body, against the calling
|
||||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
bottle's resolved config (`request()` stashed it — see `_flow_ctx`)."""
|
||||||
|
config, _slug, env = self._flow_ctx(flow)
|
||||||
|
route = match_route(config.routes, flow.request.pretty_host)
|
||||||
if route is None:
|
if route is None:
|
||||||
return
|
return
|
||||||
if flow.response is None:
|
if flow.response is None:
|
||||||
return
|
return
|
||||||
if self.config.log >= LOG_FULL:
|
if config.log >= LOG_FULL:
|
||||||
self._log_response(flow)
|
self._log_response(flow, env)
|
||||||
resp_headers = {k.lower(): v for k, v in flow.response.headers.items()}
|
resp_headers = {k.lower(): v for k, v in flow.response.headers.items()}
|
||||||
body = flow.response.get_text(strict=False) or ""
|
body = flow.response.get_text(strict=False) or ""
|
||||||
scan_text = build_inbound_scan_text(resp_headers, body)
|
scan_text = build_inbound_scan_text(resp_headers, body)
|
||||||
@@ -539,7 +624,7 @@ class EgressAddon:
|
|||||||
resp_ctx = {**resp_ctx, "context": result.context}
|
resp_ctx = {**resp_ctx, "context": result.context}
|
||||||
if result.severity == "block":
|
if result.severity == "block":
|
||||||
self._block(flow, f"egress DLP: {result.reason}", ctx=resp_ctx)
|
self._block(flow, f"egress DLP: {result.reason}", ctx=resp_ctx)
|
||||||
elif result.severity == "warn" and self.config.log >= LOG_BLOCKS:
|
elif result.severity == "warn" and config.log >= LOG_BLOCKS:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"event": "egress_warn",
|
"event": "egress_warn",
|
||||||
@@ -550,7 +635,9 @@ class EgressAddon:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def websocket_message(self, flow: http.HTTPFlow) -> None:
|
def websocket_message(self, flow: http.HTTPFlow) -> None:
|
||||||
"""DLP scan on WebSocket frames.
|
"""DLP scan on WebSocket frames, against the calling bottle's resolved
|
||||||
|
config (see `_flow_ctx`). `request()` resolves and stashes the per-flow
|
||||||
|
(config, slug, env) at the upgrade, and every frame reuses it.
|
||||||
|
|
||||||
Outbound frames (from_client) are scanned for credential leakage;
|
Outbound frames (from_client) are scanned for credential leakage;
|
||||||
inbound frames are scanned for prompt injection. On a block the
|
inbound frames are scanned for prompt injection. On a block the
|
||||||
@@ -559,10 +646,8 @@ class EgressAddon:
|
|||||||
"""
|
"""
|
||||||
if flow.websocket is None: # type: ignore[union-attr]
|
if flow.websocket is None: # type: ignore[union-attr]
|
||||||
return
|
return
|
||||||
# WebSocket DLP runs against the static config only (single-tenant); in
|
config, slug, env = self._flow_ctx(flow)
|
||||||
# the consolidated gateway self.config has no routes, so this is inert
|
route = match_route(config.routes, flow.request.pretty_host)
|
||||||
# until websocket routing is made source-IP-aware (a separate slice).
|
|
||||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
|
||||||
if route is None:
|
if route is None:
|
||||||
return
|
return
|
||||||
message = flow.websocket.messages[-1] # type: ignore[union-attr]
|
message = flow.websocket.messages[-1] # type: ignore[union-attr]
|
||||||
@@ -571,8 +656,8 @@ class EgressAddon:
|
|||||||
# A WebSocket data frame is not an HTTP request line, so CRLF is
|
# A WebSocket data frame is not an HTTP request line, so CRLF is
|
||||||
# not an injection vector here — scan only for credential leakage.
|
# not an injection vector here — scan only for credential leakage.
|
||||||
result = scan_outbound(
|
result = scan_outbound(
|
||||||
route, content, os.environ,
|
route, content, env,
|
||||||
safe_tokens=self._safe_tokens_for(self._supervise_slug), crlf_text="",
|
safe_tokens=self._safe_tokens_for(slug), crlf_text="",
|
||||||
)
|
)
|
||||||
if result is not None and result.severity == "block":
|
if result is not None and result.severity == "block":
|
||||||
sys.stderr.write(f"egress DLP: {result.reason}\n")
|
sys.stderr.write(f"egress DLP: {result.reason}\n")
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ exercise the parse + decision functions without depending on the
|
|||||||
`mitmproxy.http.HTTPFlow` API and is loaded inside the gateway
|
`mitmproxy.http.HTTPFlow` API and is loaded inside the gateway
|
||||||
container.
|
container.
|
||||||
|
|
||||||
Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
|
Imports: stdlib + sibling package modules (`yaml_subset`,
|
||||||
ships flat into the sidecar bundle image alongside this file —
|
`egress_dlp_config`). Available in the gateway via the installed
|
||||||
see `Dockerfile.sidecars`)."""
|
`bot_bottle` package (see `Dockerfile.gateway`)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,36 +16,20 @@ import re
|
|||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
try:
|
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||||
from yaml_subset import YamlSubsetError, parse_yaml_subset # type: ignore[import-not-found]
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from .yaml_subset import YamlSubsetError, parse_yaml_subset
|
|
||||||
|
|
||||||
# DLP detector-config parsing lives in a sibling module (also flat-bundled
|
# DLP detector-config parsing lives in a sibling module. Re-exported below
|
||||||
# into the gateway — see Dockerfile.sidecars). Re-exported below so existing
|
# so existing `from egress_addon_core import ON_MATCH_*` callers keep working.
|
||||||
# `from egress_addon_core import ON_MATCH_*` callers keep working.
|
from .egress_dlp_config import (
|
||||||
try:
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
from egress_dlp_config import ( # type: ignore[import-not-found]
|
INBOUND_DETECTOR_NAMES,
|
||||||
DEFAULT_OUTBOUND_ON_MATCH,
|
ON_MATCH_BLOCK,
|
||||||
INBOUND_DETECTOR_NAMES,
|
ON_MATCH_REDACT,
|
||||||
ON_MATCH_BLOCK,
|
ON_MATCH_SUPERVISE,
|
||||||
ON_MATCH_REDACT,
|
OUTBOUND_DETECTOR_NAMES,
|
||||||
ON_MATCH_SUPERVISE,
|
OUTBOUND_ON_MATCH_VALUES,
|
||||||
OUTBOUND_DETECTOR_NAMES,
|
parse_dlp_block,
|
||||||
OUTBOUND_ON_MATCH_VALUES,
|
)
|
||||||
parse_dlp_block,
|
|
||||||
)
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from .egress_dlp_config import (
|
|
||||||
DEFAULT_OUTBOUND_ON_MATCH,
|
|
||||||
INBOUND_DETECTOR_NAMES,
|
|
||||||
ON_MATCH_BLOCK,
|
|
||||||
ON_MATCH_REDACT,
|
|
||||||
ON_MATCH_SUPERVISE,
|
|
||||||
OUTBOUND_DETECTOR_NAMES,
|
|
||||||
OUTBOUND_ON_MATCH_VALUES,
|
|
||||||
parse_dlp_block,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -450,28 +434,31 @@ def resolve_client_config(
|
|||||||
|
|
||||||
class ContextResolverLike(typing.Protocol):
|
class ContextResolverLike(typing.Protocol):
|
||||||
"""The bit of `policy_resolver.PolicyResolver` `resolve_client_context`
|
"""The bit of `policy_resolver.PolicyResolver` `resolve_client_context`
|
||||||
needs — one round-trip returning both policy and bottle id."""
|
needs — one round-trip returning policy, bottle id, and auth tokens."""
|
||||||
|
|
||||||
def resolve_policy_and_bottle_id(
|
def resolve_policy_and_bottle_id(
|
||||||
self, source_ip: str, identity_token: str = ...,
|
self, source_ip: str, identity_token: str = ...,
|
||||||
) -> "tuple[str | None, str | None]":
|
) -> "tuple[str | None, str | None, dict[str, str]]":
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
def resolve_client_context(
|
def resolve_client_context(
|
||||||
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
|
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
|
||||||
) -> "tuple[Config, str]":
|
) -> "tuple[Config, str, dict[str, str]]":
|
||||||
"""The calling client's `(Config, bottle_id)` in one round-trip —
|
"""The calling client's `(Config, bottle_id, tokens)` in one round-trip —
|
||||||
**fail-closed**. The Config follows `resolve_client_config`'s deny-all
|
**fail-closed**. The Config follows `resolve_client_config`'s deny-all
|
||||||
rules; the bottle id is `""` whenever unattributed or the orchestrator
|
rules; the bottle id is `""` whenever unattributed or the orchestrator
|
||||||
errored, which the caller treats as "supervise unavailable for this
|
errored (caller treats as "supervise unavailable", never another bottle's
|
||||||
bottle" (never another bottle's queue). One `/resolve` keys both the
|
queue); `tokens` are the per-bottle upstream auth values the addon injects.
|
||||||
per-request egress policy and the per-bottle supervise queue + safelist."""
|
One `/resolve` keys the egress policy, the supervise queue + safelist, and
|
||||||
|
auth injection."""
|
||||||
try:
|
try:
|
||||||
policy, bottle_id = resolver.resolve_policy_and_bottle_id(client_ip, identity_token)
|
policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id(
|
||||||
|
client_ip, identity_token,
|
||||||
|
)
|
||||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
return Config(routes=()), "" # orchestrator unreachable/errored → deny
|
return Config(routes=()), "", {} # orchestrator unreachable/errored → deny
|
||||||
return _config_from_policy(policy), (bottle_id or "")
|
return _config_from_policy(policy), (bottle_id or ""), tokens
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -15,14 +15,26 @@
|
|||||||
# mitmproxy at it. The option REPLACES mitmproxy's default
|
# mitmproxy at it. The option REPLACES mitmproxy's default
|
||||||
# trust store, so passing the upstream CA alone would break
|
# trust store, so passing the upstream CA alone would break
|
||||||
# non-chained hosts.
|
# non-chained hosts.
|
||||||
# * `-s /app/egress_addon.py` loads the addon that reads
|
# * `-s /app/egress_addon.py` loads the addon that resolves each
|
||||||
# /etc/egress/routes.yaml.
|
# request's policy from the orchestrator control plane by source
|
||||||
|
# IP (PRD 0070). There is no static routes file.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Fail closed on a missing policy source. The addon itself raises at
|
||||||
|
# load when BOT_BOTTLE_ORCHESTRATOR_URL is unset (so mitmdump exits via
|
||||||
|
# its errorcheck addon), but that leaves the fail-closed guarantee at the
|
||||||
|
# mercy of a mitmproxy version keeping that behavior. Refuse here too, so
|
||||||
|
# a misconfigured gateway can never come up as a bare TLS-bumping open
|
||||||
|
# proxy with no policy — independent of mitmproxy's startup-error handling.
|
||||||
|
if [ -z "$BOT_BOTTLE_ORCHESTRATOR_URL" ]; then
|
||||||
|
echo "egress: BOT_BOTTLE_ORCHESTRATOR_URL is required (no static routes fallback)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -78,8 +78,8 @@ def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
|
|||||||
_DAEMONS: tuple[_DaemonSpec, ...] = (
|
_DAEMONS: tuple[_DaemonSpec, ...] = (
|
||||||
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
|
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
|
||||||
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
|
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
|
||||||
_DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")),
|
_DaemonSpec("git-http", ("python3", "-m", "bot_bottle.git_http_backend")),
|
||||||
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
|
_DaemonSpec("supervise", ("python3", "-m", "bot_bottle.supervise_server")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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."""
|
||||||
|
|
||||||
@@ -112,8 +112,10 @@ class GitGate(ABC):
|
|||||||
access_hook = stage_dir / "git_gate_access_hook.sh"
|
access_hook = stage_dir / "git_gate_access_hook.sh"
|
||||||
access_hook.write_text(git_gate_render_access_hook())
|
access_hook.write_text(git_gate_render_access_hook())
|
||||||
# 0o700 (not 0o600): git daemon execs --access-hook directly,
|
# 0o700 (not 0o600): git daemon execs --access-hook directly,
|
||||||
# not via `sh`, so the script needs the x bit. docker cp
|
# not via `sh`, so the script needs the x bit. The gateway copy
|
||||||
# preserves source mode into the container.
|
# does not necessarily preserve this mode (`docker cp` does, the
|
||||||
|
# Apple `container cp` does not), so provision_git_gate re-applies
|
||||||
|
# +x on the gateway side — see backend/docker/gateway_provision.py.
|
||||||
access_hook.chmod(0o700)
|
access_hook.chmod(0o700)
|
||||||
upstreams_with_files: list[GitGateUpstream] = []
|
upstreams_with_files: list[GitGateUpstream] = []
|
||||||
for u in upstreams:
|
for u in upstreams:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import dataclasses
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .bottle_state import globalize_slug
|
||||||
from .errors import MissingEnvVarError
|
from .errors import MissingEnvVarError
|
||||||
from .log import info
|
from .log import info
|
||||||
from .manifest import ManifestBottle, ManifestGitEntry
|
from .manifest import ManifestBottle, ManifestGitEntry
|
||||||
@@ -46,7 +47,7 @@ def _provision_dynamic_key(
|
|||||||
owner_repo = entry.UpstreamPath
|
owner_repo = entry.UpstreamPath
|
||||||
if owner_repo.endswith(".git"):
|
if owner_repo.endswith(".git"):
|
||||||
owner_repo = owner_repo[:-4]
|
owner_repo = owner_repo[:-4]
|
||||||
title = f"bot-bottle:{slug}:{entry.Name}"
|
title = f"bot-bottle:{globalize_slug(slug)}:{entry.Name}"
|
||||||
|
|
||||||
info(f"provisioning deploy key for git-gate.repos[{entry.Name!r}]")
|
info(f"provisioning deploy key for git-gate.repos[{entry.Name!r}]")
|
||||||
key_id, private_key_bytes = provisioner.create(owner_repo, title)
|
key_id, private_key_bytes = provisioner.create(owner_repo, title)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,15 +14,12 @@ import shlex
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
||||||
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:
|
|
||||||
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
|
|
||||||
# git_http_backend, and the git http-backend CGI subprocess.
|
|
||||||
GIT_GATE_TIMEOUT_SECS = 15
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -38,7 +35,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
|
||||||
@@ -75,6 +72,7 @@ def _gitconfig_validate_value(field: str, value: str) -> None:
|
|||||||
|
|
||||||
def git_gate_render_gitconfig(
|
def git_gate_render_gitconfig(
|
||||||
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
|
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
|
||||||
|
identity_token: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render the agent's ~/.gitconfig content for git-gate
|
"""Render the agent's ~/.gitconfig content for git-gate
|
||||||
`insteadOf` rewrites. Pure host-side, no docker / VM;
|
`insteadOf` rewrites. Pure host-side, no docker / VM;
|
||||||
@@ -96,6 +94,15 @@ def git_gate_render_gitconfig(
|
|||||||
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
||||||
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
||||||
]
|
]
|
||||||
|
# Over the smart-HTTP transport (VM backends), attach the per-bottle
|
||||||
|
# identity token as a request header on requests to the gate, scoped to
|
||||||
|
# its URL so it never goes to any other remote. git-http requires it (the
|
||||||
|
# gateway's mandatory (source_ip, token) attribution). git:// (single-tenant
|
||||||
|
# docker) carries no header — attribution there is the network alias.
|
||||||
|
if identity_token and scheme == "http":
|
||||||
|
_gitconfig_validate_value("identity_token", identity_token)
|
||||||
|
out.append(f'[http "http://{gate_host}/"]\n')
|
||||||
|
out.append(f"\textraHeader = {IDENTITY_HEADER}: {identity_token}\n")
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
_gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream)
|
_gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream)
|
||||||
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
|
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
|
||||||
|
|||||||
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Consolidated (PRD 0070): when `BOT_BOTTLE_ORCHESTRATOR_URL` is set, one
|
One shared gateway serves every bottle (PRD 0070): each request is served
|
||||||
shared gateway serves every bottle, and each request is served from the
|
from the calling bottle's repo namespace (`<root>/<bottle_id>`), attributed
|
||||||
calling bottle's repo namespace (`<root>/<bottle_id>`), attributed from
|
from the unspoofable source IP via the orchestrator. Per-repo credentials +
|
||||||
the unspoofable source IP via the orchestrator. Per-repo credentials +
|
|
||||||
hooks scope by repo directory, so isolating the *root* per bottle isolates
|
hooks scope by repo directory, so isolating the *root* per bottle isolates
|
||||||
its creds too. Unattributed clients fail closed (404). Unset → the legacy
|
its creds too. Unattributed clients — and a missing/unreachable orchestrator
|
||||||
per-bottle single-tenant flat root, unchanged — a transitional path that
|
— fail closed (404). `BOT_BOTTLE_ORCHESTRATOR_URL` is mandatory: there is no
|
||||||
gets stripped out once every backend runs the consolidated gateway.
|
single-tenant flat-root fallback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,36 +26,19 @@ 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
|
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
||||||
# image (see Dockerfile.sidecars); the bot_bottle.* fallback is the
|
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
# host-side / test path. Mirrors egress_addon's import shape.
|
|
||||||
try:
|
|
||||||
from policy_resolver import ( # type: ignore[import-not-found]
|
|
||||||
PolicyResolveError,
|
|
||||||
PolicyResolver,
|
|
||||||
)
|
|
||||||
except ImportError: # pragma: no cover - host-side path
|
|
||||||
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PORT = 9420
|
DEFAULT_PORT = 9420
|
||||||
|
|
||||||
# Consolidated (multi-tenant) mode: when this points at the per-host
|
# The per-host orchestrator control plane the backend attributes each request
|
||||||
# orchestrator's control plane, the backend serves each request from the
|
# to, serving from the *calling* bottle's repo namespace selected by source IP.
|
||||||
# *calling* bottle's repo namespace, selected by source IP, instead of a
|
# Mandatory — the same env the egress addon requires; there is no single flat
|
||||||
# single flat repo root. Unset → legacy per-bottle single-tenant mode
|
# repo-root fallback.
|
||||||
# (unchanged). Same env the egress addon reads, so one orchestrator setting
|
|
||||||
# flips the whole shared gateway multi-tenant.
|
|
||||||
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
||||||
|
|
||||||
# App-layer identity token (defense-in-depth over the source-IP invariant);
|
# The base under which each bottle's `<bottle_id>` repo namespace is nested.
|
||||||
# the agent injects it, the backend reads it for attribution and never
|
|
||||||
# forwards it to `git http-backend`. Mirrors egress_addon.IDENTITY_HEADER
|
|
||||||
# (duplicated, not imported: egress_addon pulls in mitmproxy).
|
|
||||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
|
||||||
|
|
||||||
# Default flat repo root (single-tenant, and the base under which
|
|
||||||
# consolidated mode nests each sandbox's namespace).
|
|
||||||
DEFAULT_REPO_ROOT = "/git"
|
DEFAULT_REPO_ROOT = "/git"
|
||||||
|
|
||||||
|
|
||||||
@@ -71,25 +53,16 @@ class ResolverLike(typing.Protocol):
|
|||||||
|
|
||||||
|
|
||||||
def resolve_sandbox_root(
|
def resolve_sandbox_root(
|
||||||
resolver: "ResolverLike | None",
|
resolver: "ResolverLike",
|
||||||
base_root: Path,
|
base_root: Path,
|
||||||
source_ip: str,
|
source_ip: str,
|
||||||
identity_token: str = "",
|
identity_token: str = "",
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
"""The per-sandbox repo root to serve this request from, or None to
|
"""The per-sandbox repo root to serve this request from — `base_root/
|
||||||
deny (404).
|
<bottle_id>`, where the sandbox is attributed from the source IP via the
|
||||||
|
orchestrator — or None to deny (404). Fail-closed: an unattributed client, a
|
||||||
Single-tenant (`resolver is None`): the flat `base_root`, unchanged.
|
resolver error, or a namespace that would escape `base_root` all deny, so one
|
||||||
NOTE: this legacy per-bottle single-tenant path is transitional — it
|
sandbox can never reach another's repos."""
|
||||||
will be stripped out once every backend runs the consolidated gateway
|
|
||||||
(PRD 0070), leaving only the source-IP-attributed path below.
|
|
||||||
|
|
||||||
Consolidated: `base_root/<bottle_id>`, where the sandbox is attributed
|
|
||||||
from the source IP via the orchestrator. Fail-closed — an unattributed
|
|
||||||
client, a resolver error, or a namespace that would escape `base_root`
|
|
||||||
all deny, so one sandbox can never reach another's repos."""
|
|
||||||
if resolver is None:
|
|
||||||
return base_root
|
|
||||||
try:
|
try:
|
||||||
bottle_id = resolver.resolve_bottle_id(source_ip, identity_token)
|
bottle_id = resolver.resolve_bottle_id(source_ip, identity_token)
|
||||||
except PolicyResolveError:
|
except PolicyResolveError:
|
||||||
@@ -102,13 +75,6 @@ def resolve_sandbox_root(
|
|||||||
return None # bottle_id tried to escape the root → deny
|
return None # bottle_id tried to escape the root → deny
|
||||||
return namespace
|
return namespace
|
||||||
|
|
||||||
# Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than
|
|
||||||
# imported: this module ships as a flat top-level sibling in the sidecar
|
|
||||||
# bundle image (see Dockerfile.sidecars), not as part of the bot_bottle
|
|
||||||
# package, so `bot_bottle.git_gate` and its dependency chain aren't
|
|
||||||
# available at runtime.
|
|
||||||
GIT_GATE_TIMEOUT_SECS = 15
|
|
||||||
|
|
||||||
# Bound memory use while still allowing ordinary git push packfiles.
|
# Bound memory use while still allowing ordinary git push packfiles.
|
||||||
MAX_BODY_BYTES = 100 * 1024 * 1024
|
MAX_BODY_BYTES = 100 * 1024 * 1024
|
||||||
|
|
||||||
@@ -123,12 +89,13 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
self._run_backend()
|
self._run_backend()
|
||||||
|
|
||||||
def _sandbox_root(self) -> Path | None:
|
def _sandbox_root(self) -> Path | None:
|
||||||
"""This request's per-sandbox repo root, or None to deny. Single-tenant
|
"""This request's per-sandbox repo root (the calling bottle's source-IP-
|
||||||
unless the server was started with a resolver (consolidated mode), in
|
selected `<base>/<bottle_id>` namespace), or None to deny. `GIT_PROJECT_
|
||||||
which case the root is the calling sandbox's source-IP-selected
|
ROOT` keeps git's own env-var name."""
|
||||||
namespace. `GIT_PROJECT_ROOT` keeps git's own env-var name."""
|
|
||||||
base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_REPO_ROOT))
|
base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_REPO_ROOT))
|
||||||
resolver = getattr(self.server, "policy_resolver", None)
|
resolver = getattr(self.server, "policy_resolver", None)
|
||||||
|
if resolver is None:
|
||||||
|
return None # server started without a resolver (misconfig) → deny
|
||||||
token = self.headers.get(IDENTITY_HEADER, "")
|
token = self.headers.get(IDENTITY_HEADER, "")
|
||||||
return resolve_sandbox_root(resolver, base, self.client_address[0], token)
|
return resolve_sandbox_root(resolver, base, self.client_address[0], token)
|
||||||
|
|
||||||
@@ -148,12 +115,24 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
"GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook",
|
"GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook",
|
||||||
)
|
)
|
||||||
peer = self.client_address[0]
|
peer = self.client_address[0]
|
||||||
hook = subprocess.run(
|
try:
|
||||||
[hook_path, "upload-pack", str(repo_dir), peer, peer],
|
hook = subprocess.run(
|
||||||
capture_output=True,
|
[hook_path, "upload-pack", str(repo_dir), peer, peer],
|
||||||
check=False,
|
capture_output=True,
|
||||||
timeout=GIT_GATE_TIMEOUT_SECS,
|
check=False,
|
||||||
)
|
timeout=GIT_GATE_TIMEOUT_SECS,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
# The access-hook couldn't be run (missing, not executable,
|
||||||
|
# timed out, …). Fail closed with a real HTTP error rather
|
||||||
|
# than letting the exception kill the handler thread — an
|
||||||
|
# unhandled exception closes the socket with no response, which
|
||||||
|
# the client sees as an opaque "empty reply from server".
|
||||||
|
self.log_message(
|
||||||
|
"access-hook could not run for %s: %s", parsed.path, exc,
|
||||||
|
)
|
||||||
|
self.send_error(503, "git-gate access-hook unavailable")
|
||||||
|
return
|
||||||
if hook.returncode != 0:
|
if hook.returncode != 0:
|
||||||
detail = (hook.stderr or hook.stdout).decode(
|
detail = (hook.stderr or hook.stdout).decode(
|
||||||
"utf-8", errors="replace",
|
"utf-8", errors="replace",
|
||||||
@@ -188,14 +167,11 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
"SERVER_PORT": str(self.server.server_port), # type: ignore
|
"SERVER_PORT": str(self.server.server_port), # type: ignore
|
||||||
"SERVER_PROTOCOL": self.request_version,
|
"SERVER_PROTOCOL": self.request_version,
|
||||||
})
|
})
|
||||||
# Consolidated mode: attribute the gitleaks-allow supervise proposal
|
# Attribute the gitleaks-allow supervise proposal (written by
|
||||||
# (written by receive-pack's pre-receive hook, a child of the CGI we
|
# receive-pack's pre-receive hook, a child of the CGI we spawn below) to
|
||||||
# spawn below) to the calling bottle. The namespaced root is
|
# the calling bottle. The namespaced root is `<base>/<bottle_id>`, so its
|
||||||
# `<base>/<bottle_id>`, so its final component is the bottle id — the
|
# final component is the bottle id — the same per-bottle key egress uses.
|
||||||
# same per-bottle key egress uses. Single-tenant leaves the hook's
|
env["SUPERVISE_BOTTLE_SLUG"] = sandbox_root.name
|
||||||
# container-stamped SUPERVISE_BOTTLE_SLUG untouched.
|
|
||||||
if getattr(self.server, "policy_resolver", None) is not None:
|
|
||||||
env["SUPERVISE_BOTTLE_SLUG"] = sandbox_root.name
|
|
||||||
for header, variable in (
|
for header, variable in (
|
||||||
("accept", "HTTP_ACCEPT"),
|
("accept", "HTTP_ACCEPT"),
|
||||||
("content-encoding", "HTTP_CONTENT_ENCODING"),
|
("content-encoding", "HTTP_CONTENT_ENCODING"),
|
||||||
@@ -285,14 +261,20 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
|
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
|
||||||
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
|
|
||||||
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
|
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
|
||||||
# Consolidated mode: resolve each request's sandbox namespace by source
|
if not orch_url:
|
||||||
# IP. Absent → single-tenant (flat repo root); behaviour unchanged.
|
# Resolver-only: without an orchestrator the backend can't attribute a
|
||||||
resolver = PolicyResolver(orch_url) if orch_url else None
|
# request to a bottle namespace, so it must not serve (fail-closed).
|
||||||
server.policy_resolver = resolver # type: ignore[attr-defined]
|
sys.stderr.write(
|
||||||
mode = "multi-tenant" if orch_url else "single-tenant"
|
f"git-http: {ORCHESTRATOR_URL_ENV} is required "
|
||||||
sys.stdout.write(f"git-http listening on 0.0.0.0:{port} ({mode})\n")
|
"(no single-tenant flat-root fallback)\n"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
|
||||||
|
# Resolve each request's sandbox namespace by source IP against the
|
||||||
|
# orchestrator control plane.
|
||||||
|
server.policy_resolver = PolicyResolver(orch_url) # type: ignore[attr-defined]
|
||||||
|
sys.stdout.write(f"git-http listening on 0.0.0.0:{port} (multi-tenant)\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -25,8 +25,9 @@ class ManifestAgentProvider:
|
|||||||
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
|
||||||
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 provider auth token into
|
||||||
the egress sidecar (Codex only).
|
the egress sidecar (Codex and Claude). For Codex this reads
|
||||||
|
`~/.codex/auth.json`; for Claude it reads `~/.claude/.credentials.json`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template: str = "claude"
|
template: str = "claude"
|
||||||
@@ -92,10 +93,15 @@ class ManifestAgentProvider:
|
|||||||
f"is only supported for built-in templates "
|
f"is only supported for built-in templates "
|
||||||
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
|
||||||
)
|
)
|
||||||
if forward_host_credentials and template != "codex":
|
if forward_host_credentials and template not in {"codex", "claude"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
"is currently only supported for template 'codex'"
|
"is only supported for templates 'codex' and 'claude'"
|
||||||
|
)
|
||||||
|
if forward_host_credentials and auth_token:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
|
||||||
|
"and auth_token both set; use one or the other"
|
||||||
)
|
)
|
||||||
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
|
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
|
||||||
return cls(
|
return cls(
|
||||||
|
|||||||
@@ -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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import secrets
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import log
|
from .. import log
|
||||||
|
from ..store_manager import StoreManager
|
||||||
from .broker import LaunchBroker, StubBroker
|
from .broker import LaunchBroker, StubBroker
|
||||||
from .control_plane import make_server
|
from .control_plane import make_server
|
||||||
from .docker_broker import DockerBroker
|
from .docker_broker import DockerBroker
|
||||||
@@ -39,19 +40,30 @@ 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)
|
||||||
|
|
||||||
registry = RegistryStore(args.db)
|
registry = RegistryStore(args.db)
|
||||||
registry.migrate()
|
registry.migrate()
|
||||||
|
# One DB per host: the supervise queue + audit tables live in the SAME
|
||||||
|
# SQLite file the registry owns, so the control plane is the single
|
||||||
|
# source of truth. The in-VM supervise daemon writes here; the host
|
||||||
|
# operator reaches it over HTTP (never a second, disconnected DB).
|
||||||
|
StoreManager(registry.db_path).migrate()
|
||||||
|
|
||||||
# An ephemeral signing secret ties the orchestrator (signer) to its
|
# An ephemeral signing secret ties the orchestrator (signer) to its
|
||||||
# broker (verifier). 'stub' records launches instead of starting
|
# broker (verifier). 'stub' records launches instead of starting
|
||||||
# anything; 'docker' runs real containers (firecracker drops in later).
|
# anything; 'docker' runs real containers (firecracker drops in later).
|
||||||
secret = secrets.token_bytes(32)
|
secret = secrets.token_bytes(32)
|
||||||
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
|
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
|
||||||
gateway: Gateway | None = DockerGateway() if args.gateway else None
|
# Standalone `--gateway` (not the consolidated flow, where the host
|
||||||
|
# lifecycle runs the gateway). The gateway resolves against this same
|
||||||
|
# process; the URL is only reachable when they share a docker network.
|
||||||
|
gateway: Gateway | None = (
|
||||||
|
DockerGateway(orchestrator_url=f"http://{args.host}:{args.port}")
|
||||||
|
if args.gateway else None
|
||||||
|
)
|
||||||
orchestrator = Orchestrator(registry, broker, secret, gateway)
|
orchestrator = Orchestrator(registry, broker, secret, gateway)
|
||||||
|
|
||||||
# One persistent per-host gateway, shared by every bottle: build the
|
# One persistent per-host gateway, shared by every bottle: build the
|
||||||
|
|||||||
@@ -17,9 +17,22 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from ..paths import host_control_plane_token
|
||||||
|
from .control_plane import CONTROL_AUTH_HEADER
|
||||||
|
|
||||||
DEFAULT_TIMEOUT_SECONDS = 5.0
|
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _host_auth_token() -> str:
|
||||||
|
"""The per-host control-plane secret, or "" if it can't be read. "" means
|
||||||
|
'send no auth header' — correct against an open (unconfigured) control
|
||||||
|
plane, and harmlessly rejected by a secured one."""
|
||||||
|
try:
|
||||||
|
return host_control_plane_token()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class OrchestratorClientError(RuntimeError):
|
class OrchestratorClientError(RuntimeError):
|
||||||
"""A control-plane call failed (unreachable, or an unexpected status)."""
|
"""A control-plane call failed (unreachable, or an unexpected status)."""
|
||||||
|
|
||||||
@@ -34,11 +47,24 @@ class RegisteredBottle:
|
|||||||
|
|
||||||
|
|
||||||
class OrchestratorClient:
|
class OrchestratorClient:
|
||||||
"""Trusted host-side client for the orchestrator control plane."""
|
"""Trusted host-side client for the orchestrator control plane.
|
||||||
|
|
||||||
def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
Presents the per-host control-plane secret on every call (the header the
|
||||||
|
control plane requires on all routes but `/health`). The secret is read
|
||||||
|
from the host file — this client only ever runs host-side (CLI, launcher,
|
||||||
|
discovery), so it can read what an agent can't. `auth_token` is overridable
|
||||||
|
for tests; the default reads the host file, minting it on first use."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
auth_token: str | None = None,
|
||||||
|
) -> None:
|
||||||
self._base = base_url.rstrip("/")
|
self._base = base_url.rstrip("/")
|
||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
|
self._auth_token = auth_token if auth_token is not None else _host_auth_token()
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
self, method: str, path: str, body: dict[str, object] | None = None,
|
self, method: str, path: str, body: dict[str, object] | None = None,
|
||||||
@@ -49,6 +75,8 @@ class OrchestratorClient:
|
|||||||
callers can treat 404 as a meaningful "no such bottle"."""
|
callers can treat 404 as a meaningful "no such bottle"."""
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||||
|
if self._auth_token:
|
||||||
|
headers[CONTROL_AUTH_HEADER] = self._auth_token
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{self._base}{path}", data=data, method=method, headers=headers,
|
f"{self._base}{path}", data=data, method=method, headers=headers,
|
||||||
)
|
)
|
||||||
@@ -90,14 +118,18 @@ class OrchestratorClient:
|
|||||||
image_ref: str = "",
|
image_ref: str = "",
|
||||||
metadata: str = "",
|
metadata: str = "",
|
||||||
policy: str = "",
|
policy: str = "",
|
||||||
|
tokens: dict[str, str] | None = None,
|
||||||
) -> RegisteredBottle:
|
) -> RegisteredBottle:
|
||||||
"""Register a bottle and broker its launch (`POST /bottles`). Returns
|
"""Register a bottle and broker its launch (`POST /bottles`). `tokens`
|
||||||
its minted id + identity token."""
|
are the per-bottle egress auth values (env_name -> value) the
|
||||||
|
orchestrator holds in memory for the gateway to inject. Returns the
|
||||||
|
minted id + identity token."""
|
||||||
payload = self._ok("POST", "/bottles", {
|
payload = self._ok("POST", "/bottles", {
|
||||||
"source_ip": source_ip,
|
"source_ip": source_ip,
|
||||||
"image_ref": image_ref,
|
"image_ref": image_ref,
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"policy": policy,
|
"policy": policy,
|
||||||
|
"tokens": tokens or {},
|
||||||
})
|
})
|
||||||
bottle_id = payload.get("bottle_id")
|
bottle_id = payload.get("bottle_id")
|
||||||
token = payload.get("identity_token")
|
token = payload.get("identity_token")
|
||||||
@@ -131,10 +163,78 @@ class OrchestratorClient:
|
|||||||
bottles = payload.get("bottles")
|
bottles = payload.get("bottles")
|
||||||
return bottles if isinstance(bottles, list) else []
|
return bottles if isinstance(bottles, list) else []
|
||||||
|
|
||||||
|
# --- supervise queue (operator TUI) ------------------------------------
|
||||||
|
|
||||||
|
def supervise_pending(self) -> list[dict[str, object]]:
|
||||||
|
"""Pending supervise proposals across all bottles
|
||||||
|
(`GET /supervise/proposals`)."""
|
||||||
|
payload = self._ok("GET", "/supervise/proposals")
|
||||||
|
proposals = payload.get("proposals")
|
||||||
|
return proposals if isinstance(proposals, list) else []
|
||||||
|
|
||||||
|
def supervise_respond(
|
||||||
|
self,
|
||||||
|
proposal_id: str,
|
||||||
|
*,
|
||||||
|
bottle_slug: str,
|
||||||
|
decision: str,
|
||||||
|
notes: str = "",
|
||||||
|
final_file: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record an operator decision (`POST /supervise/respond`). `decision`
|
||||||
|
is approve/modify/reject. Raises `OrchestratorClientError` if the
|
||||||
|
proposal is gone or the bottle can no longer be applied to (409)."""
|
||||||
|
body: dict[str, object] = {
|
||||||
|
"proposal_id": proposal_id,
|
||||||
|
"bottle_slug": bottle_slug,
|
||||||
|
"decision": decision,
|
||||||
|
"notes": notes,
|
||||||
|
}
|
||||||
|
if final_file is not None:
|
||||||
|
body["final_file"] = final_file
|
||||||
|
self._ok("POST", "/supervise/respond", body)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
|
||||||
|
"""The URL of the one running per-host orchestrator control plane, probing
|
||||||
|
the backends' well-known control-plane addresses (both on port 8099):
|
||||||
|
docker publishes it on loopback; the firecracker infra VM serves it on the
|
||||||
|
orchestrator TAP. Returns the first that answers `/health`; raises if none
|
||||||
|
do (no orchestrator up — launch a bottle first)."""
|
||||||
|
candidates: list[str] = []
|
||||||
|
try: # docker: loopback-published control plane
|
||||||
|
from .lifecycle import DEFAULT_PORT as _DOCKER_PORT
|
||||||
|
candidates.append(f"http://127.0.0.1:{_DOCKER_PORT}")
|
||||||
|
except Exception: # noqa: BLE001 — backend optional
|
||||||
|
candidates.append("http://127.0.0.1:8099")
|
||||||
|
try: # firecracker: infra VM control plane on the orchestrator TAP
|
||||||
|
from ..backend.firecracker import netpool
|
||||||
|
from ..backend.firecracker.infra_vm import CONTROL_PLANE_PORT
|
||||||
|
candidates.append(
|
||||||
|
f"http://{netpool.orch_slot().guest_ip}:{CONTROL_PLANE_PORT}")
|
||||||
|
except Exception: # noqa: BLE001 — backend optional / not firecracker
|
||||||
|
pass
|
||||||
|
try: # macOS: infra container control plane on its host-only address
|
||||||
|
from ..backend.macos_container.infra import probe_control_plane_url
|
||||||
|
url = probe_control_plane_url()
|
||||||
|
if url:
|
||||||
|
candidates.append(url)
|
||||||
|
except Exception: # noqa: BLE001 — backend optional / not macOS
|
||||||
|
pass
|
||||||
|
for url in candidates:
|
||||||
|
if OrchestratorClient(url, timeout=timeout).health():
|
||||||
|
return url
|
||||||
|
raise OrchestratorClientError(
|
||||||
|
"no running orchestrator control plane found (tried "
|
||||||
|
+ ", ".join(candidates)
|
||||||
|
+ "); launch a bottle first"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"OrchestratorClient",
|
"OrchestratorClient",
|
||||||
"OrchestratorClientError",
|
"OrchestratorClientError",
|
||||||
"RegisteredBottle",
|
"RegisteredBottle",
|
||||||
"DEFAULT_TIMEOUT_SECONDS",
|
"DEFAULT_TIMEOUT_SECONDS",
|
||||||
|
"discover_orchestrator_url",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ vsock / unix-socket portability caveats):
|
|||||||
POST /attribute -> 200 {"bottle_id"} | 403
|
POST /attribute -> 200 {"bottle_id"} | 403
|
||||||
POST /resolve -> 200 {"bottle_id","policy"} | 403
|
POST /resolve -> 200 {"bottle_id","policy"} | 403
|
||||||
body: {"source_ip","identity_token"}
|
body: {"source_ip","identity_token"}
|
||||||
|
GET /supervise/proposals -> 200 {"proposals": [ <proposal>, ...]}
|
||||||
|
POST /supervise/respond -> 200 {"responded": true} | 409 (operator)
|
||||||
|
body: {"proposal_id","bottle_slug",
|
||||||
|
"decision", ["notes"],["final_file"]}
|
||||||
|
|
||||||
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
|
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
|
||||||
tear down) the bottle in the registry AND broker the backend-native launch
|
tear down) the bottle in the registry AND broker the backend-native launch
|
||||||
@@ -30,18 +34,29 @@ returned only once, to the caller that launches the bottle.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socketserver
|
import socketserver
|
||||||
|
import sys
|
||||||
import typing
|
import typing
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from ..paths import CONTROL_PLANE_TOKEN_ENV
|
||||||
from .service import Orchestrator
|
from .service import Orchestrator
|
||||||
|
|
||||||
# JSON body payload type (parsed request / rendered response).
|
# JSON body payload type (parsed request / rendered response).
|
||||||
Json = dict[str, object]
|
Json = dict[str, object]
|
||||||
|
|
||||||
|
# The request header carrying the per-host control-plane secret. Every route
|
||||||
|
# except `GET /health` requires it (see `dispatch`). The trusted callers hold
|
||||||
|
# the secret (the gateway's PolicyResolver, the host CLI's OrchestratorClient);
|
||||||
|
# an agent that can merely *reach* the port cannot present it, so it can't
|
||||||
|
# enumerate bottles, rewrite policy, read injected upstream tokens, or approve
|
||||||
|
# its own supervise proposals.
|
||||||
|
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_object(body: bytes) -> Json:
|
def _parse_json_object(body: bytes) -> Json:
|
||||||
"""Parse a JSON object body. Raises ValueError for non-objects / bad JSON."""
|
"""Parse a JSON object body. Raises ValueError for non-objects / bad JSON."""
|
||||||
@@ -54,15 +69,29 @@ def _parse_json_object(body: bytes) -> Json:
|
|||||||
|
|
||||||
|
|
||||||
def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
||||||
orch: Orchestrator, method: str, path: str, body: bytes
|
orch: Orchestrator, method: str, path: str, body: bytes, *, authorized: bool = True,
|
||||||
) -> tuple[int, Json]:
|
) -> tuple[int, Json]:
|
||||||
"""Route one control-plane request to a (status, payload) pair. Pure —
|
"""Route one control-plane request to a (status, payload) pair. Pure —
|
||||||
no I/O beyond the orchestrator — so it is fully testable without a socket."""
|
no I/O beyond the orchestrator — so it is fully testable without a socket.
|
||||||
|
|
||||||
|
`authorized` is whether the request presented the control-plane secret (or
|
||||||
|
no secret is configured — see `ControlPlaneServer`). Every route except
|
||||||
|
`GET /health` requires it: the source-IP + identity-token checks inside
|
||||||
|
`/resolve` and `/attribute` authenticate the *bottle* a request is about,
|
||||||
|
not the *caller*, so without this gate any agent that can reach the port
|
||||||
|
could rewrite another bottle's policy, read the injected upstream tokens,
|
||||||
|
or approve its own supervise proposals. Defaults True so unit tests of the
|
||||||
|
routing logic don't have to thread it through."""
|
||||||
route = urlsplit(path).path.rstrip("/") or "/"
|
route = urlsplit(path).path.rstrip("/") or "/"
|
||||||
|
|
||||||
if method == "GET" and route == "/health":
|
if method == "GET" and route == "/health":
|
||||||
return 200, {"status": "ok"}
|
return 200, {"status": "ok"}
|
||||||
|
|
||||||
|
if not authorized:
|
||||||
|
# Everything below is a trusted-caller operation. Deny before touching
|
||||||
|
# the registry / broker / supervise store.
|
||||||
|
return 401, {"error": "control-plane authentication required"}
|
||||||
|
|
||||||
if method == "GET" and route == "/gateway":
|
if method == "GET" and route == "/gateway":
|
||||||
return 200, orch.gateway_status()
|
return 200, orch.gateway_status()
|
||||||
|
|
||||||
@@ -80,11 +109,16 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
image_ref = data.get("image_ref")
|
image_ref = data.get("image_ref")
|
||||||
metadata = data.get("metadata")
|
metadata = data.get("metadata")
|
||||||
policy = data.get("policy")
|
policy = data.get("policy")
|
||||||
|
raw_tokens = data.get("tokens")
|
||||||
|
tokens = {
|
||||||
|
k: v for k, v in raw_tokens.items() if isinstance(k, str) and isinstance(v, str)
|
||||||
|
} if isinstance(raw_tokens, dict) else {}
|
||||||
rec = orch.launch_bottle(
|
rec = orch.launch_bottle(
|
||||||
source_ip,
|
source_ip,
|
||||||
image_ref=image_ref if isinstance(image_ref, str) else "",
|
image_ref=image_ref if isinstance(image_ref, str) else "",
|
||||||
metadata=metadata if isinstance(metadata, str) else "",
|
metadata=metadata if isinstance(metadata, str) else "",
|
||||||
policy=policy if isinstance(policy, str) else "",
|
policy=policy if isinstance(policy, str) else "",
|
||||||
|
tokens=tokens,
|
||||||
)
|
)
|
||||||
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
||||||
|
|
||||||
@@ -121,10 +155,44 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
return 403, {"error": "unattributed"}
|
return 403, {"error": "unattributed"}
|
||||||
return 200, {"bottle_id": rec.bottle_id}
|
return 200, {"bottle_id": rec.bottle_id}
|
||||||
|
|
||||||
|
if method == "GET" and route == "/supervise/proposals":
|
||||||
|
# Operator TUI: pending supervise proposals across all bottles.
|
||||||
|
return 200, {"proposals": orch.supervise_pending()}
|
||||||
|
|
||||||
|
if method == "POST" and route == "/supervise/respond":
|
||||||
|
# Operator decision: apply (approve/modify rewrites egress policy),
|
||||||
|
# write the queued response, audit — all server-side on the one DB.
|
||||||
|
try:
|
||||||
|
data = _parse_json_object(body)
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"error": f"invalid JSON: {e}"}
|
||||||
|
proposal_id = data.get("proposal_id")
|
||||||
|
bottle_slug = data.get("bottle_slug")
|
||||||
|
decision = data.get("decision")
|
||||||
|
if not (isinstance(proposal_id, str) and proposal_id):
|
||||||
|
return 400, {"error": "proposal_id (string) is required"}
|
||||||
|
if not (isinstance(bottle_slug, str) and bottle_slug):
|
||||||
|
return 400, {"error": "bottle_slug (string) is required"}
|
||||||
|
if not (isinstance(decision, str) and decision):
|
||||||
|
return 400, {"error": "decision (string) is required"}
|
||||||
|
notes = data.get("notes")
|
||||||
|
final_file = data.get("final_file")
|
||||||
|
ok, err = orch.supervise_respond(
|
||||||
|
proposal_id,
|
||||||
|
bottle_slug=bottle_slug,
|
||||||
|
decision=decision,
|
||||||
|
notes=notes if isinstance(notes, str) else "",
|
||||||
|
final_file=final_file if isinstance(final_file, str) else None,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
return 200, {"responded": True}
|
||||||
|
return 409, {"error": err}
|
||||||
|
|
||||||
if method == "POST" and route == "/resolve":
|
if method == "POST" and route == "/resolve":
|
||||||
# The per-request lookup the multi-tenant gateway makes: returns the
|
# The per-request lookup the multi-tenant gateway makes: returns the
|
||||||
# bottle's policy. identity_token is OPTIONAL — absent means resolve
|
# bottle's policy. Requires a matching (source_ip, identity_token)
|
||||||
# by source IP alone (network-layer attribution).
|
# pair — a missing/empty/mismatched token fail-closes (403), no
|
||||||
|
# source-IP-only fallback.
|
||||||
try:
|
try:
|
||||||
data = _parse_json_object(body)
|
data = _parse_json_object(body)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -136,7 +204,13 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
||||||
if rec is None:
|
if rec is None:
|
||||||
return 403, {"error": "unattributed"}
|
return 403, {"error": "unattributed"}
|
||||||
return 200, {"bottle_id": rec.bottle_id, "policy": rec.policy}
|
# tokens are the in-memory per-bottle egress auth values the gateway
|
||||||
|
# injects; served here, never persisted.
|
||||||
|
return 200, {
|
||||||
|
"bottle_id": rec.bottle_id,
|
||||||
|
"policy": rec.policy,
|
||||||
|
"tokens": orch.tokens_for(rec.bottle_id),
|
||||||
|
}
|
||||||
|
|
||||||
return 404, {"error": "not found"}
|
return 404, {"error": "not found"}
|
||||||
|
|
||||||
@@ -151,12 +225,22 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
super().log_message(format, *args)
|
super().log_message(format, *args)
|
||||||
|
|
||||||
def _serve(self, method: str) -> None:
|
def _serve(self, method: str) -> None:
|
||||||
"""Read the request body, dispatch it, and write the JSON reply."""
|
"""Read the request body, dispatch it, and write the JSON reply. A
|
||||||
|
dispatch failure (e.g. a broker error) returns a 500 rather than
|
||||||
|
crashing the connection, so one bad request can't take the control
|
||||||
|
plane down for the caller."""
|
||||||
server = self.server
|
server = self.server
|
||||||
assert isinstance(server, ControlPlaneServer)
|
assert isinstance(server, ControlPlaneServer)
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
body = self.rfile.read(length) if length > 0 else b""
|
body = self.rfile.read(length) if length > 0 else b""
|
||||||
status, payload = dispatch(server.orchestrator, method, self.path, body)
|
authorized = server.is_authorized(self.headers.get(CONTROL_AUTH_HEADER, ""))
|
||||||
|
try:
|
||||||
|
status, payload = dispatch(
|
||||||
|
server.orchestrator, method, self.path, body, authorized=authorized)
|
||||||
|
except Exception as e: # noqa: BLE001 — the control plane must stay up
|
||||||
|
sys.stderr.write(f"orchestrator: {method} {self.path} failed: {e!r}\n")
|
||||||
|
sys.stderr.flush()
|
||||||
|
status, payload = 500, {"error": f"internal error: {e}"}
|
||||||
data = json.dumps(payload).encode()
|
data = json.dumps(payload).encode()
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
@@ -178,15 +262,40 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
class ControlPlaneServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||||
"""Threading HTTP server that carries the orchestrator for its handlers."""
|
"""Threading HTTP server that carries the orchestrator for its handlers.
|
||||||
|
|
||||||
|
Holds the per-host control-plane secret (from `$BOT_BOTTLE_CONTROL_PLANE_TOKEN`,
|
||||||
|
injected by the launcher into this container only). When a secret is set,
|
||||||
|
every route but `/health` requires it; when it is unset the server runs
|
||||||
|
**open** and says so loudly at startup — a fail-visible fallback for tests
|
||||||
|
and any backend that hasn't wired the secret yet (e.g. Firecracker, whose
|
||||||
|
nft boundary already blocks agents from the control-plane port)."""
|
||||||
|
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
|
|
||||||
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
|
def __init__(self, address: tuple[str, int], orchestrator: Orchestrator) -> None:
|
||||||
self.orchestrator = orchestrator
|
self.orchestrator = orchestrator
|
||||||
|
self._auth_token = os.environ.get(CONTROL_PLANE_TOKEN_ENV, "").strip()
|
||||||
|
if not self._auth_token:
|
||||||
|
sys.stderr.write(
|
||||||
|
"orchestrator: WARNING — no control-plane secret "
|
||||||
|
f"(${CONTROL_PLANE_TOKEN_ENV}); running WITHOUT caller "
|
||||||
|
"authentication. Any client that can reach this port can drive "
|
||||||
|
"it. Backends that put the control plane on an agent-reachable "
|
||||||
|
"network MUST set this.\n"
|
||||||
|
)
|
||||||
|
sys.stderr.flush()
|
||||||
super().__init__(address, Handler)
|
super().__init__(address, Handler)
|
||||||
|
|
||||||
|
def is_authorized(self, presented: str) -> bool:
|
||||||
|
"""True iff the request may proceed past `/health`: either no secret is
|
||||||
|
configured (open mode) or the presented header matches it. Constant-time
|
||||||
|
compare so a wrong token leaks nothing timing-wise."""
|
||||||
|
if not self._auth_token:
|
||||||
|
return True
|
||||||
|
return hmac.compare_digest(presented, self._auth_token)
|
||||||
|
|
||||||
|
|
||||||
def make_server(
|
def make_server(
|
||||||
orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0
|
orchestrator: Orchestrator, host: str = "127.0.0.1", port: int = 0
|
||||||
@@ -196,4 +305,7 @@ def make_server(
|
|||||||
return ControlPlaneServer((host, port), orchestrator)
|
return ControlPlaneServer((host, port), orchestrator)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["dispatch", "Handler", "ControlPlaneServer", "make_server", "Json"]
|
__all__ = [
|
||||||
|
"dispatch", "Handler", "ControlPlaneServer", "make_server", "Json",
|
||||||
|
"CONTROL_AUTH_HEADER",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -19,22 +19,60 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import abc
|
import abc
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..docker_cmd import run_docker
|
from ..docker_cmd import run_docker
|
||||||
|
from ..paths import (
|
||||||
|
CONTROL_PLANE_TOKEN_ENV,
|
||||||
|
host_control_plane_token,
|
||||||
|
host_db_path,
|
||||||
|
)
|
||||||
|
from ..supervise import DB_PATH_IN_CONTAINER
|
||||||
|
|
||||||
|
# The host DB dir is bind-mounted here so the gateway's supervise daemon
|
||||||
|
# writes its queued proposals into the ONE host DB (the same file the
|
||||||
|
# orchestrator container opens and the operator reaches over HTTP).
|
||||||
|
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
|
||||||
|
|
||||||
|
# The gateway's mitmproxy writes its CA a beat after the container starts, so
|
||||||
|
# reads poll for it rather than assuming it's there on a fresh launch.
|
||||||
|
_CA_POLL_SECONDS = 0.5
|
||||||
|
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
|
||||||
|
|
||||||
GATEWAY_NAME = "bot-bottle-orch-gateway"
|
GATEWAY_NAME = "bot-bottle-orch-gateway"
|
||||||
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
|
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
|
||||||
|
# The single user-defined network the gateway and every agent bottle share.
|
||||||
|
# Agents attach here with a pinned IP and reach the gateway's egress /
|
||||||
|
# git-http / supervise ports by its address — no host port publishing, and
|
||||||
|
# the source IP the gateway attributes by is the address on this network.
|
||||||
|
GATEWAY_NETWORK = "bot-bottle-gateway"
|
||||||
|
|
||||||
# The real sidecar-bundle image + its Dockerfile. Kept as a local constant
|
# mitmproxy's CA dir in the bundle. A persistent named volume here keeps the
|
||||||
# rather than imported from backend.docker.sidecar_bundle, which would drag
|
# gateway's self-generated CA STABLE across container recreation — every agent
|
||||||
|
# installs this one CA to trust the shared gateway's TLS interception, so it
|
||||||
|
# must not rotate when the gateway restarts.
|
||||||
|
MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
|
||||||
|
GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy"
|
||||||
|
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
|
||||||
|
|
||||||
|
# The gateway data-plane image + its Dockerfile. Kept as a local constant
|
||||||
|
# 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]
|
||||||
|
|
||||||
|
|
||||||
|
def _host_db_dir() -> str:
|
||||||
|
"""The host DB directory (created if missing), for the gateway's
|
||||||
|
supervise-DB bind-mount."""
|
||||||
|
db_dir = host_db_path().parent
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(db_dir)
|
||||||
|
|
||||||
|
|
||||||
class GatewayError(Exception):
|
class GatewayError(Exception):
|
||||||
"""The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
|
"""The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
|
||||||
|
|
||||||
@@ -67,8 +105,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.)"""
|
||||||
|
|
||||||
@@ -77,29 +115,50 @@ class DockerGateway(Gateway):
|
|||||||
image_ref: str = GATEWAY_IMAGE,
|
image_ref: str = GATEWAY_IMAGE,
|
||||||
*,
|
*,
|
||||||
name: str = GATEWAY_NAME,
|
name: str = GATEWAY_NAME,
|
||||||
|
network: str = GATEWAY_NETWORK,
|
||||||
|
orchestrator_url: str = "",
|
||||||
build_context: Path | None = None,
|
build_context: Path | None = None,
|
||||||
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
||||||
|
host_port_bindings: tuple[int, ...] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
self.image_ref = image_ref
|
self.image_ref = image_ref
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.network = network
|
||||||
|
# The control-plane URL the gateway's data plane resolves per bottle
|
||||||
|
# against — reached by container name over docker DNS on the shared
|
||||||
|
# network (container↔container, no host firewall). Mandatory to *run*
|
||||||
|
# the gateway (see `ensure_running`); empty is tolerated only for the
|
||||||
|
# construct-then-read-CA path (`ca_cert_pem` on an already-running
|
||||||
|
# container), which never launches a container.
|
||||||
|
self._orchestrator_url = orchestrator_url
|
||||||
self._build_context = build_context or _REPO_ROOT
|
self._build_context = build_context or _REPO_ROOT
|
||||||
self._dockerfile = dockerfile
|
self._dockerfile = dockerfile
|
||||||
|
# Ports published on the host (0.0.0.0). Used by the Firecracker
|
||||||
|
# backend's dev-harness gateway so VMs can reach it via their TAP link;
|
||||||
|
# Docker's DNAT + the nft `ct status dnat accept` rule handle the rest.
|
||||||
|
self._host_port_bindings = host_port_bindings
|
||||||
|
|
||||||
def image_exists(self) -> bool:
|
def image_exists(self) -> bool:
|
||||||
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
|
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
|
||||||
|
|
||||||
def ensure_built(self) -> None:
|
def ensure_built(self) -> None:
|
||||||
"""Build the bundle image from its Dockerfile when it's missing.
|
"""Build the bundle image from its Dockerfile, **cache-aware** — cheap
|
||||||
No-op when the image is already present, or when no dockerfile is
|
(a cache check) when nothing changed, a real rebuild when the flat
|
||||||
configured (e.g. a pre-pulled image)."""
|
sources (egress addon / git-http / policy_resolver / supervise) moved.
|
||||||
if self._dockerfile is None or self.image_exists():
|
|
||||||
|
This deliberately builds every time rather than build-if-missing: the
|
||||||
|
per-bottle model kept the image fresh via compose's `build:` on up, and
|
||||||
|
a stale image silently runs the OLD single-tenant daemons. No-op only
|
||||||
|
when no dockerfile is configured (a pre-pulled image). BOT_BOTTLE_NO_CACHE
|
||||||
|
forces a full rebuild (parity with `start --no-cache`)."""
|
||||||
|
if self._dockerfile is None:
|
||||||
return
|
return
|
||||||
proc = run_docker([
|
argv = ["docker", "build", "-t", self.image_ref,
|
||||||
"docker", "build",
|
"-f", str(self._build_context / self._dockerfile),
|
||||||
"-t", self.image_ref,
|
str(self._build_context)]
|
||||||
"-f", str(self._build_context / self._dockerfile),
|
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||||
str(self._build_context),
|
argv.insert(2, "--no-cache")
|
||||||
])
|
proc = run_docker(argv)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
|
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
|
||||||
|
|
||||||
@@ -112,21 +171,100 @@ class DockerGateway(Gateway):
|
|||||||
])
|
])
|
||||||
return self.name in proc.stdout.split()
|
return self.name in proc.stdout.split()
|
||||||
|
|
||||||
def ensure_running(self) -> None:
|
def _running_image_is_current(self) -> bool:
|
||||||
if self.is_running():
|
"""True iff the running gateway was created from the *current*
|
||||||
|
`image_ref`. When `ensure_built` rebuilds the image (a source change),
|
||||||
|
the running container is still the OLD image running the OLD flat
|
||||||
|
daemons — so this is how a rebuild actually takes effect: a mismatch
|
||||||
|
means recreate."""
|
||||||
|
running = run_docker(["docker", "inspect", "--format", "{{.Image}}", self.name])
|
||||||
|
current = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", self.image_ref])
|
||||||
|
if running.returncode != 0 or current.returncode != 0:
|
||||||
|
return True # can't compare → don't churn a working container
|
||||||
|
return running.stdout.strip() == current.stdout.strip()
|
||||||
|
|
||||||
|
def _ensure_network(self) -> None:
|
||||||
|
"""Create the shared gateway network if it doesn't exist. Idempotent —
|
||||||
|
a concurrent create loses harmlessly (the loser sees 'already exists').
|
||||||
|
Docker picks the subnet; the launcher reads it back to allocate IPs."""
|
||||||
|
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
|
||||||
return
|
return
|
||||||
# Clear any stale (stopped) container holding the fixed name, then
|
proc = run_docker(["docker", "network", "create", self.network])
|
||||||
# start fresh. `rm --force` on an absent name is a tolerated no-op.
|
if proc.returncode != 0 and "already exists" not in proc.stderr:
|
||||||
|
raise GatewayError(
|
||||||
|
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_running(self) -> None:
|
||||||
|
# Fail closed on a missing policy source. The data-plane daemons are
|
||||||
|
# resolver-only now (PRD 0070) — without an orchestrator URL egress
|
||||||
|
# raises, git-http exits 1, and supervise exits 2 — so launching a
|
||||||
|
# gateway without one would only crash-loop its daemons. Refuse here so
|
||||||
|
# the misconfiguration surfaces as a clear error, not a broken container.
|
||||||
|
if not self._orchestrator_url:
|
||||||
|
raise GatewayError(
|
||||||
|
"gateway requires an orchestrator URL to run "
|
||||||
|
"(resolver-only data plane; no single-tenant fallback)"
|
||||||
|
)
|
||||||
|
# Recreate when the running container's image is stale (a rebuild),
|
||||||
|
# so source changes to the gateway's flat daemons take effect — not
|
||||||
|
# just when the container is absent.
|
||||||
|
if self.is_running() and self._running_image_is_current():
|
||||||
|
return
|
||||||
|
self._ensure_network()
|
||||||
|
# Clear any stale (stopped OR outdated-image) container holding the
|
||||||
|
# fixed name, then start fresh. `rm --force` on an absent name is a
|
||||||
|
# tolerated no-op.
|
||||||
run_docker(["docker", "rm", "--force", self.name])
|
run_docker(["docker", "rm", "--force", self.name])
|
||||||
proc = run_docker([
|
argv = [
|
||||||
"docker", "run", "--detach",
|
"docker", "run", "--detach",
|
||||||
"--name", self.name,
|
"--name", self.name,
|
||||||
"--label", GATEWAY_LABEL,
|
"--label", GATEWAY_LABEL,
|
||||||
self.image_ref,
|
"--network", self.network,
|
||||||
])
|
# Persist the self-generated CA so it survives restarts (agents
|
||||||
|
# trust it) — see GATEWAY_CA_VOLUME.
|
||||||
|
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
|
||||||
|
# Share the one host DB: the supervise daemon queues proposals
|
||||||
|
# into the same file the orchestrator (and the operator, over
|
||||||
|
# HTTP) reads — no second, disconnected DB in the container.
|
||||||
|
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
|
||||||
|
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
|
]
|
||||||
|
for port in self._host_port_bindings:
|
||||||
|
argv += ["--publish", f"0.0.0.0:{port}:{port}"]
|
||||||
|
run_env = dict(os.environ)
|
||||||
|
# The gateway's egress / git / supervise daemons resolve source-IP ->
|
||||||
|
# policy against the control plane per request (guaranteed non-empty by
|
||||||
|
# the check above).
|
||||||
|
argv += ["--env", f"BOT_BOTTLE_ORCHESTRATOR_URL={self._orchestrator_url}"]
|
||||||
|
# ...and present the control-plane secret on those /resolve calls (the
|
||||||
|
# control plane requires it). Bare `--env NAME` keeps the value off argv
|
||||||
|
# / `docker inspect`; only the gateway (not the agent) is given it.
|
||||||
|
argv += ["--env", CONTROL_PLANE_TOKEN_ENV]
|
||||||
|
run_env[CONTROL_PLANE_TOKEN_ENV] = host_control_plane_token()
|
||||||
|
argv.append(self.image_ref)
|
||||||
|
proc = run_docker(argv, env=run_env)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
|
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
|
||||||
|
|
||||||
|
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
||||||
|
"""The gateway's CA certificate (PEM) that agents install to trust its
|
||||||
|
TLS interception. mitmproxy generates it a moment after the container
|
||||||
|
starts, so this **polls** for it (up to `timeout`) rather than assuming
|
||||||
|
it's already there on a fresh gateway — raising only if it never
|
||||||
|
appears."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT])
|
||||||
|
if proc.returncode == 0 and proc.stdout.strip():
|
||||||
|
return proc.stdout
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise GatewayError(
|
||||||
|
f"gateway CA cert not available after {timeout:g}s: "
|
||||||
|
f"{proc.stderr.strip() or 'empty'}"
|
||||||
|
)
|
||||||
|
time.sleep(_CA_POLL_SECONDS)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
proc = run_docker(["docker", "rm", "--force", self.name])
|
proc = run_docker(["docker", "rm", "--force", self.name])
|
||||||
if proc.returncode != 0 and "No such container" not in proc.stderr:
|
if proc.returncode != 0 and "No such container" not in proc.stderr:
|
||||||
@@ -135,5 +273,6 @@ class DockerGateway(Gateway):
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Gateway", "DockerGateway", "GatewayError",
|
"Gateway", "DockerGateway", "GatewayError",
|
||||||
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE",
|
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
|
||||||
|
"GATEWAY_CA_VOLUME", "GATEWAY_CA_CERT",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,91 +1,256 @@
|
|||||||
"""Orchestrator process lifecycle (PRD 0070, docker slice).
|
"""Orchestrator + gateway lifecycle (PRD 0070, docker slice).
|
||||||
|
|
||||||
Before the CLI can register or launch bottles against the consolidated
|
Runs the orchestrator control plane **as a container** on the shared gateway
|
||||||
model, exactly one orchestrator control plane — and the single per-host
|
network, alongside the gateway container. This is the PRD's "virtualize the
|
||||||
gateway it manages — must be running. This starts the orchestrator
|
orchestrator": container↔container between the gateway and the orchestrator
|
||||||
dev-harness (`python -m bot_bottle.orchestrator`) as a background host
|
avoids the host firewall (which drops container→host traffic), and the gateway
|
||||||
process and health-checks it.
|
reaches the control plane by container name over docker DNS. The host CLI
|
||||||
|
reaches it via a published loopback port.
|
||||||
|
|
||||||
It is an **idempotent singleton**: `ensure_running` returns immediately if a
|
The orchestrator runs with the **register-only broker** — the *backend*
|
||||||
healthy control plane already answers on the port, and otherwise spawns one
|
launches agent containers (compose), so the orchestrator needs no docker
|
||||||
and waits for it to come up. The control-plane port is the singleton key —
|
socket. That keeps this control-plane container unprivileged; the host manages
|
||||||
a second orchestrator can't bind it, so a stray double-start fails fast
|
both containers. `ensure_running` is an idempotent singleton (fixed container
|
||||||
rather than forking a rival.
|
names + the published port).
|
||||||
|
|
||||||
Host-process (not container) on purpose: the PRD sequences the orchestrator
|
|
||||||
as a plain-process dev-harness first (fast iteration, and it already has the
|
|
||||||
host user's docker access to broker launches), while the data-plane
|
|
||||||
*gateway* it manages runs as a container. Wrapping the orchestrator itself
|
|
||||||
in a backend-native unit is a later step.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import hashlib
|
||||||
import sys
|
import os
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import log
|
from .. import log
|
||||||
from ..paths import bot_bottle_root
|
from ..docker_cmd import run_docker
|
||||||
|
from ..paths import CONTROL_PLANE_TOKEN_ENV, bot_bottle_root, host_control_plane_token
|
||||||
|
from .gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK, DockerGateway, GatewayError
|
||||||
|
|
||||||
|
DEFAULT_PORT = 8099
|
||||||
|
ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
|
||||||
|
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
|
||||||
|
# `python -m bot_bottle.orchestrator` resolves the package (the orchestrator
|
||||||
|
# is stdlib-only, so the lean orchestrator image's python is enough).
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
_APP_DIR = "/app"
|
||||||
|
_ROOT_IN_CONTAINER = "/bot-bottle-root"
|
||||||
|
|
||||||
DEFAULT_HOST = "127.0.0.1"
|
|
||||||
DEFAULT_PORT = 8080
|
|
||||||
# Poll cadence + default ceiling while waiting for a freshly-spawned control
|
|
||||||
# plane to answer /health (the first start also builds/boots the gateway).
|
|
||||||
_HEALTH_POLL_SECONDS = 0.25
|
_HEALTH_POLL_SECONDS = 0.25
|
||||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
||||||
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
class OrchestratorStartError(RuntimeError):
|
class OrchestratorStartError(RuntimeError):
|
||||||
"""The orchestrator process did not become healthy within the timeout."""
|
"""The orchestrator container did not become healthy within the timeout."""
|
||||||
|
|
||||||
|
|
||||||
class OrchestratorProcess:
|
def source_hash(repo_root: Path) -> str:
|
||||||
"""Manages the local orchestrator control-plane process for the docker
|
"""Content hash of the orchestrator's bind-mounted Python source (the
|
||||||
backend. Backend-neutral callers only need `ensure_running()` + `url`."""
|
`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:
|
||||||
|
"""Manages the orchestrator control-plane container + the shared gateway.
|
||||||
|
Callers only need `ensure_running()` + `url`.
|
||||||
|
|
||||||
|
`orchestrator_name` / `orchestrator_label` let backends run independent
|
||||||
|
orchestrators on the same host without name collisions (e.g. the
|
||||||
|
Firecracker backend uses `bot-bottle-fc-orchestrator` alongside the Docker
|
||||||
|
backend's `bot-bottle-orchestrator`); `gateway_name` gives the paired
|
||||||
|
gateway container the same treatment (e.g. isolated integration tests
|
||||||
|
that can't share the production `GATEWAY_NAME` singleton). Subclass and
|
||||||
|
override `_gateway()` for anything `_gateway_image`/`gateway_name` can't
|
||||||
|
express (a genuinely backend-specific gateway variant)."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
host: str = DEFAULT_HOST,
|
|
||||||
port: int = DEFAULT_PORT,
|
|
||||||
*,
|
*,
|
||||||
broker: str = "docker",
|
port: int = DEFAULT_PORT,
|
||||||
gateway: bool = True,
|
network: str = GATEWAY_NETWORK,
|
||||||
|
image: str = ORCHESTRATOR_IMAGE,
|
||||||
|
gateway_image: str = GATEWAY_IMAGE,
|
||||||
|
gateway_name: str = GATEWAY_NAME,
|
||||||
|
repo_root: Path = _REPO_ROOT,
|
||||||
|
host_root: Path | None = None,
|
||||||
|
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||||
|
orchestrator_label: str = ORCHESTRATOR_LABEL,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.host = host
|
|
||||||
self.port = port
|
self.port = port
|
||||||
self._broker = broker
|
self.network = network
|
||||||
self._gateway = gateway
|
# 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._gateway_image = gateway_image
|
||||||
|
self._gateway_name = gateway_name
|
||||||
|
self._repo_root = repo_root
|
||||||
|
self._host_root = host_root or bot_bottle_root()
|
||||||
|
self._orchestrator_name = orchestrator_name
|
||||||
|
self._orchestrator_label = orchestrator_label
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
"""The control-plane base URL — also what the data plane's
|
"""Host-side control-plane URL (published loopback port)."""
|
||||||
BOT_BOTTLE_ORCHESTRATOR_URL points at."""
|
return f"http://127.0.0.1:{self.port}"
|
||||||
return f"http://{self.host}:{self.port}"
|
|
||||||
|
@property
|
||||||
|
def internal_url(self) -> str:
|
||||||
|
"""Control-plane URL as the gateway container reaches it — by name over
|
||||||
|
docker DNS on the shared network. This is the gateway's
|
||||||
|
BOT_BOTTLE_ORCHESTRATOR_URL."""
|
||||||
|
return f"http://{self._orchestrator_name}:{self.port}"
|
||||||
|
|
||||||
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
|
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
|
||||||
"""True iff a control plane answers `GET /health` with 200 — the
|
|
||||||
singleton liveness check."""
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
|
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
|
||||||
return resp.status == 200
|
return resp.status == 200
|
||||||
except (urllib.error.URLError, TimeoutError, OSError):
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _container_running(self, name: str) -> bool:
|
||||||
|
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
|
||||||
|
return name in proc.stdout.split()
|
||||||
|
|
||||||
|
def _run_orchestrator_container(self, current_hash: str) -> None:
|
||||||
|
"""Start the control-plane container (idempotent: clears a stale
|
||||||
|
fixed-name container first). Register-only broker → no docker socket.
|
||||||
|
Labels the container with `current_hash` so a later `ensure_running`
|
||||||
|
can detect a real code change (see `source_hash`)."""
|
||||||
|
run_docker(["docker", "rm", "--force", self._orchestrator_name])
|
||||||
|
proc = run_docker([
|
||||||
|
"docker", "run", "--detach",
|
||||||
|
"--name", self._orchestrator_name,
|
||||||
|
"--label", self._orchestrator_label,
|
||||||
|
"--label", f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current_hash}",
|
||||||
|
"--network", self.network,
|
||||||
|
# Host CLI reaches the control plane here; bound to loopback so it
|
||||||
|
# is not exposed on the host's external interfaces. NOTE: the
|
||||||
|
# container is still on `self.network` (the shared gateway network),
|
||||||
|
# so agents can reach it by container IP — which is exactly why the
|
||||||
|
# control plane requires the secret below rather than trusting the
|
||||||
|
# network boundary.
|
||||||
|
"--publish", f"127.0.0.1:{self.port}:{self.port}",
|
||||||
|
"--volume", f"{self._repo_root}:{_APP_DIR}:ro",
|
||||||
|
"--workdir", _APP_DIR,
|
||||||
|
# Persist the registry DB on the host (sole-owner: only the
|
||||||
|
# orchestrator opens bot-bottle.db).
|
||||||
|
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
|
||||||
|
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
|
||||||
|
# The control-plane secret it requires on every route but /health.
|
||||||
|
# Bare `--env NAME` → docker inherits the value from the run env
|
||||||
|
# below, so the secret never lands on argv / `docker inspect`.
|
||||||
|
"--env", CONTROL_PLANE_TOKEN_ENV,
|
||||||
|
"--entrypoint", "python3",
|
||||||
|
self.image,
|
||||||
|
"-m", "bot_bottle.orchestrator",
|
||||||
|
"--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub",
|
||||||
|
], env={**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()})
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise OrchestratorStartError(
|
||||||
|
f"orchestrator container failed to start: {proc.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _gateway(self) -> DockerGateway:
|
||||||
|
return DockerGateway(
|
||||||
|
self._gateway_image,
|
||||||
|
name=self._gateway_name,
|
||||||
|
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(self._orchestrator_name):
|
||||||
|
return False
|
||||||
|
proc = run_docker([
|
||||||
|
"docker", "inspect", "--format",
|
||||||
|
"{{ index .Config.Labels \"" + ORCHESTRATOR_SOURCE_HASH_LABEL + "\" }}",
|
||||||
|
self._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:
|
||||||
"""Return the control-plane URL, starting the orchestrator first if it
|
"""Ensure the control plane + shared gateway are up; return the host
|
||||||
isn't already healthy. Idempotent — a healthy control plane is left
|
control-plane URL. Idempotent — a healthy control plane running
|
||||||
untouched. Raises `OrchestratorStartError` if a freshly-spawned one
|
current code and a running gateway are left untouched. Raises
|
||||||
doesn't answer within `startup_timeout`."""
|
`OrchestratorStartError` on timeout."""
|
||||||
if self.is_healthy():
|
gateway = self._gateway()
|
||||||
|
gateway.ensure_built() # rebuild the bundle image on a source change
|
||||||
|
gateway.ensure_running() # creates the shared network + (re)starts gateway
|
||||||
|
|
||||||
|
# Recreate the orchestrator container only when its bind-mounted
|
||||||
|
# source has actually changed since it started — its Python process
|
||||||
|
# loaded that code at startup and won't reload, so a stale container
|
||||||
|
# would keep running OLD control-plane code. Recreating on *every*
|
||||||
|
# launch (the prior behaviour) would drop every other active
|
||||||
|
# bottle's in-memory egress tokens each time a new bottle starts,
|
||||||
|
# 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
|
return self.url
|
||||||
log.info("starting orchestrator", context={"url": self.url, "broker": self._broker})
|
|
||||||
self._spawn()
|
self._ensure_orchestrator_image()
|
||||||
|
log.info(
|
||||||
|
"starting orchestrator container",
|
||||||
|
context={"name": self._orchestrator_name},
|
||||||
|
)
|
||||||
|
self._run_orchestrator_container(current_hash)
|
||||||
|
|
||||||
deadline = time.monotonic() + startup_timeout
|
deadline = time.monotonic() + startup_timeout
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
if self.is_healthy():
|
if self.is_healthy():
|
||||||
@@ -93,49 +258,20 @@ class OrchestratorProcess:
|
|||||||
return self.url
|
return self.url
|
||||||
time.sleep(_HEALTH_POLL_SECONDS)
|
time.sleep(_HEALTH_POLL_SECONDS)
|
||||||
raise OrchestratorStartError(
|
raise OrchestratorStartError(
|
||||||
f"orchestrator at {self.url} did not become healthy within "
|
f"orchestrator at {self.url} did not become healthy within {startup_timeout:g}s"
|
||||||
f"{startup_timeout:g}s"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _argv(self) -> list[str]:
|
def stop(self) -> None:
|
||||||
"""`python -m bot_bottle.orchestrator ...` — static flags only."""
|
"""Remove the orchestrator + gateway containers (idempotent)."""
|
||||||
argv = [
|
run_docker(["docker", "rm", "--force", self._orchestrator_name])
|
||||||
sys.executable, "-m", "bot_bottle.orchestrator",
|
self._gateway().stop()
|
||||||
"--host", self.host, "--port", str(self.port),
|
|
||||||
"--broker", self._broker,
|
|
||||||
]
|
|
||||||
if self._gateway:
|
|
||||||
argv.append("--gateway")
|
|
||||||
return argv
|
|
||||||
|
|
||||||
def _log_path(self) -> str:
|
|
||||||
"""Where the detached orchestrator's stdout/stderr goes so a failed
|
|
||||||
start is diagnosable after the CLI has moved on."""
|
|
||||||
return str(bot_bottle_root() / "orchestrator.log")
|
|
||||||
|
|
||||||
def _spawn(self) -> None:
|
|
||||||
"""Launch the orchestrator detached so it outlives this CLI process,
|
|
||||||
with its output tee'd to a log file under the bot-bottle root."""
|
|
||||||
root = bot_bottle_root()
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
logfile = open(self._log_path(), "a", encoding="utf-8") # noqa: SIM115 # pylint: disable=consider-using-with
|
|
||||||
try:
|
|
||||||
subprocess.Popen( # noqa: S603 # pylint: disable=consider-using-with
|
|
||||||
self._argv(),
|
|
||||||
stdout=logfile,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
# The child inherits its own dup'd fd; this handle is ours to drop.
|
|
||||||
logfile.close()
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"OrchestratorProcess",
|
"OrchestratorService",
|
||||||
"OrchestratorStartError",
|
"OrchestratorStartError",
|
||||||
"DEFAULT_HOST",
|
"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
|
||||||
|
|||||||
@@ -149,7 +149,15 @@ class RegistryStore(DbStore):
|
|||||||
policy: str = "",
|
policy: str = "",
|
||||||
) -> BottleRecord:
|
) -> BottleRecord:
|
||||||
"""Register (or replace) a bottle. Mints a bottle_id / identity token
|
"""Register (or replace) a bottle. Mints a bottle_id / identity token
|
||||||
when not supplied. Live — this is the control-plane reload path."""
|
when not supplied. Live — this is the control-plane reload path.
|
||||||
|
|
||||||
|
Supersedes any *other* active bottle at the same source IP first:
|
||||||
|
`by_source_ip` fail-closes on ambiguity (>1 active row → None), so a
|
||||||
|
leftover row at a reused IP — from an untorn-down bottle, crash
|
||||||
|
recovery, or a persisted DB — would otherwise *brick* the new bottle's
|
||||||
|
attribution. The new registration is authoritative for its IP; the
|
||||||
|
stale rows go. INSERT OR REPLACE handles a same-`bottle_id` reload in
|
||||||
|
place (its own row is exempt from the sweep)."""
|
||||||
rec = BottleRecord(
|
rec = BottleRecord(
|
||||||
bottle_id=bottle_id or secrets.token_hex(8),
|
bottle_id=bottle_id or secrets.token_hex(8),
|
||||||
source_ip=source_ip,
|
source_ip=source_ip,
|
||||||
@@ -160,6 +168,11 @@ class RegistryStore(DbStore):
|
|||||||
policy=policy,
|
policy=policy,
|
||||||
)
|
)
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM orchestrator_bottles "
|
||||||
|
"WHERE source_ip = ? AND state = 'active' AND bottle_id != ?",
|
||||||
|
(rec.source_ip, rec.bottle_id),
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO orchestrator_bottles "
|
"INSERT OR REPLACE INTO orchestrator_bottles "
|
||||||
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) "
|
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) "
|
||||||
|
|||||||
@@ -17,9 +17,37 @@ Launch lifecycle:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from .broker import LaunchBroker, LaunchRequest, sign_request
|
from .broker import LaunchBroker, LaunchRequest, sign_request
|
||||||
from .registry import BottleRecord, RegistryStore
|
from .registry import BottleRecord, RegistryStore
|
||||||
from .gateway import Gateway
|
from .gateway import Gateway
|
||||||
|
from ..supervise import (
|
||||||
|
AuditEntry,
|
||||||
|
COMPONENT_FOR_TOOL,
|
||||||
|
Response,
|
||||||
|
STATUS_APPROVED,
|
||||||
|
STATUS_MODIFIED,
|
||||||
|
STATUS_REJECTED,
|
||||||
|
TOOL_EGRESS_ALLOW,
|
||||||
|
TOOL_EGRESS_BLOCK,
|
||||||
|
list_all_pending_proposals,
|
||||||
|
read_proposal,
|
||||||
|
render_diff,
|
||||||
|
write_audit_entry,
|
||||||
|
write_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Operator decision → Response.status. The apply half (egress tools) runs
|
||||||
|
# for approve/modify only.
|
||||||
|
_RESPOND_STATUS = {
|
||||||
|
"approve": STATUS_APPROVED,
|
||||||
|
"modify": STATUS_MODIFIED,
|
||||||
|
"reject": STATUS_REJECTED,
|
||||||
|
}
|
||||||
|
_APPLY_TOOLS = (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK)
|
||||||
|
|
||||||
|
|
||||||
class Orchestrator:
|
class Orchestrator:
|
||||||
@@ -38,6 +66,12 @@ class Orchestrator:
|
|||||||
self._broker = broker
|
self._broker = broker
|
||||||
self._secret = sign_secret
|
self._secret = sign_secret
|
||||||
self._gateway = gateway
|
self._gateway = gateway
|
||||||
|
# Per-bottle egress auth tokens (env_name -> value), keyed by bottle_id.
|
||||||
|
# Held **in memory only** — never written to the registry DB — so the
|
||||||
|
# gateway can inject each bottle's upstream credential without secrets
|
||||||
|
# at rest. Lost on restart (re-launch re-registers them); the future
|
||||||
|
# SecretProvider (#355) replaces this with per-request minting.
|
||||||
|
self._tokens: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
def launch_bottle(
|
def launch_bottle(
|
||||||
self,
|
self,
|
||||||
@@ -47,11 +81,14 @@ class Orchestrator:
|
|||||||
slot: int | None = None,
|
slot: int | None = None,
|
||||||
metadata: str = "",
|
metadata: str = "",
|
||||||
policy: str = "",
|
policy: str = "",
|
||||||
|
tokens: dict[str, str] | None = None,
|
||||||
) -> BottleRecord:
|
) -> BottleRecord:
|
||||||
"""Register a bottle (with its gateway policy) and broker its launch.
|
"""Register a bottle (with its gateway policy + in-memory egress auth
|
||||||
Rolls the registry entry back if the launch doesn't take, so a
|
tokens) and broker its launch. Rolls the registry entry back if the
|
||||||
failure leaves no orphan."""
|
launch doesn't take, so a failure leaves no orphan."""
|
||||||
rec = self.registry.register(source_ip, metadata=metadata, policy=policy)
|
rec = self.registry.register(source_ip, metadata=metadata, policy=policy)
|
||||||
|
if tokens:
|
||||||
|
self._tokens[rec.bottle_id] = dict(tokens)
|
||||||
req = LaunchRequest(
|
req = LaunchRequest(
|
||||||
op="launch",
|
op="launch",
|
||||||
bottle_id=rec.bottle_id,
|
bottle_id=rec.bottle_id,
|
||||||
@@ -66,6 +103,7 @@ class Orchestrator:
|
|||||||
finally:
|
finally:
|
||||||
if not launched:
|
if not launched:
|
||||||
self.registry.deregister(rec.bottle_id)
|
self.registry.deregister(rec.bottle_id)
|
||||||
|
self._tokens.pop(rec.bottle_id, None)
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
def teardown_bottle(self, bottle_id: str) -> bool:
|
def teardown_bottle(self, bottle_id: str) -> bool:
|
||||||
@@ -76,28 +114,147 @@ class Orchestrator:
|
|||||||
req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip)
|
req = LaunchRequest(op="teardown", bottle_id=bottle_id, source_ip=rec.source_ip)
|
||||||
self._broker.submit(sign_request(req, self._secret))
|
self._broker.submit(sign_request(req, self._secret))
|
||||||
self.registry.deregister(bottle_id)
|
self.registry.deregister(bottle_id)
|
||||||
|
self._tokens.pop(bottle_id, None)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def tokens_for(self, bottle_id: str) -> dict[str, str]:
|
||||||
|
"""The bottle's in-memory egress auth tokens (env_name -> value), or
|
||||||
|
empty. The gateway injects these per request; they are never
|
||||||
|
persisted."""
|
||||||
|
return dict(self._tokens.get(bottle_id, {}))
|
||||||
|
|
||||||
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
||||||
"""Fail-closed attribution (delegates to the registry)."""
|
"""Fail-closed attribution (delegates to the registry)."""
|
||||||
return self.registry.attribute(source_ip, identity_token)
|
return self.registry.attribute(source_ip, identity_token)
|
||||||
|
|
||||||
def resolve(self, source_ip: str, identity_token: str = "") -> BottleRecord | None:
|
def resolve(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
||||||
"""Resolve the bottle behind a request — the source-IP-keyed lookup
|
"""Resolve the bottle behind a request — the per-request lookup the
|
||||||
the multi-tenant gateway makes per request; the returned record
|
multi-tenant gateway makes; the returned record carries its `policy`.
|
||||||
carries its `policy`. With a token, full attribution (source IP +
|
|
||||||
token); without, network-layer attribution by source IP alone
|
**Mandatory pair**: requires a matching `(source_ip, identity_token)`
|
||||||
(valid where the IP is unspoofable and the control plane is
|
(constant-time). There is no source-IP-only fallback — the app-layer
|
||||||
gateway-only)."""
|
token is delivered on every attributed data plane (egress proxy
|
||||||
if identity_token:
|
credentials, git-gate/supervise headers), so a missing or mismatched
|
||||||
return self.registry.attribute(source_ip, identity_token)
|
token fail-closes. This keeps a spoofed source IP (which the /31 TAP
|
||||||
return self.registry.by_source_ip(source_ip)
|
alone does not prevent) from selecting another bottle's policy/tokens
|
||||||
|
without also holding that bottle's unguessable token."""
|
||||||
|
return self.registry.attribute(source_ip, identity_token)
|
||||||
|
|
||||||
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
||||||
"""Update a bottle's gateway policy in place (live reload). False if
|
"""Update a bottle's gateway policy in place (live reload). False if
|
||||||
the bottle is unknown."""
|
the bottle is unknown."""
|
||||||
return self.registry.set_policy(bottle_id, policy)
|
return self.registry.set_policy(bottle_id, policy)
|
||||||
|
|
||||||
|
# --- supervise queue (operator approvals) ------------------------------
|
||||||
|
#
|
||||||
|
# The orchestrator owns the single DB *and* the live policy, so operator
|
||||||
|
# decisions are applied here, server-side, and reached over HTTP by the
|
||||||
|
# host TUI (no direct-DB access, one path for every backend).
|
||||||
|
|
||||||
|
def supervise_pending(self) -> list[dict[str, object]]:
|
||||||
|
"""All pending proposals across bottles, FIFO, as JSON dicts
|
||||||
|
(`Proposal.to_dict`, round-trippable via `Proposal.from_dict`).
|
||||||
|
|
||||||
|
Each dict carries an extra `bottle_label`: the bottle's human slug
|
||||||
|
resolved from the registry (the proposal itself is keyed by the
|
||||||
|
orchestrator-assigned bottle_id, which is opaque to an operator). The
|
||||||
|
CLI renders the label but still responds against `bottle_slug`."""
|
||||||
|
out: list[dict[str, object]] = []
|
||||||
|
for p in list_all_pending_proposals():
|
||||||
|
d = p.to_dict()
|
||||||
|
d["bottle_label"] = self._label_for(p.bottle_slug)
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _label_for(self, bottle_slug: str) -> str:
|
||||||
|
"""The human slug recorded in registry metadata for a proposal's
|
||||||
|
bottle, or the bottle_slug unchanged when the bottle is gone or has no
|
||||||
|
recorded slug — so the label is always non-empty."""
|
||||||
|
rec = self.registry.get(bottle_slug)
|
||||||
|
if rec is None:
|
||||||
|
return bottle_slug
|
||||||
|
try:
|
||||||
|
meta = json.loads(rec.metadata) if rec.metadata else {}
|
||||||
|
except ValueError:
|
||||||
|
meta = {}
|
||||||
|
slug = meta.get("slug") if isinstance(meta, dict) else None
|
||||||
|
return slug if isinstance(slug, str) and slug else bottle_slug
|
||||||
|
|
||||||
|
def _record_for_slug(self, slug: str) -> BottleRecord | None:
|
||||||
|
"""The live registry record for a proposal's bottle, or None (e.g. the
|
||||||
|
bottle was torn down before the operator responded).
|
||||||
|
|
||||||
|
In consolidated mode the supervise server attributes each proposal to
|
||||||
|
the orchestrator-assigned bottle_id and stores that as the proposal's
|
||||||
|
`bottle_slug` (see supervise_server `_attributed_config`), so the fast
|
||||||
|
path is a direct bottle_id lookup. The metadata-slug scan is the
|
||||||
|
fallback for legacy single-tenant proposals keyed by the human slug."""
|
||||||
|
rec = self.registry.get(slug)
|
||||||
|
if rec is not None:
|
||||||
|
return rec
|
||||||
|
for rec in self.registry.all():
|
||||||
|
try:
|
||||||
|
meta = json.loads(rec.metadata) if rec.metadata else {}
|
||||||
|
except ValueError:
|
||||||
|
meta = {}
|
||||||
|
if isinstance(meta, dict) and meta.get("slug") == slug:
|
||||||
|
return rec
|
||||||
|
return None
|
||||||
|
|
||||||
|
def supervise_respond(
|
||||||
|
self,
|
||||||
|
proposal_id: str,
|
||||||
|
*,
|
||||||
|
bottle_slug: str,
|
||||||
|
decision: str,
|
||||||
|
notes: str = "",
|
||||||
|
final_file: str | None = None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Record an operator decision on a queued proposal, applying it
|
||||||
|
server-side. `decision` is approve/modify/reject.
|
||||||
|
|
||||||
|
Approve/modify on an egress tool rewrites the bottle's policy so the
|
||||||
|
gateway serves the new routes on its next `/resolve` (the live apply);
|
||||||
|
then the queued Response is written (unblocking the agent's MCP call)
|
||||||
|
and an audit entry recorded — all against the one DB. Returns
|
||||||
|
(ok, error): ok=False with a message when the proposal or decision is
|
||||||
|
unknown, or the bottle is gone so an approval can't be applied."""
|
||||||
|
status = _RESPOND_STATUS.get(decision)
|
||||||
|
if status is None:
|
||||||
|
return False, f"unknown decision {decision!r}"
|
||||||
|
try:
|
||||||
|
proposal = read_proposal(bottle_slug, proposal_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "no such proposal"
|
||||||
|
|
||||||
|
diff_before, diff_after = "", ""
|
||||||
|
if status in (STATUS_APPROVED, STATUS_MODIFIED) and proposal.tool in _APPLY_TOOLS:
|
||||||
|
new_policy = final_file if final_file is not None else proposal.proposed_file
|
||||||
|
rec = self._record_for_slug(bottle_slug)
|
||||||
|
if rec is None:
|
||||||
|
return False, (
|
||||||
|
f"bottle {bottle_slug!r} is no longer registered; "
|
||||||
|
"cannot apply the route change"
|
||||||
|
)
|
||||||
|
diff_before, diff_after = rec.policy, new_policy
|
||||||
|
self.set_policy(rec.bottle_id, new_policy)
|
||||||
|
|
||||||
|
write_response(bottle_slug, Response(
|
||||||
|
proposal_id=proposal_id, status=status, notes=notes, final_file=final_file,
|
||||||
|
))
|
||||||
|
component = COMPONENT_FOR_TOOL.get(proposal.tool)
|
||||||
|
if component is not None:
|
||||||
|
write_audit_entry(AuditEntry(
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
|
bottle_slug=bottle_slug,
|
||||||
|
component=component,
|
||||||
|
operator_action=status,
|
||||||
|
operator_notes=notes,
|
||||||
|
justification=proposal.justification,
|
||||||
|
diff=render_diff(diff_before, diff_after, label=component),
|
||||||
|
))
|
||||||
|
return True, ""
|
||||||
|
|
||||||
# --- consolidated gateway ----------------------------------------------
|
# --- consolidated gateway ----------------------------------------------
|
||||||
|
|
||||||
def ensure_gateway(self) -> None:
|
def ensure_gateway(self) -> None:
|
||||||
|
|||||||
+61
-3
@@ -10,12 +10,14 @@ 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
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# The single shared host state DB. All bot-bottle SQLite stores (supervise
|
# The single shared host state DB. All bot-bottle SQLite stores (supervise
|
||||||
@@ -23,6 +25,14 @@ from pathlib import Path
|
|||||||
# TableMigrations schema_key namespaces each store's tables.
|
# TableMigrations schema_key namespaces each store's tables.
|
||||||
HOST_DB_FILENAME = "bot-bottle.db"
|
HOST_DB_FILENAME = "bot-bottle.db"
|
||||||
|
|
||||||
|
# The per-host control-plane secret file, and the env var the launchers inject
|
||||||
|
# its value into. The control plane requires this secret on every mutating /
|
||||||
|
# reading route (see orchestrator/control_plane.py); it is held only by the
|
||||||
|
# trusted callers (control plane, gateway, host CLI) and never handed to an
|
||||||
|
# agent, so an agent that can reach the control-plane port still can't drive it.
|
||||||
|
CONTROL_PLANE_TOKEN_FILENAME = "control-plane-token"
|
||||||
|
CONTROL_PLANE_TOKEN_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
|
||||||
|
|
||||||
|
|
||||||
def bot_bottle_root() -> Path:
|
def bot_bottle_root() -> Path:
|
||||||
"""The app data root — `$BOT_BOTTLE_ROOT` if set, else `~/.bot-bottle`."""
|
"""The app data root — `$BOT_BOTTLE_ROOT` if set, else `~/.bot-bottle`."""
|
||||||
@@ -35,9 +45,57 @@ 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
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["HOST_DB_FILENAME", "bot_bottle_root", "host_db_path"]
|
def host_db_dir() -> Path:
|
||||||
|
"""The directory holding the shared host state DB, created if missing.
|
||||||
|
Backends bind-mount this into their gateway so the supervise daemon writes
|
||||||
|
to the one DB the orchestrator (and the operator over HTTP) reads."""
|
||||||
|
db_dir = host_db_path().parent
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return db_dir
|
||||||
|
|
||||||
|
|
||||||
|
def host_control_plane_token() -> str:
|
||||||
|
"""The per-host control-plane secret, minted (256-bit, url-safe) and
|
||||||
|
persisted 0600 on first use, then reused.
|
||||||
|
|
||||||
|
This is the shared secret the launchers inject into the control-plane and
|
||||||
|
gateway containers and that the host CLI presents on every call. It is a
|
||||||
|
*host* artifact — the file lives under the root the agent never mounts, and
|
||||||
|
the env var is set only on the trusted containers — so reading it here is
|
||||||
|
safe on the host launch path but the value never reaches a bottle."""
|
||||||
|
path = bot_bottle_root() / CONTROL_PLANE_TOKEN_FILENAME
|
||||||
|
try:
|
||||||
|
existing = path.read_text().strip()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
# Create 0600 up front (O_EXCL loses a concurrent race harmlessly — we
|
||||||
|
# re-read the winner's token below) so the secret is never briefly world-
|
||||||
|
# readable between write and chmod.
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
except FileExistsError:
|
||||||
|
return path.read_text().strip()
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(token)
|
||||||
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HOST_DB_FILENAME",
|
||||||
|
"CONTROL_PLANE_TOKEN_FILENAME",
|
||||||
|
"CONTROL_PLANE_TOKEN_ENV",
|
||||||
|
"bot_bottle_root",
|
||||||
|
"host_db_path",
|
||||||
|
"host_db_dir",
|
||||||
|
"host_control_plane_token",
|
||||||
|
]
|
||||||
|
|||||||
@@ -22,18 +22,35 @@ 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.
|
||||||
the sidecar bundle.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
DEFAULT_TIMEOUT_SECONDS = 2.0
|
DEFAULT_TIMEOUT_SECONDS = 2.0
|
||||||
|
|
||||||
|
# The control-plane secret this gateway presents on every /resolve call, read
|
||||||
|
# from the env the launcher injects into the gateway container. The control
|
||||||
|
# plane requires it (orchestrator/control_plane.py). Constant + env-var name are
|
||||||
|
# duplicated here rather than imported because this module is COPYed flat into
|
||||||
|
# the gateway image, free of bot-bottle imports — same rationale as
|
||||||
|
# IDENTITY_HEADER in egress_addon / git_http_backend.
|
||||||
|
CONTROL_AUTH_HEADER = "x-bot-bottle-control-auth"
|
||||||
|
CONTROL_PLANE_TOKEN_ENV = "BOT_BOTTLE_CONTROL_PLANE_TOKEN"
|
||||||
|
|
||||||
|
|
||||||
|
def _control_auth_headers() -> dict[str, str]:
|
||||||
|
"""The auth header to send, or {} when no secret is configured (an open
|
||||||
|
control plane, e.g. Firecracker behind its nft boundary — sending nothing
|
||||||
|
is correct there and harmlessly ignored)."""
|
||||||
|
token = os.environ.get(CONTROL_PLANE_TOKEN_ENV, "").strip()
|
||||||
|
return {CONTROL_AUTH_HEADER: token} if token else {}
|
||||||
|
|
||||||
|
|
||||||
class PolicyResolveError(RuntimeError):
|
class PolicyResolveError(RuntimeError):
|
||||||
"""The orchestrator was unreachable or returned an unexpected status —
|
"""The orchestrator was unreachable or returned an unexpected status —
|
||||||
@@ -57,7 +74,7 @@ class PolicyResolver:
|
|||||||
).encode()
|
).encode()
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{self._base}/resolve", data=body, method="POST",
|
f"{self._base}/resolve", data=body, method="POST",
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json", **_control_auth_headers()},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||||
@@ -102,21 +119,26 @@ class PolicyResolver:
|
|||||||
|
|
||||||
def resolve_policy_and_bottle_id(
|
def resolve_policy_and_bottle_id(
|
||||||
self, source_ip: str, identity_token: str = "",
|
self, source_ip: str, identity_token: str = "",
|
||||||
) -> tuple[str | None, str | None]:
|
) -> tuple[str | None, str | None, dict[str, str]]:
|
||||||
"""Both the policy blob and the bottle id in a single `/resolve` — so
|
"""The policy blob, bottle id, **and per-bottle egress auth tokens** in
|
||||||
a caller that needs each (the egress addon: policy for routing, bottle
|
a single `/resolve` — so the egress addon gets everything it needs
|
||||||
id to key the calling bottle's supervise queue + safelist) makes one
|
(policy for routing, bottle id for the supervise queue/safelist, tokens
|
||||||
round-trip, not two. Returns `(None, None)` when unattributed (a clean
|
to inject upstream auth) in one round-trip. Returns `(None, None, {})`
|
||||||
`403`). Raises `PolicyResolveError` if the orchestrator can't be
|
when unattributed (a clean `403`). Raises `PolicyResolveError` if the
|
||||||
reached, so the caller still fails closed."""
|
orchestrator can't be reached, so the caller still fails closed."""
|
||||||
payload = self._post_resolve(source_ip, identity_token)
|
payload = self._post_resolve(source_ip, identity_token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return None, None
|
return None, None, {}
|
||||||
policy = payload.get("policy")
|
policy = payload.get("policy")
|
||||||
bottle_id = payload.get("bottle_id")
|
bottle_id = payload.get("bottle_id")
|
||||||
|
raw = payload.get("tokens")
|
||||||
|
tokens = {
|
||||||
|
k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)
|
||||||
|
} if isinstance(raw, dict) else {}
|
||||||
return (
|
return (
|
||||||
policy if isinstance(policy, str) else "",
|
policy if isinstance(policy, str) else "",
|
||||||
bottle_id if isinstance(bottle_id, str) and bottle_id else None,
|
bottle_id if isinstance(bottle_id, str) and bottle_id else None,
|
||||||
|
tokens,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+30
-48
@@ -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
|
||||||
@@ -37,56 +37,38 @@ from abc import ABC
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
from .supervise_types import (
|
||||||
from .supervise_types import (
|
ACTION_OPERATOR_EDIT,
|
||||||
ACTION_OPERATOR_EDIT,
|
AuditEntry,
|
||||||
AuditEntry,
|
Proposal,
|
||||||
Proposal,
|
Response,
|
||||||
Response,
|
STATUSES,
|
||||||
STATUSES,
|
STATUS_APPROVED,
|
||||||
STATUS_APPROVED,
|
STATUS_MODIFIED,
|
||||||
STATUS_MODIFIED,
|
STATUS_REJECTED,
|
||||||
STATUS_REJECTED,
|
TOOLS,
|
||||||
TOOLS,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_BLOCK,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
TOOL_EGRESS_TOKEN_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_LIST_EGRESS_ROUTES,
|
||||||
TOOL_LIST_EGRESS_ROUTES,
|
)
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
from supervise_types import ( # type: ignore[import-not-found,no-redef] # pylint: disable=import-error,no-name-in-module
|
|
||||||
ACTION_OPERATOR_EDIT,
|
|
||||||
AuditEntry,
|
|
||||||
Proposal,
|
|
||||||
Response,
|
|
||||||
STATUSES,
|
|
||||||
STATUS_APPROVED,
|
|
||||||
STATUS_MODIFIED,
|
|
||||||
STATUS_REJECTED,
|
|
||||||
TOOLS,
|
|
||||||
TOOL_EGRESS_ALLOW,
|
|
||||||
TOOL_EGRESS_BLOCK,
|
|
||||||
TOOL_EGRESS_TOKEN_ALLOW,
|
|
||||||
TOOL_GITLEAKS_ALLOW,
|
|
||||||
TOOL_LIST_EGRESS_ROUTES,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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 +99,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 +204,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 +222,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`,
|
||||||
@@ -11,16 +11,12 @@ Each queued tool call:
|
|||||||
3. Blocks polling for a matching Response row.
|
3. Blocks polling for a matching Response row.
|
||||||
4. Returns the operator's `{status, notes}` to the agent.
|
4. Returns the operator's `{status, notes}` to the agent.
|
||||||
|
|
||||||
The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
|
One shared server fronts every bottle (PRD 0070) and attributes each
|
||||||
container creation by the backend's start step). SUPERVISE_DB_PATH
|
proposal to the calling bottle by source IP, resolved from the orchestrator
|
||||||
|
— an unattributed or unreachable source fails closed. BOT_BOTTLE_ORCHESTRATOR_URL
|
||||||
|
is mandatory: there is no fixed-slug single-tenant fallback. SUPERVISE_DB_PATH
|
||||||
points at the bind-mounted host database.
|
points at the bind-mounted host database.
|
||||||
|
|
||||||
Consolidated (PRD 0070): when BOT_BOTTLE_ORCHESTRATOR_URL is set, one
|
|
||||||
shared server fronts every bottle and attributes each proposal to the
|
|
||||||
calling bottle by source IP (resolved from the orchestrator) instead of a
|
|
||||||
fixed slug — an unattributed source fails closed. Unset → the legacy
|
|
||||||
per-bottle single-tenant server, unchanged.
|
|
||||||
|
|
||||||
Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
||||||
|
|
||||||
* `initialize` — handshake; returns server info + caps.
|
* `initialize` — handshake; returns server info + caps.
|
||||||
@@ -30,9 +26,8 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
|||||||
|
|
||||||
Everything else returns JSON-RPC error -32601 (method not found).
|
Everything else returns JSON-RPC error -32601 (method not found).
|
||||||
|
|
||||||
Stdlib-only. The Dockerfile copies this file + bot_bottle/supervise.py
|
The Dockerfile copies this script to /app/supervise_server.py and installs
|
||||||
into the image; the server imports `supervise` for the queue / Proposal
|
the bot_bottle package so its `from bot_bottle.*` imports resolve.
|
||||||
plumbing.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -44,21 +39,14 @@ import socketserver
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
try:
|
from bot_bottle.constants import IDENTITY_HEADER
|
||||||
# Same-directory imports inside the bundle container; these files are
|
from bot_bottle.egress_addon_core import (
|
||||||
# COPYed flat under /app by Dockerfile.sidecars.
|
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
||||||
from egress_addon_core import LOG_OFF, load_config
|
)
|
||||||
from policy_resolver import PolicyResolveError, PolicyResolver
|
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
import supervise as _sv
|
from bot_bottle import supervise as _sv
|
||||||
except ModuleNotFoundError:
|
|
||||||
# Package imports for host-side tests and tooling.
|
|
||||||
from .egress_addon_core import LOG_OFF, load_config
|
|
||||||
from .policy_resolver import PolicyResolveError, PolicyResolver
|
|
||||||
from . import supervise as _sv
|
|
||||||
|
|
||||||
|
|
||||||
# --- JSON-RPC / MCP plumbing ----------------------------------------------
|
# --- JSON-RPC / MCP plumbing ----------------------------------------------
|
||||||
@@ -79,12 +67,10 @@ ERR_INTERNAL = -32603
|
|||||||
|
|
||||||
DEFAULT_RESPONSE_TIMEOUT_SECONDS = 30.0
|
DEFAULT_RESPONSE_TIMEOUT_SECONDS = 30.0
|
||||||
MIN_RESPONSE_POLL_INTERVAL_SECONDS = 0.05
|
MIN_RESPONSE_POLL_INTERVAL_SECONDS = 0.05
|
||||||
EGRESS_LIST_TIMEOUT_SECONDS = 5.0
|
|
||||||
|
|
||||||
# Consolidated (multi-tenant) mode: when set, one shared supervise server
|
# The per-host orchestrator control plane the shared supervise server attributes
|
||||||
# fronts every bottle and attributes each proposal to the calling bottle by
|
# each proposal to, by source IP. Mandatory — there is no single-tenant
|
||||||
# source IP (resolved from the orchestrator), instead of a single
|
# SUPERVISE_BOTTLE_SLUG fallback.
|
||||||
# SUPERVISE_BOTTLE_SLUG env. Unset → legacy per-bottle single-tenant.
|
|
||||||
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
||||||
|
|
||||||
|
|
||||||
@@ -304,42 +290,6 @@ def handle_tools_list(_params: dict[str, object]) -> dict[str, object]:
|
|||||||
return {"tools": TOOL_DEFINITIONS}
|
return {"tools": TOOL_DEFINITIONS}
|
||||||
|
|
||||||
|
|
||||||
def handle_list_egress_routes(
|
|
||||||
_params: dict[str, object],
|
|
||||||
_config: ServerConfig,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""Fetch the live egress route table via its
|
|
||||||
`_egress.local/allowlist` introspection endpoint. The
|
|
||||||
request goes through egress as a forward proxy; the
|
|
||||||
addon recognises the magic host and synthesizes a response —
|
|
||||||
no real upstream connection, no allowlist enforcement
|
|
||||||
against the magic host. Returns the JSON payload as the
|
|
||||||
tool's text content."""
|
|
||||||
proxy_handler = urllib.request.ProxyHandler({
|
|
||||||
"http": _sv.EGRESS_FORWARD_PROXY,
|
|
||||||
})
|
|
||||||
opener = urllib.request.build_opener(proxy_handler)
|
|
||||||
try:
|
|
||||||
with opener.open(_sv.EGRESS_INTROSPECT_URL, timeout=EGRESS_LIST_TIMEOUT_SECONDS) as resp:
|
|
||||||
body = resp.read().decode("utf-8")
|
|
||||||
except (urllib.error.URLError, OSError) as e:
|
|
||||||
return {
|
|
||||||
"content": [{
|
|
||||||
"type": "text",
|
|
||||||
"text": (
|
|
||||||
f"list-egress-routes: could not reach "
|
|
||||||
f"{_sv.EGRESS_INTROSPECT_URL!r} via "
|
|
||||||
f"{_sv.EGRESS_FORWARD_PROXY!r}: {e}"
|
|
||||||
),
|
|
||||||
}],
|
|
||||||
"isError": True,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"content": [{"type": "text", "text": body}],
|
|
||||||
"isError": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def handle_tools_call(
|
def handle_tools_call(
|
||||||
params: dict[str, object],
|
params: dict[str, object],
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
@@ -347,14 +297,13 @@ def handle_tools_call(
|
|||||||
"""Validates the proposal, writes it to the queue, blocks waiting
|
"""Validates the proposal, writes it to the queue, blocks waiting
|
||||||
for a Response, returns the result wrapped in MCP `content`.
|
for a Response, returns the result wrapped in MCP `content`.
|
||||||
|
|
||||||
Side-effect-free `list-*` tools short-circuit before the queue/
|
`list-egress-routes` never reaches here — the handler answers it from
|
||||||
blocking machinery — they're read-only introspection that
|
the calling bottle's resolved policy before dispatching (see
|
||||||
doesn't need operator approval."""
|
`MCPHandler._dispatch`); this path is the queued, operator-approved
|
||||||
|
`egress-allow` / `egress-block` tools."""
|
||||||
name = params.get("name")
|
name = params.get("name")
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
||||||
if name == _sv.TOOL_LIST_EGRESS_ROUTES:
|
|
||||||
return handle_list_egress_routes(typing.cast(dict[str, object], params.get("arguments", {})), config)
|
|
||||||
|
|
||||||
args_raw = params.get("arguments", {})
|
args_raw = params.get("arguments", {})
|
||||||
if not isinstance(args_raw, dict):
|
if not isinstance(args_raw, dict):
|
||||||
@@ -525,24 +474,58 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
if method == "tools/list":
|
if method == "tools/list":
|
||||||
return handle_tools_list(req.params)
|
return handle_tools_list(req.params)
|
||||||
if method == "tools/call":
|
if method == "tools/call":
|
||||||
# Attribute the proposal to the calling bottle. Single-tenant → the
|
# `list-egress-routes` is read-only introspection. The shared gateway
|
||||||
# env slug on `config`; consolidated → the source-IP-resolved
|
# has no static route table (routes are resolved per request by
|
||||||
# bottle id, so one shared server queues each bottle's proposal
|
# source IP), so answer it from the calling bottle's resolved policy.
|
||||||
# under its own slug.
|
# Otherwise the agent sees an empty allowlist and composes an egress
|
||||||
|
# proposal that *replaces* the live routes instead of extending them
|
||||||
|
# — silently dropping base routes like api.anthropic.com on approval.
|
||||||
|
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
|
||||||
|
return self._resolved_routes_payload()
|
||||||
|
# Attribute the proposal to the source-IP-resolved bottle, so the one
|
||||||
|
# shared server queues each bottle's proposal under its own slug.
|
||||||
return handle_tools_call(req.params, self._attributed_config(config))
|
return handle_tools_call(req.params, self._attributed_config(config))
|
||||||
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
|
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
|
||||||
|
|
||||||
def _attributed_config(self, config: ServerConfig) -> ServerConfig:
|
def _resolver_or_fail(self) -> "PolicyResolver":
|
||||||
"""The ServerConfig with `bottle_slug` bound to *this request's* bottle.
|
"""This server's policy resolver. A server started without one is a
|
||||||
Single-tenant (no resolver): unchanged. Consolidated: the bottle id
|
misconfiguration, not a tenancy mode — fail closed rather than
|
||||||
attributed from the source IP — **fail-closed**, an unattributed or
|
attribute (or list) anything."""
|
||||||
unreachable source raises so no proposal is queued under the wrong (or
|
|
||||||
empty) slug."""
|
|
||||||
resolver = getattr(self.server, "policy_resolver", None)
|
resolver = getattr(self.server, "policy_resolver", None)
|
||||||
if resolver is None:
|
if resolver is None:
|
||||||
return config
|
raise _RpcInternalError("supervise server has no policy resolver")
|
||||||
|
return resolver
|
||||||
|
|
||||||
|
def _resolved_routes_payload(self) -> dict[str, object]:
|
||||||
|
"""The calling bottle's live egress routes as the `list-egress-routes`
|
||||||
|
JSON payload, resolved by (source_ip, identity token). Fail-closed like
|
||||||
|
`_attributed_config`: an unattributed source or an unreachable
|
||||||
|
orchestrator yields an empty route list (never another bottle's),
|
||||||
|
courtesy of `resolve_client_context`."""
|
||||||
|
resolver = self._resolver_or_fail()
|
||||||
|
headers = getattr(self, "headers", None)
|
||||||
|
token = headers.get(IDENTITY_HEADER, "") if headers is not None else ""
|
||||||
|
conf, _slug, _tokens = resolve_client_context(
|
||||||
|
resolver, self.client_address[0], token,
|
||||||
|
)
|
||||||
|
body = json.dumps(
|
||||||
|
{"routes": [route_to_yaml_dict(r) for r in conf.routes]}, indent=2,
|
||||||
|
)
|
||||||
|
return {"content": [{"type": "text", "text": body}], "isError": False}
|
||||||
|
|
||||||
|
def _attributed_config(self, config: ServerConfig) -> ServerConfig:
|
||||||
|
"""The ServerConfig with `bottle_slug` bound to *this request's* bottle:
|
||||||
|
the bottle id attributed from the source IP — **fail-closed**, an
|
||||||
|
unattributed or unreachable source raises so no proposal is queued under
|
||||||
|
the wrong (or empty) slug."""
|
||||||
|
resolver = self._resolver_or_fail()
|
||||||
|
# The agent's MCP client sends the identity token as a request header
|
||||||
|
# (provisioned via `mcp add --header`); the orchestrator requires the
|
||||||
|
# (source_ip, token) pair, so a missing/wrong token fail-closes below.
|
||||||
|
headers = getattr(self, "headers", None)
|
||||||
|
token = headers.get(IDENTITY_HEADER, "") if headers is not None else ""
|
||||||
try:
|
try:
|
||||||
bottle_id = resolver.resolve_bottle_id(self.client_address[0])
|
bottle_id = resolver.resolve_bottle_id(self.client_address[0], token)
|
||||||
except PolicyResolveError as e:
|
except PolicyResolveError as e:
|
||||||
raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e
|
raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e
|
||||||
if not bottle_id:
|
if not bottle_id:
|
||||||
@@ -572,8 +555,9 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
config: ServerConfig = ServerConfig(bottle_slug="")
|
config: ServerConfig = ServerConfig(bottle_slug="")
|
||||||
# None → single-tenant (proposals use config.bottle_slug); set → consolidated
|
# Set by `serve`; every proposal is attributed to the source-IP-resolved
|
||||||
# (each proposal attributed to the source-IP-resolved bottle).
|
# bottle. The class default is a placeholder — a server without a resolver
|
||||||
|
# fails closed per request (see `_resolver_or_fail`).
|
||||||
policy_resolver: "PolicyResolver | None" = None
|
policy_resolver: "PolicyResolver | None" = None
|
||||||
|
|
||||||
|
|
||||||
@@ -582,21 +566,21 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
|
|
||||||
def serve(
|
def serve(
|
||||||
*,
|
*,
|
||||||
bottle_slug: str,
|
resolver: "PolicyResolver",
|
||||||
port: int = _sv.SUPERVISE_PORT,
|
port: int = _sv.SUPERVISE_PORT,
|
||||||
bind: str = "0.0.0.0",
|
bind: str = "0.0.0.0",
|
||||||
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
|
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
|
||||||
resolver: "PolicyResolver | None" = None,
|
|
||||||
) -> typing.NoReturn:
|
) -> typing.NoReturn:
|
||||||
server = MCPServer((bind, port), MCPHandler)
|
server = MCPServer((bind, port), MCPHandler)
|
||||||
|
# bottle_slug is a placeholder: every request's proposal is attributed to
|
||||||
|
# the source-IP-resolved bottle (see MCPHandler._attributed_config).
|
||||||
server.config = ServerConfig(
|
server.config = ServerConfig(
|
||||||
bottle_slug=bottle_slug,
|
bottle_slug="",
|
||||||
response_timeout_seconds=response_timeout_seconds,
|
response_timeout_seconds=response_timeout_seconds,
|
||||||
)
|
)
|
||||||
server.policy_resolver = resolver
|
server.policy_resolver = resolver
|
||||||
mode = "multi-tenant" if resolver else f"slug={bottle_slug!r}"
|
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"supervise listening on {bind}:{port}; {mode}; "
|
f"supervise listening on {bind}:{port}; multi-tenant; "
|
||||||
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
|
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
@@ -612,12 +596,13 @@ def serve(
|
|||||||
def main(argv: list[str]) -> int:
|
def main(argv: list[str]) -> int:
|
||||||
del argv # config is env-only, no CLI flags
|
del argv # config is env-only, no CLI flags
|
||||||
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
|
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
|
||||||
resolver = PolicyResolver(orch_url) if orch_url else None
|
if not orch_url:
|
||||||
bottle_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
# Resolver-only: without an orchestrator the server can't attribute a
|
||||||
# Consolidated mode resolves the slug per request, so the env slug is
|
# proposal to a bottle, so it must not serve (fail-closed).
|
||||||
# optional there; single-tenant still requires it.
|
sys.stderr.write(
|
||||||
if not bottle_slug and resolver is None:
|
f"supervise: {ORCHESTRATOR_URL_ENV} is required "
|
||||||
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
|
"(no single-tenant SUPERVISE_BOTTLE_SLUG fallback)\n"
|
||||||
|
)
|
||||||
return 2
|
return 2
|
||||||
port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
|
port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
|
||||||
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
|
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
|
||||||
@@ -627,11 +612,10 @@ def main(argv: list[str]) -> int:
|
|||||||
sys.stderr.write(f"supervise: {e}\n")
|
sys.stderr.write(f"supervise: {e}\n")
|
||||||
return 2
|
return 2
|
||||||
serve(
|
serve(
|
||||||
bottle_slug=bottle_slug,
|
resolver=PolicyResolver(orch_url),
|
||||||
port=port,
|
port=port,
|
||||||
bind=bind,
|
bind=bind,
|
||||||
response_timeout_seconds=response_timeout_seconds,
|
response_timeout_seconds=response_timeout_seconds,
|
||||||
resolver=resolver,
|
|
||||||
)
|
)
|
||||||
return 0 # serve() does not return
|
return 0 # serve() does not return
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -8,16 +8,20 @@
|
|||||||
> **Superseded in part by [PRD 0070](0070-per-host-orchestrator.md) (#351):**
|
> **Superseded in part by [PRD 0070](0070-per-host-orchestrator.md) (#351):**
|
||||||
> the sidecar-consolidation framing here (Stage 1, per-host sidecar; Stage 4,
|
> the sidecar-consolidation framing here (Stage 1, per-host sidecar; Stage 4,
|
||||||
> sidecar-as-VM) is taken over by 0070's per-host orchestrator. This PRD still
|
> sidecar-as-VM) is taken over by 0070's per-host orchestrator. This PRD still
|
||||||
> owns the docker-free **image-building** work — Stage 2 (nix-built fixed
|
> owns the docker-free **image-provisioning** work — Stage 2 (pull the fixed
|
||||||
> images, a dependency of 0070) and Stage 3 (in-VM Dockerfile builder).
|
> images from an OCI registry instead of building them with host Docker, a
|
||||||
|
> dependency of 0070) and Stage 3 (in-VM Dockerfile builder).
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Make the Firecracker backend depend on **firecracker + KVM only**, removing
|
Make the Firecracker backend depend on **firecracker + KVM only**, removing
|
||||||
Docker from the host. Two moves get us there: run the **sidecar bundle as a
|
Docker from the host. Two moves get us there: run the **sidecar bundle as a
|
||||||
persistent, per-host service** (eventually a Firecracker VM) instead of a
|
persistent, per-host service** (eventually a Firecracker VM) instead of a
|
||||||
per-bottle container, and **build agent rootfs images without a host Docker
|
per-bottle container, and **provision rootfs images without a host Docker
|
||||||
daemon** (nix for the fixed images; an in-VM builder for user Dockerfiles).
|
daemon** — pull the fixed images (orchestrator/gateway/infra) from an OCI
|
||||||
|
registry and unpack them daemonlessly, and build user Dockerfiles in an in-VM
|
||||||
|
builder. The images are still *built* with Docker, but off the launch host
|
||||||
|
(CI / a publish step) and pushed to the registry; the launch host only pulls.
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
|
|
||||||
@@ -93,13 +97,51 @@ torn down at exit."
|
|||||||
Can ship as a container first (quick resource/ops win) and become a VM in
|
Can ship as a container first (quick resource/ops win) and become a VM in
|
||||||
Stage 4.
|
Stage 4.
|
||||||
|
|
||||||
### Stage 2 — Fixed images built with nix (no Docker)
|
### Stage 2 — Fixed rootfs prebuilt + pulled as an artifact (no host Docker)
|
||||||
|
|
||||||
The images bot-bottle *ships* — the sidecar, the agent base, and the builder
|
The one fixed image the Firecracker backend needs at launch — the combined
|
||||||
(Stage 3) — are built declaratively with nix (`nixos-generators` /
|
**infra** rootfs the infra VM boots (orchestrator control plane + gateway +
|
||||||
`make-ext4-fs` / `pkgs.dockerTools` for the rootfs), producing an ext4 or
|
buildah, with the control-plane init as PID 1) — is **prebuilt end-to-end off
|
||||||
tar with correct ownership. Removes Docker for everything we own and gives
|
the launch host and published as a versioned, ready-to-boot ext4 artifact**.
|
||||||
the rootless-rootfs correctness (#347) for free on these images.
|
The launch host **downloads the `.ext4` and boots it directly** — no
|
||||||
|
`docker build`, no `docker export`, no `mke2fs`, no image tooling at all.
|
||||||
|
|
||||||
|
This is possible because the infra rootfs is already **host- and
|
||||||
|
bottle-agnostic**: the per-boot bits (authorized_keys, guest IP) arrive on the
|
||||||
|
**kernel cmdline**, not in the rootfs (see `build_base_rootfs_dir`). So one
|
||||||
|
published ext4 boots on any launch host.
|
||||||
|
|
||||||
|
- **Artifact.** `rootfs.ext4` + a `rootfs.ext4.sha256`, published as a Gitea
|
||||||
|
**generic package** (`bot-bottle-firecracker-infra/<tag>`) — generic packages take
|
||||||
|
arbitrary large binaries (no attachment size cap / file-type allowlist that
|
||||||
|
release attachments impose). The matching `vmlinux` kernel can ship the same
|
||||||
|
way, so the whole VM is fetchable.
|
||||||
|
- **Pull.** The launch host `GET`s
|
||||||
|
`…/api/packages/<owner>/generic/bot-bottle-firecracker-infra/<tag>/rootfs.ext4` (+
|
||||||
|
`.sha256`) for its pinned tag, verifies the checksum, caches it under the
|
||||||
|
tag, and attaches it as the infra VM's root disk. Host prerequisite is an
|
||||||
|
HTTP client — nothing else. Public packages need no auth to pull; a token
|
||||||
|
with `read:package` covers a private instance.
|
||||||
|
- **Registry.** The artifact base URL + owner are configurable, defaulting to
|
||||||
|
this deployment's Gitea (`https://gitea.dideric.is` / `didericis`);
|
||||||
|
overridable via env for other deployments / air-gapped mirrors.
|
||||||
|
- **Versioning.** A pinned tag bumped when the infra rootfs contents change
|
||||||
|
(bot_bottle's shipped files, the base deps, or the init), so a launch host
|
||||||
|
pulls the artifact matching its code and a content change can't silently
|
||||||
|
boot a stale rootfs. A checksum mismatch fails closed.
|
||||||
|
- **Publish.** A `publish` step (CLI subcommand / CI job) runs the full
|
||||||
|
pipeline **on a build/CI host** — `docker build` the three Dockerfiles →
|
||||||
|
export → inject guest boot → `mke2fs` → upload the `.ext4` + `.sha256`.
|
||||||
|
Building still uses Docker, but never on the launch/runner host, which is
|
||||||
|
the one #348 needs unprivileged.
|
||||||
|
- **Dev escape hatch.** An explicit opt-in still builds the rootfs locally
|
||||||
|
with Docker (for iterating on the Dockerfiles without a publish
|
||||||
|
round-trip); it is never the default path.
|
||||||
|
|
||||||
|
Removes Docker from the launch host entirely for the fixed image, and the
|
||||||
|
launch host needs no OCI/rootfs tooling — just fetch + boot. The build-time
|
||||||
|
cache / build-time-egress open problems a from-scratch build would face don't
|
||||||
|
arise: the launch host never builds, it downloads a finished disk.
|
||||||
|
|
||||||
### Stage 3 — User Dockerfiles built in a builder VM (the unlock)
|
### Stage 3 — User Dockerfiles built in a builder VM (the unlock)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# PRD prd-new: Claude forward_host_credentials
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** claude
|
||||||
|
- **Created:** 2026-07-01
|
||||||
|
- **Issue:** #325
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add `agent_provider.forward_host_credentials: true` support for the
|
||||||
|
`claude` template, mirroring the existing Codex flow. When enabled,
|
||||||
|
bot-bottle reads the host's Claude OAuth session key from
|
||||||
|
`~/.claude/.credentials.json` at launch, forwards it only to the egress sidecar,
|
||||||
|
and injects a placeholder `CLAUDE_CODE_OAUTH_TOKEN` into the agent so
|
||||||
|
Claude Code starts without ever seeing the real credential.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Running a Claude agent in a container today requires the operator to
|
||||||
|
manually extract a long-lived OAuth token (`claude setup-token`), export
|
||||||
|
it as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, and reference it explicitly in
|
||||||
|
the manifest with `agent_provider.auth_token:
|
||||||
|
"BOT_BOTTLE_CLAUDE_OAUTH_TOKEN"`. This is a two-step manual ceremony
|
||||||
|
that is easy to skip or do incorrectly.
|
||||||
|
|
||||||
|
The host already stores a valid Claude session in `~/.claude/.credentials.json`
|
||||||
|
after `claude login`. Codex already automates an
|
||||||
|
equivalent extraction from `~/.codex/auth.json`. There is no reason
|
||||||
|
Claude bottles cannot do the same.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- A Claude bottle with `forward_host_credentials: true` in the manifest
|
||||||
|
uses the host's `~/.claude/.credentials.json` session key at launch with no
|
||||||
|
additional operator steps.
|
||||||
|
- The agent container receives only `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder`
|
||||||
|
— never the real token.
|
||||||
|
- The real session key lives only in the egress sidecar's environment.
|
||||||
|
- Missing, malformed, or expired host Claude auth fails launch with a
|
||||||
|
clear operator-facing message.
|
||||||
|
- Existing `auth_token` behavior is unchanged.
|
||||||
|
- `forward_host_credentials: true` is rejected in the manifest when both
|
||||||
|
`auth_token` and `forward_host_credentials` are set, since they serve
|
||||||
|
the same purpose.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Refreshing Claude OAuth tokens in the sidecar.
|
||||||
|
- Writing a dummy `~/.claude.json` auth state to the agent (unlike the
|
||||||
|
Codex flow, Claude Code reads its credential from `CLAUDE_CODE_OAUTH_TOKEN`
|
||||||
|
in env, not from an auth file — no guest-side auth marker is needed).
|
||||||
|
- Supporting `forward_host_credentials` for providers other than `codex`
|
||||||
|
and `claude`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Manifest schema
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
agent_provider:
|
||||||
|
template: claude
|
||||||
|
forward_host_credentials: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Rejects in manifest validation when:
|
||||||
|
- Template is not `codex` or `claude`.
|
||||||
|
- Both `auth_token` and `forward_host_credentials` are set.
|
||||||
|
|
||||||
|
### Host auth extraction (`contrib/claude/claude_auth.py`)
|
||||||
|
|
||||||
|
Claude Code credential storage varies by platform:
|
||||||
|
|
||||||
|
- **Linux**: `~/.claude/.credentials.json`
|
||||||
|
- **macOS**: macOS Keychain, service `"Claude Code-credentials"`
|
||||||
|
(the file path is tried first; Keychain is the fallback when the file
|
||||||
|
is absent)
|
||||||
|
|
||||||
|
`~/.claude.json` contains only UI state and profile metadata — no token.
|
||||||
|
|
||||||
|
The credentials JSON schema (same whether from file or Keychain):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "<access-token>",
|
||||||
|
"refreshToken": "<refresh-token>",
|
||||||
|
"expiresAt": 1748276587173,
|
||||||
|
"scopes": ["user:inference", "user:profile"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expiresAt` is in **milliseconds** (not seconds).
|
||||||
|
|
||||||
|
At prepare/launch time, when `forward_host_credentials: true`:
|
||||||
|
|
||||||
|
1. Try `~/.claude/.credentials.json`; on macOS, if absent, run
|
||||||
|
`security find-generic-password -s "Claude Code-credentials" -w`
|
||||||
|
and parse its stdout as JSON.
|
||||||
|
2. Require a `claudeAiOauth` dict.
|
||||||
|
3. Require a non-empty `claudeAiOauth.accessToken` string.
|
||||||
|
4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require
|
||||||
|
the result to be in the future.
|
||||||
|
5. Return only the access token to the launch path.
|
||||||
|
|
||||||
|
Errors name the missing or invalid condition and point the operator at
|
||||||
|
`claude login`, without printing token values.
|
||||||
|
|
||||||
|
### Egress route
|
||||||
|
|
||||||
|
When `forward_host_credentials: true`:
|
||||||
|
|
||||||
|
- Provision the session key in `provisioned_env` under
|
||||||
|
`BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN` (new constant in `egress.py`).
|
||||||
|
- Set up the `api.anthropic.com` egress route with `auth_scheme: Bearer`
|
||||||
|
and `token_ref: BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN`.
|
||||||
|
- Set `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder` in the agent env and
|
||||||
|
add it to `hidden_env_names`.
|
||||||
|
|
||||||
|
No dummy auth file and no `verify` step are needed — Claude Code reads
|
||||||
|
the credential from the env var, not from a file.
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
- `CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"`
|
||||||
|
in `egress.py` (alongside the existing `CODEX_HOST_CREDENTIAL_TOKEN_REF`).
|
||||||
|
- `CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)` in
|
||||||
|
`agent_provider.py` (alongside the existing `CODEX_HOST_CREDENTIAL_HOSTS`).
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Host ~/.claude/.credentials.json → bot-bottle launch
|
||||||
|
│
|
||||||
|
├──► egress sidecar env (real token only)
|
||||||
|
│
|
||||||
|
└──► agent env: CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder
|
||||||
|
|
||||||
|
Agent → HTTPS to api.anthropic.com (via egress)
|
||||||
|
Egress → injects Authorization: Bearer <real token>
|
||||||
|
Egress → forwards to api.anthropic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None — the Codex precedent makes the design clear.
|
||||||
@@ -15,6 +15,25 @@ the biggest credential risk).
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
> **Status — implemented (2026-07-14).** This note's recommendation
|
||||||
|
> shipped, so the "today" / "in-flight" tenses below are now historical
|
||||||
|
> (kept as the reasoning that led here). Current reality: bot-bottle no
|
||||||
|
> longer injects raw provider tokens into the agent. The agent's environ
|
||||||
|
> carries only a **placeholder** (e.g.
|
||||||
|
> `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder`); the real token is held
|
||||||
|
> in the egress sidecar and injected as the upstream `Authorization`
|
||||||
|
> header. Rather than adding a *separate* in-container reverse proxy (as
|
||||||
|
> proposed below), credential injection was folded into the **existing
|
||||||
|
> pipelock/mitmproxy egress firewall**: an `EgressRoute` carries
|
||||||
|
> `auth_scheme` + `token_ref`, the host token is forwarded into the
|
||||||
|
> sidecar addon under an `EGRESS_TOKEN_N` slot, and the addon rewrites
|
||||||
|
> `Authorization` on matching routes — **one MITM, not two** (this
|
||||||
|
> resolves the Infisical-doubling concern noted later). The same
|
||||||
|
> mechanism now covers Codex and Pi provider tokens and git-host PATs.
|
||||||
|
> A `printenv` inside the bottle yields the placeholder, not the secret.
|
||||||
|
> For current wiring see `bot_bottle/egress.py` and
|
||||||
|
> `bot_bottle/contrib/*/agent_provider.py`.
|
||||||
|
|
||||||
Today every bot-bottle agent gets `CLAUDE_CODE_OAUTH_TOKEN` (and
|
Today every bot-bottle agent gets `CLAUDE_CODE_OAUTH_TOKEN` (and
|
||||||
any `bottle.env` secrets like a Gitea PAT) injected as env vars,
|
any `bottle.env` secrets like a Gitea PAT) injected as env vars,
|
||||||
which means the agent process can read them with `printenv` or
|
which means the agent process can read them with `printenv` or
|
||||||
@@ -36,6 +55,24 @@ small bot-bottle-specific reverse proxy modeled on the
|
|||||||
phantom-token shape is probably the right call. For Gitea / GitHub /
|
phantom-token shape is probably the right call. For Gitea / GitHub /
|
||||||
GitLab, the same proxy generalizes by config.
|
GitLab, the same proxy generalizes by config.
|
||||||
|
|
||||||
|
**Updated 2026-07-14:** OneCLI ([onecli.sh](https://onecli.sh/)) —
|
||||||
|
already listed below — has matured into a GA, YC-backed Rust
|
||||||
|
credential gateway ("The Identity Gateway for AI Agents", ~2.5k⭐,
|
||||||
|
300k+ downloads) that implements the **same phantom-token pattern**
|
||||||
|
this note recommends: the agent holds a placeholder token, the
|
||||||
|
gateway swaps it for the real (AES-256-GCM-encrypted) credential at
|
||||||
|
request time. It's now the most mature open-source realization of the
|
||||||
|
exact design proposed here — a production-ready alternative to
|
||||||
|
alpha-stage nono — at the cost of being a broader product (built-in
|
||||||
|
vault + management dashboard + hosted cloud tier + 50+ app
|
||||||
|
integrations) rather than a minimal proxy. Its managed/cloud tier and
|
||||||
|
per-agent dashboard also overlap bot-bottle's own planned paid control
|
||||||
|
plane (bot-bottle-console, issue #327), so it's worth tracking as both
|
||||||
|
a build-vs-adopt option *and* a product-level competitor. The
|
||||||
|
build-first recommendation still stands (see synthesis below), but
|
||||||
|
adopting OneCLI's OSS core is now a credible alternative where nono was
|
||||||
|
too green.
|
||||||
|
|
||||||
## The shared problem
|
## The shared problem
|
||||||
|
|
||||||
Linux has no per-env-var ACL. Once a var is in a process's
|
Linux has no per-env-var ACL. Once a var is in a process's
|
||||||
@@ -77,7 +114,9 @@ The remaining credible designs reduce to three:
|
|||||||
|
|
||||||
### Anthropic / Claude Code
|
### Anthropic / Claude Code
|
||||||
|
|
||||||
**Today's wiring** (`bot_bottle/cli/start.py`): the host's
|
**Original wiring** (pre-gateway; **superseded 2026-07-14** — see the
|
||||||
|
Status note in the Summary; the token now lives in the egress sidecar
|
||||||
|
and the agent gets a placeholder): the host's
|
||||||
`BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` is forwarded into the bottle as
|
`BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` is forwarded into the bottle as
|
||||||
`CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e CLAUDE_CODE_OAUTH_TOKEN`
|
`CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e CLAUDE_CODE_OAUTH_TOKEN`
|
||||||
(no `=value`, so the value never lands on argv — good). Inside the
|
(no `=value`, so the value never lands on argv — good). Inside the
|
||||||
@@ -223,7 +262,7 @@ Two categories:
|
|||||||
| **Infisical Agent Vault** | B | MIT (EE carve-out) | In-process HTTPS_PROXY forward proxy | TLS MITM, dummy-to-real swap | No — HTTPS_PROXY model | Service-level | Active; v0.19.0 May 2026, ~1k⭐ |
|
| **Infisical Agent Vault** | B | MIT (EE carve-out) | In-process HTTPS_PROXY forward proxy | TLS MITM, dummy-to-real swap | No — HTTPS_PROXY model | Service-level | Active; v0.19.0 May 2026, ~1k⭐ |
|
||||||
| **nono** | B | Apache-2.0 | In-process reverse proxy | Phantom token, explicit URL routing | **Yes** — `BASE_URL=http://127.0.0.1:PORT/…` | Host + endpoint | Early alpha; v0.53.0 May 2026, 2.4k⭐ |
|
| **nono** | B | Apache-2.0 | In-process reverse proxy | Phantom token, explicit URL routing | **Yes** — `BASE_URL=http://127.0.0.1:PORT/…` | Host + endpoint | Early alpha; v0.53.0 May 2026, 2.4k⭐ |
|
||||||
| **Aegis** | B | Apache-2.0 | In-process reverse proxy | Path routing (`localhost:3100/{svc}/…`) | Configurable, undocumented for Anthropic | Method/path/rate/time | Very new, 10⭐ |
|
| **Aegis** | B | Apache-2.0 | In-process reverse proxy | Path routing (`localhost:3100/{svc}/…`) | Configurable, undocumented for Anthropic | Method/path/rate/time | Very new, 10⭐ |
|
||||||
| **OneCLI** | B | Apache-2.0 | Reverse proxy + management UI | Host/path matching, Bitwarden integration | Configurable | Per-agent scoping | Active; v1.23.0 May 2026, 2.1k⭐ |
|
| **OneCLI** | B | Apache-2.0 | Reverse proxy + built-in vault + dashboard | **Phantom token** (placeholder→real swap at request time), AES-256-GCM vault | Yes (host match) | Per-agent + endpoint/method + rate limits | GA; Rust; YC-backed; ~2.5k⭐, 300k+ downloads (Jul 2026) |
|
||||||
| **Aembit** | B | Proprietary | Sidecar + cloud control plane | TLS intercept, SPIFFE, JIT creds | No — intercepts by destination | Policy-based | GA (Apr 2026) |
|
| **Aembit** | B | Proprietary | Sidecar + cloud control plane | TLS intercept, SPIFFE, JIT creds | No — intercepts by destination | Policy-based | GA (Apr 2026) |
|
||||||
| **LiteLLM Proxy** | A | MIT | Reverse proxy | Virtual key → upstream key | Yes — set base URL to LiteLLM | Route-level | 45k⭐; **CVE-2026-42208 exploited Apr 2026**, patch v1.83.7 |
|
| **LiteLLM Proxy** | A | MIT | Reverse proxy | Virtual key → upstream key | Yes — set base URL to LiteLLM | Route-level | 45k⭐; **CVE-2026-42208 exploited Apr 2026**, patch v1.83.7 |
|
||||||
| **Portkey Gateway** | A | MIT (OSS core) | Reverse proxy | Virtual key vault (cloud or Enterprise self-host) | Yes — documented for Claude Code | Config-based | Production; virtual-key vault needs Enterprise for self-host |
|
| **Portkey Gateway** | A | MIT (OSS core) | Reverse proxy | Virtual key vault (cloud or Enterprise self-host) | Yes — documented for Claude Code | Config-based | Production; virtual-key vault needs Enterprise for self-host |
|
||||||
@@ -242,6 +281,18 @@ Two categories:
|
|||||||
`ANTHROPIC_BASE_URL`. **Blocker:** nono is explicitly
|
`ANTHROPIC_BASE_URL`. **Blocker:** nono is explicitly
|
||||||
"early alpha, not security audited."
|
"early alpha, not security audited."
|
||||||
|
|
||||||
|
**Update (2026-07-14):** OneCLI ([onecli.sh](https://onecli.sh/))
|
||||||
|
ships the same phantom-token shape but is GA and YC-backed (Rust,
|
||||||
|
~2.5k⭐, 300k+ downloads) — the maturity nono lacks. Trade-offs: it's
|
||||||
|
a full identity-gateway *product* (encrypted vault, management
|
||||||
|
dashboard, 50+ one-click app integrations, hosted cloud tier), much
|
||||||
|
heavier than the ~100-line proxy proposed below, and its hosted tier
|
||||||
|
is a third-party credential custodian — precisely the trust
|
||||||
|
dependency bot-bottle's isolation model exists to avoid. So: its
|
||||||
|
Apache-2.0 OSS core is a credible *adopt* candidate for the
|
||||||
|
phantom-token slice; its managed offering is a *competitor*, not a
|
||||||
|
dependency to lean on.
|
||||||
|
|
||||||
- **TLS-MITM forward proxies** (Infisical Agent Vault, Cloudflare
|
- **TLS-MITM forward proxies** (Infisical Agent Vault, Cloudflare
|
||||||
Sandbox Auth, Aembit, the existing pipelock) all double up on
|
Sandbox Auth, Aembit, the existing pipelock) all double up on
|
||||||
the CA-trust machinery PRD 0006 already built for pipelock.
|
the CA-trust machinery PRD 0006 already built for pipelock.
|
||||||
@@ -273,6 +324,15 @@ routing matches the design recommended here exactly; zero TLS
|
|||||||
work. But "not security audited" + "early alpha" means adopting it
|
work. But "not security audited" + "early alpha" means adopting it
|
||||||
is a bet on the project rather than a buy-vs-build win.
|
is a bet on the project rather than a buy-vs-build win.
|
||||||
|
|
||||||
|
**Mature phantom-token option (added 2026-07-14):** OneCLI — same
|
||||||
|
architecture as nono, but GA, Rust, and YC-backed. Its Apache-2.0 OSS
|
||||||
|
core is now the strongest *adopt* candidate for the phantom-token slice
|
||||||
|
if building is undesirable; the caveats are product surface you don't
|
||||||
|
need (bundled vault + dashboard) and that its hosted tier is a
|
||||||
|
competitor rather than a dependency. Doesn't change the build-first
|
||||||
|
recommendation for the narrow Anthropic-token slice, but it does mean
|
||||||
|
"is there a mature drop-in?" now has a real answer.
|
||||||
|
|
||||||
**Most mature OSS purpose-built:** Infisical Agent Vault. MIT,
|
**Most mature OSS purpose-built:** Infisical Agent Vault. MIT,
|
||||||
v0.19.0 active, v0.17.0 added a containerized agent mode that
|
v0.19.0 active, v0.17.0 added a containerized agent mode that
|
||||||
maps directly to bot-bottle. Friction is the TLS-MITM topology
|
maps directly to bot-bottle. Friction is the TLS-MITM topology
|
||||||
@@ -293,6 +353,12 @@ exposed.
|
|||||||
|
|
||||||
## Recommended path forward
|
## Recommended path forward
|
||||||
|
|
||||||
|
> **Mostly implemented (2026-07-14).** Steps 1–3 shipped — the token
|
||||||
|
> injection moved into the egress sidecar (folded into pipelock rather
|
||||||
|
> than a standalone reverse proxy) and generalized to Codex/Pi/git-host
|
||||||
|
> tokens. Steps 4–5 (issuance-side scope narrowing, per-route allowlists)
|
||||||
|
> remain good hygiene. See the Status note in the Summary.
|
||||||
|
|
||||||
In priority order:
|
In priority order:
|
||||||
|
|
||||||
1. **In-container reverse proxy holding `CLAUDE_CODE_OAUTH_TOKEN`.**
|
1. **In-container reverse proxy holding `CLAUDE_CODE_OAUTH_TOKEN`.**
|
||||||
@@ -376,6 +442,9 @@ already gives us for upstream push credentials.
|
|||||||
- [nono — phantom token blog](https://nono.sh/blog/blog-credential-injection)
|
- [nono — phantom token blog](https://nono.sh/blog/blog-credential-injection)
|
||||||
- [Aegis — GitHub](https://github.com/getaegis/aegis)
|
- [Aegis — GitHub](https://github.com/getaegis/aegis)
|
||||||
- [OneCLI — GitHub](https://github.com/onecli/onecli)
|
- [OneCLI — GitHub](https://github.com/onecli/onecli)
|
||||||
|
- [OneCLI — homepage](https://onecli.sh/)
|
||||||
|
- [OneCLI — Y Combinator company page](https://www.ycombinator.com/companies/onecli)
|
||||||
|
- [Show HN: OneCLI – Vault for AI Agents in Rust](https://news.ycombinator.com/item?id=47353558)
|
||||||
- [Sandbox0 — GitHub](https://github.com/sandbox0-ai/sandbox0)
|
- [Sandbox0 — GitHub](https://github.com/sandbox0-ai/sandbox0)
|
||||||
- [Buildkite Cleanroom — GitHub](https://github.com/buildkite/cleanroom)
|
- [Buildkite Cleanroom — GitHub](https://github.com/buildkite/cleanroom)
|
||||||
- [Aembit IAM for Agentic AI — GA](https://aembit.io/blog/aembit-iam-for-agentic-ai-is-now-generally-available/)
|
- [Aembit IAM for Agentic AI — GA](https://aembit.io/blog/aembit-iam-for-agentic-ai-is-now-generally-available/)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user