Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38a67d2767 | |||
| dbbb185d0a | |||
| 95cbd0e1c8 | |||
| f242733d15 | |||
| fd295d4c14 | |||
| f33566941b | |||
| c7c3a79028 | |||
| bb1776a858 | |||
| a24fe0264d | |||
| 105538d3a6 | |||
| ffda40abae | |||
| 7dcce2ff12 | |||
| 31a7efc0ed | |||
| a25ea7c188 | |||
| 3dbf1780b4 | |||
| ff4da6f41e | |||
| 3bb90da11c | |||
| 0146450951 | |||
| ecaf23cdb5 | |||
| 6e46a9b191 | |||
| a59e495faa | |||
| b8818948a0 | |||
| b09952045a | |||
| de192359ee | |||
| 7d9933edc0 | |||
| 2bc9ef8ec0 | |||
| 47b6bead69 | |||
| 15ecada022 | |||
| e2222bd96b | |||
| 74ec9843f0 | |||
| ed9fc76f97 | |||
| bd8a146a46 | |||
| 85fb6b0c98 | |||
| 902286dbc0 | |||
| 73c566f3ff | |||
| 33b7bcd082 | |||
| 9e83ff1992 | |||
| 1d10595e5c | |||
| 364cad7e56 | |||
| ff355f81de | |||
| cf97a49eac | |||
| e607af73ab | |||
| 998b7c5dd7 | |||
| 9a4899d4e1 | |||
| 5083b45f42 | |||
| 24aeba9676 | |||
| ff36b73ff1 | |||
| 10d295eaf6 | |||
| 69c4c85cd5 | |||
| 52e1249c2f | |||
| bd4f384038 | |||
| 0500e71383 | |||
| 72165b5db5 | |||
| 0edd46d56d | |||
| 4682dd441f | |||
| 1827593b89 | |||
| 73e70e326c | |||
| d744bec7b1 | |||
| f3fbfb3cc3 | |||
| b2245ae1f3 | |||
| f0fe33b1d0 | |||
| d4889663d1 | |||
| 1022247ce5 | |||
| 671d91070e | |||
| 1c6d30ffd8 | |||
| 583ff98b27 | |||
| fafb828bb7 | |||
| f9ad6c85aa | |||
| 7488110e71 | |||
| 22dde95561 | |||
| e29b79d517 | |||
| 1d85acfd99 | |||
| 90defdc9cd | |||
| 2039ef635f | |||
| 0fc5457e41 | |||
| c0493f0b01 | |||
| 5828f5e900 | |||
| be025ff8fb | |||
| 9537c96586 | |||
| 6b43fe73c1 | |||
| d3370a88bb | |||
| 39167528db | |||
| b25ace4c00 | |||
| 9c06702b32 | |||
| cd0983d943 | |||
| 99176b1edf | |||
| 652f14dcb1 | |||
| 38c13708c7 | |||
| 82669b22d5 | |||
| 1a4b390e8a | |||
| 955cb3bcbd | |||
| 605146d287 | |||
| 27dea58ae1 | |||
| 5401f036a9 | |||
| 238f5f7614 | |||
| 2644759b0d | |||
| e53104d5c1 | |||
| 8f6148d571 | |||
| 45f3cefbc5 | |||
| 0e70d26af4 | |||
| f2d8158742 |
@@ -20,3 +20,9 @@ omit =
|
||||
bot_bottle/cli/tui.py
|
||||
bot_bottle/cli/init.py
|
||||
tests/*
|
||||
# Build-time only: setuptools invokes it out-of-process to build the
|
||||
# wheel/sdist (it's never imported by the running app), so in-process
|
||||
# coverage can't reach it. Its one job — bundling the root resources into
|
||||
# bot_bottle/_resources/ — is exercised end-to-end by test_wheel_install,
|
||||
# which builds and installs a real wheel and checks the result.
|
||||
setup.py
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# digest, etc.) without coupling every dev push to upstream registry
|
||||
# availability.
|
||||
#
|
||||
# Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run
|
||||
# Opt-in via BOT_BOTTLE_RUN_CANARIES=1 so the same files can be run
|
||||
# locally with the same gating.
|
||||
|
||||
name: canaries
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
canaries:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CLAUDE_BOTTLE_RUN_CANARIES: "1"
|
||||
BOT_BOTTLE_RUN_CANARIES: "1"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -25,4 +25,7 @@ jobs:
|
||||
# No actions/setup-python: canaries are stdlib unittest on the image's
|
||||
# system Python 3.12 (older act_runner mishandles setup-python's PATH).
|
||||
- name: Run canaries
|
||||
run: python3 -m unittest discover -t . -s tests/canaries -v
|
||||
run: |
|
||||
python3 -m scripts.unittest_gate \
|
||||
-t . -s tests/canaries -v \
|
||||
--minimum-executed 1 --fail-on-skip
|
||||
|
||||
@@ -4,6 +4,13 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- "**.py"
|
||||
- "Dockerfile*"
|
||||
- "bot_bottle/contrib/*/Dockerfile"
|
||||
- "bot_bottle/contrib/*/package.json"
|
||||
- "bot_bottle/contrib/*/package-lock.json"
|
||||
- "bot_bottle/contrib/codex/codex-package_SHA256SUMS"
|
||||
- "requirements.gateway.*"
|
||||
- "image-build-args.json"
|
||||
- ".pylintrc"
|
||||
- ".gitea/workflows/lint.yml"
|
||||
|
||||
@@ -13,6 +20,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Enforce immutable image inputs
|
||||
run: python3 scripts/check_image_inputs.py
|
||||
|
||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||
# and older act_runner engines mishandle setup-python's PATH. Install
|
||||
# into the ephemeral job container's system Python — the pylint/pyright
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: prd-number-check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
require-numbered-prds:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Reject unnumbered PRDs
|
||||
run: |
|
||||
unnumbered=$(find docs/prds -maxdepth 1 -type f \
|
||||
-name 'prd-new-*.md' -print | sort)
|
||||
|
||||
if [ -n "$unnumbered" ]; then
|
||||
echo "::error::Assign every new PRD its final sequential number before merge."
|
||||
echo "Unnumbered PRDs:"
|
||||
echo "$unnumbered"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All PRDs have final numbers."
|
||||
@@ -1,122 +0,0 @@
|
||||
# Assign sequential numbers to prd-new-*.md files on merge to main.
|
||||
#
|
||||
# When a PR merges to main and includes prd-new-*.md files this workflow:
|
||||
# 1. Finds the next available NNNN number by scanning existing PRDs.
|
||||
# 2. Renames each prd-new-*.md to NNNN-<slug>.md.
|
||||
# 3. Updates the title header (# PRD prd-new: → # PRD NNNN:).
|
||||
# 4. Flips Status: Draft → Active when the push touched files outside
|
||||
# docs/prds/ anywhere in its commit range (i.e. the implementation
|
||||
# shipped together with the PRD).
|
||||
# 5. Commits the renaming back to main.
|
||||
#
|
||||
# No-op if the working tree contains no prd-new-*.md files.
|
||||
#
|
||||
# NOTE: The workflow scans the working tree (not just HEAD~1..HEAD) because
|
||||
# PRs land as multi-commit pushes and the prd-new file is often added in an
|
||||
# earlier commit on the branch, not in the final squash/merge commit.
|
||||
|
||||
name: prd-number
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/prds/prd-new-*.md'
|
||||
|
||||
jobs:
|
||||
assign-numbers:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# No actions/setup-python: the inline script is stdlib-only on the
|
||||
# image's system Python 3.12 (older act_runner mishandles its PATH).
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Assign PRD numbers
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
prds_dir = Path("docs/prds")
|
||||
|
||||
# Scan the working tree — prd-new files may have landed in any
|
||||
# commit of a multi-commit push, not just HEAD.
|
||||
new_prds = sorted(prds_dir.glob("prd-new-*.md"))
|
||||
|
||||
if not new_prds:
|
||||
print("No prd-new-*.md files found — nothing to do.")
|
||||
sys.exit(0)
|
||||
|
||||
# Determine whether non-PRD files were also changed anywhere in
|
||||
# the push range (BEFORE_SHA → HEAD). Falls back to HEAD~1 when
|
||||
# the env var isn't set (e.g. local act runs).
|
||||
before_sha = os.environ.get("GITHUB_EVENT_BEFORE", "HEAD~1")
|
||||
all_changed = subprocess.run(
|
||||
["git", "diff", "--name-only", before_sha, "HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.splitlines()
|
||||
non_prd_changed = any(
|
||||
not f.startswith("docs/prds/") for f in all_changed
|
||||
)
|
||||
|
||||
# Find next available number.
|
||||
existing = sorted(
|
||||
int(m.group(1))
|
||||
for p in prds_dir.glob("*.md")
|
||||
if (m := re.match(r"^(\d{4})-", p.name))
|
||||
)
|
||||
next_num = (max(existing) + 1) if existing else 1
|
||||
|
||||
for prd_path in sorted(new_prds):
|
||||
slug = re.sub(r"^prd-new-", "", prd_path.stem)
|
||||
new_name = f"{next_num:04d}-{slug}.md"
|
||||
new_path = prds_dir / new_name
|
||||
print(f" {prd_path.name} → {new_name}")
|
||||
|
||||
content = prd_path.read_text()
|
||||
|
||||
# Update title header.
|
||||
content = re.sub(
|
||||
r"^(#\s+PRD\s+)prd-new(:)",
|
||||
rf"\g<1>{next_num:04d}\2",
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# Conditionally flip Status.
|
||||
if non_prd_changed:
|
||||
content = re.sub(
|
||||
r"(\*\*Status:\*\*\s*)Draft",
|
||||
r"\g<1>Active",
|
||||
content,
|
||||
count=1,
|
||||
)
|
||||
|
||||
new_path.write_text(content)
|
||||
subprocess.run(["git", "rm", str(prd_path)], check=True)
|
||||
subprocess.run(["git", "add", str(new_path)], check=True)
|
||||
next_num += 1
|
||||
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "ci(prd): assign sequential numbers to new PRDs"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "push"], check=True)
|
||||
EOF
|
||||
@@ -0,0 +1,363 @@
|
||||
# Run the complete backend test suite before a release. This workflow is
|
||||
# intentionally manual because Firecracker and macOS use privileged,
|
||||
# self-hosted runners.
|
||||
#
|
||||
# The suite uses stdlib `unittest` discovery — no external Python
|
||||
# dependencies are required to execute it. Tests are split by directory:
|
||||
#
|
||||
# tests/unit/ — pure unit tests; always run
|
||||
# tests/integration/ — need a reachable backend; skip cleanly when
|
||||
# the backend isn't available on the runner
|
||||
# tests/canaries/ — upstream regression canaries; run on a separate
|
||||
# schedule (see canaries.yml), not here
|
||||
#
|
||||
# Unit, Docker, and Firecracker run once under coverage and upload a small
|
||||
# .coverage.* artifact for the combined coverage job. macOS reports coverage
|
||||
# in place because it is an advisory host-mode runner.
|
||||
|
||||
name: pre-release-test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||
# lands in one interpreter, `python3` resolves to another). Install
|
||||
# straight into the ephemeral job container's system Python —
|
||||
# --break-system-packages is safe because the container is disposable.
|
||||
- name: Install dev requirements
|
||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
env:
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.unit
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
||||
|
||||
- name: Report unit coverage
|
||||
env:
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.unit
|
||||
run: python3 -m coverage report -m
|
||||
|
||||
# upload-artifact@v3's glob skips dotfiles, so a bare `.coverage.unit`
|
||||
# silently uploads nothing ("No files were found"). Stage it under a
|
||||
# non-dot name; the coverage job renames it back before `coverage
|
||||
# combine`. `cp` also fails loudly if coverage never wrote the file.
|
||||
- name: Stage unit coverage for upload
|
||||
run: cp .coverage.unit coverage-unit.dat
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-unit
|
||||
path: coverage-unit.dat
|
||||
|
||||
integration-docker:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: integration-docker-infra
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# No actions/setup-python (see the note in the `unit` job); the
|
||||
# container's system Python 3.12 runs the stdlib test suite directly.
|
||||
- name: Install coverage
|
||||
run: python3 -m pip install --break-system-packages coverage
|
||||
|
||||
# Fail loudly if the backend this job promises isn't actually usable,
|
||||
# rather than letting every test silently `unittest.skip` and the job
|
||||
# go green on zero coverage. `backend status` prints a clear per-check
|
||||
# summary (docker on PATH, daemon reachable) and exits non-zero when a
|
||||
# prerequisite is missing — the same readiness check the skip guards
|
||||
# gate on via `has_backend`.
|
||||
- name: Preflight — Docker backend is ready
|
||||
run: |
|
||||
python3 --version
|
||||
python3 cli.py backend status --backend=docker
|
||||
|
||||
- name: Run integration tests (docker) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: docker
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DOCKER_CLIENT_NETWORK=$(
|
||||
docker inspect "$(hostname)" |
|
||||
python3 -c 'import json,sys; n=json.load(sys.stdin)[0]["NetworkSettings"]["Networks"]; print(next(iter(n)))'
|
||||
)
|
||||
test -n "$DOCKER_CLIENT_NETWORK"
|
||||
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
|
||||
export NO_PROXY="*"
|
||||
export no_proxy="*"
|
||||
export BOT_BOTTLE_DOCKER_CLIENT_NETWORK="$DOCKER_CLIENT_NETWORK"
|
||||
export BOT_BOTTLE_DOCKER_ROOT_MOUNT="bot-bottle-ci-root-$RUN_KEY"
|
||||
export BOT_BOTTLE_DOCKER_CA_MOUNT="bot-bottle-ci-ca-$RUN_KEY"
|
||||
python3 -m coverage run -m scripts.unittest_gate \
|
||||
-t . -s tests/integration -v \
|
||||
--minimum-executed 22 --fail-on-skip
|
||||
|
||||
- name: Clean Docker integration volumes
|
||||
if: always()
|
||||
run: |
|
||||
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
|
||||
docker volume rm --force \
|
||||
"bot-bottle-ci-root-$RUN_KEY" \
|
||||
"bot-bottle-ci-ca-$RUN_KEY" 2>/dev/null || true
|
||||
|
||||
# Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
|
||||
- name: Stage docker coverage for upload
|
||||
run: cp .coverage.docker coverage-docker.dat
|
||||
|
||||
- name: Upload docker coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-docker
|
||||
path: coverage-docker.dat
|
||||
|
||||
# Integration tests against the Firecracker backend. Runs on a self-hosted
|
||||
# KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available.
|
||||
#
|
||||
# Manual only: the privileged KVM runner does not execute proposed changes
|
||||
# unattended.
|
||||
#
|
||||
# Runner prerequisites (provision once; see README "Firecracker on Linux"):
|
||||
# `firecracker` on PATH, `/dev/kvm` accessible, cached kernel +
|
||||
# static dropbear at /var/cache/bot-bottle-fc/dropbear, and the pool as a
|
||||
# persistent systemd unit.
|
||||
#
|
||||
# The infra candidate is built here directly (no artifact download) to
|
||||
# eliminate the ~70 s ubuntu-latest upload + ~83 s combined download that
|
||||
# the old build-infra → integration-firecracker + coverage chain incurred.
|
||||
integration-firecracker:
|
||||
runs-on: [self-hosted, kvm]
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preflight — Firecracker host is ready
|
||||
run: |
|
||||
command -v firecracker >/dev/null || {
|
||||
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
|
||||
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
|
||||
# `backend status` exits non-zero unless the TAP pool is up + no
|
||||
# range overlap; it prints the exact `backend setup` fix.
|
||||
python3 cli.py backend status --backend=firecracker
|
||||
|
||||
- name: Build infra candidate from this checkout
|
||||
env:
|
||||
BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear
|
||||
run: python3 -m bot_bottle.backend.firecracker.publish_infra --output infra-candidate --reuse-published
|
||||
|
||||
- name: Replace the persistent infra VM with the candidate
|
||||
run: python3 -c 'from bot_bottle.backend.firecracker import infra_vm; infra_vm.stop()'
|
||||
|
||||
# No dev-requirements install: `coverage` is already provided by the
|
||||
# self-hosted runner's Nix python env, and that env has no `pip`
|
||||
# module to install into anyway.
|
||||
- name: Run integration tests (firecracker) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: firecracker
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.firecracker
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
|
||||
|
||||
- name: Stage firecracker coverage for upload
|
||||
run: cp .coverage.firecracker coverage-firecracker.dat
|
||||
|
||||
- name: Upload firecracker coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-firecracker
|
||||
path: coverage-firecracker.dat
|
||||
|
||||
- name: Upload tested rootfs
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate/
|
||||
|
||||
- name: Upload dropbear for publish verification
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: /var/cache/bot-bottle-fc/dropbear
|
||||
|
||||
# Integration tests against the macOS Apple Container backend. Runs on a
|
||||
# self-hosted macOS runner (label `macos`) registered in HOST mode — Apple
|
||||
# Container needs the host `container` CLI + virtualization framework and
|
||||
# cannot run inside a Linux container, so this cannot reuse the KVM runner.
|
||||
#
|
||||
# Advisory only: workflow_dispatch (manual) exclusively — never push or
|
||||
# pull_request. A single non-redundant laptop that sleeps/roams must not run
|
||||
# unattended on every push to main, let alone block a PR merge, so this job is
|
||||
# deliberately NOT in the `coverage` job's `needs` and its coverage never
|
||||
# feeds the diff-coverage gate. Dispatch-only also means no fork PR (or any
|
||||
# push) ever executes on the host-mode runner.
|
||||
#
|
||||
# The infra container is a singleton (`bot-bottle-mac-infra`); the
|
||||
# `concurrency` group serializes runs so two never collide on it (#425), and
|
||||
# the always-run teardown removes it so a crashed run can't wedge the next.
|
||||
#
|
||||
# Runner prerequisites (provision once; see README "macOS Apple Container"):
|
||||
# the `container` CLI on PATH with `container system status` running, and a
|
||||
# Python >=3.11 with `coverage` importable on the launchd service PATH.
|
||||
integration-macos:
|
||||
runs-on: [self-hosted, macos]
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
concurrency:
|
||||
group: integration-macos-infra
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Fail loudly if the backend this job promises isn't actually usable,
|
||||
# rather than letting every test silently `unittest.skip` and the job go
|
||||
# green on zero coverage. `backend status` exits non-zero (and prints the
|
||||
# per-check summary) when the `container` CLI or its system service is
|
||||
# missing — the same readiness check the skip guards gate on.
|
||||
- name: Preflight — Apple Container backend is ready
|
||||
run: |
|
||||
command -v container >/dev/null || {
|
||||
echo "container CLI not on PATH — provision the runner (README: macOS Apple Container)"; exit 1; }
|
||||
container system status || {
|
||||
echo "container system service not running — run 'container system start'"; exit 1; }
|
||||
python3 cli.py backend status --backend=macos-container
|
||||
|
||||
# `coverage` comes from the runner's provisioned Python (no pip install
|
||||
# into the host interpreter). Advisory job: report coverage in-line for
|
||||
# visibility but don't upload — it never feeds the combined gate.
|
||||
- name: Run integration tests (macos-container) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: macos-container
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
|
||||
|
||||
- name: Report macos coverage
|
||||
env:
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.macos
|
||||
run: python3 -m coverage report -m
|
||||
|
||||
# On failure, capture the infra containers' state and logs BEFORE the
|
||||
# teardown below removes them — otherwise a control-plane crash is
|
||||
# undiagnosable from CI, since `stop()` deletes the orchestrator (and its
|
||||
# logs) on every run. Best-effort: never let the diagnostics themselves
|
||||
# fail the job, and keep going if a container is already gone.
|
||||
- name: Dump infra diagnostics (on failure)
|
||||
if: failure()
|
||||
run: |
|
||||
set +e
|
||||
echo "=== containers ==="
|
||||
container ls -a | grep bot-bottle-mac || echo "(no bot-bottle-mac containers)"
|
||||
echo "=== networks ==="
|
||||
container network ls | grep bot-bottle-mac || echo "(no bot-bottle-mac networks)"
|
||||
for c in bot-bottle-mac-orchestrator bot-bottle-mac-infra; do
|
||||
echo "=== inspect $c ==="
|
||||
container inspect "$c" || echo "($c not found)"
|
||||
echo "=== logs $c ==="
|
||||
container logs "$c" || echo "($c logs unavailable)"
|
||||
done
|
||||
exit 0
|
||||
|
||||
# Remove the singleton infra container so a crashed or cancelled run
|
||||
# cannot leave `bot-bottle-mac-infra` wedged for the next job.
|
||||
- name: Teardown infra singleton
|
||||
if: always()
|
||||
run: python3 -c 'from bot_bottle.backend.macos_container.infra import MacosInfraService; MacosInfraService().stop()'
|
||||
|
||||
# Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
|
||||
# test job, then runs the diff-coverage gate (new/changed lines >= 90%).
|
||||
#
|
||||
# Runs on ubuntu-latest — no KVM needed, no test reruns. Coverage files use
|
||||
# relative_files = True (.coveragerc) so they combine cleanly across runners.
|
||||
# Each test job sets COVERAGE_FILE to an absolute path so coverage.py writes
|
||||
# to a known location that upload-artifact can find regardless of runner env.
|
||||
#
|
||||
coverage:
|
||||
needs: [unit, integration-docker, integration-firecracker]
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install coverage
|
||||
run: python3 -m pip install --break-system-packages coverage
|
||||
|
||||
- name: Download unit coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-unit
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
- name: Download docker coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-docker
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
- name: Download firecracker coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-firecracker
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
# Rename the non-dot upload names back to the .coverage.* files that
|
||||
# `coverage combine` discovers (see the staging steps in each test job).
|
||||
- name: Reassemble coverage data files
|
||||
run: |
|
||||
mv coverage-unit.dat .coverage.unit
|
||||
mv coverage-docker.dat .coverage.docker
|
||||
mv coverage-firecracker.dat .coverage.firecracker
|
||||
|
||||
- name: Combined coverage (unit + integration, incl. firecracker)
|
||||
run: PYTHON=python3 bash scripts/coverage.sh aggregate critical
|
||||
|
||||
- name: Diff-coverage gate (changed lines >= 90%)
|
||||
run: |
|
||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
python3 scripts/diff_coverage.py --base origin/main --min 90
|
||||
|
||||
publish-infra:
|
||||
needs:
|
||||
- unit
|
||||
- integration-docker
|
||||
- integration-firecracker
|
||||
- integration-macos
|
||||
- coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the tested revision
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download the tested rootfs
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate
|
||||
|
||||
# publish_infra re-derives the version from the checkout to confirm the
|
||||
# bundle matches before uploading, and the version hashes the dropbear
|
||||
# bytes. Download the same dropbear integration-firecracker used.
|
||||
- name: Download the staged dropbear
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: firecracker-inputs
|
||||
|
||||
- name: Publish the tested candidate
|
||||
env:
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
|
||||
BOT_BOTTLE_FC_DROPBEAR: ${{ github.workspace }}/firecracker-inputs/dropbear
|
||||
run: python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir infra-candidate
|
||||
@@ -0,0 +1,97 @@
|
||||
# Manually refresh the committed package locks and the pinned Codex installer
|
||||
# checksum after deliberately changing a direct version in the source files.
|
||||
#
|
||||
# The job uploads the generated files for review; it never commits or pushes.
|
||||
|
||||
name: refresh-image-locks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'requirements.gateway.in'
|
||||
- 'bot_bottle/contrib/claude/package.json'
|
||||
- 'bot_bottle/contrib/pi/package.json'
|
||||
- 'bot_bottle/contrib/codex/Dockerfile'
|
||||
- 'image-build-args.json'
|
||||
- '.gitea/workflows/refresh-image-locks.yml'
|
||||
|
||||
permissions:
|
||||
code: read
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve image runtime versions
|
||||
id: runtimes
|
||||
run: |
|
||||
python3 - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
import json
|
||||
import re
|
||||
inputs = json.load(open("image-build-args.json"))
|
||||
for output, name in (
|
||||
("python-version", "PYTHON_BASE_IMAGE"),
|
||||
("node-version", "NODE_BASE_IMAGE"),
|
||||
):
|
||||
match = re.search(r":(\d+\.\d+\.\d+)-", inputs[name])
|
||||
if match is None:
|
||||
raise SystemExit(f"cannot resolve runtime version from {name}")
|
||||
print(f"{output}={match.group(1)}")
|
||||
PY
|
||||
|
||||
- name: Use the image Python version
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '${{ steps.runtimes.outputs.python-version }}'
|
||||
|
||||
- name: Use the image Node version
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '${{ steps.runtimes.outputs.node-version }}'
|
||||
|
||||
- name: Compile gateway Python lock
|
||||
run: |
|
||||
python3 -m venv /tmp/image-lock-tools
|
||||
/tmp/image-lock-tools/bin/python -m pip install \
|
||||
pip==25.2 \
|
||||
pip-tools==7.5.1
|
||||
/tmp/image-lock-tools/bin/python -m piptools compile \
|
||||
--generate-hashes \
|
||||
--output-file requirements.gateway.lock \
|
||||
requirements.gateway.in
|
||||
|
||||
- name: Resolve provider npm locks
|
||||
run: |
|
||||
for provider in claude pi; do
|
||||
(
|
||||
cd "bot_bottle/contrib/$provider"
|
||||
npm install --package-lock-only --ignore-scripts --no-audit --no-fund
|
||||
)
|
||||
done
|
||||
python3 scripts/complete_npm_lock_integrity.py \
|
||||
bot_bottle/contrib/claude/package-lock.json \
|
||||
bot_bottle/contrib/pi/package-lock.json
|
||||
|
||||
- name: Refresh pinned Codex archive checksums
|
||||
run: |
|
||||
CODEX_VERSION=$(
|
||||
sed -n 's/^ARG CODEX_VERSION=//p' bot_bottle/contrib/codex/Dockerfile
|
||||
)
|
||||
test -n "$CODEX_VERSION"
|
||||
curl -fsSL \
|
||||
"https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-package_SHA256SUMS" \
|
||||
-o bot_bottle/contrib/codex/codex-package_SHA256SUMS
|
||||
|
||||
- name: Upload refreshed inputs
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: image-input-locks
|
||||
path: |
|
||||
requirements.gateway.lock
|
||||
bot_bottle/contrib/claude/package-lock.json
|
||||
bot_bottle/contrib/pi/package-lock.json
|
||||
bot_bottle/contrib/codex/codex-package_SHA256SUMS
|
||||
+171
-173
@@ -1,21 +1,6 @@
|
||||
# Run the project's test suite when package or runtime inputs change on a PR
|
||||
# or on push to main.
|
||||
#
|
||||
# The suite uses stdlib `unittest` discovery — no external Python
|
||||
# dependencies are required to execute it. Tests are split by directory:
|
||||
#
|
||||
# tests/unit/ — pure unit tests; always run
|
||||
# tests/integration/ — need a reachable backend; skip cleanly when
|
||||
# the backend isn't available on the runner
|
||||
# tests/canaries/ — upstream regression canaries; run on a separate
|
||||
# schedule (see canaries.yml), not here
|
||||
#
|
||||
# Each test job runs once under coverage and uploads a small .coverage.*
|
||||
# artifact. The `coverage` job combines them — no test reruns, no KVM
|
||||
# dependency on that job. For main-branch pushes only, the tested rootfs
|
||||
# and matching dropbear are uploaded so `publish-infra` can publish the
|
||||
# byte-identical artifact that was tested. PRs avoid the ~194 MB rootfs
|
||||
# transfer entirely.
|
||||
# Run the automated test gate when package or runtime inputs change on a PR
|
||||
# or on push to main. Privileged self-hosted backends live in the manually
|
||||
# dispatched pre-release-test workflow.
|
||||
|
||||
name: test
|
||||
|
||||
@@ -27,32 +12,49 @@ on:
|
||||
- 'bot_bottle/**'
|
||||
- 'tests/**/*.py'
|
||||
- 'cli.py'
|
||||
- 'install.sh'
|
||||
- 'setup.py'
|
||||
- 'MANIFEST.in'
|
||||
- 'flake.nix'
|
||||
- 'nix/firecracker-netpool.nix'
|
||||
- 'scripts/coverage.sh'
|
||||
- 'scripts/critical-modules.txt'
|
||||
- 'scripts/diff_coverage.py'
|
||||
- 'scripts/tracker_policy.py'
|
||||
- 'scripts/**/*.py'
|
||||
- 'scripts/firecracker-netpool.sh'
|
||||
- 'Dockerfile*'
|
||||
- 'image-build-args.json'
|
||||
- 'pyproject.toml'
|
||||
- 'requirements-dev.txt'
|
||||
- 'requirements.gateway.*'
|
||||
- '.coveragerc'
|
||||
- '.dockerignore'
|
||||
- '.gitea/workflows/test.yml'
|
||||
- '.gitea/workflows/refresh-image-locks.yml'
|
||||
- '.gitea/workflows/pre-release-test.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'bot_bottle/**'
|
||||
- 'tests/**/*.py'
|
||||
- 'cli.py'
|
||||
- 'install.sh'
|
||||
- 'setup.py'
|
||||
- 'MANIFEST.in'
|
||||
- 'flake.nix'
|
||||
- 'nix/firecracker-netpool.nix'
|
||||
- 'scripts/coverage.sh'
|
||||
- 'scripts/critical-modules.txt'
|
||||
- 'scripts/diff_coverage.py'
|
||||
- 'scripts/tracker_policy.py'
|
||||
- 'scripts/**/*.py'
|
||||
- 'scripts/firecracker-netpool.sh'
|
||||
- 'Dockerfile*'
|
||||
- 'image-build-args.json'
|
||||
- 'pyproject.toml'
|
||||
- 'requirements-dev.txt'
|
||||
- 'requirements.gateway.*'
|
||||
- '.coveragerc'
|
||||
- '.dockerignore'
|
||||
workflow_dispatch:
|
||||
- '.gitea/workflows/test.yml'
|
||||
- '.gitea/workflows/refresh-image-locks.yml'
|
||||
- '.gitea/workflows/pre-release-test.yml'
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
@@ -61,11 +63,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
||||
# lands in one interpreter, `python3` resolves to another). Install
|
||||
# straight into the ephemeral job container's system Python —
|
||||
# --break-system-packages is safe because the container is disposable.
|
||||
- name: Install dev requirements
|
||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||
|
||||
@@ -79,10 +76,6 @@ jobs:
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.unit
|
||||
run: python3 -m coverage report -m
|
||||
|
||||
# upload-artifact@v3's glob skips dotfiles, so a bare `.coverage.unit`
|
||||
# silently uploads nothing ("No files were found"). Stage it under a
|
||||
# non-dot name; the coverage job renames it back before `coverage
|
||||
# combine`. `cp` also fails loudly if coverage never wrote the file.
|
||||
- name: Stage unit coverage for upload
|
||||
run: cp .coverage.unit coverage-unit.dat
|
||||
|
||||
@@ -94,33 +87,67 @@ jobs:
|
||||
|
||||
integration-docker:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: integration-docker-infra
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# No actions/setup-python (see the note in the `unit` job); the
|
||||
# container's system Python 3.12 runs the stdlib test suite directly.
|
||||
- name: Install coverage
|
||||
run: python3 -m pip install --break-system-packages coverage
|
||||
|
||||
# Fail loudly if the backend this job promises isn't actually usable,
|
||||
# rather than letting every test silently `unittest.skip` and the job
|
||||
# go green on zero coverage. `backend status` prints a clear per-check
|
||||
# summary (docker on PATH, daemon reachable) and exits non-zero when a
|
||||
# prerequisite is missing — the same readiness check the skip guards
|
||||
# gate on via `has_backend`.
|
||||
- name: Preflight — Docker backend is ready
|
||||
run: |
|
||||
python3 --version
|
||||
python3 cli.py backend status --backend=docker
|
||||
|
||||
- name: Preflight — clear any leftover poisoned gateway network
|
||||
run: |
|
||||
# The gateway network has a fixed name and persists across jobs on
|
||||
# this shared runner. A pre-fix or concurrent launch can leave it with
|
||||
# a malformed IPv6 subnet that trips docker's own ParseAddr in
|
||||
# `network inspect` (see PR #515); the code now self-heals it, but the
|
||||
# heal can't run if `network inspect` is what's broken on some daemon
|
||||
# versions. Drop the network here so this run recreates it IPv4-only.
|
||||
# Remove the attached gateway container first (else `network rm` fails
|
||||
# on active endpoints); both are recreated by ensure_running. Harmless
|
||||
# when absent.
|
||||
docker rm --force bot-bottle-orch-gateway 2>/dev/null || true
|
||||
docker network rm bot-bottle-gateway 2>/dev/null || true
|
||||
|
||||
- name: Run integration tests (docker) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: docker
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# act_runner executes this job in a container while sharing the host
|
||||
# Docker socket. Attach control-plane siblings to the job's network,
|
||||
# and use named volumes for state the host daemon must mount.
|
||||
DOCKER_CLIENT_NETWORK=$(
|
||||
docker inspect "$(hostname)" |
|
||||
python3 -c 'import json,sys; n=json.load(sys.stdin)[0]["NetworkSettings"]["Networks"]; print(next(iter(n)))'
|
||||
)
|
||||
test -n "$DOCKER_CLIENT_NETWORK"
|
||||
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
|
||||
export NO_PROXY="*"
|
||||
export no_proxy="*"
|
||||
export BOT_BOTTLE_DOCKER_CLIENT_NETWORK="$DOCKER_CLIENT_NETWORK"
|
||||
export BOT_BOTTLE_DOCKER_ROOT_MOUNT="bot-bottle-ci-root-$RUN_KEY"
|
||||
export BOT_BOTTLE_DOCKER_CA_MOUNT="bot-bottle-ci-ca-$RUN_KEY"
|
||||
python3 -m coverage run -m scripts.unittest_gate \
|
||||
-t . -s tests/integration -v \
|
||||
--minimum-executed 22 --fail-on-skip
|
||||
|
||||
- name: Clean Docker integration volumes
|
||||
if: always()
|
||||
run: |
|
||||
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
|
||||
docker volume rm --force \
|
||||
"bot-bottle-ci-root-$RUN_KEY" \
|
||||
"bot-bottle-ci-ca-$RUN_KEY" 2>/dev/null || true
|
||||
|
||||
# Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
|
||||
- name: Stage docker coverage for upload
|
||||
run: cp .coverage.docker coverage-docker.dat
|
||||
|
||||
@@ -130,107 +157,118 @@ jobs:
|
||||
name: coverage-docker
|
||||
path: coverage-docker.dat
|
||||
|
||||
# Integration tests against the Firecracker backend. Runs on a self-hosted
|
||||
# KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available.
|
||||
#
|
||||
# Restricted to same-repo PRs, push to main, and workflow_dispatch — fork
|
||||
# PRs don't execute untrusted code on the privileged runner.
|
||||
#
|
||||
# Runner prerequisites (provision once; see README "Firecracker on Linux"):
|
||||
# `firecracker` on PATH, `/dev/kvm` accessible, cached kernel +
|
||||
# static dropbear at /var/cache/bot-bottle-fc/dropbear, and the pool as a
|
||||
# persistent systemd unit.
|
||||
#
|
||||
# The infra candidate is built here directly (no artifact download) to
|
||||
# eliminate the ~70 s ubuntu-latest upload + ~83 s combined download that
|
||||
# the old build-infra → integration-firecracker + coverage chain incurred.
|
||||
# For main-branch pushes the tested rootfs and matching dropbear are
|
||||
# uploaded so publish-infra can publish the byte-identical artifact; PRs
|
||||
# skip those uploads entirely.
|
||||
integration-firecracker:
|
||||
runs-on: [self-hosted, kvm]
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
image-input-builds:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preflight — Firecracker host is ready
|
||||
- name: Verify shared bases cover supported architectures
|
||||
run: |
|
||||
command -v firecracker >/dev/null || {
|
||||
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
|
||||
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
|
||||
# `backend status` exits non-zero unless the TAP pool is up + no
|
||||
# range overlap; it prints the exact `backend setup` fix.
|
||||
python3 cli.py backend status --backend=firecracker
|
||||
set -euo pipefail
|
||||
python_ref=$(python3 -c \
|
||||
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
|
||||
node_ref=$(python3 -c \
|
||||
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
|
||||
docker_cli_ref=$(python3 -c \
|
||||
'import json; print(json.load(open("image-build-args.json"))["DOCKER_CLI_BASE_IMAGE"])')
|
||||
test -n "$python_ref"
|
||||
test -n "$node_ref"
|
||||
test -n "$docker_cli_ref"
|
||||
for ref in "$python_ref" "$node_ref" "$docker_cli_ref"; do
|
||||
docker buildx imagetools inspect --raw "$ref" |
|
||||
python3 -c '
|
||||
import json
|
||||
import sys
|
||||
manifest = json.load(sys.stdin)
|
||||
platforms = {
|
||||
(item["platform"]["os"], item["platform"]["architecture"])
|
||||
for item in manifest["manifests"]
|
||||
if item.get("platform", {}).get("os") != "unknown"
|
||||
}
|
||||
required = {("linux", "amd64"), ("linux", "arm64")}
|
||||
missing = required - platforms
|
||||
if missing:
|
||||
raise SystemExit(f"base manifest lacks supported platforms: {missing}")
|
||||
'
|
||||
done
|
||||
|
||||
- name: Build infra candidate from this checkout
|
||||
env:
|
||||
BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear
|
||||
run: python3 -m bot_bottle.backend.firecracker.publish_infra --output infra-candidate --reuse-published
|
||||
- name: Verify Codex archives for all supported architectures
|
||||
run: |
|
||||
set -euo pipefail
|
||||
codex_version=$(
|
||||
sed -n 's/^ARG CODEX_VERSION=//p' bot_bottle/contrib/codex/Dockerfile
|
||||
)
|
||||
test -n "$codex_version"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
for target in \
|
||||
aarch64-unknown-linux-musl \
|
||||
x86_64-unknown-linux-musl
|
||||
do
|
||||
asset="codex-package-${target}.tar.gz"
|
||||
expected=$(
|
||||
awk -v asset="$asset" '$2 == asset { print $1 }' \
|
||||
bot_bottle/contrib/codex/codex-package_SHA256SUMS
|
||||
)
|
||||
test -n "$expected"
|
||||
curl -fsSL \
|
||||
"https://github.com/openai/codex/releases/download/rust-v${codex_version}/${asset}" \
|
||||
-o "$tmp/$asset"
|
||||
echo "$expected $tmp/$asset" | sha256sum -c -
|
||||
tar -tzf "$tmp/$asset" > "$tmp/$asset.contents"
|
||||
grep -Fx 'bin/codex' "$tmp/$asset.contents"
|
||||
grep -Fx 'bin/codex-code-mode-host' "$tmp/$asset.contents"
|
||||
grep -Fx 'codex-package.json' "$tmp/$asset.contents"
|
||||
done
|
||||
|
||||
- name: Replace the persistent infra VM with the candidate
|
||||
run: python3 -c 'from bot_bottle.backend.firecracker import infra_vm; infra_vm.stop()'
|
||||
- name: Build and smoke-test all supported images
|
||||
run: |
|
||||
set -euo pipefail
|
||||
suffix="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-image-inputs}}"
|
||||
orchestrator="bot-bottle-orchestrator-inputs:${suffix}"
|
||||
gateway="bot-bottle-gateway-inputs:${suffix}"
|
||||
orchestrator_fc="bot-bottle-orchestrator-fc-inputs:${suffix}"
|
||||
claude="bot-bottle-claude-inputs:${suffix}"
|
||||
codex="bot-bottle-codex-inputs:${suffix}"
|
||||
pi="bot-bottle-pi-inputs:${suffix}"
|
||||
python_base=$(python3 -c \
|
||||
'import json; print(json.load(open("image-build-args.json"))["PYTHON_BASE_IMAGE"])')
|
||||
node_base=$(python3 -c \
|
||||
'import json; print(json.load(open("image-build-args.json"))["NODE_BASE_IMAGE"])')
|
||||
|
||||
# No dev-requirements install: `coverage` is already provided by the
|
||||
# self-hosted runner's Nix python env, and that env has no `pip`
|
||||
# module to install into anyway.
|
||||
- name: Run integration tests (firecracker) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: firecracker
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
|
||||
COVERAGE_FILE: ${{ github.workspace }}/.coverage.firecracker
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v
|
||||
docker build --build-arg "PYTHON_BASE_IMAGE=$python_base" \
|
||||
-t "$orchestrator" -f Dockerfile.orchestrator .
|
||||
orchestrator_id=$(docker image inspect --format '{{.Id}}' "$orchestrator")
|
||||
case "$orchestrator_id" in sha256:*) ;; *) exit 1 ;; esac
|
||||
orchestrator_base="bot-bottle-orchestrator-inputs:sha256-${orchestrator_id#sha256:}"
|
||||
docker image tag "$orchestrator_id" "$orchestrator_base"
|
||||
test "$(
|
||||
docker image inspect --format '{{.Id}}' "$orchestrator_base"
|
||||
)" = "$orchestrator_id"
|
||||
docker build --build-arg "PYTHON_BASE_IMAGE=$python_base" \
|
||||
-t "$gateway" -f Dockerfile.gateway .
|
||||
docker build \
|
||||
--build-arg "ORCHESTRATOR_BASE_IMAGE=$orchestrator_base" \
|
||||
-t "$orchestrator_fc" -f Dockerfile.orchestrator.fc .
|
||||
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
|
||||
-t "$claude" -f bot_bottle/contrib/claude/Dockerfile .
|
||||
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
|
||||
-t "$codex" -f bot_bottle/contrib/codex/Dockerfile .
|
||||
docker build --build-arg "NODE_BASE_IMAGE=$node_base" \
|
||||
-t "$pi" -f bot_bottle/contrib/pi/Dockerfile .
|
||||
|
||||
# Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
|
||||
- name: Stage firecracker coverage for upload
|
||||
run: cp .coverage.firecracker coverage-firecracker.dat
|
||||
docker run --rm --entrypoint python3 "$orchestrator" -c \
|
||||
'import bot_bottle.orchestrator'
|
||||
docker run --rm --entrypoint mitmdump "$gateway" --version
|
||||
docker run --rm "$claude" claude --version
|
||||
docker run --rm "$codex" codex --version
|
||||
docker run --rm "$pi" pi --version
|
||||
|
||||
- name: Upload firecracker coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-firecracker
|
||||
path: coverage-firecracker.dat
|
||||
|
||||
# Only upload the large rootfs artifact on main-branch pushes;
|
||||
# PRs avoid the ~194 MB transfer. publish-infra only runs on main
|
||||
# and downloads these to publish the byte-identical tested rootfs.
|
||||
- name: Upload tested rootfs (main branch only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate/
|
||||
|
||||
- name: Upload dropbear for publish verification (main branch only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: /var/cache/bot-bottle-fc/dropbear
|
||||
|
||||
# Combined coverage gate: aggregates .coverage.* artifacts uploaded by each
|
||||
# test job, then runs the diff-coverage gate (new/changed lines >= 90%).
|
||||
#
|
||||
# Runs on ubuntu-latest — no KVM needed, no test reruns. Coverage files use
|
||||
# relative_files = True (.coveragerc) so they combine cleanly across runners.
|
||||
# Each test job sets COVERAGE_FILE to an absolute path so coverage.py writes
|
||||
# to a known location that upload-artifact can find regardless of runner env.
|
||||
#
|
||||
# Restricted to the same events as integration-firecracker: it depends on
|
||||
# that job's coverage artifact and skips for fork PRs alongside it.
|
||||
coverage:
|
||||
needs: [unit, integration-docker, integration-firecracker]
|
||||
needs: [unit, integration-docker]
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -252,55 +290,15 @@ jobs:
|
||||
name: coverage-docker
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
- name: Download firecracker coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-firecracker
|
||||
path: ${{ github.workspace }}
|
||||
|
||||
# Rename the non-dot upload names back to the .coverage.* files that
|
||||
# `coverage combine` discovers (see the staging steps in each test job).
|
||||
- name: Reassemble coverage data files
|
||||
run: |
|
||||
mv coverage-unit.dat .coverage.unit
|
||||
mv coverage-docker.dat .coverage.docker
|
||||
mv coverage-firecracker.dat .coverage.firecracker
|
||||
|
||||
- name: Combined coverage (unit + integration, incl. firecracker)
|
||||
- name: Combined coverage (unit + docker integration)
|
||||
run: PYTHON=python3 bash scripts/coverage.sh aggregate critical
|
||||
|
||||
- name: Diff-coverage gate (changed lines >= 90%)
|
||||
- name: Diff-coverage gate (changed lines >= 80%)
|
||||
run: |
|
||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
python3 scripts/diff_coverage.py --base origin/main --min 90
|
||||
|
||||
publish-infra:
|
||||
needs: [unit, integration-docker, integration-firecracker, coverage]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout the tested revision
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download the tested rootfs
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate
|
||||
|
||||
# publish_infra re-derives the version from the checkout to confirm the
|
||||
# bundle matches before uploading, and the version hashes the dropbear
|
||||
# bytes. Download the SAME dropbear integration-firecracker used, or
|
||||
# the recheck computes a "<missing>"-dropbear version and rejects the
|
||||
# candidate.
|
||||
- name: Download the staged dropbear (matches build's version)
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: firecracker-inputs
|
||||
|
||||
- name: Publish the tested candidate
|
||||
env:
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
|
||||
BOT_BOTTLE_FC_DROPBEAR: ${{ github.workspace }}/firecracker-inputs/dropbear
|
||||
run: python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir infra-candidate
|
||||
python3 scripts/diff_coverage.py --base origin/main --min 80
|
||||
|
||||
@@ -33,19 +33,28 @@ jobs:
|
||||
- name: Run coverage and extract percentage
|
||||
id: coverage
|
||||
run: |
|
||||
python3 -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
||||
PERCENT=$(python3 -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||
set -euo pipefail
|
||||
# Never publish a badge from a failed or partial test run.
|
||||
python3 -m coverage run -m unittest discover -t . -s tests/unit
|
||||
REPORT=$(python3 -m coverage report)
|
||||
printf '%s\n' "$REPORT"
|
||||
PERCENT=$(printf '%s\n' "$REPORT" | awk '$1 == "TOTAL" {gsub("%", "", $NF); print $NF}')
|
||||
test -n "$PERCENT"
|
||||
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||
echo "Coverage: $PERCENT%"
|
||||
|
||||
- name: Extract core (critical-module) coverage percentage
|
||||
id: core_coverage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Reuses the .coverage data from the previous step. The core list is
|
||||
# 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.
|
||||
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
|
||||
PERCENT=$(python3 -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||
# validated single source of truth. Fail if a listed path disappeared
|
||||
# or if the measured core falls below ADR 0004's 90% minimum.
|
||||
INCLUDE=$(python3 scripts/critical_modules.py)
|
||||
REPORT=$(python3 -m coverage report --include="$INCLUDE" --fail-under=90)
|
||||
printf '%s\n' "$REPORT"
|
||||
PERCENT=$(printf '%s\n' "$REPORT" | awk '$1 == "TOTAL" {gsub("%", "", $NF); print $NF}')
|
||||
test -n "$PERCENT"
|
||||
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||
echo "Core coverage: $PERCENT%"
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
# setuptools/build_meta output (wheels, sdists, build tree)
|
||||
/build/
|
||||
/dist/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
|
||||
@@ -44,10 +44,10 @@ backend remains available with `BOT_BOTTLE_BACKEND=docker` or
|
||||
|
||||
- Three kinds of doc, each with its own conventions in-folder; see
|
||||
`docs/README.md` for when to write which:
|
||||
- **PRDs** (`docs/prds/`) — one feature per file. While a PR is open
|
||||
the file is named `prd-new-<kebab>.md`; CI assigns a sequential
|
||||
number on merge to `main` and renames it. A `Status:` line tracks
|
||||
lifecycle: Draft → Active (shipped to `main`) →
|
||||
- **PRDs** (`docs/prds/`) — one feature per file. A draft may initially
|
||||
use `prd-new-<kebab>.md`, but its author must assign the next
|
||||
sequential number before merge; CI rejects unnumbered PRDs. A
|
||||
`Status:` line tracks lifecycle: Draft → Active (shipped to `main`) →
|
||||
Superseded/Retargeted. Format in `docs/prds/README.md`.
|
||||
- **Research notes** (`docs/research/`) — opinionated investigations;
|
||||
unnumbered kebab-case, freeform and verdict-first. See
|
||||
|
||||
+21
-6
@@ -36,11 +36,23 @@
|
||||
# 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
|
||||
# Based on an exact `python:3.12.13-slim-trixie` multi-architecture manifest
|
||||
# rather than the
|
||||
# `mitmproxy/mitmproxy` image (Debian bookworm), matching the trixie base the
|
||||
# orchestrator image needs for buildah (Dockerfile.orchestrator.fc). mitmproxy
|
||||
# is pip-installed to the same effect as the upstream image.
|
||||
FROM python:3.12-slim
|
||||
ARG PYTHON_BASE_IMAGE
|
||||
FROM ${PYTHON_BASE_IMAGE}
|
||||
|
||||
# Freeze apt's package universe as well as the base filesystem. Without a
|
||||
# snapshot, the same Dockerfile resolves different package versions over time.
|
||||
ARG DEBIAN_SNAPSHOT=20260724T000000Z
|
||||
RUN sed -i \
|
||||
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
|
||||
# Runtime system deps:
|
||||
# git supplies the `git daemon` subcommand (no separate package)
|
||||
@@ -48,16 +60,19 @@ FROM python:3.12-slim
|
||||
# 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 \
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false 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
|
||||
# this in; on the plain python base we install a fully resolved lock whose
|
||||
# distributions are all hash-verified. 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
|
||||
COPY requirements.gateway.lock /tmp/requirements.gateway.lock
|
||||
RUN pip install --no-cache-dir --require-hashes \
|
||||
-r /tmp/requirements.gateway.lock
|
||||
|
||||
# gitleaks (the pre-receive hook's secret scanner). Installed from its
|
||||
# official release, pinned by version + SHA256 and verified — rather than
|
||||
|
||||
+13
-8
@@ -9,19 +9,24 @@
|
||||
# Keeping the content 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.orchestrator.fc). Keeping the
|
||||
# secret-dense control plane on a minimal dependency surface is the point
|
||||
# (PRD 0070's "secret concentration").
|
||||
# It stays deliberately lean: only the pinned FastAPI/Uvicorn control-plane
|
||||
# stack is installed here — none of the gateway's mitmproxy/git/gitleaks
|
||||
# (that's Dockerfile.gateway) and no buildah (that's firecracker-only).
|
||||
#
|
||||
# Shares the trixie `python:3.12-slim` base with the gateway image.
|
||||
# Shares an exact multi-architecture Python/trixie manifest with the gateway
|
||||
# image. The version-qualified tag keeps the human-readable upstream version;
|
||||
# the digest makes the bytes immutable.
|
||||
|
||||
FROM python:3.12-slim
|
||||
ARG PYTHON_BASE_IMAGE
|
||||
FROM ${PYTHON_BASE_IMAGE}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.orchestrator.lock /tmp/requirements.orchestrator.lock
|
||||
RUN pip install --no-cache-dir --require-hashes \
|
||||
-r /tmp/requirements.orchestrator.lock \
|
||||
&& rm /tmp/requirements.orchestrator.lock
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -12,10 +12,21 @@
|
||||
# bare microVM (no fuse-overlayfs / overlay module / subuid maps). The trixie
|
||||
# base (from Dockerfile.orchestrator's python:3.12-slim) carries buildah 1.39,
|
||||
# which parses the Dockerfile heredocs agent images use (bookworm's 1.28 can't).
|
||||
# Matches image_builder.
|
||||
FROM bot-bottle-orchestrator:latest
|
||||
# Matches image_builder. There is deliberately no default: the build coordinator
|
||||
# passes the exact local image ID returned by `docker image inspect`, so this
|
||||
# stage cannot silently resolve a stale `:latest` tag.
|
||||
ARG ORCHESTRATOR_BASE_IMAGE
|
||||
FROM ${ORCHESTRATOR_BASE_IMAGE}
|
||||
|
||||
RUN apt-get update \
|
||||
ARG DEBIAN_SNAPSHOT=20260724T000000Z
|
||||
RUN sed -i \
|
||||
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
buildah crun netavark aardvark-dns \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Root-level build resources copied into bot_bottle/_resources/ at build time
|
||||
# (see setup.py). Included in the sdist so `pip install` from an sdist can
|
||||
# still bundle them into the wheel.
|
||||
include Dockerfile.gateway
|
||||
include Dockerfile.orchestrator
|
||||
include Dockerfile.orchestrator.fc
|
||||
include image-build-args.json
|
||||
include requirements.gateway.in
|
||||
include requirements.gateway.lock
|
||||
include nix/firecracker-netpool.nix
|
||||
include scripts/firecracker-netpool.sh
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## Architecture
|
||||
|
||||
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 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. git-gate runs over the gateway's consolidated `git-http` daemon (the legacy per-bottle `git://` daemon is not used on this backend); keys are provisioned dynamically at launch and revoked on teardown.
|
||||
|
||||
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.
|
||||
|
||||
@@ -75,6 +75,8 @@ On compatible macOS hosts, the default backend requires Apple's `container` CLI
|
||||
|
||||
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.
|
||||
|
||||
> **CI (macOS Apple Container):** the advisory `integration-macos` job in `.gitea/workflows/pre-release-test.yml` runs only on manual dispatch. It targets a self-hosted host-mode runner labelled `macos`; Apple Container cannot run inside the Linux pull-request runner. Provision an Apple Silicon host with the `container` CLI running and Python ≥ 3.11 plus `coverage` on the launchd service's explicit `PATH`. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1. Its coverage is reported separately and never feeds the required pull-request gate.
|
||||
|
||||
### Containers inside a bottle
|
||||
|
||||
A bottle may set `nested_containers: true`. On the macOS backend this starts a
|
||||
@@ -172,7 +174,7 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
|
||||
|
||||
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
|
||||
|
||||
> **CI:** the coverage gate (`.gitea/workflows/test.yml` → `coverage` job) runs on a self-hosted runner labelled `kvm`, because the Firecracker backend's VM/SSH orchestration is exercised only by the integration suite, which needs `/dev/kvm` + the provisioned pool (a container runner would skip it and read as uncovered). Provision that runner exactly like a normal Firecracker host — `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel + static dropbear, and the pool installed as the persistent systemd unit — then register it with the `kvm` label. A Docker-capable hosted job builds the candidate once; KVM tests boot those exact bytes, and a successful main run publishes them. The unit/lint jobs still run on `ubuntu-latest`.
|
||||
> **CI:** Firecracker integration runs in the manually dispatched `.gitea/workflows/pre-release-test.yml` on a self-hosted runner labelled `kvm`; privileged KVM hosts never execute unreviewed PR code automatically. Provision it like a normal Firecracker host: `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel and static dropbear, and the persistent TAP/nft pool. The required pull-request workflow runs unit plus the complete Docker integration suite on `ubuntu-latest`; see `docs/ci.md`.
|
||||
|
||||
```sh
|
||||
./cli.py start <agent> # builds the image on first run, drops you into claude
|
||||
|
||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
||||
BottleImages,
|
||||
BottlePlan,
|
||||
BottleSpec,
|
||||
EnumerationError,
|
||||
ExecResult,
|
||||
)
|
||||
from .selection import (
|
||||
@@ -59,6 +60,7 @@ _LAZY_MODULES: dict[str, str] = {
|
||||
"BottleImages": "base",
|
||||
"BottleBackend": "base",
|
||||
"BackendStatus": "base",
|
||||
"EnumerationError": "base",
|
||||
"get_bottle_backend": "selection",
|
||||
"known_backend_names": "selection",
|
||||
"has_backend": "selection",
|
||||
@@ -100,6 +102,7 @@ __all__ = [
|
||||
"BottlePlan",
|
||||
"BottleSpec",
|
||||
"ExecResult",
|
||||
"EnumerationError",
|
||||
"CommitCancelled",
|
||||
"Freezer",
|
||||
"get_freezer",
|
||||
|
||||
+19
-75
@@ -23,14 +23,14 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Generator, Generic, Sequence, TypeVar
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
|
||||
from ..agent_provider import AgentProvisionPlan, get_provider
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..log import die, info
|
||||
from ..util import expand_tilde
|
||||
from ..manifest import Manifest, ManifestIndex
|
||||
from ..supervisor.plan import SupervisePlan
|
||||
from ..env import resolve_env, ResolvedEnv
|
||||
from ..env import ResolvedEnv
|
||||
from ..workspace import WorkspacePlan, workspace_plan
|
||||
from .print_util import print_multi, visible_agent_env_names
|
||||
from .util import host_skill_dir
|
||||
@@ -42,6 +42,10 @@ class BackendStatus(enum.IntEnum):
|
||||
READY = 0
|
||||
|
||||
|
||||
class EnumerationError(RuntimeError):
|
||||
"""A backend could not produce an authoritative live-resource snapshot."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BottleSpec:
|
||||
"""CLI-supplied intent. Backend-agnostic — each backend's prepare
|
||||
@@ -168,6 +172,10 @@ class BottleCleanupPlan(ABC):
|
||||
"""True iff there is nothing to clean up; the CLI uses this to
|
||||
short-circuit before showing the y/N."""
|
||||
|
||||
@abstractmethod
|
||||
def intersect(self, current: "BottleCleanupPlan") -> "BottleCleanupPlan":
|
||||
"""Resources both displayed to the operator and currently removable."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecResult:
|
||||
@@ -296,82 +304,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
backend-specific resolution (names, scratch files, etc.). The
|
||||
validation step is enforced here so a future backend cannot
|
||||
accidentally skip it. No remote/runtime resources are created."""
|
||||
from .resolve_common import (
|
||||
merge_provision_env_vars,
|
||||
mint_slug,
|
||||
prepare_agent_state_dir,
|
||||
prepare_egress,
|
||||
prepare_git_gate,
|
||||
prepare_supervise,
|
||||
reject_nested_containers,
|
||||
resolve_manifest_dockerfile,
|
||||
write_launch_metadata,
|
||||
)
|
||||
|
||||
manifest = self._validate(spec)
|
||||
|
||||
if not self.supports_nested_containers:
|
||||
reject_nested_containers(self.name, manifest)
|
||||
|
||||
self._preflight()
|
||||
|
||||
from ..git_gate import GitGate
|
||||
manifest = GitGate().preflight_host_keys(
|
||||
manifest,
|
||||
headless=spec.headless,
|
||||
home_md=spec.manifest.home_md,
|
||||
)
|
||||
|
||||
manifest_bottle = manifest.bottle
|
||||
manifest_agent_provider = manifest_bottle.agent_provider
|
||||
agent_provider = get_provider(manifest_agent_provider.template)
|
||||
resolved_env = resolve_env(manifest)
|
||||
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
||||
|
||||
slug = mint_slug(spec)
|
||||
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
|
||||
|
||||
# Manifest may override the Dockerfile per-bottle; otherwise fall
|
||||
# back to the provider plugin's bundled Dockerfile (next to its
|
||||
# agent_provider.py module).
|
||||
if manifest_agent_provider.dockerfile:
|
||||
agent_dockerfile_path = resolve_manifest_dockerfile(
|
||||
manifest_agent_provider.dockerfile, spec,
|
||||
)
|
||||
else:
|
||||
agent_dockerfile_path = str(agent_provider.dockerfile)
|
||||
|
||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
||||
|
||||
agent_provision_plan = build_agent_provision_plan(
|
||||
template=manifest_agent_provider.template,
|
||||
dockerfile=agent_dockerfile_path,
|
||||
state_dir=agent_dir,
|
||||
instance_name=f"bot-bottle-{slug}",
|
||||
prompt_file=prompt_file,
|
||||
guest_env=self._build_guest_env(resolved_env),
|
||||
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
|
||||
auth_token=manifest_agent_provider.auth_token,
|
||||
host_env=dict(os.environ),
|
||||
trusted_project_path=workspace.workdir,
|
||||
label=spec.label,
|
||||
color=spec.color,
|
||||
provider_settings=manifest_agent_provider.settings,
|
||||
)
|
||||
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
||||
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
||||
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
||||
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
||||
from .preparation import BottlePreparationPlanner
|
||||
prepared = BottlePreparationPlanner(self).prepare(spec)
|
||||
|
||||
return self._resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
manifest=prepared.manifest,
|
||||
slug=prepared.slug,
|
||||
resolved_env=prepared.resolved_env,
|
||||
agent_provision_plan=prepared.agent_provision_plan,
|
||||
egress_plan=prepared.egress_plan,
|
||||
supervise_plan=prepared.supervise_plan,
|
||||
git_gate_plan=prepared.git_gate_plan,
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Shared destructive-cleanup execution and failure accounting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class CleanupError(RuntimeError):
|
||||
"""One or more approved cleanup mutations did not complete."""
|
||||
|
||||
|
||||
class CleanupFailures:
|
||||
"""Attempt every approved mutation, then fail with complete diagnostics."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._messages: list[str] = []
|
||||
|
||||
def run(self, argv: Sequence[str], description: str) -> None:
|
||||
raw_timeout = os.environ.get(
|
||||
"BOT_BOTTLE_CLEANUP_COMMAND_TIMEOUT_SECONDS", "120",
|
||||
)
|
||||
try:
|
||||
timeout = float(raw_timeout)
|
||||
except ValueError:
|
||||
timeout = 120.0
|
||||
try:
|
||||
result = subprocess.run(
|
||||
list(argv), capture_output=True, text=True, check=False,
|
||||
timeout=max(timeout, 1.0),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
self._messages.append(f"{description}: {exc}")
|
||||
return
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
self._messages.append(
|
||||
f"{description}: {detail or f'exit {result.returncode}'}"
|
||||
)
|
||||
|
||||
def remove_tree(self, path: Path, description: str) -> None:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as exc:
|
||||
self._messages.append(f"{description}: {exc}")
|
||||
|
||||
def record(self, message: str) -> None:
|
||||
self._messages.append(message)
|
||||
|
||||
def raise_if_any(self) -> None:
|
||||
if self._messages:
|
||||
raise CleanupError("; ".join(self._messages))
|
||||
|
||||
|
||||
__all__ = ["CleanupError", "CleanupFailures"]
|
||||
@@ -46,6 +46,22 @@ class DockerBottleCleanupPlan(BottleCleanupPlan):
|
||||
and not self.orphan_state_dirs
|
||||
)
|
||||
|
||||
def intersect(self, current: BottleCleanupPlan) -> "DockerBottleCleanupPlan":
|
||||
if not isinstance(current, DockerBottleCleanupPlan):
|
||||
raise TypeError("cleanup plans must have the same backend type")
|
||||
return DockerBottleCleanupPlan(
|
||||
projects=tuple(x for x in self.projects if x in current.projects),
|
||||
stray_containers=tuple(
|
||||
x for x in self.stray_containers if x in current.stray_containers
|
||||
),
|
||||
stray_networks=tuple(
|
||||
x for x in self.stray_networks if x in current.stray_networks
|
||||
),
|
||||
orphan_state_dirs=tuple(
|
||||
x for x in self.orphan_state_dirs if x in current.orphan_state_dirs
|
||||
),
|
||||
)
|
||||
|
||||
def print(self) -> None:
|
||||
print(file=sys.stderr)
|
||||
for name in self.projects:
|
||||
|
||||
@@ -23,11 +23,12 @@ Active-agent enumeration lives in `backend/docker/enumerate.py`.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from ...paths import bot_bottle_root
|
||||
from ...log import info, warn
|
||||
from ...log import info
|
||||
from .. import EnumerationError
|
||||
from ..cleanup_control import CleanupFailures
|
||||
from . import util as docker_mod
|
||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||
from ...bottle_state import bottle_state_dir, is_preserved
|
||||
@@ -36,15 +37,17 @@ from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
|
||||
|
||||
def _list_prefixed_containers() -> list[str]:
|
||||
"""All bot-bottle-prefixed containers, running or stopped."""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-a",
|
||||
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
|
||||
"--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-a",
|
||||
"--filter", f"name=^{COMPOSE_PROJECT_PREFIX}",
|
||||
"--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise EnumerationError(f"docker ps failed: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
warn(f"docker ps failed: {result.stderr.strip()}")
|
||||
return []
|
||||
raise EnumerationError(f"docker ps failed: {result.stderr.strip()}")
|
||||
out: list[str] = []
|
||||
for line in (result.stdout or "").splitlines():
|
||||
if not line:
|
||||
@@ -63,15 +66,19 @@ def _list_prefixed_networks() -> list[str]:
|
||||
to a compose project. Compose-managed networks have a
|
||||
`com.docker.compose.project` label; bare ones (from pre-compose
|
||||
code paths) don't."""
|
||||
result = subprocess.run(
|
||||
["docker", "network", "ls",
|
||||
"--filter", f"name={COMPOSE_PROJECT_PREFIX}",
|
||||
"--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "network", "ls",
|
||||
"--filter", f"name={COMPOSE_PROJECT_PREFIX}",
|
||||
"--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise EnumerationError(f"docker network ls failed: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
warn(f"docker network ls failed: {result.stderr.strip()}")
|
||||
return []
|
||||
raise EnumerationError(
|
||||
f"docker network ls failed: {result.stderr.strip()}"
|
||||
)
|
||||
out: list[str] = []
|
||||
for line in (result.stdout or "").splitlines():
|
||||
if not line:
|
||||
@@ -120,7 +127,10 @@ def prepare_cleanup() -> DockerBottleCleanupPlan:
|
||||
`enumerate_active_agents()` so the orphan-state-dir bucket
|
||||
doesn't include slugs whose non-docker bottle is still up."""
|
||||
docker_mod.require_docker()
|
||||
projects = list_compose_projects()
|
||||
projects = list_compose_projects(
|
||||
warn_on_error=False,
|
||||
raise_on_error=True,
|
||||
)
|
||||
project_set = set(projects)
|
||||
# Late import to avoid a circular at module-load time —
|
||||
# the backend package's __init__ imports this module.
|
||||
@@ -140,40 +150,30 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
|
||||
"""Remove everything in the plan. Projects first (whose `compose
|
||||
down` reaps their containers + networks atomically), then stray
|
||||
legacy resources, then orphan state dirs."""
|
||||
failures = CleanupFailures()
|
||||
for project in plan.projects:
|
||||
info(f"docker compose down ({project})")
|
||||
result = subprocess.run(
|
||||
failures.run(
|
||||
["docker", "compose", "-p", project, "down", "--volumes"],
|
||||
capture_output=True, text=True, check=False,
|
||||
f"docker compose down failed for {project}",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
warn(
|
||||
f"compose down failed for {project}: "
|
||||
f"{result.stderr.strip()}"
|
||||
)
|
||||
|
||||
for name in plan.stray_containers:
|
||||
info(f"removing stray container {name}")
|
||||
subprocess.run(
|
||||
failures.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
f"removing stray container {name}",
|
||||
)
|
||||
|
||||
for name in plan.stray_networks:
|
||||
info(f"removing stray network {name}")
|
||||
subprocess.run(
|
||||
failures.run(
|
||||
["docker", "network", "rm", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
f"removing stray network {name}",
|
||||
)
|
||||
|
||||
for identity in plan.orphan_state_dirs:
|
||||
path = bottle_state_dir(identity)
|
||||
info(f"removing orphan state dir {path}")
|
||||
try:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
except OSError as e:
|
||||
warn(f"failed to remove {path}: {e}")
|
||||
failures.remove_tree(path, f"removing orphan state dir {path}")
|
||||
failures.raise_if_any()
|
||||
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...log import die, warn
|
||||
from ..base import EnumerationError
|
||||
|
||||
|
||||
# --- Lifecycle helpers (PRD 0018 chunk 3) ----------------------------------
|
||||
@@ -52,19 +53,20 @@ def slug_from_compose_project(project: str) -> str:
|
||||
|
||||
|
||||
def list_compose_projects(
|
||||
*, include_stopped: bool = True, warn_on_error: bool = True,
|
||||
*,
|
||||
include_stopped: bool = True,
|
||||
warn_on_error: bool = True,
|
||||
raise_on_error: bool = False,
|
||||
) -> list[str]:
|
||||
"""All compose project names starting with `bot-bottle-`.
|
||||
`include_stopped=True` (default) runs `docker compose ls --all`
|
||||
so exited projects appear too; pass False to get only projects
|
||||
with at least one running container.
|
||||
|
||||
Returns [] on docker daemon errors or malformed output rather
|
||||
than raising — callers should treat the empty list as "no
|
||||
projects discoverable", not "no projects exist". `warn_on_error`
|
||||
stays true for explicit operator commands like cleanup, but active
|
||||
discovery paths set it false so dashboard refreshes don't spam
|
||||
stderr while Docker Desktop is stopped."""
|
||||
Best-effort callers get ``[]`` on Docker errors or malformed output.
|
||||
Enumeration callers pass ``raise_on_error=True`` so a failed query is not
|
||||
reported as an authoritative empty result.
|
||||
"""
|
||||
argv = ["docker", "compose", "ls", "--format", "json"]
|
||||
if include_stopped:
|
||||
argv.insert(3, "--all")
|
||||
@@ -72,19 +74,27 @@ def list_compose_projects(
|
||||
result = subprocess.run(
|
||||
argv, capture_output=True, text=True, check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# docker binary not on PATH — same shape as a daemon-down
|
||||
# error from the caller's POV: no projects discoverable.
|
||||
except FileNotFoundError as exc:
|
||||
if raise_on_error:
|
||||
raise EnumerationError(
|
||||
"docker compose ls failed: docker not found"
|
||||
) from exc
|
||||
return []
|
||||
if result.returncode != 0:
|
||||
message = f"docker compose ls failed: {result.stderr.strip()}"
|
||||
if raise_on_error:
|
||||
raise EnumerationError(message)
|
||||
if warn_on_error:
|
||||
warn(f"docker compose ls failed: {result.stderr.strip()}")
|
||||
warn(message)
|
||||
return []
|
||||
try:
|
||||
projects = json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError as e:
|
||||
message = f"docker compose ls returned malformed JSON: {e}"
|
||||
if raise_on_error:
|
||||
raise EnumerationError(message) from e
|
||||
if warn_on_error:
|
||||
warn(f"docker compose ls returned malformed JSON: {e}")
|
||||
warn(message)
|
||||
return []
|
||||
names: list[str] = []
|
||||
for p in projects:
|
||||
@@ -97,7 +107,10 @@ def list_compose_projects(
|
||||
|
||||
|
||||
def list_active_slugs(
|
||||
*, include_stopped: bool = False, warn_on_error: bool = True,
|
||||
*,
|
||||
include_stopped: bool = False,
|
||||
warn_on_error: bool = True,
|
||||
raise_on_error: bool = False,
|
||||
) -> list[str]:
|
||||
"""Slugs (project name minus prefix) of currently-running
|
||||
bottles. Used by the dashboard's operator-edit verbs to choose
|
||||
@@ -108,6 +121,7 @@ def list_active_slugs(
|
||||
for p in list_compose_projects(
|
||||
include_stopped=include_stopped,
|
||||
warn_on_error=warn_on_error,
|
||||
raise_on_error=raise_on_error,
|
||||
)
|
||||
) if slug
|
||||
)
|
||||
|
||||
@@ -68,6 +68,11 @@ def _network_container_ips(network: str) -> list[str]:
|
||||
"docker", "network", "inspect", "--format",
|
||||
"{{range .Containers}}{{.IPv4Address}} {{end}}", network,
|
||||
])
|
||||
if proc.returncode != 0:
|
||||
detail = proc.stderr.strip() or f"exit {proc.returncode}"
|
||||
raise ConsolidatedLaunchError(
|
||||
f"could not inspect addresses on gateway network {network}: {detail}"
|
||||
)
|
||||
ips: list[str] = []
|
||||
for entry in proc.stdout.split():
|
||||
ips.append(entry.split("/", 1)[0])
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Active-agent enumeration for the docker backend.
|
||||
|
||||
Returns `ActiveAgent` records the CLI `active` command and the
|
||||
dashboard agents pane consume. Empty when docker isn't reachable
|
||||
— gated by `has_backend('docker')` at the cross-backend caller
|
||||
so this module trusts that docker is available when called.
|
||||
dashboard agents pane consume. Docker query failures raise rather
|
||||
than masquerading as an authoritative empty result.
|
||||
|
||||
The parser (`_parse_services_by_project`) is exposed for direct
|
||||
unit testing; the docker `docker ps` invocation is in
|
||||
@@ -13,17 +12,18 @@ from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from .. import ActiveAgent
|
||||
from .. import ActiveAgent, EnumerationError
|
||||
from ...bottle_state import read_metadata
|
||||
from .compose import compose_project_name, list_active_slugs
|
||||
|
||||
|
||||
def enumerate_active() -> list[ActiveAgent]:
|
||||
"""All currently-running docker-backed agents. Caller is
|
||||
responsible for gating on `has_backend('docker')` if it
|
||||
matters; if docker is missing the `docker ps` call below
|
||||
returns an empty list silently."""
|
||||
slugs = list_active_slugs(include_stopped=False, warn_on_error=False)
|
||||
"""All currently-running docker-backed agents."""
|
||||
slugs = list_active_slugs(
|
||||
include_stopped=False,
|
||||
warn_on_error=False,
|
||||
raise_on_error=True,
|
||||
)
|
||||
if not slugs:
|
||||
return []
|
||||
services_by_project = _query_services_by_project()
|
||||
@@ -74,8 +74,8 @@ def _query_services_by_project() -> dict[str, set[str]]:
|
||||
],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except FileNotFoundError as exc:
|
||||
raise EnumerationError("docker ps failed: docker not found") from exc
|
||||
if r.returncode != 0:
|
||||
return {}
|
||||
raise EnumerationError(f"docker ps failed: {r.stderr.strip()}")
|
||||
return _parse_services_by_project(r.stdout or "")
|
||||
|
||||
@@ -10,12 +10,17 @@ from ...paths import (
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ... import resources
|
||||
from ...gateway import (
|
||||
Gateway, GatewayTransport, GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK,
|
||||
GATEWAY_DOCKERFILE, REPO_ROOT, GATEWAY_LABEL, MITMPROXY_HOME,
|
||||
GATEWAY_DOCKERFILE, GATEWAY_LABEL, MITMPROXY_HOME,
|
||||
DEFAULT_CA_TIMEOUT_SECONDS, CA_POLL_SECONDS, GATEWAY_CA_CERT, GatewayError
|
||||
)
|
||||
|
||||
DEFAULT_GATEWAY_SUBNET = "10.242.255.0/24"
|
||||
_GATEWAY_SUBNET_LABEL = "bot-bottle.gateway-subnet"
|
||||
|
||||
|
||||
class DockerGateway(Gateway):
|
||||
"""The consolidated gateway as a single, fixed-name Docker container.
|
||||
|
||||
@@ -34,6 +39,8 @@ class DockerGateway(Gateway):
|
||||
build_context: Path | None = None,
|
||||
dockerfile: str | None = GATEWAY_DOCKERFILE,
|
||||
host_port_bindings: tuple[int, ...] = (),
|
||||
ca_mount_source: str | Path | None = None,
|
||||
subnet: str | None = None,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
@@ -50,12 +57,23 @@ class DockerGateway(Gateway):
|
||||
# `address` / `stop` work on an already-running gateway without it.
|
||||
self._orchestrator_url = ""
|
||||
self._gateway_token = ""
|
||||
self._build_context = build_context or REPO_ROOT
|
||||
# Resolved lazily in ensure_built() so merely constructing a gateway to
|
||||
# read its CA never stages a build root from an installed wheel.
|
||||
self._build_context = build_context
|
||||
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
|
||||
self._subnet = (
|
||||
subnet
|
||||
or os.environ.get("BOT_BOTTLE_DOCKER_GATEWAY_SUBNET", "").strip()
|
||||
or DEFAULT_GATEWAY_SUBNET
|
||||
)
|
||||
configured_ca = os.environ.get("BOT_BOTTLE_DOCKER_CA_MOUNT", "").strip()
|
||||
self._ca_mount_source = str(
|
||||
ca_mount_source or configured_ca or host_gateway_ca_dir()
|
||||
)
|
||||
|
||||
def image_exists(self) -> bool:
|
||||
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
|
||||
@@ -72,11 +90,17 @@ class DockerGateway(Gateway):
|
||||
forces a full rebuild (parity with `start --no-cache`)."""
|
||||
if self._dockerfile is None:
|
||||
return
|
||||
context = self._build_context or resources.build_root()
|
||||
argv = ["docker", "build", "-t", self.image_ref,
|
||||
"-f", str(self._build_context / self._dockerfile),
|
||||
str(self._build_context)]
|
||||
"-f", str(context / self._dockerfile),
|
||||
str(context)]
|
||||
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||
argv.insert(2, "--no-cache")
|
||||
for name, value in resources.image_build_args(
|
||||
self._dockerfile,
|
||||
context=context,
|
||||
).items():
|
||||
argv[-1:-1] = ["--build-arg", f"{name}={value}"]
|
||||
proc = run_docker(argv)
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
|
||||
@@ -105,10 +129,72 @@ class DockerGateway(Gateway):
|
||||
def _ensure_network(self) -> None:
|
||||
"""Create the shared gateway network if it doesn't exist. Idempotent —
|
||||
a concurrent create loses harmlessly (the loser sees 'already exists').
|
||||
Docker picks the subnet; the launcher reads it back to allocate IPs."""
|
||||
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
|
||||
return
|
||||
proc = run_docker(["docker", "network", "create", self.network])
|
||||
The explicit subnet is required because bottle attribution pins source
|
||||
IPs; Docker rejects static endpoint addresses on an auto-IPAM network."""
|
||||
inspected = run_docker([
|
||||
"docker", "network", "inspect",
|
||||
"--format", f'{{{{index .Labels "{_GATEWAY_SUBNET_LABEL}"}}}}',
|
||||
self.network,
|
||||
])
|
||||
if inspected.returncode == 0:
|
||||
marker = inspected.stdout.strip()
|
||||
if marker in {"", self._subnet}:
|
||||
return
|
||||
# Inspectable but mislabelled: the stale auto-IPAM network created
|
||||
# by older releases. Replace it below.
|
||||
stale = True
|
||||
else:
|
||||
# inspect failed. Classify by stderr — do NOT assume "not absent"
|
||||
# implies "poisoned": a transient daemon/API error, permission
|
||||
# failure, timeout, or bad context also fails here, and destroying
|
||||
# the shared gateway on that guess would tear the network out from
|
||||
# under every live bottle.
|
||||
err = inspected.stderr.lower()
|
||||
if "no such network" in err or "not found" in err:
|
||||
# Absent: nothing to replace — create it below.
|
||||
stale = False
|
||||
elif "parseaddr" in err:
|
||||
# Present but poisoned. A daemon that default-enables IPv6
|
||||
# attaches an fdd0::/64 subnet whose `::1/64` gateway trips
|
||||
# docker's own netip.ParseAddr in `network inspect`/`ls`, so the
|
||||
# command exits non-zero with that signature. A fixed release
|
||||
# never *creates* such a network, but one can survive on a
|
||||
# shared host from an older or concurrent launch — and
|
||||
# `--ipv6=false` alone can't heal it, since the create below only
|
||||
# no-ops on "already exists". Force-replace it so later reads
|
||||
# (e.g. `_network_cidr` pinning a source IP) stop failing.
|
||||
stale = True
|
||||
else:
|
||||
# Unrecognized failure: no evidence the network is malformed.
|
||||
# Surface it rather than mutate shared state on a guess.
|
||||
raise GatewayError(
|
||||
f"gateway network {self.network} could not be inspected: "
|
||||
f"{inspected.stderr.strip()}"
|
||||
)
|
||||
if stale:
|
||||
# Migrate the stale/poisoned network. Removing the fixed gateway is
|
||||
# safe here: this launch recreates it.
|
||||
run_docker(["docker", "rm", "--force", self.name])
|
||||
removed = run_docker(["docker", "network", "rm", self.network])
|
||||
if removed.returncode != 0 and "no such network" not in removed.stderr.lower():
|
||||
raise GatewayError(
|
||||
f"gateway network {self.network} needs explicit subnet "
|
||||
f"{self._subnet} but could not be replaced: "
|
||||
f"{removed.stderr.strip()}"
|
||||
)
|
||||
proc = run_docker([
|
||||
"docker", "network", "create",
|
||||
# bot-bottle attribution pins IPv4 source IPs; it has no IPv6
|
||||
# support. Disable IPv6 explicitly so a daemon that default-enables
|
||||
# it (default-address-pools) can't attach an fdd0::/64 subnet — a
|
||||
# malformed `::1/64` gateway address then trips docker's own
|
||||
# ParseAddr in `network inspect`/`ls`, which poisons every launch
|
||||
# that reads this network's subnet.
|
||||
"--ipv6=false",
|
||||
"--subnet", self._subnet,
|
||||
"--label", f"{_GATEWAY_SUBNET_LABEL}={self._subnet}",
|
||||
self.network,
|
||||
])
|
||||
if proc.returncode != 0 and "already exists" not in proc.stderr:
|
||||
raise GatewayError(
|
||||
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
|
||||
@@ -139,9 +225,9 @@ class DockerGateway(Gateway):
|
||||
# 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.
|
||||
self._ensure_network()
|
||||
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.
|
||||
@@ -154,7 +240,7 @@ class DockerGateway(Gateway):
|
||||
# Persist the self-generated CA on the host so it survives both
|
||||
# container recreation AND docker volume pruning (agents trust it)
|
||||
# — see host_gateway_ca_dir / issue #450.
|
||||
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
|
||||
"--volume", f"{self._ca_mount_source}:{MITMPROXY_HOME}",
|
||||
# No DB mount: the data plane (egress / supervise / git-gate) reaches
|
||||
# the supervise queue over the control-plane RPC and never opens
|
||||
# bot-bottle.db, so the gateway container gets no file handle on it
|
||||
@@ -249,4 +335,4 @@ class DockerGateway(Gateway):
|
||||
def provisioning_transport(self) -> GatewayTransport:
|
||||
"""The exec/cp transport git-gate provisioning stages per-bottle repos +
|
||||
deploy keys through (over the docker socket)."""
|
||||
return DockerGatewayTransport(self.name)
|
||||
return DockerGatewayTransport(self.name)
|
||||
|
||||
@@ -33,7 +33,7 @@ from .orchestrator import (
|
||||
ORCHESTRATOR_NAME,
|
||||
ORCHESTRATOR_NETWORK,
|
||||
)
|
||||
from ...paths import bot_bottle_root
|
||||
from ... import resources
|
||||
from ...gateway import (
|
||||
GATEWAY_IMAGE,
|
||||
GATEWAY_NAME,
|
||||
@@ -50,8 +50,6 @@ from ...orchestrator.lifecycle import (
|
||||
# the pair's public identity.
|
||||
INFRA_NAME = GATEWAY_NAME # the container agents attribute against is the gateway
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DockerInfraService(InfraService):
|
||||
"""Composes the per-host control plane + gateway as two containers.
|
||||
@@ -68,8 +66,10 @@ class DockerInfraService(InfraService):
|
||||
control_network: str = ORCHESTRATOR_NETWORK,
|
||||
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
||||
gateway_image: str = GATEWAY_IMAGE,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
host_root: Path | None = None,
|
||||
root_mount_source: str | Path | None = None,
|
||||
gateway_ca_mount_source: str | Path | None = None,
|
||||
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||
orchestrator_label: str = ORCHESTRATOR_LABEL,
|
||||
gateway_name: str = GATEWAY_NAME,
|
||||
@@ -79,8 +79,14 @@ class DockerInfraService(InfraService):
|
||||
self.control_network = control_network
|
||||
self.orchestrator_image = orchestrator_image
|
||||
self.gateway_image = gateway_image
|
||||
self._repo_root = repo_root
|
||||
self._host_root = host_root or bot_bottle_root()
|
||||
# Build context: the repo root in a checkout, a staged copy from the
|
||||
# installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
if host_root is not None and root_mount_source is not None:
|
||||
raise ValueError("pass host_root or root_mount_source, not both")
|
||||
self._host_root = host_root
|
||||
self._root_mount_source = root_mount_source
|
||||
self._gateway_ca_mount_source = gateway_ca_mount_source
|
||||
self._orchestrator_name = orchestrator_name
|
||||
self._orchestrator_label = orchestrator_label
|
||||
self._gateway_name = gateway_name
|
||||
@@ -97,6 +103,7 @@ class DockerInfraService(InfraService):
|
||||
control_network=self.control_network,
|
||||
repo_root=self._repo_root,
|
||||
host_root=self._host_root,
|
||||
root_mount_source=self._root_mount_source,
|
||||
)
|
||||
|
||||
def gateway(self) -> DockerGateway:
|
||||
@@ -111,6 +118,7 @@ class DockerInfraService(InfraService):
|
||||
network=self.network,
|
||||
control_network=self.control_network,
|
||||
build_context=self._repo_root,
|
||||
ca_mount_source=self._gateway_ca_mount_source,
|
||||
)
|
||||
|
||||
def ensure_running(
|
||||
|
||||
@@ -33,7 +33,6 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import os
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
@@ -65,10 +64,7 @@ from ...orchestrator.store.config_store import resolve_teardown_timeout
|
||||
from .consolidated_launch import launch_consolidated, deprovision_consolidated
|
||||
from .infra import INFRA_NAME
|
||||
from .gateway import DockerGateway
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
from ... import resources
|
||||
|
||||
|
||||
def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
|
||||
@@ -88,7 +84,7 @@ def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
return BottleImages(agent=plan.image)
|
||||
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
docker_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
|
||||
docker_mod.verify_agent_image(
|
||||
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
|
||||
)
|
||||
|
||||
@@ -15,11 +15,11 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from ... import log
|
||||
from ... import resources
|
||||
from .util import run_docker
|
||||
from ...paths import (
|
||||
ORCHESTRATOR_TOKEN_ENV,
|
||||
bot_bottle_root,
|
||||
host_orchestrator_token,
|
||||
)
|
||||
from ...gateway import GatewayError
|
||||
from ...orchestrator.lifecycle import (
|
||||
@@ -42,13 +42,9 @@ ORCHESTRATOR_IMAGE = os.environ.get(
|
||||
)
|
||||
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
|
||||
# Baked as a container label so `ensure_running` can detect whether the running
|
||||
# orchestrator is executing the current bind-mounted source.
|
||||
# orchestrator image was built from the current source.
|
||||
ORCHESTRATOR_SOURCE_HASH_LABEL = "bot-bottle-orchestrator-source-hash"
|
||||
|
||||
# The bind-mount path for the live control-plane source inside the container.
|
||||
# PYTHONPATH points here so a code change takes effect on the next launch
|
||||
# without an image rebuild.
|
||||
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
||||
# Bot-bottle host-root bind-mount (DB + state) inside the orchestrator. The
|
||||
# control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT ->
|
||||
# host_db_path()); it is the ONLY container with a handle on it (issue #469).
|
||||
@@ -56,8 +52,6 @@ _ROOT_IN_CONTAINER = "/bot-bottle-root"
|
||||
|
||||
_HEALTH_POLL_SECONDS = 0.25
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DockerOrchestrator(Orchestrator):
|
||||
"""The control plane as a single fixed-name container. `ensure_built` builds
|
||||
@@ -72,23 +66,53 @@ class DockerOrchestrator(Orchestrator):
|
||||
label: str = ORCHESTRATOR_LABEL,
|
||||
port: int = DEFAULT_PORT,
|
||||
control_network: str = ORCHESTRATOR_NETWORK,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
host_root: Path | None = None,
|
||||
root_mount_source: str | Path | None = None,
|
||||
client_host: str | None = None,
|
||||
client_network: str | None = None,
|
||||
bind_host: str | None = None,
|
||||
dockerfile: str | None = ORCHESTRATOR_DOCKERFILE,
|
||||
) -> None:
|
||||
if host_root is not None and root_mount_source is not None:
|
||||
raise ValueError("pass host_root or root_mount_source, not both")
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
self.label = label
|
||||
self.port = port
|
||||
self.control_network = control_network
|
||||
self._repo_root = repo_root
|
||||
self._host_root = host_root or bot_bottle_root()
|
||||
# Build context: the repo root in a checkout, a staged copy from the
|
||||
# installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
configured_root = os.environ.get("BOT_BOTTLE_DOCKER_ROOT_MOUNT", "").strip()
|
||||
self._root_mount_source = str(
|
||||
root_mount_source or configured_root or host_root or bot_bottle_root()
|
||||
)
|
||||
configured_network = os.environ.get(
|
||||
"BOT_BOTTLE_DOCKER_CLIENT_NETWORK", ""
|
||||
).strip()
|
||||
self._client_network = client_network or configured_network or None
|
||||
configured_host = os.environ.get(
|
||||
"BOT_BOTTLE_DOCKER_HOST_ADDRESS", ""
|
||||
).strip()
|
||||
self._client_host = (
|
||||
client_host or configured_host
|
||||
or (self.name if self._client_network else "127.0.0.1")
|
||||
)
|
||||
# A socket-shared CI runner reaches published ports through its Docker
|
||||
# network rather than its own loopback. Production stays bound to host
|
||||
# loopback unless a caller explicitly selects another client.
|
||||
self._bind_host = bind_host or (
|
||||
"0.0.0.0"
|
||||
if not self._client_network and self._client_host != "127.0.0.1"
|
||||
else "127.0.0.1"
|
||||
)
|
||||
self._dockerfile = dockerfile
|
||||
|
||||
def url(self) -> str:
|
||||
"""Host-side control-plane URL — the orchestrator's published loopback,
|
||||
which the CLI reaches."""
|
||||
return f"http://127.0.0.1:{self.port}"
|
||||
"""Control-plane URL reachable by this Docker client."""
|
||||
port = DEFAULT_PORT if self._client_network else self.port
|
||||
return f"http://{self._client_host}:{port}"
|
||||
|
||||
def gateway_url(self) -> str:
|
||||
"""The URL the gateway's data plane resolves policy against — the
|
||||
@@ -107,6 +131,11 @@ class DockerOrchestrator(Orchestrator):
|
||||
str(self._repo_root)]
|
||||
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||
argv.insert(2, "--no-cache")
|
||||
for name, value in resources.image_build_args(
|
||||
self._dockerfile,
|
||||
context=self._repo_root,
|
||||
).items():
|
||||
argv[-1:-1] = ["--build-arg", f"{name}={value}"]
|
||||
proc = run_docker(argv)
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(
|
||||
@@ -119,8 +148,7 @@ class DockerOrchestrator(Orchestrator):
|
||||
return self.name in proc.stdout.split()
|
||||
|
||||
def _source_current(self, current_hash: str) -> bool:
|
||||
"""True iff the running orchestrator was started from the current
|
||||
bind-mounted source."""
|
||||
"""True iff the running orchestrator image matches current source."""
|
||||
if not self.is_running():
|
||||
return False
|
||||
proc = run_docker([
|
||||
@@ -171,7 +199,9 @@ class DockerOrchestrator(Orchestrator):
|
||||
fixed-name container first)."""
|
||||
self._ensure_control_network()
|
||||
run_docker(["docker", "rm", "--force", self.name])
|
||||
_signing_key = host_orchestrator_token()
|
||||
# The signing key comes through the shared provisioning contract (#476),
|
||||
# which fail-closes rather than yield an empty key that would run OPEN.
|
||||
_signing_key = self.control_plane_key()
|
||||
proc = run_docker([
|
||||
"docker", "run", "--detach",
|
||||
"--name", self.name,
|
||||
@@ -180,15 +210,19 @@ class DockerOrchestrator(Orchestrator):
|
||||
# Control network only — agents are never on it, so they have no
|
||||
# route to the control plane (the L3 block, not just the JWT).
|
||||
"--network", self.control_network,
|
||||
# Host CLI reaches the control plane here (loopback only). The
|
||||
# Host CLI reaches the control plane here (loopback by default).
|
||||
# Socket-shared CI joins the container directly to the job network;
|
||||
# the host-side mapping remains loopback-only in that topology. The
|
||||
# orchestrator listens on the fixed DEFAULT_PORT inside the
|
||||
# container; self.port is the host-side published port.
|
||||
"--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}",
|
||||
# Live control-plane source (code changes without an image rebuild).
|
||||
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
|
||||
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
|
||||
"--publish", f"{self._bind_host}:{self.port}:{DEFAULT_PORT}",
|
||||
# The image was rebuilt from `_repo_root` immediately before this
|
||||
# launch. Running its baked package avoids a host-path bind mount,
|
||||
# which is both more production-like and works with socket-shared
|
||||
# CI where the daemon cannot see the job container's workspace.
|
||||
# Orchestrator registry DB on the host (sole writer: control plane).
|
||||
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
|
||||
# `root_mount_source` may be a host path or a named Docker volume.
|
||||
"--volume", f"{self._root_mount_source}:{_ROOT_IN_CONTAINER}",
|
||||
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
|
||||
# The signing key — held ONLY by the orchestrator (it verifies
|
||||
# tokens); the gateway gets the pre-minted `gateway` JWT, never the
|
||||
@@ -203,6 +237,15 @@ class DockerOrchestrator(Orchestrator):
|
||||
raise OrchestratorStartError(
|
||||
f"orchestrator container failed to start: {proc.stderr.strip()}"
|
||||
)
|
||||
if self._client_network:
|
||||
proc = run_docker([
|
||||
"docker", "network", "connect", self._client_network, self.name,
|
||||
])
|
||||
if proc.returncode != 0:
|
||||
raise OrchestratorStartError(
|
||||
f"orchestrator container failed to join client network "
|
||||
f"{self._client_network}: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Remove the control-plane container (idempotent)."""
|
||||
|
||||
@@ -6,12 +6,18 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Iterator
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from ...util import slugify as _slugify
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Compatibility wrapper; new generic callers import ``bot_bottle.util``."""
|
||||
return _slugify(name)
|
||||
|
||||
|
||||
def run_docker(
|
||||
@@ -114,20 +120,13 @@ def docker_cp(src: str, dest: str) -> None:
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, non-alnum runs → '-', trimmed. Dies on empty result."""
|
||||
if not name:
|
||||
die("slugify: missing name")
|
||||
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
|
||||
if not slug:
|
||||
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
|
||||
return slug
|
||||
|
||||
|
||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
def build_image(
|
||||
ref: str,
|
||||
context: str,
|
||||
*,
|
||||
dockerfile: str = "",
|
||||
build_args: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Invokes `docker build` every call. Layer cache makes no-change
|
||||
rebuilds cheap; running every time means Dockerfile edits land
|
||||
without manual `docker rmi`.
|
||||
@@ -147,10 +146,61 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
args.append("--no-cache")
|
||||
if dockerfile:
|
||||
args.extend(["-f", dockerfile])
|
||||
effective_build_args = resources.image_build_args(
|
||||
dockerfile,
|
||||
context=context,
|
||||
) if dockerfile else {}
|
||||
effective_build_args.update(build_args or {})
|
||||
for name, value in effective_build_args.items():
|
||||
args.extend(["--build-arg", f"{name}={value}"])
|
||||
args.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def image_id(ref: str) -> str:
|
||||
"""Return the exact content-addressed ID for a local image.
|
||||
|
||||
This is used when one locally built image is another Dockerfile's base:
|
||||
passing the ID prevents a mutable tag from being resolved between builds.
|
||||
"""
|
||||
result = run_docker(["docker", "image", "inspect", "--format", "{{.Id}}", ref])
|
||||
image = result.stdout.strip()
|
||||
if result.returncode != 0 or not image.startswith("sha256:"):
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
die(f"could not resolve exact image ID for {ref!r}: {detail or '<no detail>'}")
|
||||
return image
|
||||
|
||||
|
||||
def pinned_local_image_ref(ref: str) -> str:
|
||||
"""Give a local image a content-derived tag and verify the tag resolves
|
||||
back to the same image ID.
|
||||
|
||||
BuildKit treats a bare ``sha256:...`` ID in ``FROM`` as a registry
|
||||
repository name. A tag whose complete suffix is the local image ID remains
|
||||
resolvable by BuildKit, while the post-tag inspection keeps the handoff
|
||||
fail-closed.
|
||||
"""
|
||||
image = image_id(ref)
|
||||
digest = image.removeprefix("sha256:")
|
||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||
die(f"could not derive a local base tag from invalid image ID {image!r}")
|
||||
|
||||
repository = ref.split("@", 1)[0]
|
||||
last_slash = repository.rfind("/")
|
||||
last_colon = repository.rfind(":")
|
||||
if last_colon > last_slash:
|
||||
repository = repository[:last_colon]
|
||||
pinned_ref = f"{repository}:sha256-{digest}"
|
||||
|
||||
result = run_docker(["docker", "image", "tag", image, pinned_ref])
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
die(f"could not tag exact local image {image}: {detail or '<no detail>'}")
|
||||
if image_id(pinned_ref) != image:
|
||||
die(f"content-derived local tag {pinned_ref!r} did not resolve to {image}")
|
||||
return pinned_ref
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -11,7 +11,8 @@ from pathlib import Path
|
||||
|
||||
from ..bottle_state import egress_state_dir
|
||||
from ..egress import EGRESS_ROUTES_FILENAME
|
||||
from ..gateway.egress.addon_core import LOG_OFF, load_config
|
||||
from ..gateway.egress.schema import load_config
|
||||
from ..gateway.egress.types import LOG_OFF
|
||||
|
||||
|
||||
class EgressApplyError(RuntimeError):
|
||||
|
||||
@@ -27,3 +27,11 @@ class FirecrackerBottleCleanupPlan(BottleCleanupPlan):
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return not (self.vm_pids or self.run_dirs)
|
||||
|
||||
def intersect(self, current: BottleCleanupPlan) -> "FirecrackerBottleCleanupPlan":
|
||||
if not isinstance(current, FirecrackerBottleCleanupPlan):
|
||||
raise TypeError("cleanup plans must have the same backend type")
|
||||
return FirecrackerBottleCleanupPlan(
|
||||
vm_pids=tuple(x for x in self.vm_pids if x in current.vm_pids),
|
||||
run_dirs=tuple(x for x in self.run_dirs if x in current.run_dirs),
|
||||
)
|
||||
|
||||
@@ -11,10 +11,9 @@ Reaps *orphans* only — resources with no live VM behind them:
|
||||
— a VMM left lingering after its dir was removed.
|
||||
|
||||
A run dir with a *live* firecracker process is a running bottle and is
|
||||
left strictly alone: it is neither killed nor removed. (The backend's
|
||||
`enumerate_active` registry is still a stub — #354 — so a live process
|
||||
is the only reliable "this bottle is in use" signal we have. Once the
|
||||
registry lands, registry-orphaned-but-running VMs can be reaped too.)
|
||||
left strictly alone: it is neither killed nor removed. Active-agent
|
||||
enumeration uses this same process snapshot, so cleanup and generic
|
||||
backend consumers agree about which bottles are running.
|
||||
|
||||
TAP slots free themselves (the flock drops when the launcher exits), so
|
||||
there is nothing to reclaim there.
|
||||
@@ -22,14 +21,16 @@ there is nothing to reclaim there.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import info
|
||||
from . import util
|
||||
from .. import EnumerationError
|
||||
from ..cleanup_control import CleanupError, CleanupFailures
|
||||
from . import lifecycle_lock, util
|
||||
from .bottle_cleanup_plan import FirecrackerBottleCleanupPlan
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ def _run_root() -> Path:
|
||||
return util.cache_dir() / "run"
|
||||
|
||||
|
||||
def _run_dir_of(cmd: str, run_root: Path) -> Path | None:
|
||||
def _run_dir_of(args: Sequence[str], run_root: Path) -> Path | None:
|
||||
"""The bottle run dir a firecracker cmdline belongs to, or None.
|
||||
|
||||
A bottle VM is launched with `--config-file <run_root>/<slug>/config.json`,
|
||||
@@ -45,15 +46,35 @@ def _run_dir_of(cmd: str, run_root: Path) -> Path | None:
|
||||
the run root. Anything else (a builder VM, the infra VM elsewhere) is
|
||||
not ours to reap here.
|
||||
"""
|
||||
toks = cmd.split()
|
||||
for i, tok in enumerate(toks):
|
||||
if tok == "--config-file" and i + 1 < len(toks):
|
||||
parent = Path(toks[i + 1]).parent
|
||||
for i, arg in enumerate(args):
|
||||
if arg == "--config-file" and i + 1 < len(args):
|
||||
parent = Path(args[i + 1]).parent
|
||||
if parent.parent == run_root:
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _decode_cmdline(raw: bytes) -> tuple[str, ...]:
|
||||
"""Decode Linux's NUL-delimited argv without losing embedded spaces."""
|
||||
return tuple(
|
||||
value.decode(errors="surrogateescape")
|
||||
for value in raw.split(b"\0") if value
|
||||
)
|
||||
|
||||
|
||||
def _process_args(pid: int) -> tuple[str, ...] | None:
|
||||
"""Read one process's lossless argv, or None when it exited meanwhile."""
|
||||
try:
|
||||
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
raise EnumerationError(
|
||||
f"could not inspect Firecracker pid {pid}: {exc}"
|
||||
) from exc
|
||||
return _decode_cmdline(raw)
|
||||
|
||||
|
||||
def _scan_processes(run_root: Path) -> tuple[set[str], list[int]]:
|
||||
"""Inspect running firecracker VMs under ``run_root``.
|
||||
|
||||
@@ -62,23 +83,34 @@ def _scan_processes(run_root: Path) -> tuple[set[str], list[int]]:
|
||||
* ``orphan_pids`` — firecracker pids whose run dir no longer exists
|
||||
(a lingering VMM to kill).
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["pgrep", "-a", "firecracker"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "firecracker"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise EnumerationError(
|
||||
f"could not enumerate Firecracker processes: {exc}"
|
||||
) from exc
|
||||
if result.returncode == 1:
|
||||
# pgrep's documented "no processes matched" result.
|
||||
return set(), []
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or "").strip() or f"exit {result.returncode}"
|
||||
raise EnumerationError(
|
||||
f"could not enumerate Firecracker processes: {detail}"
|
||||
)
|
||||
live: set[str] = set()
|
||||
orphan_pids: list[int] = []
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
try:
|
||||
pid = int(parts[0])
|
||||
pid = int(line.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
run_dir = _run_dir_of(parts[1], run_root)
|
||||
args = _process_args(pid)
|
||||
if args is None:
|
||||
continue
|
||||
run_dir = _run_dir_of(args, run_root)
|
||||
if run_dir is None:
|
||||
continue
|
||||
if run_dir.is_dir():
|
||||
@@ -114,12 +146,54 @@ def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
||||
|
||||
|
||||
def cleanup(plan: FirecrackerBottleCleanupPlan) -> None:
|
||||
for pid in plan.vm_pids:
|
||||
"""Revalidate the preview under the launch lock, then remove its survivors."""
|
||||
with lifecycle_lock.hold():
|
||||
fresh = prepare_cleanup()
|
||||
approved_pids = set(plan.vm_pids).intersection(fresh.vm_pids)
|
||||
approved_dirs = set(plan.run_dirs).intersection(fresh.run_dirs)
|
||||
failures = CleanupFailures()
|
||||
for pid in sorted(approved_pids):
|
||||
try:
|
||||
_terminate_orphan(pid, _run_root())
|
||||
except CleanupError as exc:
|
||||
failures.record(str(exc))
|
||||
for path in sorted(approved_dirs):
|
||||
info(f"rm -rf {path}")
|
||||
failures.remove_tree(Path(path), f"removing Firecracker run dir {path}")
|
||||
failures.raise_if_any()
|
||||
|
||||
|
||||
def _terminate_orphan(pid: int, run_root: Path) -> None:
|
||||
"""Signal exactly the process identity that still owns an orphan config."""
|
||||
try:
|
||||
pidfd = os.pidfd_open(pid)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except OSError as exc:
|
||||
raise EnumerationError(
|
||||
f"could not pin Firecracker pid {pid} for cleanup: {exc}"
|
||||
) from exc
|
||||
try:
|
||||
try:
|
||||
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as exc:
|
||||
raise EnumerationError(
|
||||
f"could not revalidate Firecracker pid {pid}: {exc}"
|
||||
) from exc
|
||||
args = _decode_cmdline(raw)
|
||||
run_dir = _run_dir_of(args, run_root)
|
||||
if run_dir is None or run_dir.is_dir():
|
||||
return
|
||||
info(f"kill firecracker VM pid {pid}")
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
signal.pidfd_send_signal(pidfd, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
for path in plan.run_dirs:
|
||||
info(f"rm -rf {path}")
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
return
|
||||
except OSError as exc:
|
||||
raise CleanupError(
|
||||
f"could not signal Firecracker pid {pid}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
os.close(pidfd)
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
"""Active-agent enumeration for the Firecracker backend.
|
||||
|
||||
The backend is disabled during the companion-container removal (#385) — it can't
|
||||
launch bottles, so there are none to enumerate. Real enumeration returns
|
||||
with the backend's consolidated relaunch (#354).
|
||||
Running bottles are the Firecracker processes whose ``--config-file`` points
|
||||
at an existing per-bottle run directory. The same authoritative process scan
|
||||
protects cleanup from deleting live VMs; operational scan failures propagate
|
||||
as ``EnumerationError`` instead of masquerading as an empty host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...bottle_state import read_metadata
|
||||
from .. import ActiveAgent
|
||||
from .cleanup import live_run_dirs
|
||||
|
||||
|
||||
def enumerate_active() -> list[ActiveAgent]:
|
||||
return []
|
||||
out: list[ActiveAgent] = []
|
||||
for run_dir in live_run_dirs():
|
||||
slug = run_dir.name
|
||||
metadata = read_metadata(slug)
|
||||
out.append(ActiveAgent(
|
||||
backend_name="firecracker",
|
||||
slug=slug,
|
||||
agent_name=metadata.agent_name if metadata else "?",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
# Firecracker uses the shared gateway, so there are no
|
||||
# per-bottle gateway service containers to report.
|
||||
services=(),
|
||||
label=metadata.label if metadata else "",
|
||||
color=metadata.color if metadata else "",
|
||||
))
|
||||
return out
|
||||
|
||||
@@ -19,12 +19,14 @@ from __future__ import annotations
|
||||
import fcntl
|
||||
import hashlib
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from . import util
|
||||
from .infra import FirecrackerInfraService
|
||||
@@ -55,6 +57,11 @@ def _rootfs_digest(dockerfile: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
h.update(_dockerfile_hash(dockerfile).encode())
|
||||
h.update(b"\0")
|
||||
for name, value in resources.image_build_args(dockerfile).items():
|
||||
h.update(name.encode())
|
||||
h.update(b"=")
|
||||
h.update(value.encode())
|
||||
h.update(b"\0")
|
||||
h.update(util._GUEST_INIT.encode())
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
@@ -147,7 +154,13 @@ def _build_in_infra(
|
||||
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)
|
||||
_buildah_build(
|
||||
key,
|
||||
ip,
|
||||
ctx,
|
||||
tag,
|
||||
resources.image_build_args(dockerfile),
|
||||
)
|
||||
_smoke_test(key, ip, tag, smoke_ctr, smoke_test)
|
||||
_stream_rootfs(key, ip, tag, export_ctr, base)
|
||||
finally:
|
||||
@@ -184,15 +197,26 @@ def _send_dockerfile(private_key: Path, guest_ip: str, dockerfile: Path, ctx: st
|
||||
f"{proc.stderr.decode(errors='replace').strip()}")
|
||||
|
||||
|
||||
def _buildah_build(private_key: Path, guest_ip: str, ctx: str, tag: str) -> None:
|
||||
def _buildah_build(
|
||||
private_key: Path,
|
||||
guest_ip: str,
|
||||
ctx: str,
|
||||
tag: str,
|
||||
build_args: dict[str, 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)")
|
||||
arg_flags = " ".join(
|
||||
f"--build-arg {shlex.quote(f'{name}={value}')}"
|
||||
for name, value in build_args.items()
|
||||
)
|
||||
rc = _ssh_streamed(
|
||||
private_key, guest_ip,
|
||||
f"buildah build {_BUILD_FLAGS} -t {tag} -f {ctx}/Dockerfile {ctx}/ctx",
|
||||
f"buildah build {_BUILD_FLAGS} {arg_flags} "
|
||||
f"-t {tag} -f {ctx}/Dockerfile {ctx}/ctx",
|
||||
timeout=_BUILD_TIMEOUT_SECONDS,
|
||||
)
|
||||
if rc != 0:
|
||||
|
||||
@@ -37,22 +37,32 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from . import util
|
||||
|
||||
ARTIFACT_HTTP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# 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]
|
||||
|
||||
# The two per-plane infra VM roles. Each publishes/pulls its own rootfs artifact
|
||||
# from its own generic package; the Dockerfiles baked into each differ (only the
|
||||
# orchestrator rootfs carries buildah), so the versions are hashed separately.
|
||||
ROLES = ("orchestrator", "gateway")
|
||||
_DOCKERFILES = {
|
||||
"orchestrator": ("Dockerfile.orchestrator", "Dockerfile.orchestrator.fc"),
|
||||
"gateway": ("Dockerfile.gateway",),
|
||||
_BUILD_INPUTS = {
|
||||
"orchestrator": (
|
||||
"image-build-args.json",
|
||||
"Dockerfile.orchestrator",
|
||||
"Dockerfile.orchestrator.fc",
|
||||
"requirements.orchestrator.lock",
|
||||
),
|
||||
"gateway": (
|
||||
"image-build-args.json",
|
||||
"Dockerfile.gateway",
|
||||
"requirements.gateway.lock",
|
||||
),
|
||||
}
|
||||
|
||||
_DEFAULT_BASE = "https://gitea.dideric.is"
|
||||
@@ -74,7 +84,7 @@ def local_build_requested() -> bool:
|
||||
|
||||
|
||||
def infra_artifact_version(
|
||||
init_script: str, role: str, *, repo_root: Path = _REPO_ROOT,
|
||||
init_script: str, role: str, *, repo_root: Path | None = None,
|
||||
) -> str:
|
||||
"""Content hash (16 hex) of everything baked into `role`'s infra rootfs: the
|
||||
whole shipped `bot_bottle` package, that role's Dockerfiles, and its guest
|
||||
@@ -89,6 +99,8 @@ def infra_artifact_version(
|
||||
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."""
|
||||
if repo_root is None:
|
||||
repo_root = resources.build_root()
|
||||
h = hashlib.sha256()
|
||||
h.update(f"format={_ARTIFACT_FORMAT}\nrole={role}\n".encode())
|
||||
pkg = repo_root / "bot_bottle"
|
||||
@@ -100,7 +112,7 @@ def infra_artifact_version(
|
||||
h.update(str(path.relative_to(repo_root)).encode())
|
||||
h.update(b"\0")
|
||||
h.update(path.read_bytes())
|
||||
for name in _DOCKERFILES[role]:
|
||||
for name in _BUILD_INPUTS[role]:
|
||||
h.update(name.encode())
|
||||
h.update(b"\0")
|
||||
h.update((repo_root / name).read_bytes())
|
||||
@@ -154,7 +166,9 @@ 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:
|
||||
with urllib.request.urlopen(
|
||||
_open(url), timeout=ARTIFACT_HTTP_TIMEOUT_SECONDS,
|
||||
) as resp, open(tmp, "wb") as out:
|
||||
shutil.copyfileobj(resp, out, _CHUNK)
|
||||
except urllib.error.HTTPError as e:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
@@ -42,6 +42,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
from ..docker import util as docker_mod
|
||||
from . import firecracker_vm, infra_artifact, netpool, util
|
||||
@@ -65,7 +66,6 @@ _GUEST_GATEWAY_JWT_PATH = "/var/lib/bot-bottle/gateway-jwt"
|
||||
_GATEWAY_IMAGE = "bot-bottle-gateway:latest"
|
||||
_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:latest"
|
||||
_ORCHESTRATOR_FC_IMAGE = "bot-bottle-orchestrator-fc:latest"
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# Per-role rootfs source image + the extra free space `mke2fs` leaves for the
|
||||
# guest to grow into. The orchestrator keeps buildah's large build slack; the
|
||||
@@ -130,12 +130,18 @@ def build_infra_images_with_docker() -> None:
|
||||
orchestrator + buildah). The gateway VM boots the gateway image directly.
|
||||
The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local` mode;
|
||||
`publish_infra` uses it off-host to produce the published artifacts."""
|
||||
root = str(resources.build_root())
|
||||
docker_mod.build_image(
|
||||
_ORCHESTRATOR_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator")
|
||||
_ORCHESTRATOR_IMAGE, root, dockerfile="Dockerfile.orchestrator")
|
||||
orchestrator_base = docker_mod.pinned_local_image_ref(_ORCHESTRATOR_IMAGE)
|
||||
docker_mod.build_image(
|
||||
_GATEWAY_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.gateway")
|
||||
_GATEWAY_IMAGE, root, dockerfile="Dockerfile.gateway")
|
||||
docker_mod.build_image(
|
||||
_ORCHESTRATOR_FC_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator.fc")
|
||||
_ORCHESTRATOR_FC_IMAGE,
|
||||
root,
|
||||
dockerfile="Dockerfile.orchestrator.fc",
|
||||
build_args={"ORCHESTRATOR_BASE_IMAGE": orchestrator_base},
|
||||
)
|
||||
|
||||
|
||||
def build_rootfs_dir(role: str) -> Path:
|
||||
|
||||
@@ -46,7 +46,7 @@ from ...log import die, info, warn
|
||||
from ...supervisor.types import SUPERVISE_PORT
|
||||
from ..docker.egress import EGRESS_PORT
|
||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from . import firecracker_vm, image_builder, isolation_probe, netpool, util
|
||||
from . import firecracker_vm, image_builder, isolation_probe, lifecycle_lock, netpool, util
|
||||
from .bottle import FirecrackerBottle
|
||||
from .bottle_plan import FirecrackerBottlePlan
|
||||
from ...orchestrator.store.config_store import resolve_teardown_timeout
|
||||
@@ -164,25 +164,29 @@ def launch(
|
||||
)
|
||||
|
||||
# Step 6: build the per-bottle rootfs + SSH key, then boot.
|
||||
run_dir = util.cache_dir() / "run" / plan.slug
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Remove the run dir on teardown so the per-bottle rootfs.ext4 (~1G)
|
||||
# doesn't leak. Registered before vm.terminate below so it runs *after*
|
||||
# it (ExitStack is LIFO): the VM is gone before we rm its rootfs.
|
||||
stack.callback(lambda: shutil.rmtree(run_dir, ignore_errors=True))
|
||||
rootfs = run_dir / "rootfs.ext4"
|
||||
util.build_rootfs_ext4(agent_base, rootfs)
|
||||
private_key, pubkey = util.generate_keypair(run_dir)
|
||||
# Cleanup takes the same lock while refreshing its process snapshot.
|
||||
# Hold it until the VMM exists so a newly-created run dir can never be
|
||||
# mistaken for an orphan in the build-before-boot window.
|
||||
with lifecycle_lock.hold():
|
||||
run_dir = util.cache_dir() / "run" / plan.slug
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Remove the run dir on teardown so the per-bottle rootfs.ext4 (~1G)
|
||||
# doesn't leak. Registered before vm.terminate below so it runs
|
||||
# *after* it (ExitStack is LIFO).
|
||||
stack.callback(lambda: shutil.rmtree(run_dir, ignore_errors=True))
|
||||
rootfs = run_dir / "rootfs.ext4"
|
||||
util.build_rootfs_ext4(agent_base, rootfs)
|
||||
private_key, pubkey = util.generate_keypair(run_dir)
|
||||
|
||||
vm = firecracker_vm.boot(
|
||||
name=plan.container_name,
|
||||
rootfs=rootfs,
|
||||
tap=slot.iface,
|
||||
guest_ip=slot.guest_ip,
|
||||
host_ip=slot.host_ip,
|
||||
pubkey=pubkey,
|
||||
run_dir=run_dir,
|
||||
)
|
||||
vm = firecracker_vm.boot(
|
||||
name=plan.container_name,
|
||||
rootfs=rootfs,
|
||||
tap=slot.iface,
|
||||
guest_ip=slot.guest_ip,
|
||||
host_ip=slot.host_ip,
|
||||
pubkey=pubkey,
|
||||
run_dir=run_dir,
|
||||
)
|
||||
stack.callback(vm.terminate)
|
||||
firecracker_vm.wait_for_ssh(vm, private_key)
|
||||
persist_env_var_secret(private_key, slot.guest_ip, ctx.env_var_secret)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Serialize Firecracker run-directory creation with orphan cleanup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from . import util
|
||||
|
||||
|
||||
def _lock_path() -> Path:
|
||||
return util.cache_dir() / "run.lifecycle.lock"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def hold() -> Generator[None]:
|
||||
"""Exclude cleanup while a launch directory lacks a visible VMM."""
|
||||
path = _lock_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
__all__ = ["hold"]
|
||||
@@ -21,7 +21,6 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import die, info
|
||||
from ...paths import host_orchestrator_token
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
Orchestrator,
|
||||
@@ -108,11 +107,13 @@ class FirecrackerOrchestrator(Orchestrator):
|
||||
data_drive=self._ensure_registry_volume(),
|
||||
)
|
||||
# Push the host-canonical signing key (the init waits for it before
|
||||
# starting the control plane). The host token file stays the single
|
||||
# source of truth, so a co-running docker/macOS control plane keeps
|
||||
# working; the guest verifies tokens with the same key the CLI signs from.
|
||||
# starting the control plane). It comes through the shared provisioning
|
||||
# contract (#476) — the same host token file every backend uses, so a
|
||||
# co-running docker/macOS control plane keeps working and the guest
|
||||
# verifies tokens with the same key the CLI signs from; fail-closed, so
|
||||
# the guest is never handed an empty key that would run it OPEN.
|
||||
infra_vm.push_secret(
|
||||
vm, host_orchestrator_token(), infra_vm._GUEST_SIGNING_KEY_PATH,
|
||||
vm, self.control_plane_key(), infra_vm._GUEST_SIGNING_KEY_PATH,
|
||||
"the control-plane signing key to the orchestrator VM "
|
||||
"(its control plane will not start)",
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ from pathlib import Path
|
||||
from . import infra_artifact, infra_vm, util
|
||||
|
||||
_CHUNK = 1 << 20
|
||||
_REGISTRY_HTTP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
_GZ_NAME = "rootfs.ext4.gz"
|
||||
_SHA_NAME = "rootfs.ext4.gz.sha256"
|
||||
@@ -91,7 +92,9 @@ def _put(url: str, body: "bytes | Path", token: str) -> None:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
with urllib.request.urlopen(
|
||||
req, timeout=_REGISTRY_HTTP_TIMEOUT_SECONDS,
|
||||
) as resp:
|
||||
print(f" uploaded {url} (HTTP {resp.status})")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 409:
|
||||
@@ -112,7 +115,9 @@ def _delete(url: str, token: str) -> None:
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
try:
|
||||
with urllib.request.urlopen(req):
|
||||
with urllib.request.urlopen(
|
||||
req, timeout=_REGISTRY_HTTP_TIMEOUT_SECONDS,
|
||||
):
|
||||
pass
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
@@ -151,7 +156,10 @@ def _try_download_published(role: str, role_dir: Path) -> str | None:
|
||||
version = _role_version(role)
|
||||
sha_url = infra_artifact.artifact_url(version, _SHA_NAME, role=role)
|
||||
try:
|
||||
with urllib.request.urlopen(infra_artifact._open(sha_url)):
|
||||
with urllib.request.urlopen(
|
||||
infra_artifact._open(sha_url),
|
||||
timeout=_REGISTRY_HTTP_TIMEOUT_SECONDS,
|
||||
):
|
||||
pass
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
@@ -195,7 +203,10 @@ def _publish_bundle(role: str, role_dir: Path, token: str) -> str:
|
||||
# present, a re-publish is a no-op. Otherwise clear any partial upload left
|
||||
# by an interrupted prior attempt and upload the complete set.
|
||||
try:
|
||||
with urllib.request.urlopen(infra_artifact._open(sha_url)) as resp:
|
||||
with urllib.request.urlopen(
|
||||
infra_artifact._open(sha_url),
|
||||
timeout=_REGISTRY_HTTP_TIMEOUT_SECONDS,
|
||||
) as resp:
|
||||
remote_sha = resp.read().decode("utf-8").split()[0].strip().lower()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
|
||||
@@ -20,6 +20,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from . import netpool
|
||||
from . import util
|
||||
|
||||
@@ -42,13 +43,13 @@ def _has_systemd() -> bool:
|
||||
|
||||
|
||||
def _module_path() -> str:
|
||||
"""Absolute path to the importable NixOS module in this checkout."""
|
||||
return str(Path(__file__).resolve().parents[3] / "nix" / "firecracker-netpool.nix")
|
||||
"""Absolute path to the importable NixOS module (checkout or wheel)."""
|
||||
return str(resources.nix_netpool_module())
|
||||
|
||||
|
||||
def _script_path() -> str:
|
||||
"""Absolute path to the bundled bring-up script in this checkout."""
|
||||
return str(Path(__file__).resolve().parents[3] / "scripts" / "firecracker-netpool.sh")
|
||||
"""Absolute path to the bundled bring-up script (checkout or wheel)."""
|
||||
return str(resources.netpool_script())
|
||||
|
||||
|
||||
def _print_prereqs() -> None:
|
||||
|
||||
@@ -25,3 +25,11 @@ class MacosContainerBottleCleanupPlan(BottleCleanupPlan):
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return not self.containers and not self.networks
|
||||
|
||||
def intersect(self, current: BottleCleanupPlan) -> "MacosContainerBottleCleanupPlan":
|
||||
if not isinstance(current, MacosContainerBottleCleanupPlan):
|
||||
raise TypeError("cleanup plans must have the same backend type")
|
||||
return MacosContainerBottleCleanupPlan(
|
||||
containers=tuple(x for x in self.containers if x in current.containers),
|
||||
networks=tuple(x for x in self.networks if x in current.networks),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from ...log import info, warn
|
||||
from .. import EnumerationError
|
||||
from ..cleanup_control import CleanupFailures
|
||||
from ...log import info
|
||||
from . import util as container_mod
|
||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||
|
||||
@@ -19,8 +21,8 @@ def _list_prefixed_containers() -> list[str]:
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
warn(f"container list failed: {result.stderr.strip()}")
|
||||
return []
|
||||
detail = result.stderr.strip() or f"exit {result.returncode}"
|
||||
raise EnumerationError(f"container list failed: {detail}")
|
||||
return sorted(
|
||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
||||
if name.startswith(_PREFIX)
|
||||
@@ -35,7 +37,8 @@ def _list_prefixed_networks() -> list[str]:
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
detail = result.stderr.strip() or f"exit {result.returncode}"
|
||||
raise EnumerationError(f"container network list failed: {detail}")
|
||||
return sorted(
|
||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
||||
if name.startswith(_PREFIX)
|
||||
@@ -51,19 +54,17 @@ def prepare_cleanup() -> MacosContainerBottleCleanupPlan:
|
||||
|
||||
|
||||
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
|
||||
failures = CleanupFailures()
|
||||
for name in plan.containers:
|
||||
info(f"container delete --force {name}")
|
||||
subprocess.run(
|
||||
failures.run(
|
||||
["container", "delete", "--force", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
f"deleting container {name}",
|
||||
)
|
||||
for name in plan.networks:
|
||||
info(f"container network delete {name}")
|
||||
subprocess.run(
|
||||
failures.run(
|
||||
["container", "network", "delete", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
f"deleting network {name}",
|
||||
)
|
||||
failures.raise_if_any()
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
|
||||
from ...bottle_state import read_metadata
|
||||
from .. import ActiveAgent
|
||||
from .. import ActiveAgent, EnumerationError
|
||||
from .infra import INFRA_NAME, ORCHESTRATOR_NAME
|
||||
|
||||
# The name every agent container carries: `bot-bottle-<slug>`. Exported
|
||||
@@ -20,17 +20,18 @@ CONTAINER_NAME_PREFIX = "bot-bottle-"
|
||||
_INFRA_NAMES = frozenset({INFRA_NAME, ORCHESTRATOR_NAME})
|
||||
|
||||
|
||||
class EnumerationError(RuntimeError):
|
||||
"""container list failed; the resulting live set is not authoritative."""
|
||||
|
||||
|
||||
def enumerate_active() -> list[ActiveAgent]:
|
||||
result = subprocess.run(
|
||||
["container", "list", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["container", "list", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise EnumerationError(
|
||||
"container list failed: container CLI not found"
|
||||
) from exc
|
||||
if result.returncode != 0:
|
||||
raise EnumerationError(
|
||||
f"container list failed: "
|
||||
|
||||
@@ -28,6 +28,7 @@ from ...paths import (
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
host_gateway_ca_dir,
|
||||
)
|
||||
from ... import resources
|
||||
from .. import util as backend_util
|
||||
from . import util as container_mod
|
||||
|
||||
@@ -52,8 +53,6 @@ GATEWAY_DAEMONS = "egress,git-http,supervise"
|
||||
|
||||
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def ensure_networks(
|
||||
network: str = GATEWAY_NETWORK,
|
||||
@@ -84,14 +83,16 @@ class MacosGateway(Gateway):
|
||||
network: str = GATEWAY_NETWORK,
|
||||
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
self.name = name
|
||||
self.network = network
|
||||
self.egress_network = egress_network
|
||||
self.control_network = control_network
|
||||
self._repo_root = repo_root
|
||||
# Build context: the repo root in a checkout, a staged copy from the
|
||||
# installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
# Set by `connect_to_orchestrator`: the URL the daemons resolve policy
|
||||
# against + the pre-minted `gateway` token they present. The gateway
|
||||
# never mints, so it never holds the signing key (#469).
|
||||
|
||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ... import resources
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
@@ -53,8 +54,6 @@ from .orchestrator import (
|
||||
# still import it (probe / reprovision attribute against the gateway).
|
||||
INFRA_NAME = GATEWAY_NAME
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class MacosInfraService(InfraService):
|
||||
"""Composes the per-host orchestrator + gateway containers. Callers use
|
||||
@@ -70,7 +69,7 @@ class MacosInfraService(InfraService):
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
gateway_image: str = GATEWAY_IMAGE,
|
||||
orchestrator_image: str = ORCHESTRATOR_IMAGE,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||
gateway_name: str = INFRA_NAME,
|
||||
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
||||
@@ -81,7 +80,9 @@ class MacosInfraService(InfraService):
|
||||
self.control_network = control_network
|
||||
self.gateway_image = gateway_image
|
||||
self.orchestrator_image = orchestrator_image
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._orchestrator_name = orchestrator_name
|
||||
self._gateway_name = gateway_name
|
||||
self._db_volume = db_volume
|
||||
|
||||
@@ -36,7 +36,6 @@ import dataclasses
|
||||
import os
|
||||
import subprocess
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
from ...bottle_state import (
|
||||
@@ -49,6 +48,7 @@ from ...git_gate import GitGate
|
||||
from ...gateway.git_gate.http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
|
||||
from ...image_cache import check_stale
|
||||
from ...log import die, info, warn
|
||||
from ... import resources
|
||||
from .. import BottleImages
|
||||
from ...supervisor.types import SUPERVISE_PORT
|
||||
from ..docker.egress import EGRESS_PORT
|
||||
@@ -71,7 +71,6 @@ from .consolidated_launch import (
|
||||
deprovision_consolidated,
|
||||
)
|
||||
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
||||
|
||||
|
||||
@@ -94,7 +93,7 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str:
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
return plan.image
|
||||
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
container_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
|
||||
return plan.image
|
||||
|
||||
|
||||
@@ -108,7 +107,8 @@ def _layer_nested_containers(
|
||||
"""
|
||||
if not plan.nested_containers:
|
||||
return agent_image
|
||||
derived = f"{agent_image}{nested_containers_mod.IMAGE_SUFFIX}"
|
||||
pinned_base = container_mod.pinned_local_image_ref(agent_image)
|
||||
derived = f"{pinned_base}{nested_containers_mod.IMAGE_SUFFIX}"
|
||||
if plan.spec.image_policy == "cached":
|
||||
if not container_mod.image_exists(derived):
|
||||
die(
|
||||
@@ -117,7 +117,10 @@ def _layer_nested_containers(
|
||||
)
|
||||
info(f"using cached nested-container image {derived!r}")
|
||||
return derived
|
||||
return nested_containers_mod.build_image(agent_image, container_mod.build_image)
|
||||
return nested_containers_mod.build_image(
|
||||
pinned_base,
|
||||
container_mod.build_image,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -55,7 +55,7 @@ _GUEST_DEVICES = ("/dev/fuse", "/dev/net/tun")
|
||||
|
||||
|
||||
def build_image(
|
||||
base_image: str,
|
||||
pinned_base: str,
|
||||
build: Callable[..., None],
|
||||
) -> str:
|
||||
"""Layer the nested-container tooling onto an already-built agent image.
|
||||
@@ -66,14 +66,15 @@ def build_image(
|
||||
# TODO(#394): replace this hand-rolled Dockerfile with a docker-layer
|
||||
# abstraction once that infrastructure exists.
|
||||
"""
|
||||
image = f"{base_image}{IMAGE_SUFFIX}"
|
||||
image = f"{pinned_base}{IMAGE_SUFFIX}"
|
||||
init_script = Path(__file__).with_name("nested-containers-init.sh")
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-nested-containers.") as tmp:
|
||||
context = Path(tmp)
|
||||
shutil.copy2(init_script, context / "nested-containers-init.sh")
|
||||
(context / "Dockerfile").write_text(
|
||||
"FROM docker:28-cli AS docker_cli\n"
|
||||
f"FROM {base_image}\n"
|
||||
"ARG DOCKER_CLI_BASE_IMAGE\n"
|
||||
"FROM ${DOCKER_CLI_BASE_IMAGE} AS docker_cli\n"
|
||||
f"FROM {pinned_base}\n"
|
||||
"USER root\n"
|
||||
"COPY --from=docker_cli /usr/local/bin/docker /usr/local/bin/docker\n"
|
||||
"COPY --from=docker_cli /usr/local/libexec/docker/cli-plugins/"
|
||||
|
||||
@@ -18,10 +18,8 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ... import log
|
||||
from ...paths import (
|
||||
ORCHESTRATOR_TOKEN_ENV,
|
||||
host_orchestrator_token,
|
||||
)
|
||||
from ... import resources
|
||||
from ...paths import ORCHESTRATOR_TOKEN_ENV
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_HEALTH_TIMEOUT_SECONDS,
|
||||
DEFAULT_PORT,
|
||||
@@ -48,7 +46,6 @@ _DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
|
||||
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
||||
|
||||
_HEALTH_POLL_SECONDS = 0.25
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class MacosOrchestrator(Orchestrator):
|
||||
@@ -64,7 +61,7 @@ class MacosOrchestrator(Orchestrator):
|
||||
label: str = ORCHESTRATOR_LABEL,
|
||||
port: int = DEFAULT_PORT,
|
||||
control_network: str = CONTROL_NETWORK,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
repo_root: Path | None = None,
|
||||
db_volume: str = ORCHESTRATOR_DB_VOLUME,
|
||||
) -> None:
|
||||
self.image_ref = image_ref
|
||||
@@ -72,7 +69,9 @@ class MacosOrchestrator(Orchestrator):
|
||||
self.label = label
|
||||
self.port = port
|
||||
self.control_network = control_network
|
||||
self._repo_root = repo_root
|
||||
# Build context / bind-mount source: the repo root in a checkout, a
|
||||
# staged copy from the installed wheel otherwise (bot_bottle.resources).
|
||||
self._repo_root = repo_root if repo_root is not None else resources.build_root()
|
||||
self._db_volume = db_volume
|
||||
|
||||
def url(self) -> str:
|
||||
@@ -134,7 +133,9 @@ class MacosOrchestrator(Orchestrator):
|
||||
|
||||
def _run_container(self, current_hash: str) -> None:
|
||||
container_mod.force_remove_container(self.name)
|
||||
_signing_key = host_orchestrator_token()
|
||||
# The signing key comes through the shared provisioning contract (#476),
|
||||
# which fail-closes rather than yield an empty key that would run OPEN.
|
||||
_signing_key = self.control_plane_key()
|
||||
argv = [
|
||||
"container", "run", "--detach",
|
||||
"--name", self.name,
|
||||
|
||||
@@ -13,6 +13,7 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from ... import resources
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
@@ -60,7 +61,13 @@ def dns_server() -> str:
|
||||
return _host_ipv4_dns() or _DEFAULT_DNS
|
||||
|
||||
|
||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
def build_image(
|
||||
ref: str,
|
||||
context: str,
|
||||
*,
|
||||
dockerfile: str = "",
|
||||
build_args: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Build an OCI image with Apple's BuildKit-backed `container build`.
|
||||
|
||||
Set `BOT_BOTTLE_NO_CACHE=1` (the `start --no-cache` flag) to force
|
||||
@@ -83,6 +90,13 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
if not os.path.isabs(dockerfile):
|
||||
dockerfile = os.path.join(context, dockerfile)
|
||||
args.extend(["-f", dockerfile])
|
||||
effective_build_args = resources.image_build_args(
|
||||
dockerfile,
|
||||
context=context,
|
||||
) if dockerfile else {}
|
||||
effective_build_args.update(build_args or {})
|
||||
for name, value in effective_build_args.items():
|
||||
args.extend(["--build-arg", f"{name}={value}"])
|
||||
args.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
@@ -668,6 +682,40 @@ def image_id(ref: str) -> str:
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def pinned_local_image_ref(ref: str) -> str:
|
||||
"""Tag a local image with its complete content ID for a stable ``FROM``.
|
||||
|
||||
Agent images are immediately used as bases for the optional
|
||||
nested-containers layer. A content-derived tag prevents another concurrent
|
||||
build from moving the provider's ordinary ``:latest`` tag between those
|
||||
two builds.
|
||||
"""
|
||||
image = image_id(ref)
|
||||
digest = image.removeprefix("sha256:")
|
||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||
die(f"could not derive a local base tag from invalid image ID {image!r}")
|
||||
repository = ref.split("@", 1)[0]
|
||||
last_slash = repository.rfind("/")
|
||||
last_colon = repository.rfind(":")
|
||||
if last_colon > last_slash:
|
||||
repository = repository[:last_colon]
|
||||
pinned_ref = f"{repository}:sha256-{digest}"
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "image", "tag", image, pinned_ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"could not tag exact local image {image}: "
|
||||
f"{(result.stderr or result.stdout or '').strip() or '<no detail>'}"
|
||||
)
|
||||
if image_id(pinned_ref) != image:
|
||||
die(f"content-derived local tag {pinned_ref!r} did not resolve to {image}")
|
||||
return pinned_ref
|
||||
|
||||
|
||||
def image_created_at(ref: str) -> datetime | None:
|
||||
"""Return the image creation timestamp as an aware UTC datetime, or None
|
||||
when the field is absent or unparseable (e.g. FROM-scratch images, images
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Backend-neutral preparation planner.
|
||||
|
||||
This module owns the shared transformation from a CLI ``BottleSpec`` to the
|
||||
typed inputs consumed by a concrete backend's ``_resolve_plan``. Backend
|
||||
classes retain only their validation/preflight/env hooks and their
|
||||
backend-specific final resolution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider
|
||||
from ..egress import EgressPlan
|
||||
from ..env import ResolvedEnv, resolve_env
|
||||
from ..git_gate import GitGate, GitGatePlan
|
||||
from ..manifest import Manifest
|
||||
from ..supervisor.plan import SupervisePlan
|
||||
from ..workspace import workspace_plan
|
||||
from .resolve_common import (
|
||||
merge_provision_env_vars,
|
||||
mint_slug,
|
||||
prepare_agent_state_dir,
|
||||
prepare_egress,
|
||||
prepare_git_gate,
|
||||
prepare_supervise,
|
||||
reject_nested_containers,
|
||||
resolve_manifest_dockerfile,
|
||||
write_launch_metadata,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import BottleSpec
|
||||
|
||||
|
||||
class PreparationBackend(Protocol):
|
||||
"""Backend hooks needed by the shared planner."""
|
||||
|
||||
name: str
|
||||
supports_nested_containers: bool
|
||||
|
||||
def _validate(self, spec: BottleSpec) -> Manifest: ...
|
||||
def _preflight(self) -> None: ...
|
||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedBottle:
|
||||
"""Typed, backend-neutral result of shared launch preparation."""
|
||||
|
||||
manifest: Manifest
|
||||
slug: str
|
||||
resolved_env: ResolvedEnv
|
||||
agent_provision_plan: AgentProvisionPlan
|
||||
egress_plan: EgressPlan
|
||||
git_gate_plan: GitGatePlan
|
||||
supervise_plan: SupervisePlan | None
|
||||
|
||||
|
||||
class BottlePreparationPlanner:
|
||||
"""Run the common, side-effect-limited part of bottle preparation."""
|
||||
|
||||
def __init__(self, backend: PreparationBackend) -> None:
|
||||
self._backend = backend
|
||||
|
||||
def prepare(self, spec: BottleSpec) -> PreparedBottle:
|
||||
backend = self._backend
|
||||
# These are deliberately protected backend hooks: only this shared
|
||||
# planner orchestrates them, while concrete backends provide the
|
||||
# implementation.
|
||||
manifest = backend._validate(spec) # pylint: disable=protected-access
|
||||
if not backend.supports_nested_containers:
|
||||
reject_nested_containers(backend.name, manifest)
|
||||
|
||||
backend._preflight() # pylint: disable=protected-access
|
||||
manifest = GitGate().preflight_host_keys(
|
||||
manifest,
|
||||
headless=spec.headless,
|
||||
home_md=spec.manifest.home_md,
|
||||
)
|
||||
|
||||
bottle = manifest.bottle
|
||||
provider_config = bottle.agent_provider
|
||||
provider = get_provider(provider_config.template)
|
||||
resolved_env = resolve_env(manifest)
|
||||
workspace = workspace_plan(spec, guest_home=provider.guest_home)
|
||||
slug = mint_slug(spec)
|
||||
write_launch_metadata(slug, spec, compose_project="", backend=backend.name)
|
||||
|
||||
dockerfile = (
|
||||
resolve_manifest_dockerfile(provider_config.dockerfile, spec)
|
||||
if provider_config.dockerfile
|
||||
else str(provider.dockerfile)
|
||||
)
|
||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
||||
provision = build_agent_provision_plan(
|
||||
template=provider_config.template,
|
||||
dockerfile=dockerfile,
|
||||
state_dir=agent_dir,
|
||||
instance_name=f"bot-bottle-{slug}",
|
||||
prompt_file=prompt_file,
|
||||
guest_env=backend._build_guest_env( # pylint: disable=protected-access
|
||||
resolved_env
|
||||
),
|
||||
forward_host_credentials=provider_config.forward_host_credentials,
|
||||
auth_token=provider_config.auth_token,
|
||||
host_env=dict(os.environ),
|
||||
trusted_project_path=workspace.workdir,
|
||||
label=spec.label,
|
||||
color=spec.color,
|
||||
provider_settings=provider_config.settings,
|
||||
)
|
||||
provision = merge_provision_env_vars(provision)
|
||||
return PreparedBottle(
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=provision,
|
||||
egress_plan=prepare_egress(bottle, slug, provision),
|
||||
git_gate_plan=prepare_git_gate(bottle, slug),
|
||||
supervise_plan=prepare_supervise(bottle, slug),
|
||||
)
|
||||
@@ -63,12 +63,23 @@ def provision_git_gate(
|
||||
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
|
||||
creds = _creds_dir(bottle_id)
|
||||
transport.exec(["mkdir", "-p", creds])
|
||||
transport.exec(["chmod", "700", creds])
|
||||
credential_paths: list[str] = []
|
||||
for u in plan.upstreams:
|
||||
if u.identity_file:
|
||||
transport.cp_into(u.identity_file, f"{creds}/{u.name}-key")
|
||||
key_path = f"{creds}/{u.name}-key"
|
||||
transport.cp_into(u.identity_file, key_path)
|
||||
credential_paths.append(key_path)
|
||||
known_hosts = str(u.known_hosts_file)
|
||||
if known_hosts and known_hosts != ".":
|
||||
transport.cp_into(known_hosts, f"{creds}/{u.name}-known_hosts")
|
||||
known_hosts_path = f"{creds}/{u.name}-known_hosts"
|
||||
transport.cp_into(known_hosts, known_hosts_path)
|
||||
credential_paths.append(known_hosts_path)
|
||||
# Copy-mode behavior differs across Docker, Apple Container, and SSH.
|
||||
# Apply the security contract inside the gateway so every backend produces
|
||||
# the same private credential namespace.
|
||||
if credential_paths:
|
||||
transport.exec(["chmod", "600", *credential_paths])
|
||||
# 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])
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..log import die
|
||||
from ..manifest import Manifest, ManifestBottle
|
||||
from ..supervisor.plan import SupervisePlan
|
||||
from ..orchestrator.supervisor import Supervisor
|
||||
from ..util import slugify
|
||||
from . import BottleSpec
|
||||
|
||||
|
||||
@@ -44,8 +45,7 @@ def mint_slug(spec: BottleSpec) -> str:
|
||||
if spec.identity:
|
||||
return spec.identity
|
||||
if spec.label:
|
||||
from .docker import util as docker_mod
|
||||
return docker_mod.slugify(spec.label)
|
||||
return slugify(spec.label)
|
||||
return bottle_identity(spec.agent_name)
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ _HANDLERS: dict[str, str] = {
|
||||
"backend": "backend:cmd_backend",
|
||||
"cleanup": "cleanup:cmd_cleanup",
|
||||
"commit": "commit:cmd_commit",
|
||||
"doctor": "doctor:cmd_doctor",
|
||||
"edit": "edit:cmd_edit",
|
||||
"help": "help:cmd_help",
|
||||
"init": "init:cmd_init",
|
||||
@@ -53,6 +54,6 @@ COMMANDS = {name: _lazy(spec) for name, spec in _HANDLERS.items()}
|
||||
# gating it on the schema breaks preflight on a fresh CI runner where stdin
|
||||
# isn't a TTY and the migration prompt can't be answered. `help` and `login`
|
||||
# likewise never touch the store.
|
||||
NO_MIGRATION_COMMANDS = frozenset({"backend", "help", "login"})
|
||||
NO_MIGRATION_COMMANDS = frozenset({"backend", "doctor", "help", "login"})
|
||||
|
||||
__all__ = ["COMMANDS", "NO_MIGRATION_COMMANDS"]
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
|
||||
from ...backend import get_bottle_backend, has_backend, known_backend_names
|
||||
from ...backend.cleanup_control import CleanupError
|
||||
from ...log import info
|
||||
from ...util import read_tty_line
|
||||
|
||||
@@ -52,10 +53,20 @@ def cmd_cleanup(_argv: list[str]) -> int:
|
||||
info("cleanup: skipped")
|
||||
return 0
|
||||
|
||||
for name, backend, plan in prepared:
|
||||
if plan.empty:
|
||||
# Confirmation authorizes a fresh authoritative snapshot, not blind use of
|
||||
# identities that may have changed while the operator reviewed the preview.
|
||||
failures: list[str] = []
|
||||
for name, backend, displayed in prepared:
|
||||
current = backend.prepare_cleanup()
|
||||
approved = displayed.intersect(current)
|
||||
if approved.empty:
|
||||
continue
|
||||
backend.cleanup(plan)
|
||||
try:
|
||||
backend.cleanup(approved)
|
||||
except CleanupError as exc:
|
||||
failures.append(f"{name}: {exc}")
|
||||
if failures:
|
||||
raise CleanupError("cleanup incomplete: " + "; ".join(failures))
|
||||
info("cleanup: done")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""`doctor` CLI command — validate host prerequisites for running
|
||||
bot-bottle and report what's ready.
|
||||
|
||||
Fails (non-zero exit) only on the two hard requirements: a new-enough
|
||||
Python and at least one backend that is *ready* (passes its full status
|
||||
checks, so `start` can actually work). The config directory is a soft
|
||||
check — `install.sh` creates it, but a missing one only warrants a note,
|
||||
not a failure, since `start` provisions what it needs on first run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ...backend import is_backend_ready, known_backend_names
|
||||
from ..constants import PROG
|
||||
|
||||
MIN_PYTHON = (3, 11)
|
||||
CONFIG_DIR = ".bot-bottle"
|
||||
|
||||
|
||||
def _ok(label: str, detail: str) -> None:
|
||||
print(f"ok: {label}: {detail}")
|
||||
|
||||
|
||||
def _warn(label: str, detail: str) -> None:
|
||||
print(f"warn: {label}: {detail}")
|
||||
|
||||
|
||||
def _fail(label: str, detail: str) -> None:
|
||||
print(f"fail: {label}: {detail}")
|
||||
|
||||
|
||||
def _check_python() -> bool:
|
||||
v = sys.version_info
|
||||
detail = f"{v.major}.{v.minor}.{v.micro}"
|
||||
if (v.major, v.minor) >= MIN_PYTHON:
|
||||
_ok("python", detail)
|
||||
return True
|
||||
_fail("python", f"{detail}; need {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer")
|
||||
return False
|
||||
|
||||
|
||||
def _check_backends() -> bool:
|
||||
"""At least one backend must be *ready* to run a bottle — i.e. pass its
|
||||
full status() checks (daemon reachable, network pool present, KVM usable),
|
||||
not merely have a binary on PATH. A binary-only check would report `ok`
|
||||
on a host with a stopped Docker daemon or a half-configured Firecracker,
|
||||
where `start` still can't work. Each not-ready backend prints its own
|
||||
diagnostics (quiet=False) so the operator sees exactly what's missing."""
|
||||
ready = []
|
||||
for name in known_backend_names():
|
||||
if is_backend_ready(name, quiet=False):
|
||||
_ok("backend", f"{name}: ready")
|
||||
ready.append(name)
|
||||
else:
|
||||
_warn("backend", f"{name}: not ready (see diagnostics above)")
|
||||
if ready:
|
||||
return True
|
||||
_fail(
|
||||
"backend",
|
||||
"no backend is ready to run a bottle; start Docker, or finish "
|
||||
"Apple Container (macOS) / Firecracker (Linux) setup",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _check_config_dir() -> None:
|
||||
config = Path.home() / CONFIG_DIR
|
||||
if config.is_dir():
|
||||
_ok("config", str(config))
|
||||
else:
|
||||
_warn("config", f"{config} does not exist yet (created on first use)")
|
||||
|
||||
|
||||
def cmd_doctor(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=f"{PROG} doctor",
|
||||
description="Check host prerequisites for running bot-bottle.",
|
||||
)
|
||||
parser.parse_args(argv)
|
||||
|
||||
# Hard requirements gate the exit code; the config note is advisory.
|
||||
required = [_check_python(), _check_backends()]
|
||||
_check_config_dir()
|
||||
return 0 if all(required) else 1
|
||||
@@ -25,6 +25,7 @@ def cmd_help(argv: list[str] | None = None) -> int:
|
||||
w(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\n")
|
||||
w(" cleanup stop and remove all active bot-bottle containers\n")
|
||||
w(" commit snapshot a running bottle's container state to a Docker image\n")
|
||||
w(" doctor check host prerequisites (Python, backend, config dir)\n")
|
||||
w(" edit open an agent in vim for editing\n")
|
||||
w(" help show this command list\n")
|
||||
w(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||
|
||||
@@ -25,12 +25,11 @@ from typing import Callable
|
||||
from ...agent_provider import get_provider, runtime_for
|
||||
from ...backend import (
|
||||
Bottle,
|
||||
BottlePlan,
|
||||
BottleSpec,
|
||||
enumerate_active_agents,
|
||||
get_bottle_backend,
|
||||
)
|
||||
from ...backend.docker import util as docker_mod
|
||||
from ...backend.docker.bottle_plan import DockerBottlePlan
|
||||
from ...bottle_state import (
|
||||
cleanup_state,
|
||||
is_preserved,
|
||||
@@ -40,7 +39,7 @@ from ...image_cache import StaleImageError
|
||||
from ...log import info, die
|
||||
from ...manifest import Manifest, ManifestIndex
|
||||
from ..constants import PROG
|
||||
from ...util import read_tty_line
|
||||
from ...util import read_tty_line, slugify
|
||||
from .. import tui
|
||||
|
||||
|
||||
@@ -257,10 +256,10 @@ def _uniquify_label_headless(label: str) -> str:
|
||||
logging the chosen label. Orchestrators fire-and-forget many bottles,
|
||||
so silently picking a free name beats erroring on every collision."""
|
||||
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||
if docker_mod.slugify(label) not in active_slugs:
|
||||
if slugify(label) not in active_slugs:
|
||||
return label
|
||||
n = 2
|
||||
while docker_mod.slugify(f"{label}-{n}") in active_slugs:
|
||||
while slugify(f"{label}-{n}") in active_slugs:
|
||||
n += 1
|
||||
chosen = f"{label}-{n}"
|
||||
info(f"label '{label}' already in use; using '{chosen}'")
|
||||
@@ -274,11 +273,11 @@ def prepare_with_preflight(
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
stage_dir: Path,
|
||||
render_preflight: Callable[[DockerBottlePlan, str], None],
|
||||
render_preflight: Callable[[BottlePlan, str], None],
|
||||
prompt_yes: Callable[[], bool],
|
||||
dry_run: bool = False,
|
||||
backend_name: str | None = None,
|
||||
) -> tuple[DockerBottlePlan | None, str]:
|
||||
) -> tuple[BottlePlan | None, str]:
|
||||
"""Run `backend.prepare`, render the preflight summary via the
|
||||
injected callable, prompt y/N via the injected callable.
|
||||
|
||||
@@ -405,7 +404,7 @@ def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
|
||||
in use among running bottles. Passes through unchanged when no
|
||||
collision is found on the first check."""
|
||||
while True:
|
||||
slug_candidate = docker_mod.slugify(label)
|
||||
slug_candidate = slugify(label)
|
||||
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||
if slug_candidate not in active_slugs:
|
||||
return label, color
|
||||
@@ -432,7 +431,7 @@ def _select_image_policy() -> str | None:
|
||||
|
||||
|
||||
def _text_render_preflight():
|
||||
def _render(plan: DockerBottlePlan, backend_name: str) -> None:
|
||||
def _render(plan: BottlePlan, backend_name: str) -> None:
|
||||
print(file=sys.stderr)
|
||||
print(f"backend: {backend_name}", file=sys.stderr)
|
||||
print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
|
||||
|
||||
@@ -368,7 +368,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragm
|
||||
elif key in (curses.KEY_UP, ord("k")):
|
||||
selected = max(selected - 1, 0)
|
||||
elif key in (curses.KEY_ENTER, 10, 13):
|
||||
_detail_view(stdscr, qp, green_attr=green_attr)
|
||||
status_line = _detail_view(stdscr, qp, green_attr=green_attr)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
status_line = _approve_from_tui(stdscr, qp)
|
||||
@@ -456,7 +456,7 @@ def _detail_view(
|
||||
qp: QueuedProposal,
|
||||
*,
|
||||
green_attr: int = 0,
|
||||
) -> None: # pragma: no cover
|
||||
) -> str: # pragma: no cover
|
||||
"""Render the full proposal. Scrollable. Press q to return."""
|
||||
lines = _detail_lines(qp, green_attr=green_attr)
|
||||
offset = 0
|
||||
@@ -473,7 +473,7 @@ def _detail_view(
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
if key in (ord("q"), 27):
|
||||
return
|
||||
return ""
|
||||
if key in (curses.KEY_DOWN, ord("j")):
|
||||
offset = min(offset + 1, max(0, len(lines) - 1))
|
||||
elif key in (curses.KEY_UP, ord("k")):
|
||||
@@ -484,31 +484,34 @@ def _detail_view(
|
||||
offset = max(0, len(lines) - 1)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
_approve_from_tui(stdscr, qp)
|
||||
except ApplyError:
|
||||
pass
|
||||
return
|
||||
return _approve_from_tui(stdscr, qp)
|
||||
except ApplyError as exc:
|
||||
return f"apply failed: {exc}"
|
||||
elif key == ord("m"):
|
||||
if qp.proposal.tool in _REPORT_ONLY_TOOLS:
|
||||
return
|
||||
return f"modify unavailable for {qp.proposal.tool}"
|
||||
edited = _modify(stdscr, qp)
|
||||
if edited is not None:
|
||||
try:
|
||||
_approve_from_tui(
|
||||
stdscr, qp, final_file=edited,
|
||||
notes="operator modified before approving",
|
||||
)
|
||||
except ApplyError:
|
||||
pass
|
||||
return
|
||||
if edited is None:
|
||||
return "modify aborted (no change)"
|
||||
try:
|
||||
return _approve_from_tui(
|
||||
stdscr, qp, final_file=edited,
|
||||
notes="operator modified before approving",
|
||||
)
|
||||
except ApplyError as exc:
|
||||
return f"apply failed: {exc}"
|
||||
elif key == ord("r"):
|
||||
reason = _prompt(stdscr, "reject reason: ")
|
||||
if reason:
|
||||
reject(qp, reason=reason)
|
||||
return
|
||||
return f"rejected {qp.proposal.tool} for [{qp.label}]"
|
||||
return "reject aborted (empty reason)"
|
||||
|
||||
|
||||
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore # pragma: no cover
|
||||
def _modify(
|
||||
stdscr: "curses._CursesWindow", # type: ignore
|
||||
qp: QueuedProposal,
|
||||
) -> str | None: # pragma: no cover
|
||||
"""Suspend curses, open $EDITOR on the proposed file, return edited content."""
|
||||
suffix = _suffix_for_tool(qp.proposal.tool)
|
||||
curses.endwin()
|
||||
|
||||
+32
-6
@@ -16,6 +16,8 @@ import os
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..log import debug
|
||||
|
||||
|
||||
def filter_multiselect(
|
||||
items: list[str],
|
||||
@@ -42,7 +44,11 @@ def filter_multiselect(
|
||||
|
||||
try:
|
||||
tty_fd = open(tty_path, "r+b", buffering=0)
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
debug(
|
||||
"multi-select unavailable; treating it as cancellation",
|
||||
context={"error_type": type(exc).__name__, "tty": tty_path},
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -73,7 +79,11 @@ def filter_select(
|
||||
|
||||
try:
|
||||
tty_fd = open(tty_path, "r+b", buffering=0)
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
debug(
|
||||
"filter-select unavailable; treating it as cancellation",
|
||||
context={"error_type": type(exc).__name__, "tty": tty_path},
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -129,7 +139,11 @@ def _run_picker(items: list[str], *, title: str, tty_fd: int) -> Optional[str]:
|
||||
curses.nocbreak()
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except Exception: # noqa: W0718 — curses can raise many error types
|
||||
except Exception as exc: # noqa: W0718 — curses can raise many error types
|
||||
debug(
|
||||
"filter-select display failed; treating it as cancellation",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
||||
@@ -292,7 +306,11 @@ def _run_multiselect(
|
||||
curses.nocbreak()
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except Exception: # noqa: W0718
|
||||
except Exception as exc: # noqa: W0718
|
||||
debug(
|
||||
"multi-select display failed; treating it as cancellation",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
||||
@@ -558,13 +576,21 @@ def name_color_modal(
|
||||
"""
|
||||
try:
|
||||
tty_fd = open(tty_path, "r+b", buffering=0) # pylint: disable=consider-using-with
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
debug(
|
||||
"name/color picker unavailable; using defaults",
|
||||
context={"error_type": type(exc).__name__, "tty": tty_path},
|
||||
)
|
||||
return default_label, ""
|
||||
|
||||
try:
|
||||
fd_dup = os.dup(tty_fd.fileno())
|
||||
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
debug(
|
||||
"name/color picker failed; using defaults",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return default_label, ""
|
||||
finally:
|
||||
tty_fd.close()
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
# Layer ordering is deliberate: the npm install lives in its own layer so
|
||||
# changes to the rest of the repo (or to the CMD) don't bust it.
|
||||
|
||||
# Current Node LTS; slim variant keeps the image small while still
|
||||
# providing apt-get for any future additions.
|
||||
FROM node:22-trixie-slim
|
||||
# Version-qualified Node LTS, pinned to its multi-architecture manifest.
|
||||
ARG NODE_BASE_IMAGE
|
||||
FROM ${NODE_BASE_IMAGE}
|
||||
|
||||
ARG DEBIAN_SNAPSHOT=20260724T000000Z
|
||||
RUN sed -i \
|
||||
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
|
||||
# Install runtime system deps. claude-code shells out to git for several
|
||||
# features (status checks, commits, PR creation) — without git in the
|
||||
@@ -20,7 +28,7 @@ FROM node:22-trixie-slim
|
||||
# HTTPS_PROXY-aware tool (curl itself, plus anything that shells out
|
||||
# to it) works against egress's bumped TLS without the agent needing
|
||||
# local DNS.
|
||||
RUN apt-get update \
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
@@ -35,15 +43,17 @@ RUN apt-get update \
|
||||
# (claude-code is a Node CLI), but is convenient for the agent to
|
||||
# shell out to for ad-hoc scripts. Kept on its own layer so it can
|
||||
# be moved to a downstream image if the base ever needs to shrink.
|
||||
RUN apt-get update \
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install claude-code globally. Pinned to the version verified in the v1
|
||||
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
||||
# rolling forward; an unpinned install would mean rebuilds silently pick
|
||||
# up new behavior.
|
||||
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
|
||||
# Install from the committed npm lock. `npm ci` verifies every registry
|
||||
# artifact against its lockfile integrity and refuses dependency drift.
|
||||
COPY bot_bottle/contrib/claude/package.json \
|
||||
bot_bottle/contrib/claude/package-lock.json /opt/claude/
|
||||
RUN cd /opt/claude \
|
||||
&& npm ci --omit=dev --no-fund --no-audit \
|
||||
&& ln -s /opt/claude/node_modules/.bin/claude /usr/local/bin/claude \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Git reads both ~/.gitconfig and ~/.config/git/config. Keep its XDG config
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "bot-bottle-claude-image",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bot-bottle-claude-image",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "2.1.172"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code": {
|
||||
"version": "2.1.172",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.172.tgz",
|
||||
"integrity": "sha512-SfwC+5fQcmNbvvm+1vLiZbfUxt0PQz9lbXapj9+FI+XY/2e+3zgteBM4JFEXBjULZj1DtZXHmKtAROUrMM9GZg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"bin": {
|
||||
"claude": "bin/claude.exe"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-code-darwin-arm64": "2.1.172",
|
||||
"@anthropic-ai/claude-code-darwin-x64": "2.1.172",
|
||||
"@anthropic-ai/claude-code-linux-arm64": "2.1.172",
|
||||
"@anthropic-ai/claude-code-linux-arm64-musl": "2.1.172",
|
||||
"@anthropic-ai/claude-code-linux-x64": "2.1.172",
|
||||
"@anthropic-ai/claude-code-linux-x64-musl": "2.1.172",
|
||||
"@anthropic-ai/claude-code-win32-arm64": "2.1.172",
|
||||
"@anthropic-ai/claude-code-win32-x64": "2.1.172"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-darwin-arm64": {
|
||||
"version": "2.1.172",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-arm64/-/claude-code-darwin-arm64-2.1.172.tgz",
|
||||
"integrity": "sha512-pBRgDo8PAgbt2aE4oc6ZrKdOa/Ax36RAduhLCaI8NWD3a0RDb5mETzQciQLwnuenk0bs27vIRh9Yg1jAYG/0+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-darwin-x64": {
|
||||
"version": "2.1.172",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-x64/-/claude-code-darwin-x64-2.1.172.tgz",
|
||||
"integrity": "sha512-vSgibgeCyvCFiLJSXu/sgcd3L/tUjSiS/tfS9rJLXUjElw8satMJhA5pqPUiBmKMflOWKMufbZgzXvLx+OhvFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-arm64": {
|
||||
"version": "2.1.172",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64/-/claude-code-linux-arm64-2.1.172.tgz",
|
||||
"integrity": "sha512-Ql7AbyaXnlA6NwDUaQGO7ZZis21rMjYjbKzpksMCvY35CmsJqanYgbYSn/rE83u/tZpKo+NqOv+bWEDSzsZ02w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-arm64-musl": {
|
||||
"version": "2.1.172",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64-musl/-/claude-code-linux-arm64-musl-2.1.172.tgz",
|
||||
"integrity": "sha512-Pa9mGGmp8QCRC2j1cgcWRRkwsK1x4bPsS3CcLRd936Op0k5et62tD3qIYhUPQOIxeuxe9Tt/y7lX1cx7JTDxyA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-x64": {
|
||||
"version": "2.1.172",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-2.1.172.tgz",
|
||||
"integrity": "sha512-RYCY9EHkmtoAlwBKcWRzGIhuus+GM2CIVCfU81cTQAwDcCHunoBeFn3NqAcFV1VKb4dk9TRarAmQcWMKJrpPig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-linux-x64-musl": {
|
||||
"version": "2.1.172",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64-musl/-/claude-code-linux-x64-musl-2.1.172.tgz",
|
||||
"integrity": "sha512-1NflAnV/MqIlD4rzlGDVsJgGK2xJ2ldVd5pkcbu7PsDJBW5dzKYVa4PmD0715K8Yiji8jQovh/nhIo6gYyTfVQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-win32-arm64": {
|
||||
"version": "2.1.172",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-arm64/-/claude-code-win32-arm64-2.1.172.tgz",
|
||||
"integrity": "sha512-Gj8mIbHDDSGWnoriqB1Jt1uH6cvNBFDQBtIYarCcv0fs+QGCEP6qm2GIaKqxAbwkUaLT0sCW/7+ukvkNdSltuQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code-win32-x64": {
|
||||
"version": "2.1.172",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-x64/-/claude-code-win32-x64-2.1.172.tgz",
|
||||
"integrity": "sha512-OkheFwagiCEKaO2Sb0j3JTO1NrcR3zTlFik/AN+yQPefhUIGP6vHQDbBG5ksuFe+Dyd9s+P95OUh8WDhccb+Ng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "bot-bottle-claude-image",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "2.1.172"
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,22 @@
|
||||
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
||||
# non-root node user, and the provider CLI installed for that user.
|
||||
|
||||
FROM node:22-trixie-slim
|
||||
ARG NODE_BASE_IMAGE
|
||||
FROM ${NODE_BASE_IMAGE}
|
||||
|
||||
RUN apt-get update \
|
||||
# Remote-control requires the standalone package layout. Keep this exact release
|
||||
# in sync with the committed upstream archive checksums.
|
||||
ARG CODEX_VERSION=0.145.0
|
||||
|
||||
ARG DEBIAN_SNAPSHOT=20260724T000000Z
|
||||
RUN sed -i \
|
||||
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
@@ -19,7 +32,7 @@ RUN apt-get update \
|
||||
# (codex is a Node CLI), but is convenient for the agent to shell
|
||||
# out to for ad-hoc scripts. Kept on its own layer so it can be
|
||||
# moved to a downstream image if the base ever needs to shrink.
|
||||
RUN apt-get update \
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -30,10 +43,29 @@ WORKDIR /home/node
|
||||
|
||||
ENV PATH="/home/node/.local/bin:${PATH}"
|
||||
|
||||
# Remote-control support requires the standalone Codex install layout
|
||||
# under ~/.codex/packages/standalone/current. The npm package can run
|
||||
# the TUI, but remote-control commands expect this installer-owned path.
|
||||
RUN mkdir -p /home/node/.codex \
|
||||
&& curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||
# Install the exact standalone release archive selected by the target
|
||||
# architecture. The checksum list is copied from the immutable upstream release
|
||||
# and committed so a rebuild cannot silently accept changed release bytes.
|
||||
COPY --chown=node:node bot_bottle/contrib/codex/codex-package_SHA256SUMS /tmp/codex-package_SHA256SUMS
|
||||
RUN case "$(dpkg --print-architecture)" in \
|
||||
amd64) codex_target=x86_64-unknown-linux-musl ;; \
|
||||
arm64) codex_target=aarch64-unknown-linux-musl ;; \
|
||||
*) echo "unsupported Codex architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& codex_asset="codex-package-${codex_target}.tar.gz" \
|
||||
&& codex_sha256="$(awk -v asset="${codex_asset}" '$2 == asset { print $1 }' /tmp/codex-package_SHA256SUMS)" \
|
||||
&& test -n "${codex_sha256}" \
|
||||
&& curl -fsSL \
|
||||
"https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/${codex_asset}" \
|
||||
-o "/tmp/${codex_asset}" \
|
||||
&& echo "${codex_sha256} /tmp/${codex_asset}" | sha256sum -c - \
|
||||
&& codex_release="/home/node/.codex/packages/standalone/releases/${CODEX_VERSION}-${codex_target}" \
|
||||
&& mkdir -p "${codex_release}" /home/node/.local/bin \
|
||||
&& tar -xzf "/tmp/${codex_asset}" -C "${codex_release}" \
|
||||
&& ln -s bin/codex "${codex_release}/codex" \
|
||||
&& ln -s "${codex_release}" /home/node/.codex/packages/standalone/current \
|
||||
&& ln -s /home/node/.codex/packages/standalone/current/bin/codex /home/node/.local/bin/codex \
|
||||
&& rm "/tmp/${codex_asset}" \
|
||||
&& test "$(codex --version)" = "codex-cli ${CODEX_VERSION}"
|
||||
|
||||
CMD ["codex"]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
54f79a05aba6f9abf8ef988abcae8bf2fcefba20beb549b4ff2b3acdb2cb6f54 codex-package-aarch64-unknown-linux-musl.tar.gz
|
||||
71a28d362c96ac9829bf8203a2c71be451aeb726adb843167fdaf0eae8fe7dd9 codex-package-x86_64-unknown-linux-musl.tar.gz
|
||||
@@ -2,9 +2,18 @@
|
||||
#
|
||||
# Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally.
|
||||
|
||||
FROM node:22-trixie-slim
|
||||
ARG NODE_BASE_IMAGE
|
||||
FROM ${NODE_BASE_IMAGE}
|
||||
|
||||
RUN apt-get update \
|
||||
ARG DEBIAN_SNAPSHOT=20260724T000000Z
|
||||
RUN sed -i \
|
||||
-e "s|http://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian-security|http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|http://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
-e "s|https://deb.debian.org/debian|http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|g" \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
@@ -15,13 +24,10 @@ RUN apt-get update \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update \
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g --ignore-scripts --no-fund --no-audit @earendil-works/pi-coding-agent \
|
||||
&& npm cache clean --force
|
||||
|
||||
RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git \
|
||||
&& mkdir -p /home/node/.pi/agent \
|
||||
/home/node/.pi/context-mode/sessions \
|
||||
@@ -34,10 +40,15 @@ RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git
|
||||
USER node
|
||||
WORKDIR /home/node
|
||||
|
||||
RUN pi install npm:@harms-haus/pi-cwd \
|
||||
&& pi install npm:pi-web-access \
|
||||
&& pi install npm:context-mode \
|
||||
&& pi install npm:pi-subagents \
|
||||
&& pi install npm:pi-mcp-adapter
|
||||
# Pi discovers npm packages from its agent directory. Installing the CLI and
|
||||
# extensions together from the committed lock makes all direct and transitive
|
||||
# package versions deterministic, with npm integrity verification.
|
||||
COPY --chown=node:node bot_bottle/contrib/pi/package.json \
|
||||
bot_bottle/contrib/pi/package-lock.json /home/node/.pi/agent/
|
||||
RUN cd /home/node/.pi/agent \
|
||||
&& npm ci --omit=dev --no-fund --no-audit \
|
||||
&& npm cache clean --force
|
||||
|
||||
ENV PATH="/home/node/.pi/agent/node_modules/.bin:${PATH}"
|
||||
|
||||
CMD ["pi"]
|
||||
|
||||
Generated
+5448
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "bot-bottle-pi-image",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.81.1",
|
||||
"@earendil-works/pi-agent-core": "0.81.1",
|
||||
"@earendil-works/pi-ai": "0.81.1",
|
||||
"@earendil-works/pi-tui": "0.81.1",
|
||||
"@harms-haus/pi-cwd": "1.0.0",
|
||||
"context-mode": "1.0.169",
|
||||
"pi-mcp-adapter": "2.11.0",
|
||||
"pi-subagents": "0.35.1",
|
||||
"pi-web-access": "0.13.0"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..gateway.egress.addon_core import Route
|
||||
from ..gateway.egress.types import Route
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -19,7 +19,7 @@ class EgressRoute(Route):
|
||||
"""Host-side extension of the addon's `Route`.
|
||||
|
||||
Inherits `host`, `matches`, `auth_scheme`, and `token_env`
|
||||
from `egress_addon_core.Route` — those are the fields that cross the
|
||||
from the gateway's wire `Route` — those are the fields that cross the
|
||||
YAML wire into the gateway. The fields below are host-only and
|
||||
are never serialised to the addon.
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import secrets
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..gateway.egress.addon_core import (
|
||||
ON_MATCH_REDACT,
|
||||
from ..gateway.egress.dlp_config import ON_MATCH_REDACT
|
||||
from ..gateway.egress.types import (
|
||||
HeaderMatch as CoreHeaderMatch,
|
||||
MatchEntry as CoreMatchEntry,
|
||||
PathMatch as CorePathMatch,
|
||||
|
||||
@@ -62,7 +62,6 @@ GATEWAY_CA_GLOB = "mitmproxy-ca*"
|
||||
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
|
||||
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
||||
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
||||
|
||||
@@ -136,10 +136,18 @@ def _pump(name: str, stream: IO[bytes]) -> None:
|
||||
"""Read lines from `stream`, prefix with `[name]`, write to
|
||||
stdout. Runs in its own thread per child; daemon=True so a
|
||||
blocked read doesn't keep the process alive after main exits."""
|
||||
for raw in iter(stream.readline, b""):
|
||||
line = raw.decode("utf-8", errors="replace").rstrip("\n")
|
||||
sys.stdout.write(f"[{name}] {line}\n")
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
for raw in iter(stream.readline, b""):
|
||||
line = raw.decode("utf-8", errors="replace").rstrip("\n")
|
||||
sys.stdout.write(f"[{name}] {line}\n")
|
||||
sys.stdout.flush()
|
||||
except (OSError, ValueError) as exc:
|
||||
# The manager closes a dead child's pipe after wait() and before a
|
||||
# restart. A pump can be between readline calls at that exact moment;
|
||||
# closed-stream errors are normal completion, not uncaught thread
|
||||
# failures. Preserve genuinely unexpected I/O diagnostics.
|
||||
if not stream.closed:
|
||||
_log(f"{name} output pump stopped: {type(exc).__name__}: {exc}")
|
||||
|
||||
|
||||
def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Shared resource boundaries for gateway stdlib HTTP services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import io
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class Readable(Protocol):
|
||||
def read(self, size: int = -1, /) -> bytes: ...
|
||||
|
||||
|
||||
class Writable(Protocol):
|
||||
def write(self, data: bytes, /) -> object: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BodyReadError(Exception):
|
||||
status: int
|
||||
message: str
|
||||
|
||||
|
||||
def read_declared_body(
|
||||
stream: Readable,
|
||||
connection: socket.socket,
|
||||
raw_length: str | None,
|
||||
*,
|
||||
maximum: int,
|
||||
timeout_seconds: float,
|
||||
require_length: bool,
|
||||
) -> bytes:
|
||||
"""Validate and read exactly one declared body under a read deadline."""
|
||||
output = io.BytesIO()
|
||||
copy_declared_body(
|
||||
stream, output, connection, raw_length, maximum=maximum,
|
||||
timeout_seconds=timeout_seconds, require_length=require_length,
|
||||
)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def copy_declared_body(
|
||||
stream: Readable,
|
||||
output: Writable,
|
||||
connection: socket.socket,
|
||||
raw_length: str | None,
|
||||
*,
|
||||
maximum: int,
|
||||
timeout_seconds: float,
|
||||
require_length: bool,
|
||||
) -> int:
|
||||
"""Copy one declared body to a sink without retaining it in memory."""
|
||||
if raw_length is None:
|
||||
if require_length:
|
||||
raise BodyReadError(411, "Content-Length required")
|
||||
raw_length = "0"
|
||||
try:
|
||||
length = int(raw_length)
|
||||
except ValueError as exc:
|
||||
raise BodyReadError(400, "invalid Content-Length") from exc
|
||||
if length < 0:
|
||||
raise BodyReadError(400, "invalid Content-Length")
|
||||
if length > maximum:
|
||||
raise BodyReadError(413, "request body too large")
|
||||
previous_timeout = connection.gettimeout()
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
remaining = length
|
||||
try:
|
||||
while remaining:
|
||||
timeout = deadline - time.monotonic()
|
||||
if timeout <= 0:
|
||||
raise BodyReadError(408, "request body read timed out")
|
||||
connection.settimeout(timeout)
|
||||
chunk = stream.read(min(remaining, 64 * 1024))
|
||||
if not chunk:
|
||||
raise BodyReadError(400, "incomplete request body")
|
||||
output.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
except TimeoutError as exc:
|
||||
raise BodyReadError(408, "request body read timed out") from exc
|
||||
finally:
|
||||
connection.settimeout(previous_timeout)
|
||||
return length
|
||||
|
||||
|
||||
class BoundedThreadingHTTPServer(http.server.ThreadingHTTPServer):
|
||||
"""ThreadingHTTPServer with a hard cap on in-flight request threads."""
|
||||
|
||||
daemon_threads = True
|
||||
|
||||
def __init__( # pylint: disable=consider-using-with
|
||||
self, *args, max_workers: int = 32, **kwargs, # type: ignore[no-untyped-def]
|
||||
):
|
||||
if max_workers < 1:
|
||||
raise ValueError("max_workers must be positive")
|
||||
self._request_slots = threading.BoundedSemaphore(max_workers)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def process_request(
|
||||
self, request: Any, client_address: Any,
|
||||
) -> None:
|
||||
if not self._request_slots.acquire( # pylint: disable=consider-using-with
|
||||
blocking=False,
|
||||
):
|
||||
try:
|
||||
request.sendall(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\n"
|
||||
b"Content-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
finally:
|
||||
self.shutdown_request(request)
|
||||
return
|
||||
try:
|
||||
super().process_request(request, client_address)
|
||||
except BaseException:
|
||||
self._request_slots.release()
|
||||
raise
|
||||
|
||||
def process_request_thread(
|
||||
self, request: Any, client_address: Any,
|
||||
) -> None:
|
||||
try:
|
||||
super().process_request_thread(request, client_address)
|
||||
finally:
|
||||
self._request_slots.release()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BodyReadError",
|
||||
"BoundedThreadingHTTPServer",
|
||||
"copy_declared_body",
|
||||
"read_declared_body",
|
||||
]
|
||||
@@ -16,29 +16,35 @@ import typing
|
||||
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
||||
|
||||
from bot_bottle.constants import IDENTITY_HEADER
|
||||
from bot_bottle.gateway.egress.dlp_detectors import redact_tokens, strip_crlf
|
||||
from bot_bottle.gateway.egress.addon_core import (
|
||||
LOG_BLOCKS,
|
||||
LOG_FULL,
|
||||
from bot_bottle.gateway.egress.dlp_detectors import redact_tokens
|
||||
from bot_bottle.gateway.egress.dlp_config import (
|
||||
DEFAULT_OUTBOUND_ON_MATCH,
|
||||
ON_MATCH_BLOCK,
|
||||
ON_MATCH_REDACT,
|
||||
)
|
||||
from bot_bottle.gateway.egress.context import resolve_client_context
|
||||
from bot_bottle.gateway.egress.dlp import (
|
||||
build_inbound_scan_text,
|
||||
build_token_allow_payload,
|
||||
scan_inbound,
|
||||
scan_outbound,
|
||||
)
|
||||
from bot_bottle.gateway.egress.outbound_pipeline import redact_request, scan_request
|
||||
from bot_bottle.gateway.egress.matching import (
|
||||
decide,
|
||||
match_route,
|
||||
)
|
||||
from bot_bottle.gateway.egress.request_pipeline import (
|
||||
evaluate_route_policy,
|
||||
git_block_reason,
|
||||
)
|
||||
from bot_bottle.gateway.egress.schema import route_to_yaml_dict
|
||||
from bot_bottle.gateway.egress.types import (
|
||||
LOG_BLOCKS,
|
||||
LOG_FULL,
|
||||
Config,
|
||||
Route,
|
||||
ScanResult,
|
||||
build_inbound_scan_text,
|
||||
build_outbound_scan_text,
|
||||
build_token_allow_payload,
|
||||
decide,
|
||||
decide_git_fetch,
|
||||
is_git_fetch_request,
|
||||
is_git_push_request,
|
||||
match_route,
|
||||
resolve_client_context,
|
||||
outbound_scan_headers,
|
||||
route_to_yaml_dict,
|
||||
scan_inbound,
|
||||
scan_outbound,
|
||||
)
|
||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
from bot_bottle.supervisor.types import (
|
||||
@@ -383,19 +389,9 @@ class EgressAddon:
|
||||
self._passthrough_conns.discard(conn_id)
|
||||
|
||||
async def request(self, flow: http.HTTPFlow) -> None:
|
||||
config, slug, env = self._request_context(flow)
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
|
||||
# Reuse the context stashed by http_connect for HTTPS flows (one
|
||||
# orchestrator round-trip per connection). Plain-HTTP flows have no
|
||||
# prior CONNECT stash, so resolve now and stash for response/websocket.
|
||||
meta = getattr(flow, "metadata", None)
|
||||
if isinstance(meta, dict) and _FLOW_CTX_KEY in meta:
|
||||
config, slug, env = meta[_FLOW_CTX_KEY]
|
||||
self._request_token(flow) # strip identity headers; token already resolved
|
||||
else:
|
||||
config, slug, env = self._resolve_flow(flow)
|
||||
self._stash_flow_ctx(flow, config, slug, env)
|
||||
|
||||
# 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.
|
||||
@@ -416,56 +412,66 @@ class EgressAddon:
|
||||
# the path/query the git checks below rely on.
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
|
||||
if is_git_push_request(request_path, query):
|
||||
self._block(
|
||||
flow,
|
||||
"egress: git push over HTTPS is not supported; "
|
||||
"use the bottle.git SSH path (gitleaks-scanned by "
|
||||
"git-gate's pre-receive hook).",
|
||||
ctx=self._req_ctx(flow),
|
||||
)
|
||||
if not self._allow_git_request(flow, config, request_path, query):
|
||||
return
|
||||
|
||||
if is_git_fetch_request(request_path, query):
|
||||
git_decision = decide_git_fetch(
|
||||
config.routes, flow.request.pretty_host,
|
||||
)
|
||||
if git_decision.action == "block":
|
||||
self._block(
|
||||
flow,
|
||||
git_decision.reason,
|
||||
ctx=self._req_ctx(flow),
|
||||
)
|
||||
return
|
||||
self._apply_route_policy(flow, config, route, request_path, env)
|
||||
|
||||
def _request_context(
|
||||
self, flow: http.HTTPFlow,
|
||||
) -> tuple[Config, str, "typing.Mapping[str, str]"]:
|
||||
"""Resolve one bottle context, reusing the HTTPS CONNECT snapshot."""
|
||||
meta = getattr(flow, "metadata", None)
|
||||
if isinstance(meta, dict) and _FLOW_CTX_KEY in meta:
|
||||
config, slug, env = meta[_FLOW_CTX_KEY]
|
||||
self._request_token(flow)
|
||||
return config, slug, env
|
||||
config, slug, env = self._resolve_flow(flow)
|
||||
self._stash_flow_ctx(flow, config, slug, env)
|
||||
return config, slug, env
|
||||
|
||||
def _allow_git_request(
|
||||
self, flow: http.HTTPFlow, config: Config,
|
||||
request_path: str, query: str,
|
||||
) -> bool:
|
||||
"""Apply the HTTPS Git push/fetch boundary before general routing."""
|
||||
reason = git_block_reason(
|
||||
config.routes, flow.request.pretty_host, request_path, query,
|
||||
)
|
||||
if not reason:
|
||||
return True
|
||||
self._block(flow, reason, ctx=self._req_ctx(flow))
|
||||
return False
|
||||
|
||||
def _apply_route_policy(
|
||||
self, flow: http.HTTPFlow, config: Config, route: Route | None,
|
||||
request_path: str, env: "typing.Mapping[str, str]",
|
||||
) -> None:
|
||||
"""Strip agent auth, evaluate the route, then inject gateway auth."""
|
||||
# Strip agent-set Authorization after DLP scan so smuggled tokens
|
||||
# are caught above; the route may inject gateway-owned auth below.
|
||||
# Routes with preserve_auth=True pass the header through as-is so the
|
||||
# agent's own credentials (e.g. registry bearer tokens) reach the upstream.
|
||||
if route is None or not route.preserve_auth:
|
||||
result = evaluate_route_policy(
|
||||
config,
|
||||
route,
|
||||
host=flow.request.pretty_host,
|
||||
request_path=request_path,
|
||||
method=flow.request.method,
|
||||
headers=dict(flow.request.headers),
|
||||
env=env,
|
||||
)
|
||||
if result.strip_authorization:
|
||||
flow.request.headers.pop("authorization", None)
|
||||
|
||||
# Build headers mapping for match evaluation
|
||||
req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
|
||||
|
||||
decision = decide(
|
||||
config.routes,
|
||||
flow.request.pretty_host,
|
||||
request_path,
|
||||
env,
|
||||
request_method=flow.request.method,
|
||||
request_headers=req_headers,
|
||||
deny_reason=config.deny_reason,
|
||||
)
|
||||
|
||||
if decision.action == "block":
|
||||
self._block(flow, decision.reason, ctx=self._req_ctx(flow))
|
||||
if result.block_reason:
|
||||
self._block(flow, result.block_reason, ctx=self._req_ctx(flow))
|
||||
return
|
||||
|
||||
if decision.inject_authorization is not None:
|
||||
flow.request.headers["authorization"] = decision.inject_authorization
|
||||
if result.inject_authorization is not None:
|
||||
flow.request.headers["authorization"] = result.inject_authorization
|
||||
|
||||
if config.log >= LOG_FULL:
|
||||
if result.log_request:
|
||||
self._log_request(flow, env)
|
||||
|
||||
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
||||
@@ -489,20 +495,12 @@ class EgressAddon:
|
||||
Loops so the supervise policy can re-scan after each approval — a
|
||||
second, un-approved token in the same request is still caught."""
|
||||
while True:
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
body = flow.request.get_text(strict=False) or ""
|
||||
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
||||
scan_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, body,
|
||||
)
|
||||
# CRLF is scanned only over the request line + headers, never the
|
||||
# body (see scan_outbound) — a body is not an injection vector.
|
||||
crlf_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, "",
|
||||
)
|
||||
result = scan_outbound(
|
||||
route, scan_text, env,
|
||||
safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text,
|
||||
request_path, _, _ = flow.request.path.partition("?")
|
||||
result = scan_request(
|
||||
flow.request,
|
||||
route,
|
||||
env,
|
||||
safe_tokens=self._safe_tokens_for(slug),
|
||||
)
|
||||
if result is None or result.severity != "block":
|
||||
return True
|
||||
@@ -512,7 +510,7 @@ class EgressAddon:
|
||||
# redact scrubs every detection (tokens and structural CRLF) and
|
||||
# forwards; it fails closed only if a match survives the scrub.
|
||||
if policy == ON_MATCH_REDACT:
|
||||
if self._redact_outbound(flow, route, env):
|
||||
if redact_request(flow.request, route, env):
|
||||
if self._flow_log(flow) >= LOG_BLOCKS:
|
||||
sys.stderr.write(json.dumps({
|
||||
"event": "egress_redacted",
|
||||
@@ -545,41 +543,6 @@ class EgressAddon:
|
||||
return False # _supervise_token_block wrote the 403 response
|
||||
# loop: the approved value is now in safe_tokens; re-scan.
|
||||
|
||||
def _redact_outbound(
|
||||
self, flow: http.HTTPFlow, route: Route, env: "typing.Mapping[str, str]",
|
||||
) -> bool:
|
||||
"""Scrub detected tokens (and CRLF injection sequences) from the mutable
|
||||
request surfaces (body, headers, path/query) and re-scan. `env` is the
|
||||
per-bottle env overlay. Returns True if the request is now clean; False
|
||||
if a block-severity match remains on a surface redaction cannot rewrite
|
||||
(the hostname) so the caller fails closed."""
|
||||
body = flow.request.get_text(strict=False)
|
||||
if body:
|
||||
redacted_body = redact_tokens(body, env=env)
|
||||
if redacted_body != body:
|
||||
flow.request.text = redacted_body
|
||||
for name, value in list(flow.request.headers.items()):
|
||||
if name.lower() == "host":
|
||||
continue # routing-critical; never a legitimate token
|
||||
redacted = strip_crlf(redact_tokens(value, env=env))
|
||||
if redacted != value:
|
||||
flow.request.headers[name] = redacted
|
||||
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=env))
|
||||
if redacted_path != flow.request.path:
|
||||
flow.request.path = redacted_path
|
||||
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
new_body = flow.request.get_text(strict=False) or ""
|
||||
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
||||
scan_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, new_body,
|
||||
)
|
||||
crlf_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, "",
|
||||
)
|
||||
result = scan_outbound(route, scan_text, env, crlf_text=crlf_text)
|
||||
return result is None or result.severity != "block"
|
||||
|
||||
async def _supervise_token_block(
|
||||
self,
|
||||
flow: http.HTTPFlow,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
"""Fail-closed resolution of a client's policy and egress credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from ...log import debug
|
||||
from .types import Config
|
||||
|
||||
|
||||
DENY_UNATTRIBUTED = (
|
||||
"egress: this request was not attributed to any bottle, so no egress policy "
|
||||
"applies and every host is denied. Either the bottle's registry row is "
|
||||
"missing/ambiguous (torn down, or another bottle claimed its source IP), or "
|
||||
"the request carried no matching identity token — check that the caller's "
|
||||
"proxy URL includes it. This is not an allowlist problem."
|
||||
)
|
||||
DENY_UNPARSEABLE = (
|
||||
"egress: this bottle's egress policy could not be parsed, so it is being "
|
||||
"treated as deny-all. Fix the bottle's egress.routes; every host is denied "
|
||||
"until it loads."
|
||||
)
|
||||
DENY_RESOLVER_ERROR = (
|
||||
"egress: the orchestrator could not be reached to resolve this bottle's "
|
||||
"egress policy, so every host is denied (fail-closed). Check that the "
|
||||
"control plane is up; this is not an allowlist problem."
|
||||
)
|
||||
|
||||
|
||||
class PolicyResolverLike(typing.Protocol):
|
||||
def resolve(self, source_ip: str, identity_token: str = ...) -> str | None: ...
|
||||
|
||||
|
||||
class ContextResolverLike(typing.Protocol):
|
||||
def resolve_policy_and_bottle_id(
|
||||
self, source_ip: str, identity_token: str = ...,
|
||||
) -> tuple[str | None, str | None, dict[str, str]]: ...
|
||||
|
||||
|
||||
def _config_from_policy(policy: str | None) -> Config:
|
||||
# Local import keeps schema parsing independent of resolver protocols.
|
||||
from .schema import load_config
|
||||
if not policy:
|
||||
return Config(routes=(), deny_reason=DENY_UNATTRIBUTED)
|
||||
try:
|
||||
return load_config(policy)
|
||||
except ValueError:
|
||||
return Config(routes=(), deny_reason=DENY_UNPARSEABLE)
|
||||
|
||||
|
||||
def resolve_client_config(
|
||||
resolver: PolicyResolverLike, client_ip: str, identity_token: str = "",
|
||||
) -> Config:
|
||||
try:
|
||||
policy = resolver.resolve(client_ip, identity_token)
|
||||
except Exception as exc: # noqa: BLE001 - a policy lookup failure must deny
|
||||
debug(
|
||||
"egress policy resolution failed; applying deny-all",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR)
|
||||
return _config_from_policy(policy)
|
||||
|
||||
|
||||
def resolve_client_context(
|
||||
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
|
||||
) -> tuple[Config, str, dict[str, str]]:
|
||||
try:
|
||||
policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id(
|
||||
client_ip, identity_token)
|
||||
except Exception as exc: # noqa: BLE001 - a policy lookup failure must deny
|
||||
debug(
|
||||
"egress context resolution failed; applying deny-all",
|
||||
context={"error_type": type(exc).__name__},
|
||||
)
|
||||
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR), "", {}
|
||||
return _config_from_policy(policy), (bottle_id or ""), tokens
|
||||
@@ -0,0 +1,99 @@
|
||||
"""DLP scan dispatch and safe proposal rendering for egress requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .types import Route, ScanResult
|
||||
|
||||
|
||||
def build_outbound_scan_text(host: str, path: str, query: str,
|
||||
headers: typing.Mapping[str, str], body: str) -> str:
|
||||
parts = [host, path]
|
||||
if query:
|
||||
parts.append(query)
|
||||
parts.extend(f"{name}: {value}" for name, value in headers.items())
|
||||
if body:
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def outbound_scan_headers(route: Route, headers: typing.Mapping[str, str]) -> dict[str, str]:
|
||||
"""Drop agent Authorization when the route injects gateway-owned auth."""
|
||||
skip_auth = bool(route.auth_scheme and route.token_env)
|
||||
return {name: value for name, value in headers.items()
|
||||
if not (skip_auth and name.lower() == "authorization")}
|
||||
|
||||
|
||||
def build_inbound_scan_text(headers: typing.Mapping[str, str], body: str) -> str:
|
||||
parts = [f"{name}: {value}" for name, value in headers.items()]
|
||||
if body:
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _enabled(configured: tuple[str, ...] | None, name: str) -> bool:
|
||||
return configured is None or name in configured
|
||||
|
||||
|
||||
def scan_outbound(route: Route, body: str | bytes, environ: typing.Mapping[str, str], *,
|
||||
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||
crlf_text: str | None = None) -> ScanResult | None:
|
||||
if not route.inspect:
|
||||
return None
|
||||
try:
|
||||
from dlp_detectors import ( # type: ignore[import-not-found]
|
||||
scan_crlf_injection, scan_entropy, scan_known_secrets, scan_token_patterns)
|
||||
except ImportError: # pragma: no cover - gateway's flat module path
|
||||
from .dlp_detectors import (
|
||||
scan_crlf_injection, scan_entropy, scan_known_secrets, scan_token_patterns)
|
||||
if isinstance(body, bytes):
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = body.decode("latin-1")
|
||||
else:
|
||||
text = body
|
||||
result = scan_crlf_injection(text if crlf_text is None else crlf_text)
|
||||
if result is not None:
|
||||
return result
|
||||
if _enabled(route.outbound_detectors, "token_patterns"):
|
||||
result = scan_token_patterns(text, location="body", safe_tokens=safe_tokens)
|
||||
if result is not None:
|
||||
return result
|
||||
if _enabled(route.outbound_detectors, "known_secrets"):
|
||||
extra = tuple(prefix for prefix in environ.get(
|
||||
"BOT_BOTTLE_SENSITIVE_PREFIXES", "").split(",") if prefix)
|
||||
result = scan_known_secrets(text, location="body", env=environ,
|
||||
sensitive_prefixes=("EGRESS_TOKEN_",) + extra,
|
||||
safe_tokens=safe_tokens)
|
||||
if result is not None:
|
||||
return result
|
||||
if route.outbound_detectors is not None and "entropy" in route.outbound_detectors:
|
||||
return scan_entropy(text, location="body")
|
||||
return None
|
||||
|
||||
|
||||
def build_token_allow_payload(host: str, method: str, path: str, result: ScanResult) -> str:
|
||||
"""Render redacted operator context; the raw matched secret is excluded."""
|
||||
lines = [
|
||||
"egress blocked an outbound request carrying a detected token",
|
||||
f"host: {host}", f"method: {method}", f"path: {path}",
|
||||
f"detector: {result.reason}",
|
||||
]
|
||||
if result.context:
|
||||
lines.append(f"context: {result.context}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def scan_inbound(route: Route, body: str | bytes) -> ScanResult | None:
|
||||
if not route.inspect:
|
||||
return None
|
||||
try:
|
||||
from dlp_detectors import scan_naive_injection # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - gateway's flat module path
|
||||
from .dlp_detectors import scan_naive_injection
|
||||
text = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
||||
if _enabled(route.inbound_detectors, "naive_injection_detection"):
|
||||
return scan_naive_injection(text)
|
||||
return None
|
||||
@@ -19,7 +19,7 @@ from math import log2
|
||||
from collections import Counter
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
from .addon_core import ScanResult
|
||||
from .types import ScanResult
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Route matching and request-policy decisions for the egress gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .types import Decision, MatchEntry, PathMatch, Route
|
||||
|
||||
|
||||
def _path_matches(pm: PathMatch, request_path: str) -> bool:
|
||||
if pm.type == "exact":
|
||||
return request_path == pm.value
|
||||
if pm.type == "prefix":
|
||||
if request_path == pm.value:
|
||||
return True
|
||||
if not pm.value.endswith("/"):
|
||||
return request_path.startswith(pm.value + "/")
|
||||
return request_path.startswith(pm.value)
|
||||
return (
|
||||
pm.type == "regex"
|
||||
and pm.compiled is not None
|
||||
and pm.compiled.search(request_path) is not None
|
||||
)
|
||||
|
||||
|
||||
def _entry_matches(
|
||||
entry: MatchEntry, request_path: str, request_method: str,
|
||||
request_headers: typing.Mapping[str, str],
|
||||
) -> bool:
|
||||
if entry.paths and not any(_path_matches(pm, request_path) for pm in entry.paths):
|
||||
return False
|
||||
if entry.methods and request_method.upper() not in entry.methods:
|
||||
return False
|
||||
for match in entry.headers:
|
||||
value = request_headers.get(match.name.lower())
|
||||
if value is None:
|
||||
return False
|
||||
if match.type == "exact" and value != match.value:
|
||||
return False
|
||||
if match.type == "regex" and (
|
||||
match.compiled is None or match.compiled.search(value) is None
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def evaluate_matches(
|
||||
route: Route, request_path: str, request_method: str = "GET",
|
||||
request_headers: typing.Mapping[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""Return whether a request satisfies a route's optional match entries."""
|
||||
if not route.matches:
|
||||
return True
|
||||
return any(_entry_matches(entry, request_path, request_method, request_headers or {})
|
||||
for entry in route.matches)
|
||||
|
||||
|
||||
def is_git_push_request(path: str, query: str) -> bool:
|
||||
return path.endswith("/git-receive-pack") or (
|
||||
path.endswith("/info/refs") and any(
|
||||
pair.partition("=") == ("service", "=", "git-receive-pack")
|
||||
for pair in query.split("&")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_git_fetch_request(path: str, query: str) -> bool:
|
||||
return path.endswith("/git-upload-pack") or (
|
||||
path.endswith("/info/refs") and any(
|
||||
pair.partition("=") == ("service", "=", "git-upload-pack")
|
||||
for pair in query.split("&")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def match_route(routes: typing.Sequence[Route], request_host: str) -> Route | None:
|
||||
target = request_host.lower()
|
||||
return next((route for route in routes if route.host.lower() == target), None)
|
||||
|
||||
|
||||
def decide(
|
||||
routes: typing.Sequence[Route], request_host: str, request_path: str,
|
||||
environ: typing.Mapping[str, str], *, request_method: str = "GET",
|
||||
request_headers: typing.Mapping[str, str] | None = None, deny_reason: str = "",
|
||||
) -> Decision:
|
||||
route = match_route(routes, request_host)
|
||||
if route is None:
|
||||
return Decision("block", deny_reason or (
|
||||
f"egress: host {request_host!r} is not in the bottle's egress.routes "
|
||||
"allowlist. Declare a route for it or remove the request."))
|
||||
if not evaluate_matches(route, request_path, request_method, request_headers):
|
||||
return Decision("block", (
|
||||
f"egress: request {request_method} {request_path!r} does not match any "
|
||||
f"entry in matches for {route.host!r}"))
|
||||
if route.auth_scheme and route.token_env:
|
||||
token = environ.get(route.token_env, "")
|
||||
if not token:
|
||||
return Decision("block", (
|
||||
f"egress: route for {route.host!r} declared auth but env var "
|
||||
f"{route.token_env!r} is unset"))
|
||||
return Decision("forward", inject_authorization=f"{route.auth_scheme} {token}")
|
||||
return Decision("forward")
|
||||
|
||||
|
||||
def decide_git_fetch(routes: typing.Sequence[Route], request_host: str) -> Decision:
|
||||
route = match_route(routes, request_host)
|
||||
if route is not None and route.git_fetch:
|
||||
return Decision("forward")
|
||||
return Decision("block", (
|
||||
"egress: git fetch/clone over HTTPS is not allowed by default; use git-gate "
|
||||
"for declared repos or set egress.routes[].git.fetch=true for explicit "
|
||||
"read-only HTTPS Git access."))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Outbound DLP request scanning and redaction for the egress pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ItemsView, Mapping, Protocol
|
||||
|
||||
from .dlp import (
|
||||
build_outbound_scan_text,
|
||||
outbound_scan_headers,
|
||||
scan_outbound,
|
||||
)
|
||||
from .dlp_detectors import redact_tokens, strip_crlf
|
||||
from .types import Route, ScanResult
|
||||
|
||||
|
||||
class MutableHeaders(Protocol):
|
||||
def items(self) -> ItemsView[str, str]: ...
|
||||
def __getitem__(self, name: str, /) -> str: ...
|
||||
def __setitem__(self, name: str, value: str, /) -> None: ...
|
||||
|
||||
|
||||
class MutableRequest(Protocol):
|
||||
pretty_host: str
|
||||
path: str
|
||||
headers: MutableHeaders
|
||||
text: str
|
||||
|
||||
def get_text(self, strict: bool = False) -> str | None: ...
|
||||
|
||||
|
||||
def scan_request(
|
||||
request: MutableRequest,
|
||||
route: Route,
|
||||
env: Mapping[str, str],
|
||||
*,
|
||||
safe_tokens: set[str] | None = None,
|
||||
) -> ScanResult | None:
|
||||
"""Scan all mutable outbound request surfaces in their canonical order."""
|
||||
request_path, _, query = request.path.partition("?")
|
||||
headers = outbound_scan_headers(route, dict(request.headers.items()))
|
||||
body = request.get_text(strict=False) or ""
|
||||
scan_text = build_outbound_scan_text(
|
||||
request.pretty_host, request_path, query, headers, body,
|
||||
)
|
||||
# Bodies cannot alter HTTP framing, so CRLF detection is deliberately
|
||||
# restricted to the request line and headers.
|
||||
crlf_text = build_outbound_scan_text(
|
||||
request.pretty_host, request_path, query, headers, "",
|
||||
)
|
||||
return scan_outbound(
|
||||
route,
|
||||
scan_text,
|
||||
env,
|
||||
safe_tokens=safe_tokens,
|
||||
crlf_text=crlf_text,
|
||||
)
|
||||
|
||||
|
||||
def redact_request(
|
||||
request: MutableRequest,
|
||||
route: Route,
|
||||
env: Mapping[str, str],
|
||||
) -> bool:
|
||||
"""Redact mutable request surfaces and return whether the result is clean."""
|
||||
body = request.get_text(strict=False)
|
||||
if body:
|
||||
redacted_body = redact_tokens(body, env=env)
|
||||
if redacted_body != body:
|
||||
request.text = redacted_body
|
||||
for name, value in list(request.headers.items()):
|
||||
if name.lower() == "host":
|
||||
continue
|
||||
redacted = strip_crlf(redact_tokens(value, env=env))
|
||||
if redacted != value:
|
||||
request.headers[name] = redacted
|
||||
redacted_path = strip_crlf(redact_tokens(request.path, env=env))
|
||||
if redacted_path != request.path:
|
||||
request.path = redacted_path
|
||||
result = scan_request(request, route, env)
|
||||
return result is None or result.severity != "block"
|
||||
|
||||
|
||||
__all__ = ["MutableRequest", "redact_request", "scan_request"]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Framework-neutral request policy stages for the egress adapter.
|
||||
|
||||
The mitmproxy addon owns flow mutation and response construction. This module
|
||||
owns the ordered Git and route-policy decisions so those rules remain directly
|
||||
testable without a live proxy flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from .matching import (
|
||||
decide,
|
||||
decide_git_fetch,
|
||||
is_git_fetch_request,
|
||||
is_git_push_request,
|
||||
)
|
||||
from .types import LOG_FULL, Config, Route
|
||||
|
||||
GIT_PUSH_BLOCK_REASON = (
|
||||
"egress: git push over HTTPS is not supported; "
|
||||
"use the bottle.git SSH path (gitleaks-scanned by "
|
||||
"git-gate's pre-receive hook)."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutePolicyResult:
|
||||
"""The flow mutations and outcome produced by general route policy."""
|
||||
|
||||
block_reason: str = ""
|
||||
strip_authorization: bool = False
|
||||
inject_authorization: str | None = None
|
||||
log_request: bool = False
|
||||
|
||||
|
||||
def git_block_reason(
|
||||
routes: Sequence[Route],
|
||||
host: str,
|
||||
request_path: str,
|
||||
query: str,
|
||||
) -> str:
|
||||
"""Return the HTTPS Git policy denial, or ``""`` when allowed."""
|
||||
if is_git_push_request(request_path, query):
|
||||
return GIT_PUSH_BLOCK_REASON
|
||||
if not is_git_fetch_request(request_path, query):
|
||||
return ""
|
||||
decision = decide_git_fetch(routes, host)
|
||||
return decision.reason if decision.action == "block" else ""
|
||||
|
||||
|
||||
def evaluate_route_policy(
|
||||
config: Config,
|
||||
route: Route | None,
|
||||
*,
|
||||
host: str,
|
||||
request_path: str,
|
||||
method: str,
|
||||
headers: Mapping[str, str],
|
||||
env: Mapping[str, str],
|
||||
) -> RoutePolicyResult:
|
||||
"""Evaluate authorization stripping, matching, injection, and logging."""
|
||||
strip_authorization = route is None or not route.preserve_auth
|
||||
effective_headers = {
|
||||
name.lower(): value
|
||||
for name, value in headers.items()
|
||||
if not (strip_authorization and name.lower() == "authorization")
|
||||
}
|
||||
decision = decide(
|
||||
config.routes,
|
||||
host,
|
||||
request_path,
|
||||
env,
|
||||
request_method=method,
|
||||
request_headers=effective_headers,
|
||||
deny_reason=config.deny_reason,
|
||||
)
|
||||
return RoutePolicyResult(
|
||||
block_reason=decision.reason if decision.action == "block" else "",
|
||||
strip_authorization=strip_authorization,
|
||||
inject_authorization=decision.inject_authorization,
|
||||
log_request=config.log >= LOG_FULL,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GIT_PUSH_BLOCK_REASON",
|
||||
"RoutePolicyResult",
|
||||
"evaluate_route_policy",
|
||||
"git_block_reason",
|
||||
]
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Egress policy schema parsing and serialization (PRD 0017 / 0053)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing
|
||||
|
||||
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||
from .dlp_config import parse_inspect_block
|
||||
from .types import (
|
||||
HEADER_MATCH_TYPES,
|
||||
LOG_BLOCKS,
|
||||
LOG_FULL,
|
||||
LOG_OFF,
|
||||
PATH_MATCH_TYPES,
|
||||
VALID_METHODS,
|
||||
Config,
|
||||
HeaderMatch,
|
||||
MatchEntry,
|
||||
PathMatch,
|
||||
Route,
|
||||
)
|
||||
|
||||
# Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_path_match(idx: int, j: int, raw: object) -> PathMatch:
|
||||
label = f"route[{idx}] matches paths[{j}]"
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{label}: must be an object")
|
||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
||||
ptype = raw_dict.get("type", "prefix")
|
||||
if not isinstance(ptype, str) or ptype not in PATH_MATCH_TYPES:
|
||||
raise ValueError(
|
||||
f"{label}: 'type' must be one of {', '.join(PATH_MATCH_TYPES)} "
|
||||
f"(got {ptype!r})"
|
||||
)
|
||||
value = raw_dict.get("value")
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{label}: 'value' must be a non-empty string")
|
||||
if ptype in ("exact", "prefix") and not value.startswith("/"):
|
||||
raise ValueError(
|
||||
f"{label}: value {value!r} must start with '/' for "
|
||||
f"type {ptype!r}"
|
||||
)
|
||||
compiled: re.Pattern[str] | None = None
|
||||
if ptype == "regex":
|
||||
try:
|
||||
compiled = re.compile(value)
|
||||
except re.error as e:
|
||||
raise ValueError(
|
||||
f"{label}: regex {value!r} failed to compile: {e}"
|
||||
) from e
|
||||
for k in raw_dict:
|
||||
if k not in ("type", "value"):
|
||||
raise ValueError(f"{label}: unknown key {k!r}")
|
||||
return PathMatch(type=ptype, value=value, compiled=compiled)
|
||||
|
||||
|
||||
def _parse_header_match(idx: int, j: int, raw: object) -> HeaderMatch:
|
||||
label = f"route[{idx}] matches headers[{j}]"
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{label}: must be an object")
|
||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
||||
name = raw_dict.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ValueError(f"{label}: 'name' must be a non-empty string")
|
||||
value = raw_dict.get("value")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{label}: 'value' must be a string")
|
||||
htype = raw_dict.get("type", "exact")
|
||||
if not isinstance(htype, str) or htype not in HEADER_MATCH_TYPES:
|
||||
raise ValueError(
|
||||
f"{label}: 'type' must be one of {', '.join(HEADER_MATCH_TYPES)} "
|
||||
f"(got {htype!r})"
|
||||
)
|
||||
compiled: re.Pattern[str] | None = None
|
||||
if htype == "regex":
|
||||
try:
|
||||
compiled = re.compile(value)
|
||||
except re.error as e:
|
||||
raise ValueError(
|
||||
f"{label}: regex {value!r} failed to compile: {e}"
|
||||
) from e
|
||||
for k in raw_dict:
|
||||
if k not in ("name", "value", "type"):
|
||||
raise ValueError(f"{label}: unknown key {k!r}")
|
||||
return HeaderMatch(name=name, value=value, type=htype, compiled=compiled)
|
||||
|
||||
|
||||
def _parse_match_entry(idx: int, k: int, raw: object) -> MatchEntry:
|
||||
label = f"route[{idx}] matches[{k}]"
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{label}: must be an object")
|
||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
||||
|
||||
paths: tuple[PathMatch, ...] = ()
|
||||
paths_raw = raw_dict.get("paths")
|
||||
if paths_raw is not None:
|
||||
if not isinstance(paths_raw, list):
|
||||
raise ValueError(f"{label}: 'paths' must be a list")
|
||||
paths_list = typing.cast(list[object], paths_raw)
|
||||
paths = tuple(_parse_path_match(idx, j, p) for j, p in enumerate(paths_list))
|
||||
|
||||
methods: tuple[str, ...] = ()
|
||||
methods_raw = raw_dict.get("methods")
|
||||
if methods_raw is not None:
|
||||
if not isinstance(methods_raw, list):
|
||||
raise ValueError(f"{label}: 'methods' must be a list")
|
||||
methods_list = typing.cast(list[object], methods_raw)
|
||||
normalised: list[str] = []
|
||||
for j, m in enumerate(methods_list):
|
||||
if not isinstance(m, str):
|
||||
raise ValueError(f"{label}: methods[{j}] must be a string")
|
||||
upper = m.upper()
|
||||
if upper not in VALID_METHODS:
|
||||
raise ValueError(
|
||||
f"{label}: methods[{j}] {m!r} is not a valid HTTP method"
|
||||
)
|
||||
normalised.append(upper)
|
||||
methods = tuple(normalised)
|
||||
|
||||
headers: tuple[HeaderMatch, ...] = ()
|
||||
headers_raw = raw_dict.get("headers")
|
||||
if headers_raw is not None:
|
||||
if not isinstance(headers_raw, list):
|
||||
raise ValueError(f"{label}: 'headers' must be a list")
|
||||
headers_list = typing.cast(list[object], headers_raw)
|
||||
headers = tuple(
|
||||
_parse_header_match(idx, j, h) for j, h in enumerate(headers_list)
|
||||
)
|
||||
|
||||
for key in raw_dict:
|
||||
if key not in ("paths", "methods", "headers"):
|
||||
raise ValueError(f"{label}: unknown key {key!r}")
|
||||
|
||||
return MatchEntry(paths=paths, methods=methods, headers=headers)
|
||||
|
||||
|
||||
def parse_routes(payload: object) -> tuple[Route, ...]:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("routes payload: top-level must be an object")
|
||||
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
||||
raw: object = payload_dict.get("routes")
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("routes payload: 'routes' must be a list")
|
||||
raw_list: list[object] = typing.cast(list[object], raw)
|
||||
out: list[Route] = []
|
||||
for i, r in enumerate(raw_list):
|
||||
out.append(_parse_one(i, r))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _parse_one(idx: int, raw: object) -> Route:
|
||||
label = f"route[{idx}]"
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{label}: must be an object (got {type(raw).__name__})")
|
||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
||||
host: object = raw_dict.get("host")
|
||||
if not isinstance(host, str) or not host:
|
||||
raise ValueError(f"{label}: 'host' must be a non-empty string")
|
||||
legacy_flat = "inspect" not in raw_dict
|
||||
inspect_raw = raw_dict.get("inspect", {})
|
||||
if inspect_raw is False:
|
||||
inspect = False
|
||||
settings: dict[str, object] = {}
|
||||
elif isinstance(inspect_raw, dict):
|
||||
inspect = True
|
||||
settings = (
|
||||
{k: v for k, v in raw_dict.items() if k != "host"}
|
||||
if legacy_flat
|
||||
else typing.cast(dict[str, object], inspect_raw)
|
||||
)
|
||||
legacy_dlp = settings.pop("dlp", None)
|
||||
if isinstance(legacy_dlp, dict):
|
||||
settings.update(typing.cast(dict[str, object], legacy_dlp))
|
||||
elif legacy_dlp is not None:
|
||||
raise ValueError(
|
||||
f"{label} ({host}): legacy 'dlp' must be an object"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"{label} ({host}): 'inspect' must be false or an object")
|
||||
|
||||
# matches
|
||||
matches: tuple[MatchEntry, ...] = ()
|
||||
matches_raw = settings.get("matches")
|
||||
if matches_raw is not None:
|
||||
if not isinstance(matches_raw, list):
|
||||
raise ValueError(f"{label} ({host}): 'matches' must be a list")
|
||||
matches_list = typing.cast(list[object], matches_raw)
|
||||
matches = tuple(
|
||||
_parse_match_entry(idx, k, m) for k, m in enumerate(matches_list)
|
||||
)
|
||||
|
||||
# auth (unchanged wire format)
|
||||
auth_scheme: object = settings.get("auth_scheme", "")
|
||||
token_env: object = settings.get("token_env", "")
|
||||
if not isinstance(auth_scheme, str):
|
||||
raise ValueError(f"{label} ({host}): 'auth_scheme' must be a string")
|
||||
if not isinstance(token_env, str):
|
||||
raise ValueError(f"{label} ({host}): 'token_env' must be a string")
|
||||
if bool(auth_scheme) != bool(token_env):
|
||||
raise ValueError(
|
||||
f"{label} ({host}): 'auth_scheme' and 'token_env' must be both "
|
||||
f"set or both empty (got auth_scheme={auth_scheme!r}, "
|
||||
f"token_env={token_env!r})"
|
||||
)
|
||||
|
||||
# git-over-HTTPS policy
|
||||
git_fetch = False
|
||||
git_raw = settings.get("git")
|
||||
if git_raw is not None:
|
||||
if not isinstance(git_raw, dict):
|
||||
raise ValueError(f"{label} ({host}): 'git' must be an object")
|
||||
git_dict: dict[str, object] = typing.cast(dict[str, object], git_raw)
|
||||
fetch_raw = git_dict.get("fetch", False)
|
||||
if fetch_raw is True or fetch_raw is False:
|
||||
git_fetch = fetch_raw
|
||||
else:
|
||||
raise ValueError(f"{label} ({host}): 'git.fetch' must be a boolean")
|
||||
for k in git_dict:
|
||||
if k != "fetch":
|
||||
raise ValueError(
|
||||
f"{label} ({host}): git has unknown key {k!r}; "
|
||||
"accepted key is 'fetch'"
|
||||
)
|
||||
|
||||
# dlp detectors
|
||||
outbound_detectors, inbound_detectors, outbound_on_match = parse_inspect_block(
|
||||
idx, host, settings,
|
||||
)
|
||||
|
||||
preserve_auth_raw = settings.get("preserve_auth", False)
|
||||
if preserve_auth_raw is not True and preserve_auth_raw is not False:
|
||||
raise ValueError(
|
||||
f"{label} ({host}): 'preserve_auth' must be a boolean"
|
||||
)
|
||||
preserve_auth: bool = preserve_auth_raw
|
||||
|
||||
for k in settings:
|
||||
if k not in (
|
||||
"matches", "auth_scheme", "token_env", "git", "preserve_auth",
|
||||
"outbound_detectors", "inbound_detectors", "outbound_on_match",
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} ({host}): inspect has unknown key {k!r}"
|
||||
)
|
||||
for k in raw_dict:
|
||||
if not legacy_flat and k not in ("host", "inspect"):
|
||||
raise ValueError(
|
||||
f"{label} ({host}): unknown key {k!r}; accepted keys "
|
||||
f"are 'host' and 'inspect'"
|
||||
)
|
||||
|
||||
return Route(
|
||||
host=host,
|
||||
matches=matches,
|
||||
auth_scheme=auth_scheme,
|
||||
token_env=token_env,
|
||||
git_fetch=git_fetch,
|
||||
outbound_detectors=outbound_detectors,
|
||||
inbound_detectors=inbound_detectors,
|
||||
outbound_on_match=outbound_on_match,
|
||||
preserve_auth=preserve_auth,
|
||||
inspect=inspect,
|
||||
)
|
||||
|
||||
|
||||
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
|
||||
d: dict[str, object] = {"value": pm.value}
|
||||
if pm.type != "prefix":
|
||||
d["type"] = pm.type
|
||||
return d
|
||||
|
||||
|
||||
def _header_match_to_dict(hm: HeaderMatch) -> dict[str, object]:
|
||||
d: dict[str, object] = {"name": hm.name, "value": hm.value}
|
||||
if hm.type != "exact":
|
||||
d["type"] = hm.type
|
||||
return d
|
||||
|
||||
|
||||
def _match_entry_to_dict(me: MatchEntry) -> dict[str, object]:
|
||||
d: dict[str, object] = {}
|
||||
if me.paths:
|
||||
d["paths"] = [_path_match_to_dict(p) for p in me.paths]
|
||||
if me.methods:
|
||||
d["methods"] = list(me.methods)
|
||||
if me.headers:
|
||||
d["headers"] = [_header_match_to_dict(h) for h in me.headers]
|
||||
return d
|
||||
|
||||
|
||||
def route_to_yaml_dict(r: Route) -> dict[str, object]:
|
||||
"""Serialize a Route to YAML-schema-compatible dict.
|
||||
|
||||
Uses the same field names the YAML parser accepts, so the output
|
||||
can be round-tripped directly into an `allow` or `egress-block`
|
||||
proposal without translation. Fields that are empty/default are
|
||||
omitted so the agent doesn't copy irrelevant keys."""
|
||||
d: dict[str, object] = {"host": r.host}
|
||||
if not r.inspect:
|
||||
d["inspect"] = False
|
||||
return d
|
||||
inspected: dict[str, object] = {}
|
||||
if r.auth_scheme:
|
||||
inspected["auth_scheme"] = r.auth_scheme
|
||||
inspected["token_env"] = r.token_env
|
||||
if r.matches:
|
||||
inspected["matches"] = [_match_entry_to_dict(m) for m in r.matches]
|
||||
if r.git_fetch:
|
||||
inspected["git"] = {"fetch": True}
|
||||
if r.outbound_detectors is not None:
|
||||
inspected["outbound_detectors"] = list(r.outbound_detectors)
|
||||
if r.inbound_detectors is not None:
|
||||
inspected["inbound_detectors"] = list(r.inbound_detectors)
|
||||
if r.outbound_on_match:
|
||||
inspected["outbound_on_match"] = r.outbound_on_match
|
||||
if r.preserve_auth:
|
||||
inspected["preserve_auth"] = True
|
||||
if inspected:
|
||||
d["inspect"] = inspected
|
||||
return d
|
||||
|
||||
|
||||
def parse_config(payload: object) -> "Config":
|
||||
"""Parse a full egress config payload (top-level log level + routes)."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("routes payload: top-level must be an object")
|
||||
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
||||
|
||||
log_raw: object = payload_dict.get("log", LOG_OFF)
|
||||
if log_raw is True or log_raw is False or not isinstance(log_raw, int) \
|
||||
or log_raw not in (LOG_OFF, LOG_BLOCKS, LOG_FULL):
|
||||
raise ValueError(
|
||||
f"routes payload: 'log' must be {LOG_OFF}, {LOG_BLOCKS}, or {LOG_FULL}"
|
||||
)
|
||||
|
||||
routes = parse_routes(payload)
|
||||
return Config(routes=routes, log=log_raw)
|
||||
|
||||
|
||||
def load_config(text: str) -> "Config":
|
||||
"""Parse YAML text → Config (routes + log flag)."""
|
||||
try:
|
||||
payload = parse_yaml_subset(text)
|
||||
except YamlSubsetError as e:
|
||||
raise ValueError(f"routes payload: invalid YAML: {e}") from e
|
||||
return parse_config(payload)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Shared egress policy value objects.
|
||||
|
||||
Kept dependency-free so the schema parser, matcher, DLP scanner, and addon
|
||||
adapter can use the same immutable public shapes without importing each other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
PATH_MATCH_TYPES = ("exact", "prefix", "regex")
|
||||
HEADER_MATCH_TYPES = ("exact", "regex")
|
||||
VALID_METHODS = frozenset({
|
||||
"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "TRACE",
|
||||
"CONNECT",
|
||||
})
|
||||
|
||||
LOG_OFF = 0
|
||||
LOG_BLOCKS = 1
|
||||
LOG_FULL = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathMatch:
|
||||
type: str
|
||||
value: str
|
||||
compiled: re.Pattern[str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HeaderMatch:
|
||||
name: str
|
||||
value: str
|
||||
type: str = "exact"
|
||||
compiled: re.Pattern[str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchEntry:
|
||||
paths: tuple[PathMatch, ...] = ()
|
||||
methods: tuple[str, ...] = ()
|
||||
headers: tuple[HeaderMatch, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Route:
|
||||
host: str
|
||||
matches: tuple[MatchEntry, ...] = ()
|
||||
auth_scheme: str = ""
|
||||
token_env: str = ""
|
||||
git_fetch: bool = False
|
||||
outbound_detectors: tuple[str, ...] | None = None
|
||||
inbound_detectors: tuple[str, ...] | None = None
|
||||
outbound_on_match: str = ""
|
||||
preserve_auth: bool = False
|
||||
inspect: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
routes: tuple[Route, ...]
|
||||
log: int = LOG_OFF
|
||||
deny_reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
action: str
|
||||
reason: str = ""
|
||||
inject_authorization: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanResult:
|
||||
severity: str
|
||||
reason: str
|
||||
location: str = ""
|
||||
context: str = ""
|
||||
matched: str = ""
|
||||
@@ -21,12 +21,19 @@ from __future__ import annotations
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import typing
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bot_bottle.constants import GIT_GATE_TIMEOUT_SECS, IDENTITY_HEADER
|
||||
from bot_bottle.gateway.bounded_http import (
|
||||
BodyReadError,
|
||||
BoundedThreadingHTTPServer,
|
||||
copy_declared_body,
|
||||
)
|
||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
|
||||
|
||||
@@ -77,6 +84,10 @@ def resolve_sandbox_root(
|
||||
|
||||
# Bound memory use while still allowing ordinary git push packfiles.
|
||||
MAX_BODY_BYTES = 100 * 1024 * 1024
|
||||
REQUEST_BODY_TIMEOUT_SECONDS = 30.0
|
||||
MAX_REQUEST_WORKERS = 16
|
||||
MAX_BODY_WORKERS = 2
|
||||
_BODY_WORK_SLOTS = threading.BoundedSemaphore(MAX_BODY_WORKERS)
|
||||
|
||||
|
||||
class GitHttpHandler(BaseHTTPRequestHandler):
|
||||
@@ -184,27 +195,40 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
||||
value = self.headers.get(header)
|
||||
if value:
|
||||
env[variable] = value
|
||||
raw_length = self.headers.get("content-length", "0") or "0"
|
||||
if not _BODY_WORK_SLOTS.acquire(blocking=False):
|
||||
self.send_error(503, "git request capacity exhausted")
|
||||
return
|
||||
try:
|
||||
length = int(raw_length)
|
||||
except ValueError:
|
||||
self.send_error(400, "Bad Content-Length")
|
||||
return
|
||||
if length < 0:
|
||||
self.send_error(400, "Negative Content-Length")
|
||||
return
|
||||
if length > MAX_BODY_BYTES:
|
||||
self.send_error(413, "Request body too large")
|
||||
return
|
||||
body = self.rfile.read(length) if length else b""
|
||||
proc = subprocess.run(
|
||||
["git", "http-backend"],
|
||||
input=body,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=GIT_GATE_TIMEOUT_SECS,
|
||||
)
|
||||
with tempfile.TemporaryFile() as body:
|
||||
try:
|
||||
copy_declared_body(
|
||||
self.rfile,
|
||||
body,
|
||||
self.connection,
|
||||
self.headers.get("content-length"),
|
||||
maximum=MAX_BODY_BYTES,
|
||||
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
|
||||
require_length=False,
|
||||
)
|
||||
except BodyReadError as exc:
|
||||
self.send_error(exc.status, exc.message)
|
||||
return
|
||||
body.seek(0)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "http-backend"],
|
||||
stdin=body,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=GIT_GATE_TIMEOUT_SECS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
self.log_message("git http-backend unavailable: %s", exc)
|
||||
self.send_error(503, "git backend unavailable")
|
||||
return
|
||||
finally:
|
||||
_BODY_WORK_SLOTS.release()
|
||||
self._write_cgi_response(proc.stdout)
|
||||
|
||||
def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None:
|
||||
@@ -273,7 +297,9 @@ def main() -> int:
|
||||
"(no single-tenant flat-root fallback)\n"
|
||||
)
|
||||
return 1
|
||||
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
|
||||
server = BoundedThreadingHTTPServer(
|
||||
("0.0.0.0", port), GitHttpHandler, max_workers=MAX_REQUEST_WORKERS,
|
||||
)
|
||||
# Resolve each request's sandbox namespace by source IP against the
|
||||
# orchestrator control plane.
|
||||
server.policy_resolver = PolicyResolver(orch_url) # type: ignore[attr-defined]
|
||||
|
||||
@@ -252,6 +252,27 @@ cat > "$refs_file"
|
||||
|
||||
zero=0000000000000000000000000000000000000000
|
||||
|
||||
# Phase 0: reject Gitea AGit review refs before scanning or forwarding.
|
||||
# A push to refs/for/*, refs/draft/*, or refs/for-review/* asks Gitea to
|
||||
# open a pull request backed by a server-managed refs/pull/<n>/head rather
|
||||
# than an ordinary refs/heads/* branch. That breaks the git-gate workflow:
|
||||
# follow-up commits can't be pushed back through the branch, and Gitea
|
||||
# rejects later direct updates to the generated review ref. Fail the whole
|
||||
# push here (before any gitleaks scan or upstream forward) so the caller
|
||||
# pushes a real branch and opens the PR against it instead. Deletions
|
||||
# (new == zero) stay allowed so stale AGit refs can still be cleaned up.
|
||||
while IFS=' ' read -r old new ref; do
|
||||
[ -z "$ref" ] && continue
|
||||
[ "$new" = "$zero" ] && continue
|
||||
case "$ref" in
|
||||
refs/for/*|refs/draft/*|refs/for-review/*)
|
||||
echo "git-gate: refusing AGit review ref $ref" >&2
|
||||
echo "git-gate: push to refs/heads/<branch> and open a branch-backed pull request instead" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done < "$refs_file"
|
||||
|
||||
supervise_gitleaks_allow() {
|
||||
log_opts=$1
|
||||
ref=$2
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Framework-neutral MCP method and tool dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from bot_bottle.gateway.egress.schema import load_config, route_to_yaml_dict
|
||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
from bot_bottle.supervisor import types as _sv
|
||||
|
||||
|
||||
class Request(Protocol):
|
||||
@property
|
||||
def method(self) -> str: ...
|
||||
|
||||
@property
|
||||
def params(self) -> dict[str, object]: ...
|
||||
|
||||
|
||||
class MethodNotFoundError(Exception):
|
||||
"""Raised when a JSON-RPC method has no MCP handler."""
|
||||
|
||||
|
||||
class RouteResolutionError(Exception):
|
||||
"""The caller's live route table could not be resolved authoritatively."""
|
||||
|
||||
|
||||
Handler = Callable[[dict[str, object]], object]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Handlers:
|
||||
initialize: Handler
|
||||
tools_list: Handler
|
||||
list_routes: Handler
|
||||
check_proposal: Handler
|
||||
propose: Handler
|
||||
|
||||
|
||||
def dispatch(request: Request, handlers: Handlers) -> object:
|
||||
"""Route one parsed request without depending on the HTTP server."""
|
||||
if request.method == "initialize":
|
||||
return handlers.initialize(request.params)
|
||||
if request.method == "notifications/initialized":
|
||||
return None
|
||||
if request.method == "tools/list":
|
||||
return handlers.tools_list(request.params)
|
||||
if request.method != "tools/call":
|
||||
raise MethodNotFoundError(request.method)
|
||||
|
||||
tool = request.params.get("name")
|
||||
if tool == _sv.TOOL_LIST_EGRESS_ROUTES:
|
||||
return handlers.list_routes(request.params)
|
||||
if tool == _sv.TOOL_CHECK_PROPOSAL:
|
||||
return handlers.check_proposal(request.params)
|
||||
return handlers.propose(request.params)
|
||||
|
||||
|
||||
def resolved_routes_payload(
|
||||
resolver: PolicyResolver,
|
||||
source_ip: str,
|
||||
identity_token: str,
|
||||
) -> dict[str, object]:
|
||||
"""Render an authoritatively resolved route table for the calling bottle."""
|
||||
try:
|
||||
policy, bottle_id, _tokens = resolver.resolve_policy_and_bottle_id(
|
||||
source_ip, identity_token,
|
||||
)
|
||||
except PolicyResolveError as exc:
|
||||
raise RouteResolutionError("orchestrator unavailable") from exc
|
||||
if not bottle_id:
|
||||
raise RouteResolutionError("request source is not attributed to a bottle")
|
||||
try:
|
||||
config = load_config(policy or "")
|
||||
except ValueError as exc:
|
||||
raise RouteResolutionError("resolved policy is invalid") from exc
|
||||
body = json.dumps(
|
||||
{"routes": [route_to_yaml_dict(route) for route in config.routes]},
|
||||
indent=2,
|
||||
)
|
||||
return {"content": [{"type": "text", "text": body}], "isError": False}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Handlers",
|
||||
"MethodNotFoundError",
|
||||
"RouteResolutionError",
|
||||
"dispatch",
|
||||
"resolved_routes_payload",
|
||||
]
|
||||
@@ -17,7 +17,7 @@ Each queued proposal tool call:
|
||||
4. On a decision within the window, returns the operator's
|
||||
`{status, notes}`. On timeout, returns `status: pending` **with the
|
||||
proposal id** and leaves the proposal queued — the flow is
|
||||
non-blocking past the grace window (PRD prd-new / issue #412).
|
||||
non-blocking past the grace window (PRD 0072 / issue #412).
|
||||
|
||||
`check-proposal` is the non-blocking companion: given a `proposal_id`
|
||||
returned by a `pending` response, it reports the current decision
|
||||
@@ -51,17 +51,27 @@ from __future__ import annotations
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import sys
|
||||
import time
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bot_bottle.constants import IDENTITY_HEADER
|
||||
from bot_bottle.gateway.egress.addon_core import (
|
||||
LOG_OFF, load_config, resolve_client_context, route_to_yaml_dict,
|
||||
from bot_bottle.gateway.bounded_http import (
|
||||
BodyReadError,
|
||||
BoundedThreadingHTTPServer,
|
||||
read_declared_body,
|
||||
)
|
||||
from bot_bottle.gateway.egress.schema import load_config
|
||||
from bot_bottle.gateway.egress.types import LOG_OFF
|
||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||
from bot_bottle.gateway.supervisor.mcp_dispatch import (
|
||||
Handlers as DispatchHandlers,
|
||||
MethodNotFoundError,
|
||||
RouteResolutionError,
|
||||
dispatch,
|
||||
resolved_routes_payload,
|
||||
)
|
||||
from bot_bottle.supervisor import types as _sv
|
||||
|
||||
|
||||
@@ -565,6 +575,8 @@ def format_unknown_proposal_text(proposal_id: str) -> str:
|
||||
# Max request body the server accepts. 1 MB is well above any realistic
|
||||
# routes.yaml proposal.
|
||||
MAX_BODY_BYTES = 1 * 1024 * 1024
|
||||
REQUEST_BODY_TIMEOUT_SECONDS = 10.0
|
||||
MAX_REQUEST_WORKERS = 32
|
||||
|
||||
|
||||
class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||
@@ -587,19 +599,18 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||
self._write_text(405, "use POST for MCP requests\n")
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length_header = self.headers.get("Content-Length")
|
||||
if length_header is None:
|
||||
self._write_text(411, "Content-Length required\n")
|
||||
return
|
||||
try:
|
||||
length = int(length_header)
|
||||
except ValueError:
|
||||
self._write_text(400, "invalid Content-Length\n")
|
||||
body = read_declared_body(
|
||||
self.rfile,
|
||||
self.connection,
|
||||
self.headers.get("Content-Length"),
|
||||
maximum=MAX_BODY_BYTES,
|
||||
timeout_seconds=REQUEST_BODY_TIMEOUT_SECONDS,
|
||||
require_length=True,
|
||||
)
|
||||
except BodyReadError as exc:
|
||||
self._write_text(exc.status, exc.message + "\n")
|
||||
return
|
||||
if length < 0 or length > MAX_BODY_BYTES:
|
||||
self._write_text(413, "request body too large\n")
|
||||
return
|
||||
body = self.rfile.read(length)
|
||||
|
||||
try:
|
||||
req = parse_jsonrpc(body)
|
||||
@@ -611,6 +622,11 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
try:
|
||||
result = self._dispatch(req, config)
|
||||
except MethodNotFoundError as e:
|
||||
self._write_jsonrpc(
|
||||
jsonrpc_error(req.id, ERR_METHOD_NOT_FOUND, f"method not found: {e}"),
|
||||
)
|
||||
return
|
||||
except _RpcClientError as e:
|
||||
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
|
||||
return
|
||||
@@ -633,41 +649,42 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||
self._write_jsonrpc(jsonrpc_result(req.id, result))
|
||||
|
||||
def _dispatch(self, req: JsonRpcRequest, config: ServerConfig) -> object:
|
||||
method = req.method
|
||||
if method == "initialize":
|
||||
return handle_initialize(req.params)
|
||||
if method == "notifications/initialized":
|
||||
return None # ack-only
|
||||
if method == "tools/list":
|
||||
return handle_tools_list(req.params)
|
||||
if method == "tools/call":
|
||||
# `list-egress-routes` is read-only introspection. The shared gateway
|
||||
# has no static route table (routes are resolved per request by
|
||||
# source IP), so answer it from the calling bottle's resolved policy.
|
||||
# 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()
|
||||
resolver = self._resolver_or_fail()
|
||||
source_ip = self.client_address[0]
|
||||
token = self._identity_token()
|
||||
# `check-proposal` is a non-blocking read of the calling bottle's
|
||||
# own queue — attributed by (source_ip, identity_token) like a
|
||||
# proposal, but it never queues or blocks.
|
||||
if req.params.get("name") == _sv.TOOL_CHECK_PROPOSAL:
|
||||
return handle_check_proposal(
|
||||
req.params, resolver=resolver,
|
||||
source_ip=source_ip, identity_token=token,
|
||||
)
|
||||
# The control plane attributes the proposal to the source-IP + token
|
||||
# resolved bottle, so the one shared queue holds each bottle's
|
||||
# proposal under its own id — no slug is asserted by this daemon.
|
||||
return handle_tools_call(
|
||||
req.params, config, resolver=resolver,
|
||||
source_ip=source_ip, identity_token=token,
|
||||
def check(params: dict[str, object]) -> object:
|
||||
return handle_check_proposal(
|
||||
params,
|
||||
resolver=self._resolver_or_fail(),
|
||||
source_ip=self.client_address[0],
|
||||
identity_token=self._identity_token(),
|
||||
)
|
||||
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
|
||||
|
||||
def propose(params: dict[str, object]) -> object:
|
||||
return handle_tools_call(
|
||||
params,
|
||||
config,
|
||||
resolver=self._resolver_or_fail(),
|
||||
source_ip=self.client_address[0],
|
||||
identity_token=self._identity_token(),
|
||||
)
|
||||
|
||||
def list_routes(_params: dict[str, object]) -> object:
|
||||
try:
|
||||
return resolved_routes_payload(
|
||||
self._resolver_or_fail(),
|
||||
self.client_address[0],
|
||||
self._identity_token(),
|
||||
)
|
||||
except RouteResolutionError as exc:
|
||||
raise _RpcInternalError(
|
||||
f"could not resolve live egress routes: {exc}"
|
||||
) from exc
|
||||
|
||||
return dispatch(req, DispatchHandlers(
|
||||
initialize=handle_initialize,
|
||||
tools_list=handle_tools_list,
|
||||
list_routes=list_routes,
|
||||
check_proposal=check,
|
||||
propose=propose,
|
||||
))
|
||||
|
||||
def _identity_token(self) -> str:
|
||||
"""The agent's per-bottle identity token from the request header (the
|
||||
@@ -686,20 +703,6 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||
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: 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()
|
||||
conf, _slug, _tokens = resolve_client_context(
|
||||
resolver, self.client_address[0], self._identity_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 _write_jsonrpc(self, body: bytes) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
@@ -719,7 +722,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||
self.wfile.write(encoded)
|
||||
|
||||
|
||||
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
class MCPServer(BoundedThreadingHTTPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
config: ServerConfig = ServerConfig()
|
||||
@@ -728,6 +731,9 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
# closed per request (see `_resolver_or_fail`).
|
||||
policy_resolver: "PolicyResolver | None" = None
|
||||
|
||||
def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
super().__init__(*args, max_workers=MAX_REQUEST_WORKERS, **kwargs)
|
||||
|
||||
|
||||
# --- Entry point -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ if TYPE_CHECKING:
|
||||
from ..gateway import Gateway, GatewayError
|
||||
from .lifecycle import Orchestrator
|
||||
from .service import OrchestratorCore
|
||||
from .server import OrchestratorServer, dispatch, make_server
|
||||
from .server import OrchestratorServer, create_app, make_server
|
||||
|
||||
|
||||
# Facade name -> submodule that defines it. Lazy so importing a leaf (or the
|
||||
@@ -67,8 +67,8 @@ _LAZY: dict[str, str] = {
|
||||
"GatewayError": "..gateway",
|
||||
"Orchestrator": ".lifecycle",
|
||||
"OrchestratorCore": ".service",
|
||||
"create_app": ".server",
|
||||
"OrchestratorServer": ".server",
|
||||
"dispatch": ".server",
|
||||
"make_server": ".server",
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ __all__ = [
|
||||
"GatewayError",
|
||||
"Orchestrator",
|
||||
"OrchestratorCore",
|
||||
"create_app",
|
||||
"OrchestratorServer",
|
||||
"dispatch",
|
||||
"make_server",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Run the orchestrator control plane as a plain process (PRD 0070 dev-harness).
|
||||
|
||||
python -m bot_bottle.orchestrator [--host H] [--port P] [--db PATH]
|
||||
BOT_BOTTLE_ORCHESTRATOR_TOKEN=<signing-key> \
|
||||
python -m bot_bottle.orchestrator [--host H] [--port P] [--db PATH]
|
||||
|
||||
The PRD sequences the orchestrator as a plain-process dev-harness first, so
|
||||
the consolidation core (registry + attribution + HTTP control plane + live
|
||||
@@ -16,12 +17,13 @@ import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from .. import log
|
||||
from .store.store_manager import StoreManager
|
||||
from ..trust_domain import CONTROL_PLANE
|
||||
from .broker import LaunchBroker, StubBroker
|
||||
from .server import make_server
|
||||
from .docker_broker import DockerBroker
|
||||
from .store.registry_store import RegistryStore, default_db_path
|
||||
from .server import make_server
|
||||
from .service import OrchestratorCore
|
||||
from .store.store_manager import StoreManager
|
||||
from .store.registry_store import RegistryStore, default_db_path
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -38,6 +40,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
help="launch broker: 'stub' records requests; 'docker' runs containers",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if not CONTROL_PLANE.key_from_env():
|
||||
log.die(
|
||||
f"{CONTROL_PLANE.key_env} is required; refusing to start the "
|
||||
"orchestrator without caller authentication"
|
||||
)
|
||||
|
||||
registry = RegistryStore(args.db)
|
||||
registry.migrate()
|
||||
@@ -55,17 +62,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
orchestrator = OrchestratorCore(registry, broker, secret)
|
||||
|
||||
server = make_server(orchestrator, host=args.host, port=args.port)
|
||||
bound_host, bound_port = server.server_address[0], server.server_address[1]
|
||||
log.info(
|
||||
"orchestrator control plane listening",
|
||||
context={"host": bound_host, "port": bound_port, "db": str(registry.db_path)},
|
||||
context={"host": args.host, "port": args.port, "db": str(registry.db_path)},
|
||||
)
|
||||
try:
|
||||
server.serve_forever()
|
||||
server.run()
|
||||
except KeyboardInterrupt:
|
||||
log.info("orchestrator shutting down")
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""FastAPI control-plane routes for the orchestrator."""
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import sys
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from ..orchestrator_auth import ROLE_CLI, ROLES
|
||||
from ..supervisor.types import TOOLS
|
||||
from ..trust_domain import CONTROL_PLANE
|
||||
from .http_contract import (
|
||||
MAX_BODY_BYTES,
|
||||
ORCHESTRATOR_AUTH_HEADER,
|
||||
REQUEST_BODY_TIMEOUT_SECONDS,
|
||||
)
|
||||
from .service import OrchestratorCore
|
||||
|
||||
_GATEWAY_ROUTES = frozenset({
|
||||
("POST", "/resolve"),
|
||||
("POST", "/supervise/propose"),
|
||||
("POST", "/supervise/poll"),
|
||||
})
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", strict=True)
|
||||
|
||||
|
||||
class LaunchBody(_StrictModel):
|
||||
source_ip: StrictStr
|
||||
image_ref: StrictStr = ""
|
||||
metadata: StrictStr = ""
|
||||
policy: StrictStr = ""
|
||||
tokens: dict[StrictStr, StrictStr] = {}
|
||||
env_var_secret: StrictStr = ""
|
||||
|
||||
|
||||
class PolicyBody(_StrictModel):
|
||||
policy: StrictStr
|
||||
|
||||
|
||||
class ReprovisionBody(_StrictModel):
|
||||
env_var_secret: StrictStr
|
||||
|
||||
|
||||
class ReconcileBody(_StrictModel):
|
||||
live_source_ips: list[StrictStr]
|
||||
grace_seconds: float | None = None
|
||||
|
||||
|
||||
class IdentityBody(_StrictModel):
|
||||
source_ip: StrictStr
|
||||
identity_token: StrictStr = ""
|
||||
|
||||
|
||||
class AttributeBody(_StrictModel):
|
||||
source_ip: StrictStr
|
||||
identity_token: StrictStr
|
||||
|
||||
|
||||
class RespondBody(_StrictModel):
|
||||
proposal_id: StrictStr
|
||||
bottle_slug: StrictStr
|
||||
decision: StrictStr
|
||||
notes: StrictStr = ""
|
||||
final_file: StrictStr | None = None
|
||||
|
||||
|
||||
class ProposeBody(IdentityBody):
|
||||
tool: StrictStr
|
||||
proposed_file: StrictStr
|
||||
justification: StrictStr
|
||||
|
||||
|
||||
class PollBody(IdentityBody):
|
||||
proposal_id: StrictStr
|
||||
|
||||
|
||||
class ControlPlaneBoundary:
|
||||
"""Reject unauthenticated and oversized requests before reading a body."""
|
||||
|
||||
def __init__(self, app: ASGIApp, signing_key: str) -> None:
|
||||
self.app = app
|
||||
self.signing_key = signing_key
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
method = scope["method"]
|
||||
route = scope["path"].rstrip("/") or "/"
|
||||
if not (method == "GET" and route == "/health"):
|
||||
headers = dict(scope["headers"])
|
||||
presented = headers.get(
|
||||
ORCHESTRATOR_AUTH_HEADER.encode(), b"",
|
||||
).decode(errors="ignore")
|
||||
role = CONTROL_PLANE.verify(presented, self.signing_key)
|
||||
if role is None:
|
||||
await self._reject(
|
||||
scope, send, 401, "control-plane authentication required",
|
||||
)
|
||||
return
|
||||
allowed = ROLES if (method, route) in _GATEWAY_ROUTES else {ROLE_CLI}
|
||||
if role not in allowed:
|
||||
await self._reject(scope, send, 403, "insufficient role for this route")
|
||||
return
|
||||
scope.setdefault("state", {})["role"] = role
|
||||
raw_length = dict(scope["headers"]).get(b"content-length")
|
||||
if raw_length is not None:
|
||||
try:
|
||||
length = int(raw_length)
|
||||
except ValueError:
|
||||
await self._reject(scope, send, 400, "invalid Content-Length")
|
||||
return
|
||||
if length < 0:
|
||||
await self._reject(scope, send, 400, "invalid Content-Length")
|
||||
return
|
||||
if length > MAX_BODY_BYTES:
|
||||
await self._reject(scope, send, 413, "request body too large")
|
||||
return
|
||||
try:
|
||||
body = await self._read_body(receive)
|
||||
except _BodyTooLarge:
|
||||
await self._reject(scope, send, 413, "request body too large")
|
||||
return
|
||||
except TimeoutError:
|
||||
await self._reject(scope, send, 408, "request body read timed out")
|
||||
return
|
||||
try:
|
||||
await self.app(scope, self._replay_body(body), send)
|
||||
except Exception as exc: # noqa: BLE001 - redact control-plane failures
|
||||
sys.stderr.write(
|
||||
f"orchestrator: {method} {route} failed "
|
||||
f"[error_type={type(exc).__name__}]\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
await self._reject(scope, send, 500, "internal error")
|
||||
|
||||
@staticmethod
|
||||
async def _reject(
|
||||
scope: Scope, send: Send, status: int, error: str,
|
||||
) -> None:
|
||||
response = JSONResponse({"error": error}, status_code=status)
|
||||
await response(scope, ControlPlaneBoundary._empty_receive, send)
|
||||
|
||||
@staticmethod
|
||||
async def _empty_receive() -> Message:
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
@staticmethod
|
||||
async def _read_body(receive: Receive) -> bytes:
|
||||
body = bytearray()
|
||||
async with asyncio.timeout(REQUEST_BODY_TIMEOUT_SECONDS):
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] != "http.request":
|
||||
break
|
||||
body.extend(message.get("body", b""))
|
||||
if len(body) > MAX_BODY_BYTES:
|
||||
raise _BodyTooLarge
|
||||
if not message.get("more_body", False):
|
||||
break
|
||||
return bytes(body)
|
||||
|
||||
@staticmethod
|
||||
def _replay_body(body: bytes) -> Receive:
|
||||
sent = False
|
||||
|
||||
async def replay() -> Message:
|
||||
nonlocal sent
|
||||
if sent:
|
||||
return {"type": "http.disconnect"}
|
||||
sent = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
return replay
|
||||
|
||||
|
||||
class _BodyTooLarge(Exception):
|
||||
"""The streamed request exceeded the control-plane body limit."""
|
||||
|
||||
|
||||
def _required(value: str, name: str) -> str:
|
||||
if not value:
|
||||
raise HTTPException(400, f"{name} (string) is required")
|
||||
return value
|
||||
|
||||
|
||||
def create_app(orch: OrchestratorCore, *, signing_key: str) -> FastAPI:
|
||||
"""Build the authenticated orchestrator ASGI application."""
|
||||
key = signing_key.strip()
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"orchestrator control-plane signing key is required; "
|
||||
"refusing to start without caller authentication"
|
||||
)
|
||||
app = FastAPI(
|
||||
title="bot-bottle orchestrator",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
app.add_middleware(ControlPlaneBoundary, signing_key=key)
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/gateway")
|
||||
def gateway() -> dict[str, object]:
|
||||
return orch.gateway_status()
|
||||
|
||||
@app.get("/bottles")
|
||||
def bottles() -> dict[str, object]:
|
||||
return {"bottles": [record.redacted() for record in orch.registry.all()]}
|
||||
|
||||
@app.post("/bottles", status_code=201)
|
||||
def launch(body: LaunchBody) -> dict[str, str]:
|
||||
rec = orch.launch_bottle(
|
||||
_required(body.source_ip, "source_ip"),
|
||||
image_ref=body.image_ref,
|
||||
metadata=body.metadata,
|
||||
policy=body.policy,
|
||||
tokens=dict(body.tokens),
|
||||
env_var_secret=body.env_var_secret,
|
||||
)
|
||||
return {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
||||
|
||||
@app.put("/bottles/{bottle_id}/policy")
|
||||
def set_policy(bottle_id: str, body: PolicyBody) -> dict[str, object]:
|
||||
if orch.set_policy(bottle_id, body.policy):
|
||||
return {"updated": True}
|
||||
raise HTTPException(404, "no such bottle")
|
||||
|
||||
@app.post("/bottles/{bottle_id}/reprovision_gateway")
|
||||
def reprovision(bottle_id: str, body: ReprovisionBody) -> dict[str, object]:
|
||||
secret = _required(body.env_var_secret, "env_var_secret")
|
||||
if orch.reprovision_from_secret(bottle_id, secret):
|
||||
return {"reprovisioned": True}
|
||||
raise HTTPException(404, "no stored secrets for this bottle")
|
||||
|
||||
@app.delete("/bottles/{bottle_id}")
|
||||
def teardown(bottle_id: str) -> dict[str, object]:
|
||||
if orch.teardown_bottle(bottle_id):
|
||||
return {"torn_down": True}
|
||||
raise HTTPException(404, "no such bottle")
|
||||
|
||||
@app.post("/reconcile")
|
||||
def reconcile(body: ReconcileBody) -> dict[str, object]:
|
||||
if any(not ip for ip in body.live_source_ips):
|
||||
raise HTTPException(400, "live_source_ips must contain non-empty strings")
|
||||
kwargs: dict[str, float] = {}
|
||||
if body.grace_seconds is not None:
|
||||
if not math.isfinite(body.grace_seconds) or body.grace_seconds < 0:
|
||||
raise HTTPException(
|
||||
400, "grace_seconds must be a non-negative finite number",
|
||||
)
|
||||
kwargs["grace_seconds"] = body.grace_seconds
|
||||
return {"reaped": orch.reconcile(body.live_source_ips, **kwargs)}
|
||||
|
||||
@app.post("/attribute")
|
||||
def attribute(body: AttributeBody) -> dict[str, str]:
|
||||
rec = orch.attribute(body.source_ip, body.identity_token)
|
||||
if rec is None:
|
||||
raise HTTPException(403, "unattributed")
|
||||
return {"bottle_id": rec.bottle_id}
|
||||
|
||||
@app.get("/supervise/proposals")
|
||||
def proposals() -> dict[str, object]:
|
||||
return {"proposals": orch.supervise_pending()}
|
||||
|
||||
@app.post("/supervise/respond")
|
||||
def respond(body: RespondBody) -> dict[str, object]:
|
||||
ok, error = orch.supervise_respond(
|
||||
_required(body.proposal_id, "proposal_id"),
|
||||
bottle_slug=_required(body.bottle_slug, "bottle_slug"),
|
||||
decision=_required(body.decision, "decision"),
|
||||
notes=body.notes,
|
||||
final_file=body.final_file,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(409, error)
|
||||
return {"responded": True}
|
||||
|
||||
@app.post("/supervise/propose", status_code=201)
|
||||
def propose(body: ProposeBody) -> dict[str, str]:
|
||||
source_ip = _required(body.source_ip, "source_ip")
|
||||
if body.tool not in TOOLS:
|
||||
raise HTTPException(400, f"tool (string) must be one of {TOOLS}")
|
||||
rec = orch.resolve(source_ip, body.identity_token)
|
||||
if rec is None:
|
||||
raise HTTPException(403, "unattributed")
|
||||
proposal_id = orch.supervise_queue_proposal(
|
||||
rec.bottle_id,
|
||||
tool=body.tool,
|
||||
proposed_file=_required(body.proposed_file, "proposed_file"),
|
||||
justification=_required(body.justification, "justification"),
|
||||
)
|
||||
return {"proposal_id": proposal_id}
|
||||
|
||||
@app.post("/supervise/poll")
|
||||
def poll(body: PollBody) -> dict[str, object]:
|
||||
rec = orch.resolve(
|
||||
_required(body.source_ip, "source_ip"), body.identity_token,
|
||||
)
|
||||
if rec is None:
|
||||
raise HTTPException(403, "unattributed")
|
||||
return orch.supervise_poll_response(
|
||||
rec.bottle_id, _required(body.proposal_id, "proposal_id"),
|
||||
)
|
||||
|
||||
@app.post("/resolve")
|
||||
def resolve(body: IdentityBody) -> dict[str, object]:
|
||||
rec = orch.resolve(
|
||||
_required(body.source_ip, "source_ip"), body.identity_token,
|
||||
)
|
||||
if rec is None:
|
||||
raise HTTPException(403, "unattributed")
|
||||
return {
|
||||
"bottle_id": rec.bottle_id,
|
||||
"policy": rec.policy,
|
||||
"tokens": orch.tokens_for(rec.bottle_id),
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ControlPlaneBoundary",
|
||||
"MAX_BODY_BYTES",
|
||||
"ORCHESTRATOR_AUTH_HEADER",
|
||||
"create_app",
|
||||
]
|
||||
@@ -18,9 +18,10 @@ import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..orchestrator_auth import ROLE_CLI, mint
|
||||
from ..paths import host_orchestrator_token
|
||||
from .server import ORCHESTRATOR_AUTH_HEADER
|
||||
from ..log import debug
|
||||
from ..orchestrator_auth import ROLE_CLI
|
||||
from ..trust_domain import CONTROL_PLANE
|
||||
from .http_contract import ORCHESTRATOR_AUTH_HEADER
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
@@ -32,7 +33,7 @@ def _host_auth_token() -> str:
|
||||
"" means 'send no auth header' — correct against an open (unconfigured)
|
||||
control plane, and harmlessly rejected by a secured one."""
|
||||
try:
|
||||
return mint(ROLE_CLI, host_orchestrator_token())
|
||||
return CONTROL_PLANE.mint(ROLE_CLI)
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
@@ -53,6 +54,23 @@ class RegisteredBottle:
|
||||
env_var_secret: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendProbeFailure:
|
||||
"""Safe diagnostic for an optional backend discovery probe."""
|
||||
|
||||
backend: str
|
||||
error_type: str
|
||||
|
||||
|
||||
def _probe_failure(backend: str, exc: BaseException) -> BackendProbeFailure:
|
||||
failure = BackendProbeFailure(backend, type(exc).__name__)
|
||||
debug(
|
||||
"orchestrator discovery probe unavailable",
|
||||
context={"backend": failure.backend, "error_type": failure.error_type},
|
||||
)
|
||||
return failure
|
||||
|
||||
|
||||
class OrchestratorClient:
|
||||
"""Trusted host-side client for the orchestrator control plane.
|
||||
|
||||
@@ -245,32 +263,41 @@ def discover_orchestrator_url(*, timeout: float = 2.0) -> str:
|
||||
orchestrator TAP. Returns the first that answers `/health`; raises if none
|
||||
do (no orchestrator up — launch a bottle first)."""
|
||||
candidates: list[str] = []
|
||||
failures: list[BackendProbeFailure] = []
|
||||
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
|
||||
except Exception as exc: # noqa: BLE001 — backend optional
|
||||
failures.append(_probe_failure("docker", exc))
|
||||
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 ORCHESTRATOR_PORT
|
||||
candidates.append(
|
||||
f"http://{netpool.orch_slot().guest_ip}:{ORCHESTRATOR_PORT}")
|
||||
except Exception: # noqa: BLE001 — backend optional / not firecracker
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 — backend optional / not firecracker
|
||||
failures.append(_probe_failure("firecracker", exc))
|
||||
try: # macOS: orchestrator container on its host-only address
|
||||
from ..backend.macos_container.infra import probe_orchestrator_url
|
||||
url = probe_orchestrator_url()
|
||||
if url:
|
||||
candidates.append(url)
|
||||
except Exception: # noqa: BLE001 — backend optional / not macOS
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 — backend optional / not macOS
|
||||
failures.append(_probe_failure("macos-container", exc))
|
||||
for url in candidates:
|
||||
if OrchestratorClient(url, timeout=timeout).health():
|
||||
return url
|
||||
detail = ""
|
||||
if failures:
|
||||
detail = "; optional probes unavailable: " + ", ".join(
|
||||
f"{failure.backend} ({failure.error_type})" for failure in failures
|
||||
)
|
||||
raise OrchestratorClientError(
|
||||
"no running orchestrator control plane found (tried "
|
||||
+ ", ".join(candidates)
|
||||
+ "); launch a bottle first"
|
||||
+ ")"
|
||||
+ detail
|
||||
+ "; launch a bottle first"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Dependency-free constants shared by orchestrator HTTP clients and server."""
|
||||
|
||||
ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
|
||||
MAX_BODY_BYTES = 1 * 1024 * 1024
|
||||
REQUEST_BODY_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
__all__ = [
|
||||
"MAX_BODY_BYTES",
|
||||
"ORCHESTRATOR_AUTH_HEADER",
|
||||
"REQUEST_BODY_TIMEOUT_SECONDS",
|
||||
]
|
||||
@@ -21,8 +21,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ..orchestrator_auth import ROLE_GATEWAY, mint
|
||||
from ..paths import host_orchestrator_token
|
||||
from ..trust_domain import ControlPlaneProvisioning
|
||||
|
||||
DEFAULT_PORT = 8099
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
||||
@@ -58,6 +57,12 @@ class Orchestrator(abc.ABC):
|
||||
so it — not the gateway — mints the gateway's role-scoped token.
|
||||
Backend-neutral."""
|
||||
|
||||
# The shared control-plane auth provisioning contract (#476). Every backend
|
||||
# gets its signing key + gateway token through this one seam rather than
|
||||
# re-deriving the wiring; it is fail-closed for every backend — the
|
||||
# orchestrator never starts without its signing key.
|
||||
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
"""Ensure the orchestrator's image / rootfs exists, building it if
|
||||
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
|
||||
@@ -105,9 +110,17 @@ class Orchestrator(abc.ABC):
|
||||
def mint_gateway_token(self) -> str:
|
||||
"""Mint a role-scoped `gateway` JWT from the host signing key for the
|
||||
gateway to present. The orchestrator holds the key; the gateway never
|
||||
does (#469). Backend-neutral — the same host token file is the single
|
||||
source of truth across backends."""
|
||||
return mint(ROLE_GATEWAY, host_orchestrator_token())
|
||||
does (#469). Routed through the shared provisioning contract (#476), so
|
||||
the same host token file is the single source of truth across backends."""
|
||||
return self.provisioning.gateway_token()
|
||||
|
||||
def control_plane_key(self) -> str:
|
||||
"""The raw signing key the control-plane *process* must receive — the ONE
|
||||
place a backend obtains it (docker/macOS inject it as `key_env`;
|
||||
firecracker pushes it to the guest). Fail-closed via the provisioning
|
||||
contract: it raises rather than yield an empty key that would run the
|
||||
server OPEN (#476)."""
|
||||
return self.provisioning.orchestrator_key()
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -117,4 +130,5 @@ __all__ = [
|
||||
"OrchestratorStartError",
|
||||
"source_hash",
|
||||
"Orchestrator",
|
||||
"ControlPlaneProvisioning",
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..log import debug
|
||||
from .client import OrchestratorClient, OrchestratorClientError
|
||||
|
||||
|
||||
@@ -27,7 +28,14 @@ def reprovision_bottles(
|
||||
try:
|
||||
if client.reprovision_gateway(bottle_id, secret):
|
||||
restored += 1
|
||||
except OrchestratorClientError:
|
||||
except OrchestratorClientError as exc:
|
||||
debug(
|
||||
"gateway secret reprovision failed; continuing with other bottles",
|
||||
context={
|
||||
"bottle_id": bottle_id,
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
return restored
|
||||
|
||||
|
||||
@@ -1,450 +1,80 @@
|
||||
"""Orchestrator HTTP control plane (PRD 0070).
|
||||
|
||||
The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over
|
||||
**HTTP** — the universal transport chosen in 0070 (works on every host; no
|
||||
vsock / unix-socket portability caveats):
|
||||
|
||||
GET /health -> 200 {"status": "ok"}
|
||||
GET /gateway -> 200 {"configured", ["name","running"]}
|
||||
GET /bottles -> 200 {"bottles": [ <redacted record>, ...]}
|
||||
POST /bottles -> 201 {"bottle_id","identity_token"} (launch)
|
||||
body: {"source_ip", ["image_ref"],
|
||||
["metadata"], ["policy"],
|
||||
["tokens"], ["env_var_secret"]}
|
||||
PUT /bottles/<bottle_id>/policy -> 200 {"updated": true} | 404 (live reload)
|
||||
body: {"policy"}
|
||||
POST /bottles/<bottle_id>/reprovision_gateway
|
||||
-> 200 {"reprovisioned": true} | 404
|
||||
body: {"env_var_secret"}
|
||||
DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown)
|
||||
POST /reconcile -> 200 {"reaped": [bottle_id, ...]}
|
||||
body: {"live_source_ips": [...],
|
||||
["grace_seconds"]}
|
||||
POST /attribute -> 200 {"bottle_id"} | 403
|
||||
POST /resolve -> 200 {"bottle_id","policy"} | 403
|
||||
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 /supervise/propose -> 201 {"proposal_id"} | 403 (agent)
|
||||
body: {"source_ip","identity_token",
|
||||
"tool","proposed_file","justification"}
|
||||
POST /supervise/poll -> 200 {"status", ["notes"],["final_file"]} | 403
|
||||
body: {"source_ip","identity_token",
|
||||
"proposal_id"}
|
||||
|
||||
The `/supervise/propose` + `/supervise/poll` pair is the **agent** half of the
|
||||
supervise flow: the data plane (supervise / egress / git-gate) queues a proposal
|
||||
and polls for its response over RPC instead of opening `bot-bottle.db` directly.
|
||||
`poll` is idempotent — it never archives, so a dropped connection can't lose an
|
||||
operator decision (the row is reaped when the bottle is torn down / reconciled).
|
||||
Both attribute the caller by `(source_ip, identity_token)` exactly like
|
||||
`/resolve`, so a bottle can only ever queue or read its own proposals.
|
||||
|
||||
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
|
||||
tear down) the bottle in the registry AND broker the backend-native launch
|
||||
via the orchestrator. Register/deregister without a launch are internal to
|
||||
`OrchestratorCore`, not exposed here.
|
||||
|
||||
Routing/handling is the pure function `dispatch()` so it is unit-testable
|
||||
without a socket; `Handler` / `OrchestratorServer` / `make_server` are a
|
||||
thin stdlib adapter around it. Listing redacts identity tokens — they are
|
||||
returned only once, to the caller that launches the bottle.
|
||||
"""
|
||||
"""Uvicorn transport for the FastAPI orchestrator control plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import sys
|
||||
import typing
|
||||
from urllib.parse import urlsplit
|
||||
import socket
|
||||
import threading
|
||||
|
||||
from ..orchestrator_auth import ROLE_CLI, ROLES, verify
|
||||
from ..paths import ORCHESTRATOR_TOKEN_ENV
|
||||
from ..supervisor.types import TOOLS
|
||||
import uvicorn
|
||||
|
||||
from ..trust_domain import CONTROL_PLANE
|
||||
from .api import create_app
|
||||
from .http_contract import MAX_BODY_BYTES, ORCHESTRATOR_AUTH_HEADER
|
||||
from .service import OrchestratorCore
|
||||
|
||||
# JSON body payload type (parsed request / rendered response).
|
||||
Json = dict[str, object]
|
||||
|
||||
# The request header carrying the caller's role-scoped control-plane token (a
|
||||
# signed JWT naming the caller's role — see orchestrator_auth). The role gates which
|
||||
# routes the caller may reach: the data plane holds a `gateway` token good only
|
||||
# for the agent-facing lookups; the host CLI holds a `cli` token for the
|
||||
# operator/mutating routes. An agent that can merely *reach* the port holds no
|
||||
# token at all, and a compromised gateway holds only `gateway` — neither can
|
||||
# drive the operator routes (approve proposals, rewrite policy, read tokens).
|
||||
ORCHESTRATOR_AUTH_HEADER = "x-bot-bottle-orchestrator-auth"
|
||||
|
||||
# The routes the data plane (role `gateway`) is allowed to reach — exactly the
|
||||
# per-request lookups PolicyResolver makes. Every other authenticated route is
|
||||
# operator-only. `cli` is a superset role: it may reach any route.
|
||||
_GATEWAY_ROUTES: frozenset[tuple[str, str]] = frozenset({
|
||||
("POST", "/resolve"),
|
||||
("POST", "/supervise/propose"),
|
||||
("POST", "/supervise/poll"),
|
||||
})
|
||||
MAX_REQUESTS = 32
|
||||
KEEP_ALIVE_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
def _allowed_roles(method: str, route: str) -> frozenset[str]:
|
||||
"""The roles permitted on `(method, route)`: `gateway` or `cli` on the
|
||||
data-plane routes, `cli`-only everywhere else."""
|
||||
if (method, route) in _GATEWAY_ROUTES:
|
||||
return ROLES
|
||||
return frozenset({ROLE_CLI})
|
||||
class OrchestratorServer:
|
||||
"""Small lifecycle wrapper around Uvicorn with an eagerly bound socket."""
|
||||
|
||||
def __init__(self, config: uvicorn.Config) -> None:
|
||||
self._server = uvicorn.Server(config)
|
||||
self._stopped = threading.Event()
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self._socket.bind((config.host, config.port))
|
||||
self._socket.listen(config.backlog)
|
||||
self.server_address = self._socket.getsockname()
|
||||
|
||||
def _parse_json_object(body: bytes) -> Json:
|
||||
"""Parse a JSON object body. Raises ValueError for non-objects / bad JSON."""
|
||||
if not body:
|
||||
return {}
|
||||
obj = json.loads(body) # raises json.JSONDecodeError (a ValueError)
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
return obj
|
||||
|
||||
|
||||
def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
||||
orch: OrchestratorCore, method: str, path: str, body: bytes, *, role: str | None = ROLE_CLI,
|
||||
) -> tuple[int, Json]:
|
||||
"""Route one control-plane request to a (status, payload) pair. Pure —
|
||||
no I/O beyond the orchestrator — so it is fully testable without a socket.
|
||||
|
||||
`role` is the caller's verified control-plane role (`gateway` or `cli`), or
|
||||
None for an unauthenticated request; an open-mode server (no signing key
|
||||
configured — see `OrchestratorServer`) passes `cli`. Every route except
|
||||
`GET /health` requires a role: a missing role is 401, and a role that
|
||||
doesn't cover the route is 403 — so a `gateway` data-plane token can reach
|
||||
`/resolve` + `/supervise/{propose,poll}` but not the operator routes
|
||||
(rewrite policy, read injected tokens, approve its own supervise proposals).
|
||||
The source-IP + identity-token checks inside `/resolve` and `/attribute`
|
||||
authenticate the *bottle* a request is about, not the *caller*, so this role
|
||||
gate is what protects the caller-privileged routes. Defaults `cli` so unit
|
||||
tests of the routing logic don't have to thread it through."""
|
||||
route = urlsplit(path).path.rstrip("/") or "/"
|
||||
|
||||
if method == "GET" and route == "/health":
|
||||
return 200, {"status": "ok"}
|
||||
|
||||
# Role gate — every route below is a trusted-caller operation. Deny before
|
||||
# touching the registry / broker / supervise store.
|
||||
if role is None:
|
||||
return 401, {"error": "control-plane authentication required"}
|
||||
if role not in _allowed_roles(method, route):
|
||||
return 403, {"error": "insufficient role for this route"}
|
||||
|
||||
if method == "GET" and route == "/gateway":
|
||||
return 200, orch.gateway_status()
|
||||
|
||||
if method == "GET" and route == "/bottles":
|
||||
return 200, {"bottles": [r.redacted() for r in orch.registry.all()]}
|
||||
|
||||
if method == "POST" and route == "/bottles":
|
||||
def run(self) -> None:
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
source_ip = data.get("source_ip")
|
||||
if not isinstance(source_ip, str) or not source_ip:
|
||||
return 400, {"error": "source_ip (string) is required"}
|
||||
image_ref = data.get("image_ref")
|
||||
metadata = data.get("metadata")
|
||||
policy = data.get("policy")
|
||||
raw_tokens = data.get("tokens")
|
||||
tokens = {
|
||||
k: v for k, v in raw_tokens.items() if isinstance(k, str) and isinstance(v, str)
|
||||
} if isinstance(raw_tokens, dict) else {}
|
||||
env_var_secret = data.get("env_var_secret", "")
|
||||
rec = orch.launch_bottle(
|
||||
source_ip,
|
||||
image_ref=image_ref if isinstance(image_ref, str) else "",
|
||||
metadata=metadata if isinstance(metadata, str) else "",
|
||||
policy=policy if isinstance(policy, str) else "",
|
||||
tokens=tokens,
|
||||
env_var_secret=env_var_secret if isinstance(env_var_secret, str) else "",
|
||||
)
|
||||
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
||||
self._server.run(sockets=[self._socket])
|
||||
finally:
|
||||
self._stopped.set()
|
||||
|
||||
if method == "PUT" and route.startswith("/bottles/") and route.endswith("/policy"):
|
||||
bottle_id = route[len("/bottles/"):-len("/policy")]
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
policy = data.get("policy")
|
||||
if not isinstance(policy, str):
|
||||
return 400, {"error": "policy (string) is required"}
|
||||
if orch.set_policy(bottle_id, policy):
|
||||
return 200, {"updated": True}
|
||||
return 404, {"error": "no such bottle"}
|
||||
def serve_forever(self) -> None:
|
||||
self.run()
|
||||
|
||||
if (
|
||||
method == "POST"
|
||||
and route.startswith("/bottles/")
|
||||
and route.endswith("/reprovision_gateway")
|
||||
):
|
||||
bottle_id = route[len("/bottles/") : -len("/reprovision_gateway")]
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
env_var_secret = data.get("env_var_secret")
|
||||
if not isinstance(env_var_secret, str) or not env_var_secret:
|
||||
return 400, {"error": "env_var_secret (string) is required"}
|
||||
if orch.reprovision_from_secret(bottle_id, env_var_secret):
|
||||
return 200, {"reprovisioned": True}
|
||||
return 404, {"error": "no stored secrets for this bottle"}
|
||||
def shutdown(self) -> None:
|
||||
self._server.should_exit = True
|
||||
self._stopped.wait(timeout=5)
|
||||
|
||||
if method == "DELETE" and route.startswith("/bottles/"):
|
||||
bottle_id = route[len("/bottles/"):]
|
||||
if orch.teardown_bottle(bottle_id):
|
||||
return 200, {"torn_down": True}
|
||||
return 404, {"error": "no such bottle"}
|
||||
|
||||
if method == "POST" and route == "/reconcile":
|
||||
# Host-driven self-heal: the caller enumerates its live bottles (only
|
||||
# the host can see the backend) and the orchestrator drops rows for
|
||||
# every other active bottle. Trusted-caller only — an agent that could
|
||||
# reach this would be able to unregister its neighbours.
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
raw_ips = data.get("live_source_ips")
|
||||
if not isinstance(raw_ips, list):
|
||||
return 400, {"error": "live_source_ips (list of strings) is required"}
|
||||
live = [ip for ip in raw_ips if isinstance(ip, str) and ip]
|
||||
grace = data.get("grace_seconds")
|
||||
kwargs = (
|
||||
{"grace_seconds": float(grace)}
|
||||
if isinstance(grace, (int, float)) and not isinstance(grace, bool)
|
||||
else {}
|
||||
)
|
||||
return 200, {"reaped": orch.reconcile(live, **kwargs)}
|
||||
|
||||
if method == "POST" and route == "/attribute":
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
source_ip = data.get("source_ip")
|
||||
token = data.get("identity_token")
|
||||
if not isinstance(source_ip, str) or not isinstance(token, str):
|
||||
return 400, {"error": "source_ip and identity_token (strings) required"}
|
||||
rec = orch.attribute(source_ip, token)
|
||||
if rec is None:
|
||||
return 403, {"error": "unattributed"}
|
||||
return 200, {"bottle_id": rec.bottle_id}
|
||||
|
||||
if method == "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 == "/supervise/propose":
|
||||
# Agent half: queue a proposal, attributed to the caller resolved from
|
||||
# (source_ip, identity_token) — never a caller-supplied slug — so the
|
||||
# data plane can't forge attribution. Fail-closed 403 when unattributed.
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
source_ip = data.get("source_ip")
|
||||
token = data.get("identity_token")
|
||||
tool = data.get("tool")
|
||||
proposed_file = data.get("proposed_file")
|
||||
justification = data.get("justification")
|
||||
if not isinstance(source_ip, str) or not source_ip:
|
||||
return 400, {"error": "source_ip (string) is required"}
|
||||
if not isinstance(tool, str) or tool not in TOOLS:
|
||||
return 400, {"error": f"tool (string) must be one of {TOOLS}"}
|
||||
if not isinstance(proposed_file, str) or not proposed_file:
|
||||
return 400, {"error": "proposed_file (string) is required"}
|
||||
if not isinstance(justification, str) or not justification:
|
||||
return 400, {"error": "justification (string) is required"}
|
||||
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
||||
if rec is None:
|
||||
return 403, {"error": "unattributed"}
|
||||
proposal_id = orch.supervise_queue_proposal(
|
||||
rec.bottle_id, tool=tool, proposed_file=proposed_file,
|
||||
justification=justification,
|
||||
)
|
||||
return 201, {"proposal_id": proposal_id}
|
||||
|
||||
if method == "POST" and route == "/supervise/poll":
|
||||
# Agent half: non-blocking read of the caller's own proposal decision.
|
||||
# Attributed like /propose, and scoped to the resolved bottle id, so a
|
||||
# guessed proposal_id can never read another bottle's response.
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
source_ip = data.get("source_ip")
|
||||
token = data.get("identity_token")
|
||||
proposal_id = data.get("proposal_id")
|
||||
if not isinstance(source_ip, str) or not source_ip:
|
||||
return 400, {"error": "source_ip (string) is required"}
|
||||
if not isinstance(proposal_id, str) or not proposal_id:
|
||||
return 400, {"error": "proposal_id (string) is required"}
|
||||
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
||||
if rec is None:
|
||||
return 403, {"error": "unattributed"}
|
||||
return 200, orch.supervise_poll_response(rec.bottle_id, proposal_id)
|
||||
|
||||
if method == "POST" and route == "/resolve":
|
||||
# The per-request lookup the multi-tenant gateway makes: returns the
|
||||
# bottle's policy. Requires a matching (source_ip, identity_token)
|
||||
# pair — a missing/empty/mismatched token fail-closes (403), no
|
||||
# source-IP-only fallback.
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
source_ip = data.get("source_ip")
|
||||
token = data.get("identity_token")
|
||||
if not isinstance(source_ip, str) or not source_ip:
|
||||
return 400, {"error": "source_ip (string) is required"}
|
||||
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
||||
if rec is None:
|
||||
return 403, {"error": "unattributed"}
|
||||
# tokens are the in-memory per-bottle egress auth values the gateway
|
||||
# injects; served here, never persisted.
|
||||
return 200, {
|
||||
"bottle_id": rec.bottle_id,
|
||||
"policy": rec.policy,
|
||||
"tokens": orch.tokens_for(rec.bottle_id),
|
||||
}
|
||||
|
||||
return 404, {"error": "not found"}
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
"""Thin stdlib adapter: read the body, call `dispatch`, write JSON."""
|
||||
|
||||
# Quiet by default (the orchestrator has its own logging); opt back into
|
||||
# stdlib access logging with BOT_BOTTLE_ORCHESTRATOR_DEBUG.
|
||||
def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002
|
||||
if os.environ.get("BOT_BOTTLE_ORCHESTRATOR_DEBUG"):
|
||||
super().log_message(format, *args)
|
||||
|
||||
def _serve(self, method: str) -> None:
|
||||
"""Read the request body, dispatch it, and write the JSON reply. A
|
||||
dispatch failure (e.g. a broker error) returns a 500 rather than
|
||||
crashing the connection, so one bad request can't take the control
|
||||
plane down for the caller."""
|
||||
server = self.server
|
||||
assert isinstance(server, OrchestratorServer)
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length > 0 else b""
|
||||
role = server.role_for(self.headers.get(ORCHESTRATOR_AUTH_HEADER, ""))
|
||||
try:
|
||||
status, payload = dispatch(
|
||||
server.orchestrator, method, self.path, body, role=role)
|
||||
except Exception as e: # noqa: BLE001 — the control plane must stay up
|
||||
sys.stderr.write(f"orchestrator: {method} {self.path} failed: {e!r}\n")
|
||||
sys.stderr.flush()
|
||||
status, payload = 500, {"error": f"internal error: {e}"}
|
||||
data = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._serve("GET")
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._serve("POST")
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
self._serve("PUT")
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
self._serve("DELETE")
|
||||
|
||||
|
||||
class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
"""Threading HTTP server that carries the orchestrator for its handlers.
|
||||
|
||||
Holds the per-host control-plane *signing key* (from
|
||||
`$BOT_BOTTLE_ORCHESTRATOR_TOKEN`, injected by the launcher into the
|
||||
orchestrator process only) and verifies each request's role-scoped token
|
||||
against it. When a key is set, every route but `/health` requires a valid
|
||||
token whose role covers the route; when it is unset the server runs **open**
|
||||
(full `cli` access) and says so loudly at startup — a fail-visible fallback
|
||||
for tests and any backend that hasn't wired the key yet (e.g. Firecracker,
|
||||
whose nft boundary already blocks agents from the control-plane port)."""
|
||||
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None:
|
||||
self.orchestrator = orchestrator
|
||||
self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip()
|
||||
if not self._signing_key:
|
||||
sys.stderr.write(
|
||||
"orchestrator: WARNING — no control-plane signing key "
|
||||
f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller "
|
||||
"authentication. Any client that can reach this port can drive "
|
||||
"it. Backends that put the control plane on an agent-reachable "
|
||||
"network MUST set this.\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
super().__init__(address, Handler)
|
||||
|
||||
def role_for(self, presented: str) -> str | None:
|
||||
"""The role the request is authorized as, or None if unauthenticated.
|
||||
Open mode (no signing key) grants full `cli` access — the fail-visible
|
||||
fallback. Otherwise verify the presented signed token; a missing/invalid
|
||||
token yields None (→ 401), a valid one yields its `gateway`/`cli`
|
||||
role (→ per-route 401/403 in `dispatch`)."""
|
||||
if not self._signing_key:
|
||||
return ROLE_CLI
|
||||
return verify(presented, self._signing_key)
|
||||
def server_close(self) -> None:
|
||||
self._socket.close()
|
||||
|
||||
|
||||
def make_server(
|
||||
orchestrator: OrchestratorCore, host: str = "127.0.0.1", port: int = 0
|
||||
orchestrator: OrchestratorCore,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
*,
|
||||
signing_key: str | None = None,
|
||||
) -> OrchestratorServer:
|
||||
"""Build (but do not start) a control-plane server. `port=0` binds an
|
||||
ephemeral port — read `server.server_address` for the actual one."""
|
||||
return OrchestratorServer((host, port), orchestrator)
|
||||
"""Build a bounded Uvicorn server around the orchestrator application."""
|
||||
key = CONTROL_PLANE.key_from_env() if signing_key is None else signing_key
|
||||
app = create_app(orchestrator, signing_key=key)
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
access_log=bool(os.environ.get("BOT_BOTTLE_ORCHESTRATOR_DEBUG")),
|
||||
log_level="info",
|
||||
limit_concurrency=MAX_REQUESTS,
|
||||
timeout_keep_alive=KEEP_ALIVE_TIMEOUT_SECONDS,
|
||||
server_header=False,
|
||||
)
|
||||
return OrchestratorServer(config)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"dispatch", "Handler", "OrchestratorServer", "make_server", "Json",
|
||||
"KEEP_ALIVE_TIMEOUT_SECONDS",
|
||||
"MAX_BODY_BYTES",
|
||||
"MAX_REQUESTS",
|
||||
"ORCHESTRATOR_AUTH_HEADER",
|
||||
"OrchestratorServer",
|
||||
"create_app",
|
||||
"make_server",
|
||||
]
|
||||
|
||||
@@ -371,10 +371,12 @@ class OrchestratorCore:
|
||||
if not encrypted:
|
||||
return False
|
||||
try:
|
||||
self._tokens[bottle_id] = {k: decrypt_value(env_var_secret, v)
|
||||
for k, v in encrypted.items()}
|
||||
decrypted = {
|
||||
k: decrypt_value(env_var_secret, v) for k, v in encrypted.items()
|
||||
}
|
||||
except ValueError:
|
||||
return False
|
||||
self._tokens[bottle_id] = decrypted
|
||||
return True
|
||||
|
||||
# --- consolidated gateway ----------------------------------------------
|
||||
|
||||
@@ -113,7 +113,7 @@ _MIGRATIONS = TableMigrations(
|
||||
# egress allowlist / routes / git config selected by source IP. The
|
||||
# multi-tenant gateway resolves it per request via `attribute`.
|
||||
"ALTER TABLE orchestrator_bottles ADD COLUMN policy TEXT NOT NULL DEFAULT ''",
|
||||
# v4 — per-bottle encrypted egress secrets (PRD prd-new-secret-provider).
|
||||
# v4 — per-bottle encrypted egress secrets (PRD 0080).
|
||||
# One row per env-var: key (env-var name) is plaintext for auditing;
|
||||
# value is the encrypted token string. The encryption key (ENV_VAR_SECRET)
|
||||
# lives only in the agent's environment — a row alone cannot recover the
|
||||
@@ -129,6 +129,10 @@ _MIGRATIONS = TableMigrations(
|
||||
# v5 — index for fast per-bottle lookups and bulk DELETE on teardown.
|
||||
"CREATE INDEX IF NOT EXISTS idx_bottled_agent_secrets_id "
|
||||
"ON bottled_agent_secrets (bottled_agent_id, type)",
|
||||
# v6 — unauthenticated legacy ciphertext must never be selected by
|
||||
# attacker-controlled blob contents. Existing local agents are
|
||||
# intentionally reprovisioned instead of retaining downgrade support.
|
||||
"DELETE FROM bottled_agent_secrets",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Symmetric encryption for per-bottle egress secrets (PRD prd-new-secret-provider).
|
||||
"""Symmetric encryption for per-bottle egress secrets (PRD 0080).
|
||||
|
||||
Each agent receives a random ENV_VAR_SECRET at startup — passed as an env var,
|
||||
never logged or persisted. The host uses this key to encrypt each egress auth
|
||||
@@ -12,9 +12,15 @@ reattachment path reads ENV_VAR_SECRET from the running agent container via
|
||||
``POST /bottles/<id>/reprovision_gateway``; the orchestrator decrypts the
|
||||
stored rows and re-populates ``_tokens``.
|
||||
|
||||
Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode (stdlib-only,
|
||||
no external deps). Each value is encrypted independently. The output blob is
|
||||
``nonce (16 bytes) || ciphertext`` encoded as URL-safe base64 (no padding).
|
||||
Encryption scheme: encrypt-then-MAC using independent HMAC-SHA256-derived
|
||||
encryption and authentication subkeys (stdlib-only, no external deps). Each
|
||||
value is encrypted independently. New output blobs are:
|
||||
|
||||
``version || nonce (16 bytes) || ciphertext || tag (32 bytes)``
|
||||
|
||||
encoded as URL-safe base64 (no padding). Unversioned legacy ciphertext is
|
||||
rejected; the registry migration clears those rows rather than allowing blob
|
||||
contents to select an unauthenticated decoder.
|
||||
|
||||
keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big"))
|
||||
ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)]
|
||||
@@ -30,6 +36,8 @@ import secrets
|
||||
_KEY_BYTES = 32 # 256-bit key from ENV_VAR_SECRET
|
||||
_NONCE_BYTES = 16 # 128-bit random nonce per encrypt call
|
||||
_BLOCK = 32 # HMAC-SHA256 output width == one keystream block
|
||||
_TAG_BYTES = 32
|
||||
_VERSION = b"BBSE1"
|
||||
|
||||
# Env-var name the agent container receives at startup.
|
||||
ENV_VAR_SECRET_NAME = "ENV_VAR_SECRET"
|
||||
@@ -41,7 +49,13 @@ def new_env_var_secret() -> str:
|
||||
|
||||
|
||||
def _b64dec(s: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
||||
return base64.b64decode(
|
||||
s + "=" * (-len(s) % 4), altchars=b"-_", validate=True,
|
||||
)
|
||||
|
||||
|
||||
def _subkey(key: bytes, purpose: bytes) -> bytes:
|
||||
return hmac.new(key, b"bot-bottle-secret-store:" + purpose, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
|
||||
@@ -53,37 +67,53 @@ def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
|
||||
def encrypt_value(secret_b64: str, plaintext: str) -> str:
|
||||
"""Encrypt a single string value with *secret_b64* (the ENV_VAR_SECRET).
|
||||
|
||||
Returns a URL-safe base64 blob ``nonce || ciphertext`` suitable for
|
||||
Returns a URL-safe base64 authenticated blob suitable for
|
||||
the ``bottled_agent_secrets.value`` column."""
|
||||
key = _b64dec(secret_b64)
|
||||
encryption_key = _subkey(key, b"encryption")
|
||||
authentication_key = _subkey(key, b"authentication")
|
||||
pt = plaintext.encode()
|
||||
nonce = secrets.token_bytes(_NONCE_BYTES)
|
||||
ct = bytearray()
|
||||
for i in range(0, len(pt), _BLOCK):
|
||||
chunk = pt[i : i + _BLOCK]
|
||||
ks = _keystream(key, nonce, i)[: len(chunk)]
|
||||
ks = _keystream(encryption_key, nonce, i // _BLOCK)[: len(chunk)]
|
||||
ct.extend(p ^ k for p, k in zip(chunk, ks))
|
||||
return base64.urlsafe_b64encode(nonce + bytes(ct)).rstrip(b"=").decode()
|
||||
authenticated = _VERSION + nonce + bytes(ct)
|
||||
tag = hmac.new(authentication_key, authenticated, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(authenticated + tag).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def decrypt_value(secret_b64: str, blob_b64: str) -> str:
|
||||
"""Decrypt a blob produced by :func:`encrypt_value`.
|
||||
|
||||
Returns the original plaintext string. Raises ``ValueError`` for malformed
|
||||
input or a key mismatch (wrong key produces garbage, not an error, unless
|
||||
the plaintext is non-UTF-8 — treat all such failures as wrong key)."""
|
||||
input, authentication failure, or a key mismatch."""
|
||||
key = _b64dec(secret_b64)
|
||||
try:
|
||||
blob = _b64dec(blob_b64)
|
||||
except Exception as exc:
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError(f"invalid ciphertext blob: {exc}") from exc
|
||||
if len(blob) < _NONCE_BYTES:
|
||||
if not blob.startswith(_VERSION):
|
||||
raise ValueError("unsupported ciphertext format")
|
||||
minimum = len(_VERSION) + _NONCE_BYTES + _TAG_BYTES
|
||||
if len(blob) < minimum:
|
||||
raise ValueError("ciphertext blob too short")
|
||||
nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
|
||||
authenticated, supplied_tag = blob[:-_TAG_BYTES], blob[-_TAG_BYTES:]
|
||||
authentication_key = _subkey(key, b"authentication")
|
||||
expected_tag = hmac.new(
|
||||
authentication_key, authenticated, hashlib.sha256,
|
||||
).digest()
|
||||
if not hmac.compare_digest(supplied_tag, expected_tag):
|
||||
raise ValueError("ciphertext authentication failed")
|
||||
nonce_start = len(_VERSION)
|
||||
nonce = blob[nonce_start : nonce_start + _NONCE_BYTES]
|
||||
ciphertext = blob[nonce_start + _NONCE_BYTES : -_TAG_BYTES]
|
||||
encryption_key = _subkey(key, b"encryption")
|
||||
pt = bytearray()
|
||||
for i in range(0, len(ciphertext), _BLOCK):
|
||||
chunk = ciphertext[i : i + _BLOCK]
|
||||
ks = _keystream(key, nonce, i)[: len(chunk)]
|
||||
ks = _keystream(encryption_key, nonce, i // _BLOCK)[: len(chunk)]
|
||||
pt.extend(c ^ k for c, k in zip(chunk, ks))
|
||||
try:
|
||||
return bytes(pt).decode()
|
||||
@@ -91,4 +121,9 @@ def decrypt_value(secret_b64: str, blob_b64: str) -> str:
|
||||
raise ValueError(f"decryption produced non-UTF-8 output (wrong key?): {exc}") from exc
|
||||
|
||||
|
||||
__all__ = ["ENV_VAR_SECRET_NAME", "new_env_var_secret", "encrypt_value", "decrypt_value"]
|
||||
__all__ = [
|
||||
"ENV_VAR_SECRET_NAME",
|
||||
"new_env_var_secret",
|
||||
"encrypt_value",
|
||||
"decrypt_value",
|
||||
]
|
||||
|
||||
@@ -59,12 +59,17 @@ _HEADER_SEGMENT = _b64url_encode(
|
||||
)
|
||||
|
||||
|
||||
def mint(role: str, secret: str) -> str:
|
||||
def mint(role: str, secret: str, *, roles: frozenset[str] = ROLES) -> str:
|
||||
"""A compact HS256 token asserting `role`, signed with `secret`.
|
||||
|
||||
Raises ValueError for an unknown role (mint only what the control plane will
|
||||
accept) or an empty signing key (an unsigned credential is never valid)."""
|
||||
if role not in ROLES:
|
||||
`roles` is the set the signing key is allowed to sign (default: the
|
||||
orchestrator's `{gateway, cli}`). A separate service (e.g. the host
|
||||
controller) passes its own key + role set so its tokens can't be forged with
|
||||
the orchestrator's key — see `trust_domain.py`, issues #476/#468.
|
||||
|
||||
Raises ValueError for a role outside `roles`, or an empty signing key (an
|
||||
unsigned credential is never valid)."""
|
||||
if role not in roles:
|
||||
raise ValueError(f"unknown control-plane role {role!r}")
|
||||
if not secret:
|
||||
raise ValueError("cannot mint a control-plane token without a signing key")
|
||||
@@ -73,10 +78,11 @@ def mint(role: str, secret: str) -> str:
|
||||
return f"{signing_input}.{_sign(secret, signing_input)}"
|
||||
|
||||
|
||||
def verify(token: str, secret: str) -> str | None:
|
||||
def verify(token: str, secret: str, *, roles: frozenset[str] = ROLES) -> str | None:
|
||||
"""The role a valid `token` carries, or None if it is malformed, wrongly
|
||||
signed, or names an unknown role. Constant-time signature check; rejects any
|
||||
header whose alg isn't HS256 (no alg-confusion / `none`)."""
|
||||
signed, or names a role outside `roles` (the verifying trust domain's set —
|
||||
default `{gateway, cli}`). Constant-time signature check; rejects any header
|
||||
whose alg isn't HS256 (no alg-confusion / `none`)."""
|
||||
if not token or not secret:
|
||||
return None
|
||||
parts = token.split(".")
|
||||
@@ -94,7 +100,7 @@ def verify(token: str, secret: str) -> str | None:
|
||||
if not isinstance(header, dict) or header.get("alg") != _ALG:
|
||||
return None
|
||||
role = payload.get("role") if isinstance(payload, dict) else None
|
||||
return role if isinstance(role, str) and role in ROLES else None
|
||||
return role if isinstance(role, str) and role in roles else None
|
||||
|
||||
|
||||
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]
|
||||
|
||||
+19
-9
@@ -97,16 +97,17 @@ def host_gateway_ca_dir() -> Path:
|
||||
return ca_dir
|
||||
|
||||
|
||||
def host_orchestrator_token() -> str:
|
||||
"""The per-host control-plane secret, minted (256-bit, url-safe) and
|
||||
persisted 0600 on first use, then reused.
|
||||
def host_signing_key(filename: str) -> str:
|
||||
"""A per-host signing key at `<root>/<filename>`, 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() / ORCHESTRATOR_TOKEN_FILENAME
|
||||
The generic form of `host_orchestrator_token()`: each service names its own
|
||||
key file (`trust_domain.py`), so the orchestrator and a separate service like
|
||||
the host controller (#468) get distinct keys neither can read. It is a *host*
|
||||
artifact — the file lives under the root the agent never mounts, and its value
|
||||
is injected only into the trusted control-plane process — so reading it here
|
||||
is safe on the launch path but the value never reaches a bottle."""
|
||||
path = bot_bottle_root() / filename
|
||||
try:
|
||||
existing = path.read_text().strip()
|
||||
if existing:
|
||||
@@ -128,6 +129,14 @@ def host_orchestrator_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def host_orchestrator_token() -> str:
|
||||
"""The per-host control-plane signing key — the host-canonical key the
|
||||
launchers inject into the control-plane process and the host CLI mints its
|
||||
own `cli` token from. The `control-plane` trust domain's specialization of
|
||||
`host_signing_key()`."""
|
||||
return host_signing_key(ORCHESTRATOR_TOKEN_FILENAME)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HOST_DB_FILENAME",
|
||||
"ORCHESTRATOR_TOKEN_FILENAME",
|
||||
@@ -138,5 +147,6 @@ __all__ = [
|
||||
"host_db_path",
|
||||
"host_db_dir",
|
||||
"host_gateway_ca_dir",
|
||||
"host_signing_key",
|
||||
"host_orchestrator_token",
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user