Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b679e3f6ca | |||
| b8941c101a | |||
| 92be0a4d90 | |||
| 05e99eeaf1 | |||
| 00f32a3890 |
@@ -1,10 +1,6 @@
|
||||
[run]
|
||||
branch = True
|
||||
source = .
|
||||
# Store paths relative to the project root so .coverage.* files produced on
|
||||
# different runners (ubuntu-latest vs self-hosted KVM) can be combined by the
|
||||
# coverage job without a [paths] remapping section.
|
||||
relative_files = True
|
||||
|
||||
[report]
|
||||
# Coverage policy: see docs/decisions/0004-coverage-policy.md.
|
||||
|
||||
+112
-99
@@ -9,12 +9,10 @@
|
||||
# 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.
|
||||
# Integration tests run once per backend in separate jobs. Each job sets
|
||||
# BOT_BOTTLE_BACKEND explicitly so the test suite uses the right backend.
|
||||
# Backends that aren't available on the runner fail the preflight step
|
||||
# rather than silently skipping inside the test output.
|
||||
|
||||
name: test
|
||||
|
||||
@@ -42,6 +40,53 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stage-firecracker-inputs:
|
||||
runs-on: [self-hosted, kvm]
|
||||
# Same guard as the other KVM-runner jobs: don't spin the privileged
|
||||
# runner for fork PRs (this only copies a non-secret static binary, but
|
||||
# keep the posture consistent — build-infra/integration/coverage all
|
||||
# depend on it, so gating here gates the whole Firecracker chain).
|
||||
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: Stage the provisioned static dropbear
|
||||
run: |
|
||||
mkdir -p firecracker-inputs
|
||||
cp /var/cache/bot-bottle-fc/dropbear firecracker-inputs/dropbear
|
||||
|
||||
- name: Upload Firecracker build inputs
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: firecracker-inputs/
|
||||
|
||||
build-infra:
|
||||
needs: stage-firecracker-inputs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Firecracker build inputs
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
path: firecracker-inputs
|
||||
|
||||
- name: Build infra candidate from this checkout
|
||||
env:
|
||||
BOT_BOTTLE_FC_DROPBEAR: ${{ github.workspace }}/firecracker-inputs/dropbear
|
||||
run: python3 -m bot_bottle.backend.firecracker.publish_infra --output infra-candidate
|
||||
|
||||
- name: Upload infra candidate
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate/
|
||||
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -56,17 +101,11 @@ jobs:
|
||||
- name: Install dev requirements
|
||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: python3 -m coverage run --data-file=.coverage.unit -m unittest discover -t . -s tests/unit -v
|
||||
- name: Run unit tests
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
||||
|
||||
- name: Report unit coverage
|
||||
run: python3 -m coverage report --data-file=.coverage.unit -m
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-unit
|
||||
path: ${{ github.workspace }}/.coverage.unit
|
||||
run: python3 -m coverage report -m
|
||||
|
||||
integration-docker:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -76,9 +115,6 @@ jobs:
|
||||
|
||||
# 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
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
python3 --version
|
||||
@@ -88,16 +124,10 @@ jobs:
|
||||
echo "docker not on PATH — integration tests will skip"
|
||||
fi
|
||||
|
||||
- name: Run integration tests (docker) with coverage
|
||||
- name: Run integration tests (docker)
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: docker
|
||||
run: python3 -m coverage run --data-file=.coverage.docker -m unittest discover -t . -s tests/integration -v
|
||||
|
||||
- name: Upload docker coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-docker
|
||||
path: ${{ github.workspace }}/.coverage.docker
|
||||
run: python3 -m unittest discover -t . -s tests/integration -v
|
||||
|
||||
# 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.
|
||||
@@ -107,16 +137,9 @@ jobs:
|
||||
#
|
||||
# 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.
|
||||
# static dropbear, and the pool as a persistent systemd unit.
|
||||
integration-firecracker:
|
||||
needs: build-infra
|
||||
runs-on: [self-hosted, kvm]
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
@@ -136,58 +159,49 @@ jobs:
|
||||
# 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
|
||||
- name: Download the candidate built from this checkout
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate
|
||||
|
||||
- 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
|
||||
# No dev-requirements install: the integration suite runs on stdlib
|
||||
# `unittest` (pylint/pyright are lint.yml's concern, not this job's),
|
||||
# and the self-hosted runner's Nix python env has no `pip` module
|
||||
# (`python3 -m pip` → "No module named pip"). Nothing to install.
|
||||
- name: Run integration tests (firecracker)
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: firecracker
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
|
||||
run: python3 -m coverage run --data-file=.coverage.firecracker -m unittest discover -t . -s tests/integration -v
|
||||
run: python3 -m unittest discover -t . -s tests/integration -v
|
||||
|
||||
- name: Upload firecracker coverage artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-firecracker
|
||||
path: ${{ github.workspace }}/.coverage.firecracker
|
||||
|
||||
# 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%).
|
||||
# Combined unit+integration coverage + the diff-coverage gate (the hard
|
||||
# gate: new/changed lines >= 90%). See docs/decisions/0004-coverage-policy.md.
|
||||
#
|
||||
# Runs on ubuntu-latest — no KVM needed, no test reruns. Coverage files use
|
||||
# relative_files = True (.coveragerc) so they combine cleanly across runners.
|
||||
# This runs on a self-hosted KVM runner (label `kvm`), NOT ubuntu-latest,
|
||||
# because the Firecracker backend's subprocess/VM orchestration
|
||||
# (launch/boot/SSH/isolation-probe) is covered by the integration suite,
|
||||
# and that suite needs `/dev/kvm` + the provisioned TAP/nft pool — which a
|
||||
# container-based runner doesn't have. On such a runner the firecracker
|
||||
# integration test skips and its ~230 orchestration lines read as
|
||||
# uncovered, so the gate can't pass there.
|
||||
#
|
||||
# Restricted to the same events as integration-firecracker: it depends on
|
||||
# that job's coverage artifact and skips for fork PRs alongside it.
|
||||
# Restricted to the same events as integration-firecracker (same-repo PRs,
|
||||
# push, workflow_dispatch) for the same security reason.
|
||||
#
|
||||
# See #414 for the planned follow-up: artifact-based coverage combination
|
||||
# (run tests once in their respective jobs, combine .coverage files here).
|
||||
#
|
||||
# build-infra creates one candidate from the checkout. This job boots that
|
||||
# same candidate after integration-firecracker has exercised it; the main
|
||||
# push path publishes the identical bytes only after every required job.
|
||||
coverage:
|
||||
needs: [unit, integration-docker, integration-firecracker]
|
||||
needs: [build-infra, integration-firecracker]
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, kvm]
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
@@ -199,29 +213,29 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install coverage
|
||||
run: python3 -m pip install --break-system-packages coverage
|
||||
- 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: Download unit coverage artifact
|
||||
- name: Download the candidate already exercised by integration
|
||||
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 }}
|
||||
name: infra-candidate
|
||||
path: infra-candidate
|
||||
|
||||
# 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. `scripts/coverage.sh` +
|
||||
# `diff_coverage.py` need only `coverage` (not pylint/pyright).
|
||||
- name: Combined coverage (unit + integration, incl. firecracker)
|
||||
run: PYTHON=python3 bash scripts/coverage.sh aggregate critical
|
||||
env:
|
||||
BOT_BOTTLE_CI_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
|
||||
run: PYTHON=python3 bash scripts/coverage.sh critical
|
||||
|
||||
- name: Diff-coverage gate (changed lines >= 90%)
|
||||
run: |
|
||||
@@ -229,14 +243,14 @@ jobs:
|
||||
python3 scripts/diff_coverage.py --base origin/main --min 90
|
||||
|
||||
publish-infra:
|
||||
needs: [unit, integration-docker, integration-firecracker, coverage]
|
||||
needs: [stage-firecracker-inputs, build-infra, 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
|
||||
- name: Download the tested candidate
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
@@ -244,10 +258,9 @@ jobs:
|
||||
|
||||
# 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)
|
||||
# bytes. Stage the SAME dropbear build-infra used, or the recheck
|
||||
# computes a "<missing>"-dropbear version and rejects the candidate.
|
||||
- name: Download the staged dropbear (matches build-infra's version)
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
|
||||
+13
-36
@@ -1,45 +1,22 @@
|
||||
# Firecracker single infra-VM image (PRD 0070 Stage B).
|
||||
# Shared infra image: gateway data plane + orchestrator control plane.
|
||||
#
|
||||
# The per-host infra VM runs the orchestrator control plane, the gateway
|
||||
# data plane, AND builds agent images (buildah) — all in one microVM (see
|
||||
# backend/firecracker/infra_vm.py). It composes:
|
||||
# * FROM the gateway image (mitmproxy / git / gitleaks / supervise + the
|
||||
# flat daemon modules) — now trixie-based, so buildah 1.39 is available;
|
||||
# * `COPY --from` the orchestrator image's content (the single definition
|
||||
# of the control-plane payload — see Dockerfile.orchestrator), so this
|
||||
# VM and the docker backend share one orchestrator definition; and
|
||||
# * buildah, installed HERE only (the docker orchestrator/gateway images
|
||||
# never carry it).
|
||||
# Used directly by the Docker backend (run as one `bot-bottle-infra`
|
||||
# container, replacing the prior two-container split). The Firecracker
|
||||
# backend extends this via Dockerfile.infra.fc, adding buildah/crun/
|
||||
# netavark for in-VM agent-image building.
|
||||
#
|
||||
# Dockerfile.orchestrator is the single definition of the orchestrator
|
||||
# content (the lean `bot_bottle` package on python:3.12-slim). Both this
|
||||
# image and Dockerfile.infra.fc pull it in via `COPY --from`.
|
||||
#
|
||||
# multi-`FROM` can't union two bases (that's multi-stage, not multiple
|
||||
# inheritance), so the orchestrator content is pulled in via `COPY --from`
|
||||
# rather than a second base. Both images share the trixie `python:3.12-slim`
|
||||
# base, so the copy is clean (same python; future installed deps copy too).
|
||||
#
|
||||
# The docker backend keeps orchestrator + gateway as separate images; this
|
||||
# combined image exists only for the Firecracker single-VM cut. Splitting a
|
||||
# service back into its own VM later is a routing change, not a repackaging
|
||||
# (PRD 0070's "secret concentration"; a disposable builder can boot from
|
||||
# this same image on its own TAP).
|
||||
FROM bot-bottle-gateway:latest
|
||||
|
||||
# --- in-VM agent-image builder (PRD 0069 Stage 3) -------------------
|
||||
# The Firecracker backend builds users' agent Dockerfiles *inside this VM*
|
||||
# with buildah (rootless, daemonless) instead of on the host — no host
|
||||
# Docker daemon, no root-equivalent `docker` group. `crun` is the OCI
|
||||
# runtime; `netavark` + `aardvark-dns` are the network backend for `FROM`
|
||||
# pulls + `RUN` egress. Requires the trixie base (buildah 1.39: bookworm's
|
||||
# 1.28 can't parse Dockerfile heredocs that agent images use).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
buildah crun netavark aardvark-dns \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# vfs + chroot: buildah works as root in the bare microVM (no
|
||||
# fuse-overlayfs / overlay module / subuid maps). Matches image_builder.
|
||||
ENV STORAGE_DRIVER=vfs \
|
||||
BUILDAH_ISOLATION=chroot
|
||||
|
||||
# The orchestrator content, pulled from its single definition. The gateway
|
||||
# image already has the flat daemon modules under /app; this adds the full
|
||||
# `bot_bottle` package so `python3 -m bot_bottle.orchestrator` resolves.
|
||||
# The orchestrator content, from its single definition. The gateway image
|
||||
# already has the flat daemon modules under /app; this adds the full
|
||||
# `bot_bottle` package so `python3 -m bot_bottle.orchestrator` resolves —
|
||||
# used by gateway_init when BOT_BOTTLE_GATEWAY_DAEMONS includes `orchestrator`.
|
||||
COPY --from=bot-bottle-orchestrator:latest /app/bot_bottle /app/bot_bottle
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Firecracker infra VM image (PRD 0070 Stage B).
|
||||
#
|
||||
# Extends the shared infra base (Dockerfile.infra: gateway + orchestrator
|
||||
# control plane) with the in-VM agent-image builder. The Firecracker backend
|
||||
# builds users' agent Dockerfiles *inside this VM* with buildah (rootless,
|
||||
# daemonless) instead of on the host — no host Docker daemon, no
|
||||
# root-equivalent `docker` group.
|
||||
#
|
||||
# Requires the trixie base from bot-bottle-gateway (buildah 1.39: bookworm's
|
||||
# 1.28 can't parse Dockerfile heredocs that agent images use).
|
||||
#
|
||||
# `crun` is the OCI runtime; `netavark` + `aardvark-dns` are the network
|
||||
# backend for `FROM` pulls + `RUN` egress. `vfs` + `chroot`: buildah works
|
||||
# as root in the bare microVM (no fuse-overlayfs / overlay module / subuid
|
||||
# maps). Matches image_builder.
|
||||
FROM bot-bottle-infra:latest
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
buildah crun netavark aardvark-dns \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENV STORAGE_DRIVER=vfs \
|
||||
BUILDAH_ISOLATION=chroot
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Shared helpers for the consolidated launch sequence (PRD 0070).
|
||||
|
||||
Logic that was duplicated across the docker, macos_container, and
|
||||
firecracker consolidated_launch modules — extracted so each backend
|
||||
imports it rather than re-implementing it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..orchestrator.client import OrchestratorClient
|
||||
from ..orchestrator.registration import registration_inputs
|
||||
from .docker.gateway_provision import GatewayTransport, deprovision_git_gate, provision_git_gate
|
||||
|
||||
|
||||
def provision_bottle(
|
||||
client: OrchestratorClient,
|
||||
source_ip: str,
|
||||
egress_plan: EgressPlan,
|
||||
git_gate_plan: GitGatePlan,
|
||||
transport: GatewayTransport,
|
||||
*,
|
||||
image_ref: str = "",
|
||||
tokens: dict[str, str] | None = None,
|
||||
):
|
||||
"""Register the bottle and provision its git-gate state. Rolls back the
|
||||
registration if provisioning fails so no orphan is left. Returns the
|
||||
`RegisteredBottle` from the orchestrator."""
|
||||
inputs = registration_inputs(egress_plan)
|
||||
reg = client.register_bottle(
|
||||
source_ip, image_ref=image_ref, policy=inputs.policy,
|
||||
metadata=inputs.metadata, tokens=tokens,
|
||||
)
|
||||
try:
|
||||
provision_git_gate(transport, reg.bottle_id, git_gate_plan)
|
||||
except Exception:
|
||||
client.teardown_bottle(reg.bottle_id)
|
||||
raise
|
||||
return reg
|
||||
|
||||
|
||||
def teardown_consolidated(
|
||||
bottle_id: str,
|
||||
transport: GatewayTransport,
|
||||
*,
|
||||
orchestrator_url: str,
|
||||
) -> None:
|
||||
"""Deregister the bottle and remove its git-gate state. Both steps are
|
||||
idempotent so this is safe from a cleanup trap."""
|
||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||
deprovision_git_gate(transport, bottle_id)
|
||||
|
||||
|
||||
__all__ = ["provision_bottle", "teardown_consolidated"]
|
||||
@@ -1,19 +1,13 @@
|
||||
"""Consolidated bottle launch sequence for the docker backend (PRD 0070).
|
||||
|
||||
Composes the orchestrator primitives into the register/teardown sequence that
|
||||
replaces the per-bottle gateway:
|
||||
Composes the orchestrator primitives into the register/teardown sequence:
|
||||
|
||||
1. ensure the orchestrator control plane + shared gateway are up;
|
||||
2. allocate the bottle a pinned source IP on the gateway network (the
|
||||
attribution key), skipping the gateway's own address + live bottles;
|
||||
3. register it (egress policy blob + slug metadata) → bottle id + identity
|
||||
token;
|
||||
4. provision its git-gate repos/creds into the running gateway.
|
||||
1. ensure the single infra container (control plane + gateway) is up;
|
||||
2. allocate the bottle a pinned source IP on the gateway network;
|
||||
3. register it and provision its git-gate repos/creds into the gateway.
|
||||
|
||||
It returns a `LaunchContext` with everything the agent container needs to
|
||||
attach — network, pinned IP, the gateway's address (its proxy target), the
|
||||
orchestrator URL, and the identity token. The agent `docker run` itself is
|
||||
the backend's job (it owns provider provisioning); this owns the
|
||||
Returns a `LaunchContext` with everything the agent container needs to
|
||||
attach. The agent `docker run` itself is the backend's job; this owns the
|
||||
orchestrator-facing wiring so that sequence stays testable in isolation.
|
||||
"""
|
||||
|
||||
@@ -25,15 +19,12 @@ from ...docker_cmd import run_docker
|
||||
from ...egress import EgressPlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...orchestrator.client import OrchestratorClient
|
||||
from ...orchestrator.gateway import GATEWAY_NAME, GATEWAY_NETWORK
|
||||
from ...orchestrator.lifecycle import OrchestratorService
|
||||
from ...orchestrator.registration import registration_inputs
|
||||
from ...orchestrator.gateway import GATEWAY_NETWORK
|
||||
from ...orchestrator.lifecycle import INFRA_NAME, OrchestratorService
|
||||
from ..consolidated_util import provision_bottle
|
||||
from ..consolidated_util import teardown_consolidated as _teardown_util
|
||||
from .gateway_provision import DockerGatewayTransport
|
||||
from .gateway_net import next_free_ip
|
||||
from .gateway_provision import (
|
||||
DockerGatewayTransport,
|
||||
deprovision_git_gate,
|
||||
provision_git_gate,
|
||||
)
|
||||
|
||||
|
||||
class ConsolidatedLaunchError(RuntimeError):
|
||||
@@ -75,24 +66,21 @@ def _container_ip(name: str, network: str) -> str:
|
||||
ip = proc.stdout.strip()
|
||||
if proc.returncode != 0 or not ip:
|
||||
raise ConsolidatedLaunchError(
|
||||
f"gateway {name} has no address on {network}: {proc.stderr.strip()}"
|
||||
f"container {name} has no address on {network}: {proc.stderr.strip()}"
|
||||
)
|
||||
return ip
|
||||
|
||||
|
||||
def _network_container_ips(network: str) -> list[str]:
|
||||
"""Every address currently assigned on the gateway network — the ground
|
||||
truth for "in use": the gateway + orchestrator infrastructure containers
|
||||
and every live agent. Read from the network so a new bottle can't collide
|
||||
with anything actually attached (a registry-only view would miss the
|
||||
orchestrator/gateway containers)."""
|
||||
truth for "in use": the infra container and every live agent. Read from
|
||||
the network so a new bottle can't collide with anything actually attached."""
|
||||
proc = run_docker([
|
||||
"docker", "network", "inspect", "--format",
|
||||
"{{range .Containers}}{{.IPv4Address}} {{end}}", network,
|
||||
])
|
||||
ips: list[str] = []
|
||||
for entry in proc.stdout.split():
|
||||
# entries look like "172.20.0.2/16" — keep the address.
|
||||
ips.append(entry.split("/", 1)[0])
|
||||
return ips
|
||||
|
||||
@@ -104,33 +92,24 @@ def launch_consolidated(
|
||||
image_ref: str = "",
|
||||
tokens: dict[str, str] | None = None,
|
||||
service: OrchestratorService | None = None,
|
||||
gateway_name: str = GATEWAY_NAME,
|
||||
infra_name: str = INFRA_NAME,
|
||||
network: str = GATEWAY_NETWORK,
|
||||
) -> LaunchContext:
|
||||
"""Ensure the orchestrator + gateway are up, allocate + register the
|
||||
bottle, and provision its git-gate state. Returns the agent's attach
|
||||
context. Raises `ConsolidatedLaunchError` (or the primitives' own errors)
|
||||
if any step fails — the caller tears down on failure."""
|
||||
"""Ensure the infra container is up, allocate + register the bottle, and
|
||||
provision its git-gate state. Returns the agent's attach context."""
|
||||
service = service or OrchestratorService()
|
||||
url = service.ensure_running()
|
||||
client = OrchestratorClient(url)
|
||||
|
||||
cidr = _network_cidr(network)
|
||||
gateway_ip = _container_ip(gateway_name, network)
|
||||
gateway_ip = _container_ip(infra_name, network)
|
||||
source_ip = next_free_ip(cidr, _network_container_ips(network))
|
||||
|
||||
inputs = registration_inputs(egress_plan)
|
||||
reg = client.register_bottle(
|
||||
source_ip, image_ref=image_ref, policy=inputs.policy,
|
||||
metadata=inputs.metadata, tokens=tokens,
|
||||
transport = DockerGatewayTransport(infra_name)
|
||||
reg = provision_bottle(
|
||||
client, source_ip, egress_plan, git_gate_plan, transport,
|
||||
image_ref=image_ref, tokens=tokens,
|
||||
)
|
||||
try:
|
||||
provision_git_gate(
|
||||
DockerGatewayTransport(gateway_name), reg.bottle_id, git_gate_plan)
|
||||
except Exception:
|
||||
# Roll the registration back so a provisioning failure leaves no orphan.
|
||||
client.teardown_bottle(reg.bottle_id)
|
||||
raise
|
||||
return LaunchContext(
|
||||
bottle_id=reg.bottle_id,
|
||||
identity_token=reg.identity_token,
|
||||
@@ -142,12 +121,11 @@ def launch_consolidated(
|
||||
|
||||
|
||||
def teardown_consolidated(
|
||||
bottle_id: str, *, orchestrator_url: str, gateway_name: str = GATEWAY_NAME,
|
||||
bottle_id: str, *, orchestrator_url: str, infra_name: str = INFRA_NAME,
|
||||
) -> None:
|
||||
"""Deregister the bottle and remove its git-gate state from the gateway.
|
||||
Both steps are idempotent so this is safe from a cleanup trap."""
|
||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||
deprovision_git_gate(DockerGatewayTransport(gateway_name), bottle_id)
|
||||
"""Deregister the bottle and remove its git-gate state. Idempotent."""
|
||||
_teardown_util(bottle_id, DockerGatewayTransport(infra_name),
|
||||
orchestrator_url=orchestrator_url)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -33,8 +33,7 @@ from ...orchestrator.client import OrchestratorClient
|
||||
from ...orchestrator.lifecycle import (
|
||||
OrchestratorStartError, # re-exported so callers can catch it
|
||||
)
|
||||
from ...orchestrator.registration import registration_inputs
|
||||
from ..docker.gateway_provision import deprovision_git_gate, provision_git_gate
|
||||
from ..consolidated_util import provision_bottle, teardown_consolidated as _teardown_util
|
||||
from . import infra_vm
|
||||
|
||||
|
||||
@@ -68,18 +67,11 @@ def launch_consolidated(
|
||||
url = infra.control_plane_url
|
||||
client = OrchestratorClient(url)
|
||||
|
||||
inputs = registration_inputs(egress_plan)
|
||||
reg = client.register_bottle(
|
||||
guest_ip, image_ref=image_ref, policy=inputs.policy,
|
||||
metadata=inputs.metadata, tokens=tokens,
|
||||
transport = infra_vm.gateway_transport()
|
||||
reg = provision_bottle(
|
||||
client, guest_ip, egress_plan, git_gate_plan, transport,
|
||||
image_ref=image_ref, tokens=tokens,
|
||||
)
|
||||
try:
|
||||
provision_git_gate(
|
||||
infra_vm.gateway_transport(), reg.bottle_id, git_gate_plan)
|
||||
except Exception:
|
||||
client.teardown_bottle(reg.bottle_id)
|
||||
raise
|
||||
|
||||
# The shared gateway CA every agent on this host trusts for TLS
|
||||
# interception — fetched from the infra VM over SSH.
|
||||
return LaunchContext(
|
||||
@@ -96,8 +88,7 @@ def teardown_consolidated(bottle_id: str, *, orchestrator_url: str) -> None:
|
||||
VM. Both steps are idempotent so this is safe from a cleanup trap. Does
|
||||
NOT stop the infra VM — it's a persistent per-host singleton shared by
|
||||
every bottle."""
|
||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||
deprovision_git_gate(infra_vm.gateway_transport(), bottle_id)
|
||||
_teardown_util(bottle_id, infra_vm.gateway_transport(), orchestrator_url=orchestrator_url)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -41,7 +41,7 @@ from . import util
|
||||
_ARTIFACT_FORMAT = "1"
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
_DOCKERFILES = ("Dockerfile.orchestrator", "Dockerfile.gateway", "Dockerfile.infra")
|
||||
_DOCKERFILES = ("Dockerfile.orchestrator", "Dockerfile.gateway", "Dockerfile.infra", "Dockerfile.infra.fc")
|
||||
|
||||
_DEFAULT_BASE = "https://gitea.dideric.is"
|
||||
_DEFAULT_OWNER = "didericis"
|
||||
|
||||
@@ -125,16 +125,19 @@ def ensure_built() -> None:
|
||||
|
||||
|
||||
def build_infra_images_with_docker() -> None:
|
||||
"""Build the three fixed images from source with host Docker: orchestrator,
|
||||
gateway, then the combined infra image (`COPY --from` orchestrator, `FROM`
|
||||
gateway). The launch host uses this only in `BOT_BOTTLE_INFRA_BUILD=local`
|
||||
mode; `publish_infra` uses it off-host to produce the published artifact."""
|
||||
"""Build the four fixed images from source with host Docker: orchestrator,
|
||||
gateway, the shared infra base (Dockerfile.infra), then the Firecracker
|
||||
infra image (Dockerfile.infra.fc: FROM infra + buildah). The launch host
|
||||
uses this only in `BOT_BOTTLE_INFRA_BUILD=local` mode; `publish_infra`
|
||||
uses it off-host to produce the published artifact."""
|
||||
docker_mod.build_image(
|
||||
_ORCHESTRATOR_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.orchestrator")
|
||||
docker_mod.build_image(
|
||||
_GATEWAY_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.gateway")
|
||||
docker_mod.build_image(
|
||||
_INFRA_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.infra")
|
||||
"bot-bottle-infra:latest", str(_REPO_ROOT), dockerfile="Dockerfile.infra")
|
||||
docker_mod.build_image(
|
||||
_INFRA_IMAGE, str(_REPO_ROOT), dockerfile="Dockerfile.infra.fc")
|
||||
|
||||
|
||||
def build_infra_rootfs_dir() -> Path:
|
||||
|
||||
@@ -68,14 +68,9 @@ class MacosContainerBottle(Bottle):
|
||||
# reaches the agent (PRD 0070): registration mints it *after* the
|
||||
# container exists — its source IP is the registration key and Apple
|
||||
# Container assigns that by DHCP — so it cannot be in the run-time env
|
||||
# the way docker's compose spec does it.
|
||||
#
|
||||
# `container exec --env` does NOT override a run-time value — it
|
||||
# appends, leaving duplicate entries in the agent's `environ` whose
|
||||
# resolution is runtime-specific (Node last-wins, Rust first-wins). So
|
||||
# nothing here may rely on superseding: the proxy vars are supplied
|
||||
# *only* at exec time and are deliberately absent from the run-time
|
||||
# env. See `launch._agent_env_entries`.
|
||||
# the way docker's compose spec does it. `container exec --env` wins
|
||||
# over the run-time value, so the token-bearing proxy URL set here
|
||||
# supersedes the token-less one baked in at launch.
|
||||
self._exec_env = dict(exec_env or {})
|
||||
self._closed = False
|
||||
|
||||
|
||||
@@ -37,8 +37,7 @@ from dataclasses import dataclass
|
||||
from ...egress import EgressPlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...orchestrator.client import OrchestratorClient
|
||||
from ...orchestrator.registration import registration_inputs
|
||||
from ..docker.gateway_provision import deprovision_git_gate, provision_git_gate
|
||||
from ..consolidated_util import provision_bottle, teardown_consolidated as _teardown_util
|
||||
from .gateway import GATEWAY_NETWORK
|
||||
from .gateway_provision import AppleGatewayTransport
|
||||
from .infra import MacosInfraService, OrchestratorStartError
|
||||
@@ -103,17 +102,10 @@ def register_agent(
|
||||
container — it is the attribution key the gateway resolves policy by.
|
||||
Raises on failure; the caller tears down."""
|
||||
client = OrchestratorClient(endpoint.orchestrator_url)
|
||||
inputs = registration_inputs(egress_plan)
|
||||
reg = client.register_bottle(
|
||||
source_ip, image_ref=image_ref, policy=inputs.policy,
|
||||
metadata=inputs.metadata, tokens=tokens,
|
||||
reg = provision_bottle(
|
||||
client, source_ip, egress_plan, git_gate_plan, AppleGatewayTransport(),
|
||||
image_ref=image_ref, tokens=tokens,
|
||||
)
|
||||
try:
|
||||
provision_git_gate(AppleGatewayTransport(), reg.bottle_id, git_gate_plan)
|
||||
except Exception:
|
||||
# Roll the registration back so a provisioning failure leaves no orphan.
|
||||
client.teardown_bottle(reg.bottle_id)
|
||||
raise
|
||||
return LaunchContext(
|
||||
bottle_id=reg.bottle_id,
|
||||
identity_token=reg.identity_token,
|
||||
@@ -128,8 +120,7 @@ def teardown_consolidated(bottle_id: str, *, orchestrator_url: str) -> None:
|
||||
"""Deregister the bottle and remove its git-gate state from the gateway.
|
||||
Both steps are idempotent so this is safe from a cleanup trap. Does NOT
|
||||
stop the gateway — it's a persistent per-host singleton."""
|
||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||
deprovision_git_gate(AppleGatewayTransport(), bottle_id)
|
||||
_teardown_util(bottle_id, AppleGatewayTransport(), orchestrator_url=orchestrator_url)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -8,11 +8,7 @@ from ...bottle_state import read_metadata
|
||||
from .. import ActiveAgent
|
||||
from .infra import INFRA_NAME
|
||||
|
||||
# The name every agent container carries: `bot-bottle-<slug>`. Exported
|
||||
# because callers that act on a running bottle (gateway-host rewrites,
|
||||
# registry reconciliation) have to map an enumerated slug back to a
|
||||
# container name.
|
||||
CONTAINER_NAME_PREFIX = "bot-bottle-"
|
||||
_PREFIX = "bot-bottle-"
|
||||
# The shared per-host infra container carries the same prefix as agent
|
||||
# containers but is infrastructure, not a bottle — one control plane + gateway
|
||||
# serves every agent, so listing it as an agent would invent one per host.
|
||||
@@ -30,9 +26,9 @@ def enumerate_active() -> list[ActiveAgent]:
|
||||
return []
|
||||
out: list[ActiveAgent] = []
|
||||
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
||||
if not name.startswith(CONTAINER_NAME_PREFIX) or name in _INFRA_NAMES:
|
||||
if not name.startswith(_PREFIX) or name in _INFRA_NAMES:
|
||||
continue
|
||||
slug = name[len(CONTAINER_NAME_PREFIX):]
|
||||
slug = name[len(_PREFIX):]
|
||||
metadata = read_metadata(slug)
|
||||
out.append(ActiveAgent(
|
||||
backend_name="macos-container",
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Stable gateway name for macOS agents, via each bottle's `/etc/hosts`.
|
||||
|
||||
The shared gateway's address is assigned by vmnet's DHCP and changes whenever
|
||||
the infra container is recreated — a source-hash bump, an image upgrade, a
|
||||
crash. Every agent-facing URL (egress proxy, git-http, supervise) embeds that
|
||||
address, and the proxy URL reaches the agent as **process environment** at
|
||||
`container exec` time. A running process's `environ` cannot be rewritten from
|
||||
outside, so a moved gateway used to strand every running bottle permanently:
|
||||
not degraded, unreachable, until the bottle was relaunched and its session
|
||||
thrown away.
|
||||
|
||||
So the agent never learns the address. It is given a stable *name*
|
||||
(`GATEWAY_HOSTNAME`) in every URL, resolved through its own `/etc/hosts`.
|
||||
Unlike `environ`, that is a file — it can be rewritten inside a container that
|
||||
is already running, so a gateway that comes back at a new address is picked up
|
||||
by live bottles instead of orphaning them.
|
||||
|
||||
Apple Container 1.0 offers no container-name DNS on a user network (the only
|
||||
nameserver an agent sees is vmnet's, which does not know container names) and
|
||||
`container run` has no `--add-host`, so the entry is written by exec after the
|
||||
container starts.
|
||||
|
||||
Writing it needs root, and the agent runs as `node`: the agent therefore
|
||||
cannot repoint its own gateway name, while the host (which drives `container
|
||||
exec --user root`) can. That asymmetry is deliberate — keep it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ...log import warn
|
||||
from . import util as container_mod
|
||||
from .enumerate import CONTAINER_NAME_PREFIX, enumerate_active
|
||||
|
||||
# The name every agent-facing gateway URL uses. Must not collide with a real
|
||||
# DNS name the agent might resolve; it is bottle-local by construction.
|
||||
GATEWAY_HOSTNAME = "bot-bottle-gateway"
|
||||
|
||||
# Marker so the rewrite is idempotent and only ever touches our own line —
|
||||
# the rest of /etc/hosts (localhost, the container's own name) is preserved.
|
||||
_MARKER = "# bot-bottle gateway"
|
||||
|
||||
|
||||
def _rewrite_script(gateway_ip: str) -> str:
|
||||
"""A shell one-liner that replaces our managed line in `/etc/hosts`.
|
||||
|
||||
Rewrites in place via a temp file + `cat` rather than `mv`, so the file
|
||||
keeps its original inode, ownership, and mode — a bind-mounted or
|
||||
pre-created `/etc/hosts` must not be replaced by a root-owned 0644 copy
|
||||
that the runtime then refuses to update.
|
||||
"""
|
||||
return (
|
||||
"set -e; "
|
||||
f"grep -v '{_MARKER}' /etc/hosts > /tmp/.bb-hosts || true; "
|
||||
f"printf '%s %s %s\\n' '{gateway_ip}' '{GATEWAY_HOSTNAME}' "
|
||||
f"'{_MARKER}' >> /tmp/.bb-hosts; "
|
||||
"cat /tmp/.bb-hosts > /etc/hosts; "
|
||||
"rm -f /tmp/.bb-hosts"
|
||||
)
|
||||
|
||||
|
||||
def set_gateway_host(container_name: str, gateway_ip: str) -> None:
|
||||
"""Point `GATEWAY_HOSTNAME` at `gateway_ip` inside one running container.
|
||||
|
||||
Must run before the agent is exec'd: the agent's proxy URL names the
|
||||
gateway, so the entry has to exist for its first connection. Idempotent —
|
||||
re-running with the same address is a no-op in effect.
|
||||
"""
|
||||
container_mod.exec_container_as_root(
|
||||
container_name, ["sh", "-c", _rewrite_script(gateway_ip)],
|
||||
)
|
||||
|
||||
|
||||
def refresh_gateway_host(gateway_ip: str) -> list[str]:
|
||||
"""Re-point every running bottle at the current gateway address.
|
||||
|
||||
Called once the shared gateway is known to be up, so a bottle stranded by
|
||||
an earlier gateway restart re-attaches instead of needing a relaunch.
|
||||
Returns the containers updated.
|
||||
|
||||
Best-effort per bottle: one container that refuses the write (already
|
||||
exiting, say) must not stop the others from being repaired, and must not
|
||||
fail the launch that triggered the sweep.
|
||||
"""
|
||||
updated: list[str] = []
|
||||
for agent in enumerate_active():
|
||||
name = f"{CONTAINER_NAME_PREFIX}{agent.slug}"
|
||||
try:
|
||||
set_gateway_host(name, gateway_ip)
|
||||
updated.append(name)
|
||||
# One bad bottle must not stop the sweep, so this is deliberately broad.
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
warn(f"could not re-point {name} at the gateway: {e}")
|
||||
return updated
|
||||
|
||||
|
||||
__all__ = ["GATEWAY_HOSTNAME", "set_gateway_host", "refresh_gateway_host"]
|
||||
@@ -59,11 +59,6 @@ from ..docker.egress import EGRESS_PORT
|
||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from . import util as container_mod
|
||||
from .bottle import MacosContainerBottle
|
||||
from .gateway_hosts import (
|
||||
GATEWAY_HOSTNAME,
|
||||
refresh_gateway_host,
|
||||
set_gateway_host,
|
||||
)
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
from .consolidated_launch import (
|
||||
GatewayEndpoint,
|
||||
@@ -105,11 +100,6 @@ def launch(
|
||||
# Step 1: the per-host singletons. Must precede the agent run — its
|
||||
# proxy env needs the gateway's address at `container run` time.
|
||||
endpoint = ensure_gateway()
|
||||
# The gateway's address may have changed since these bottles launched
|
||||
# (any infra recreate re-runs DHCP). They name the gateway rather than
|
||||
# address it, so re-pointing /etc/hosts re-attaches them in place
|
||||
# instead of leaving them stranded until relaunch.
|
||||
refresh_gateway_host(endpoint.gateway_ip)
|
||||
|
||||
# Step 2: mint this bottle's deploy keys, then point it at the SHARED
|
||||
# gateway's CA + git-http/supervise ports.
|
||||
@@ -127,9 +117,6 @@ def launch(
|
||||
# attribution key; `--cap-drop CAP_NET_RAW` at run is what makes it
|
||||
# unforgeable. Poll: `container run --detach` can return before vmnet's
|
||||
# DHCP has assigned the address.
|
||||
# Resolve the gateway name before anything execs: every agent-facing
|
||||
# URL uses it, so the entry must exist for the first connection.
|
||||
set_gateway_host(plan.container_name, endpoint.gateway_ip)
|
||||
source_ip = container_mod.wait_container_ipv4_on_network(
|
||||
plan.container_name, endpoint.network,
|
||||
)
|
||||
@@ -244,20 +231,13 @@ def _stamp_agent_urls(
|
||||
) -> MacosContainerBottlePlan:
|
||||
"""Point the agent's git-gate insteadOf rewrites + supervise MCP at the
|
||||
shared gateway's ports. Both bypass the egress proxy (NO_PROXY covers the
|
||||
gateway name).
|
||||
|
||||
Addressed by `GATEWAY_HOSTNAME`, never by IP: these URLs are baked into
|
||||
the agent's gitconfig and MCP config at provision time, so an address here
|
||||
would strand the bottle the moment the gateway moved. The name is resolved
|
||||
per connection through `/etc/hosts`, which stays rewritable while the
|
||||
bottle runs."""
|
||||
del endpoint # addressed by name; the address reaches the bottle via /etc/hosts
|
||||
gateway address)."""
|
||||
git_gate_url = (
|
||||
f"http://{GATEWAY_HOSTNAME}:{_GIT_HTTP_PORT}"
|
||||
f"http://{endpoint.gateway_ip}:{_GIT_HTTP_PORT}"
|
||||
if plan.git_gate_plan.upstreams else ""
|
||||
)
|
||||
supervise_url = (
|
||||
f"http://{GATEWAY_HOSTNAME}:{SUPERVISE_PORT}/"
|
||||
f"http://{endpoint.gateway_ip}:{SUPERVISE_PORT}/"
|
||||
if plan.supervise_plan is not None else ""
|
||||
)
|
||||
return dataclasses.replace(
|
||||
@@ -267,43 +247,30 @@ def _stamp_agent_urls(
|
||||
)
|
||||
|
||||
|
||||
def _proxy_url(identity_token: str = "") -> str:
|
||||
def _proxy_url(gateway_ip: str, identity_token: str = "") -> str:
|
||||
"""The agent's egress proxy URL. The identity token rides as proxy
|
||||
credentials — the gateway reads Proxy-Authorization, resolves the
|
||||
(source_ip, token) pair against the control plane, and strips it before
|
||||
upstream. Without a valid pair `/resolve` denies the request (#366).
|
||||
|
||||
Names the gateway rather than addressing it: this URL reaches the agent as
|
||||
process environment, which cannot be rewritten once the agent is running,
|
||||
so an address baked here is unfixable if the gateway moves."""
|
||||
upstream. Without a valid pair `/resolve` denies the request (#366)."""
|
||||
cred = f"bottle:{identity_token}@" if identity_token else ""
|
||||
return f"http://{cred}{GATEWAY_HOSTNAME}:{EGRESS_PORT}"
|
||||
return f"http://{cred}{gateway_ip}:{EGRESS_PORT}"
|
||||
|
||||
|
||||
def _no_proxy() -> str:
|
||||
def _no_proxy(gateway_ip: str) -> str:
|
||||
# git-http + supervise live on the gateway and must NOT go through the
|
||||
# egress proxy — the agent reaches them directly by name. Deliberately
|
||||
# address-free: NO_PROXY is baked into the run-time env and is therefore
|
||||
# just as unfixable as the proxy URL if the gateway moves.
|
||||
return f"localhost,127.0.0.1,{GATEWAY_HOSTNAME}"
|
||||
# egress proxy — the agent reaches them directly by its address.
|
||||
return f"localhost,127.0.0.1,{gateway_ip}"
|
||||
|
||||
|
||||
def _identity_proxy_env(
|
||||
endpoint: GatewayEndpoint, identity_token: str,
|
||||
) -> dict[str, str]:
|
||||
"""The token-bearing proxy env applied at `container exec` — the only way
|
||||
to get the token in, since it does not exist until after the container
|
||||
runs (registration keys on the DHCP-assigned address).
|
||||
|
||||
This is the *sole* source of `*_PROXY` for the agent. It deliberately does
|
||||
not rely on overriding a run-time value: `container exec --env` appends
|
||||
rather than replaces, so a run-time `HTTPS_PROXY` would survive alongside
|
||||
this one and first-wins runtimes would read the wrong entry. See
|
||||
`_agent_env_entries`."""
|
||||
"""The token-bearing proxy env applied at `container exec`. It supersedes
|
||||
the token-less run-time value (exec `--env` wins), which is the only way to
|
||||
get the token in: it does not exist until after the container runs."""
|
||||
if not identity_token:
|
||||
return {}
|
||||
del endpoint # the gateway is named, not addressed
|
||||
url = _proxy_url(identity_token)
|
||||
url = _proxy_url(endpoint.gateway_ip, identity_token)
|
||||
return {
|
||||
"HTTPS_PROXY": url, "HTTP_PROXY": url,
|
||||
"https_proxy": url, "http_proxy": url,
|
||||
@@ -350,23 +317,16 @@ def _agent_run_argv(
|
||||
def _agent_env_entries(
|
||||
plan: MacosContainerBottlePlan, endpoint: GatewayEndpoint,
|
||||
) -> tuple[str, ...]:
|
||||
# No `*_PROXY` here on purpose. The token-bearing URL is applied at
|
||||
# `container exec` (`_identity_proxy_env`), and Apple's `container exec
|
||||
# --env` **appends** to the run-time environment rather than replacing it:
|
||||
# setting a token-less value here leaves two `HTTPS_PROXY` entries in the
|
||||
# agent's `environ`, token-less first. Which one a runtime reads is then
|
||||
# pure luck — Node takes the last (and worked), Rust's `std::env::var`
|
||||
# takes the first, so Codex proxied without its identity token and
|
||||
# `/resolve` fail-closed on every request.
|
||||
#
|
||||
# A token-less proxy URL has no legitimate consumer anyway: the init
|
||||
# process is `sleep` and everything that egresses arrives via exec. Its
|
||||
# only value was a tidy 403 for unattributed callers, which is not worth
|
||||
# silently dropping attribution for. Without it a process that egresses
|
||||
# before the exec-time env still fails closed — the agent network is
|
||||
# host-only, so there is no route off it except the gateway.
|
||||
no_proxy = _no_proxy()
|
||||
# Token-less at run time — the token does not exist yet (see
|
||||
# `_identity_proxy_env`). Anything egressing before the exec-time override
|
||||
# is denied by `/resolve`, which is the safe direction.
|
||||
proxy_url = _proxy_url(endpoint.gateway_ip)
|
||||
no_proxy = _no_proxy(endpoint.gateway_ip)
|
||||
env = [
|
||||
f"HTTPS_PROXY={proxy_url}",
|
||||
f"HTTP_PROXY={proxy_url}",
|
||||
f"https_proxy={proxy_url}",
|
||||
f"http_proxy={proxy_url}",
|
||||
f"NO_PROXY={no_proxy}",
|
||||
f"no_proxy={no_proxy}",
|
||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
||||
|
||||
@@ -360,21 +360,6 @@ def exec_container(name: str, argv: list[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def exec_container_as_root(name: str, argv: list[str]) -> None:
|
||||
"""`exec_container`, but as uid 0 inside the container.
|
||||
|
||||
For host-driven maintenance the agent itself must not be able to perform —
|
||||
rewriting `/etc/hosts` to point the gateway name at an address. The agent
|
||||
runs as `node`, so it cannot repoint its own gateway; the host can.
|
||||
"""
|
||||
result = _run_container_op([_CONTAINER, "exec", "--user", "root", name, *argv])
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container exec (root) in {name} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
|
||||
+35
-15
@@ -61,6 +61,11 @@ class _DaemonSpec:
|
||||
_EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",)
|
||||
_READY_GATED_DAEMONS: tuple[str, ...] = ("git-gate", "git-http")
|
||||
|
||||
# Daemons that must be requested explicitly via BOT_BOTTLE_GATEWAY_DAEMONS
|
||||
# and are NOT started in the default (env-var-unset) case. The orchestrator
|
||||
# only runs in the combined infra container, never in a standalone gateway.
|
||||
_OPT_IN_DAEMONS: frozenset[str] = frozenset({"orchestrator"})
|
||||
|
||||
|
||||
def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
"""Egress sees the full bundle env. Everyone else gets a copy
|
||||
@@ -75,7 +80,14 @@ def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
# The orchestrator is listed first so it starts before the gateway daemons,
|
||||
# giving the control plane a head start to accept /resolve calls. The gateway
|
||||
# daemons tolerate early /resolve failures and retry per-request.
|
||||
_DAEMONS: tuple[_DaemonSpec, ...] = (
|
||||
_DaemonSpec("orchestrator", (
|
||||
"python3", "-m", "bot_bottle.orchestrator",
|
||||
"--host", "0.0.0.0", "--port", "8099", "--broker", "stub",
|
||||
)),
|
||||
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
|
||||
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
|
||||
_DaemonSpec("git-http", ("python3", "-m", "bot_bottle.git_http_backend")),
|
||||
@@ -103,18 +115,20 @@ def _selected_daemons(
|
||||
env: dict[str, str],
|
||||
all_daemons: Sequence[_DaemonSpec] | None = None,
|
||||
) -> tuple[_DaemonSpec, ...]:
|
||||
"""Filter the daemon set by the BOT_BOTTLE_GATEWAY_DAEMONS env
|
||||
var. Unknown names in the list are ignored — the caller is the
|
||||
source of truth for which daemons are wired.
|
||||
"""Filter the daemon set by the BOT_BOTTLE_GATEWAY_DAEMONS env var.
|
||||
|
||||
`all_daemons` defaults to `_DAEMONS` resolved at call time (not
|
||||
at definition time), so tests can monkey-patch the module-level
|
||||
`_DAEMONS` and have the new value take effect."""
|
||||
When the var is unset/empty, return all non-opt-in daemons (the
|
||||
standard gateway subset). Opt-in daemons (e.g. `orchestrator`) only
|
||||
run when explicitly named — they never start in a plain gateway
|
||||
container that doesn't set the env var. Unknown names are ignored.
|
||||
|
||||
`all_daemons` defaults to `_DAEMONS` resolved at call time (not at
|
||||
definition time), so tests can pass a custom list."""
|
||||
if all_daemons is None:
|
||||
all_daemons = _DAEMONS
|
||||
raw = env.get("BOT_BOTTLE_GATEWAY_DAEMONS", "").strip()
|
||||
if not raw:
|
||||
return tuple(all_daemons)
|
||||
return tuple(d for d in all_daemons if d.name not in _OPT_IN_DAEMONS)
|
||||
wanted = {n.strip() for n in raw.split(",") if n.strip()}
|
||||
return tuple(d for d in all_daemons if d.name in wanted)
|
||||
|
||||
@@ -136,7 +150,7 @@ def _pump(name: str, stream: IO[bytes]) -> None:
|
||||
|
||||
def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
|
||||
env = _env_for_daemon(spec.name, dict(os.environ))
|
||||
proc = subprocess.Popen(
|
||||
proc = subprocess.Popen( # pylint: disable=consider-using-with
|
||||
_argv_for_daemon(spec.name, spec.argv, env),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
@@ -183,6 +197,14 @@ class _Supervisor:
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
def _sigkill_all(self) -> None:
|
||||
for _, p in self.procs:
|
||||
if p.poll() is None:
|
||||
try:
|
||||
p.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
def request_restart(self, daemon_name: str) -> bool:
|
||||
"""Queue a daemon restart for the main loop to process.
|
||||
|
||||
@@ -235,12 +257,7 @@ class _Supervisor:
|
||||
f"grace ({_GRACE_SECONDS:.0f}s) elapsed; SIGKILL on "
|
||||
f"{', '.join(still_running)}"
|
||||
)
|
||||
for _, p in self.procs:
|
||||
if p.poll() is None:
|
||||
try:
|
||||
p.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self._sigkill_all()
|
||||
|
||||
done = all(p.poll() is not None for _, p in self.procs)
|
||||
if done:
|
||||
@@ -361,7 +378,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
# --signal HUP <bundle>` after writing routes.yaml. The kernel
|
||||
# delivers SIGHUP to PID 1 (this supervisor); forward it to
|
||||
# mitmdump so it reloads its addon.
|
||||
signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress")) # type: ignore
|
||||
signal.signal(
|
||||
signal.SIGHUP,
|
||||
lambda *_: sup.forward_signal(signal.SIGHUP, "egress"), # type: ignore[misc]
|
||||
)
|
||||
|
||||
while not sup.tick():
|
||||
time.sleep(_POLL_INTERVAL)
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"""Orchestrator + gateway lifecycle (PRD 0070, docker slice).
|
||||
|
||||
Runs the orchestrator control plane **as a container** on the shared gateway
|
||||
network, alongside the gateway container. This is the PRD's "virtualize the
|
||||
orchestrator": container↔container between the gateway and the orchestrator
|
||||
avoids the host firewall (which drops container→host traffic), and the gateway
|
||||
reaches the control plane by container name over docker DNS. The host CLI
|
||||
reaches it via a published loopback port.
|
||||
Runs both the orchestrator control plane and the gateway data plane inside
|
||||
a single `bot-bottle-infra` container on the shared gateway network —
|
||||
matching the structure already used by the macOS and Firecracker backends.
|
||||
`gateway_init` is PID 1 and supervises both; the infra container is an
|
||||
idempotent per-host singleton.
|
||||
|
||||
The orchestrator runs with the **register-only broker** — the *backend*
|
||||
launches agent containers (compose), so the orchestrator needs no docker
|
||||
socket. That keeps this control-plane container unprivileged; the host manages
|
||||
both containers. `ensure_running` is an idempotent singleton (fixed container
|
||||
names + the published port).
|
||||
The combined container replaces the prior two-container split
|
||||
(bot-bottle-orchestrator + bot-bottle-orch-gateway). The host CLI reaches
|
||||
the control plane via a published loopback port; gateway daemons reach it
|
||||
over 127.0.0.1 (same container).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,49 +24,68 @@ from pathlib import Path
|
||||
from .. import log
|
||||
from ..docker_cmd import run_docker
|
||||
from ..paths import CONTROL_PLANE_TOKEN_ENV, bot_bottle_root, host_control_plane_token
|
||||
from .gateway import GATEWAY_IMAGE, GATEWAY_NAME, GATEWAY_NETWORK, DockerGateway, GatewayError
|
||||
from ..supervise import DB_PATH_IN_CONTAINER
|
||||
from .gateway import (
|
||||
GATEWAY_CA_VOLUME,
|
||||
GATEWAY_NETWORK,
|
||||
GatewayError,
|
||||
MITMPROXY_HOME,
|
||||
_host_db_dir,
|
||||
)
|
||||
|
||||
DEFAULT_PORT = 8099
|
||||
ORCHESTRATOR_NAME = "bot-bottle-orchestrator"
|
||||
ORCHESTRATOR_LABEL = "bot-bottle-orchestrator=1"
|
||||
# The control-plane's own runtime image — lean (python + the stdlib-only
|
||||
# `bot_bottle` package, bind-mounted at run time), distinct from the heavy
|
||||
# gateway data-plane image it used to borrow (#384). Env override for
|
||||
# operators pinning a published build.
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
||||
|
||||
INFRA_NAME = "bot-bottle-infra"
|
||||
INFRA_LABEL = "bot-bottle-infra=1"
|
||||
# The combined infra image: gateway data plane + orchestrator content.
|
||||
# Built from Dockerfile.infra (FROM gateway + COPY --from orchestrator).
|
||||
INFRA_IMAGE = os.environ.get("BOT_BOTTLE_INFRA_IMAGE", "bot-bottle-infra:latest")
|
||||
INFRA_DOCKERFILE = "Dockerfile.infra"
|
||||
# Baked as a container label so `ensure_running` can detect whether the
|
||||
# running container is executing the current bind-mounted source.
|
||||
INFRA_SOURCE_HASH_LABEL = "bot-bottle-infra-source-hash"
|
||||
|
||||
# Orchestrator image: the single canonical definition of the control-plane
|
||||
# content (lean: python:3.12-slim + bot_bottle package, no mitmproxy/git).
|
||||
# Used as a build intermediate: `Dockerfile.infra` COPY --from this image.
|
||||
ORCHESTRATOR_IMAGE = os.environ.get(
|
||||
"BOT_BOTTLE_ORCHESTRATOR_IMAGE", "bot-bottle-orchestrator:latest"
|
||||
)
|
||||
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
|
||||
# Baked onto the container as a label so `ensure_running` can tell whether the
|
||||
# running process is executing the *current* bind-mounted source — see
|
||||
# `source_hash`.
|
||||
ORCHESTRATOR_SOURCE_HASH_LABEL = "bot-bottle-orchestrator-source-hash"
|
||||
|
||||
# The repo root is bind-mounted into the control-plane container so
|
||||
# `python -m bot_bottle.orchestrator` resolves the package (the orchestrator
|
||||
# is stdlib-only, so the lean orchestrator image's python is enough).
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_APP_DIR = "/app"
|
||||
# The gateway daemons + orchestrator the infra container runs.
|
||||
# BOT_BOTTLE_GATEWAY_DAEMONS listing `orchestrator` opts it in to
|
||||
# gateway_init's supervise tree (see gateway_init._OPT_IN_DAEMONS).
|
||||
_INFRA_DAEMONS = "egress,git-http,supervise,orchestrator"
|
||||
|
||||
# The bind-mount path for the live control-plane source inside the
|
||||
# container. Separate from /app so the gateway's baked scripts
|
||||
# (egress_addon.py, egress-entrypoint.sh) are not overlaid.
|
||||
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
||||
# Bot-bottle host-root bind-mount inside the container (DB + state).
|
||||
_ROOT_IN_CONTAINER = "/bot-bottle-root"
|
||||
|
||||
# The supervise daemon writes proposals into the host DB directory.
|
||||
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
|
||||
|
||||
_HEALTH_POLL_SECONDS = 0.25
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
||||
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class OrchestratorStartError(RuntimeError):
|
||||
"""The orchestrator container did not become healthy within the timeout."""
|
||||
"""The infra container did not become healthy within the timeout."""
|
||||
|
||||
|
||||
def source_hash(repo_root: Path) -> str:
|
||||
"""Content hash of the orchestrator's bind-mounted Python source (the
|
||||
`bot_bottle` package the control-plane process imports). This only
|
||||
changes when the code that would actually run inside the container
|
||||
changes — `ensure_running` recreates the container on a mismatch and
|
||||
otherwise leaves a healthy one alone, so a bottle launch that isn't
|
||||
accompanied by a code change doesn't restart the process and drop every
|
||||
*other* active bottle's in-memory egress tokens (`Orchestrator._tokens`
|
||||
in `service.py`, never persisted to disk by design)."""
|
||||
`bot_bottle` package the control-plane process imports). Changes only
|
||||
when the code that would actually run changes — `ensure_running`
|
||||
recreates the container on a mismatch so a code change takes effect,
|
||||
but leaves a healthy up-to-date container alone to preserve in-memory
|
||||
egress tokens."""
|
||||
h = hashlib.sha256()
|
||||
for path in sorted((repo_root / "bot_bottle").rglob("*.py")):
|
||||
h.update(str(path.relative_to(repo_root)).encode())
|
||||
@@ -77,57 +94,37 @@ def source_hash(repo_root: Path) -> str:
|
||||
|
||||
|
||||
class OrchestratorService:
|
||||
"""Manages the orchestrator control-plane container + the shared gateway.
|
||||
"""Manages the single per-host infra container (control plane + gateway).
|
||||
Callers only need `ensure_running()` + `url`.
|
||||
|
||||
`orchestrator_name` / `orchestrator_label` let backends run independent
|
||||
orchestrators on the same host without name collisions (e.g. the
|
||||
Firecracker backend uses `bot-bottle-fc-orchestrator` alongside the Docker
|
||||
backend's `bot-bottle-orchestrator`); `gateway_name` gives the paired
|
||||
gateway container the same treatment (e.g. isolated integration tests
|
||||
that can't share the production `GATEWAY_NAME` singleton). Subclass and
|
||||
override `_gateway()` for anything `_gateway_image`/`gateway_name` can't
|
||||
express (a genuinely backend-specific gateway variant)."""
|
||||
`infra_name` / `infra_label` let backends run independent infra containers
|
||||
on the same host without name collisions (e.g. isolated integration tests
|
||||
that can't share the production INFRA_NAME singleton)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
port: int = DEFAULT_PORT,
|
||||
network: str = GATEWAY_NETWORK,
|
||||
image: str = ORCHESTRATOR_IMAGE,
|
||||
gateway_image: str = GATEWAY_IMAGE,
|
||||
gateway_name: str = GATEWAY_NAME,
|
||||
image: str = INFRA_IMAGE,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
host_root: Path | None = None,
|
||||
orchestrator_name: str = ORCHESTRATOR_NAME,
|
||||
orchestrator_label: str = ORCHESTRATOR_LABEL,
|
||||
infra_name: str = INFRA_NAME,
|
||||
infra_label: str = INFRA_LABEL,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.network = network
|
||||
# Two distinct images (#384): `image` is the lean control-plane
|
||||
# runtime this container runs; `_gateway_image` is the heavy egress /
|
||||
# git-gate / supervise data plane the gateway container runs. They
|
||||
# were one conflated image before the split.
|
||||
self.image = image
|
||||
self._gateway_image = gateway_image
|
||||
self._gateway_name = gateway_name
|
||||
self._repo_root = repo_root
|
||||
self._host_root = host_root or bot_bottle_root()
|
||||
self._orchestrator_name = orchestrator_name
|
||||
self._orchestrator_label = orchestrator_label
|
||||
self._infra_name = infra_name
|
||||
self._infra_label = infra_label
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Host-side control-plane URL (published loopback port)."""
|
||||
return f"http://127.0.0.1:{self.port}"
|
||||
|
||||
@property
|
||||
def internal_url(self) -> str:
|
||||
"""Control-plane URL as the gateway container reaches it — by name over
|
||||
docker DNS on the shared network. This is the gateway's
|
||||
BOT_BOTTLE_ORCHESTRATOR_URL."""
|
||||
return f"http://{self._orchestrator_name}:{self.port}"
|
||||
|
||||
def is_healthy(self, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{self.url}/health", timeout=timeout) as resp:
|
||||
@@ -139,139 +136,125 @@ class OrchestratorService:
|
||||
proc = run_docker(["docker", "ps", "--filter", f"name=^/{name}$", "--format", "{{.Names}}"])
|
||||
return name in proc.stdout.split()
|
||||
|
||||
def _run_orchestrator_container(self, current_hash: str) -> None:
|
||||
"""Start the control-plane container (idempotent: clears a stale
|
||||
fixed-name container first). Register-only broker → no docker socket.
|
||||
Labels the container with `current_hash` so a later `ensure_running`
|
||||
can detect a real code change (see `source_hash`)."""
|
||||
run_docker(["docker", "rm", "--force", self._orchestrator_name])
|
||||
proc = run_docker([
|
||||
"docker", "run", "--detach",
|
||||
"--name", self._orchestrator_name,
|
||||
"--label", self._orchestrator_label,
|
||||
"--label", f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current_hash}",
|
||||
"--network", self.network,
|
||||
# Host CLI reaches the control plane here; bound to loopback so it
|
||||
# is not exposed on the host's external interfaces. NOTE: the
|
||||
# container is still on `self.network` (the shared gateway network),
|
||||
# so agents can reach it by container IP — which is exactly why the
|
||||
# control plane requires the secret below rather than trusting the
|
||||
# network boundary.
|
||||
"--publish", f"127.0.0.1:{self.port}:{self.port}",
|
||||
"--volume", f"{self._repo_root}:{_APP_DIR}:ro",
|
||||
"--workdir", _APP_DIR,
|
||||
# Persist the registry DB on the host (sole-owner: only the
|
||||
# orchestrator opens bot-bottle.db).
|
||||
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
|
||||
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
|
||||
# The control-plane secret it requires on every route but /health.
|
||||
# Bare `--env NAME` → docker inherits the value from the run env
|
||||
# below, so the secret never lands on argv / `docker inspect`.
|
||||
"--env", CONTROL_PLANE_TOKEN_ENV,
|
||||
"--entrypoint", "python3",
|
||||
self.image,
|
||||
"-m", "bot_bottle.orchestrator",
|
||||
"--host", "0.0.0.0", "--port", str(self.port), "--broker", "stub",
|
||||
], env={**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()})
|
||||
if proc.returncode != 0:
|
||||
raise OrchestratorStartError(
|
||||
f"orchestrator container failed to start: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def _gateway(self) -> DockerGateway:
|
||||
return DockerGateway(
|
||||
self._gateway_image,
|
||||
name=self._gateway_name,
|
||||
network=self.network,
|
||||
orchestrator_url=self.internal_url,
|
||||
)
|
||||
|
||||
def _ensure_orchestrator_image(self) -> None:
|
||||
"""Build the lean control-plane image from `Dockerfile.orchestrator`
|
||||
when it's missing (#384). Cheap — a `FROM python:*-slim` base with no
|
||||
deps to install, so the layer cache makes rebuilds a no-op. Unlike the
|
||||
gateway image this is build-if-missing, not build-every-time: the
|
||||
control plane bind-mounts its source, so a code change is caught by the
|
||||
source-hash recreate (below), not by an image rebuild."""
|
||||
if run_docker(["docker", "image", "inspect", self.image]).returncode == 0:
|
||||
return
|
||||
argv = ["docker", "build", "-t", self.image,
|
||||
"-f", str(self._repo_root / ORCHESTRATOR_DOCKERFILE),
|
||||
str(self._repo_root)]
|
||||
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||
argv.insert(2, "--no-cache")
|
||||
proc = run_docker(argv)
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(
|
||||
f"orchestrator image build failed: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def _orchestrator_source_current(self, current_hash: str) -> bool:
|
||||
"""True iff the running orchestrator container was created from the
|
||||
*current* bind-mounted source. Mirrors `DockerGateway`'s
|
||||
image-staleness check, but by content hash rather than image id since
|
||||
the orchestrator runs bind-mounted source, not a built image."""
|
||||
if not self._container_running(self._orchestrator_name):
|
||||
def _infra_source_current(self, current_hash: str) -> bool:
|
||||
"""True iff the running infra container was started from the current
|
||||
bind-mounted source. Mirrors the macOS backend's `_source_current`."""
|
||||
if not self._container_running(self._infra_name):
|
||||
return False
|
||||
proc = run_docker([
|
||||
"docker", "inspect", "--format",
|
||||
"{{ index .Config.Labels \"" + ORCHESTRATOR_SOURCE_HASH_LABEL + "\" }}",
|
||||
self._orchestrator_name,
|
||||
"{{ index .Config.Labels \"" + INFRA_SOURCE_HASH_LABEL + "\" }}",
|
||||
self._infra_name,
|
||||
])
|
||||
if proc.returncode != 0:
|
||||
return True # can't compare -> don't churn a working container
|
||||
return True # can't compare → don't churn a working container
|
||||
return proc.stdout.strip() == current_hash
|
||||
|
||||
def _ensure_network(self) -> None:
|
||||
if run_docker(["docker", "network", "inspect", self.network]).returncode == 0:
|
||||
return
|
||||
proc = run_docker(["docker", "network", "create", self.network])
|
||||
if proc.returncode != 0 and "already exists" not in proc.stderr:
|
||||
raise GatewayError(
|
||||
f"gateway network {self.network} failed to create: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def _build_images(self) -> None:
|
||||
"""Build the orchestrator image (build intermediate), then the infra
|
||||
image. Both are cache-aware: a no-op when nothing changed."""
|
||||
for tag, dockerfile in (
|
||||
(ORCHESTRATOR_IMAGE, ORCHESTRATOR_DOCKERFILE),
|
||||
(self.image, INFRA_DOCKERFILE),
|
||||
):
|
||||
argv = ["docker", "build", "-t", tag,
|
||||
"-f", str(self._repo_root / dockerfile),
|
||||
str(self._repo_root)]
|
||||
if os.environ.get("BOT_BOTTLE_NO_CACHE"):
|
||||
argv.insert(2, "--no-cache")
|
||||
proc = run_docker(argv)
|
||||
if proc.returncode != 0:
|
||||
raise GatewayError(f"{dockerfile} build failed: {proc.stderr.strip()}")
|
||||
|
||||
def _run_infra_container(self, current_hash: str) -> None:
|
||||
"""Start the combined infra container (idempotent: clears a stale
|
||||
fixed-name container first). Labels the container with `current_hash`
|
||||
so a later `ensure_running` can detect a real code change."""
|
||||
self._ensure_network()
|
||||
run_docker(["docker", "rm", "--force", self._infra_name])
|
||||
proc = run_docker([
|
||||
"docker", "run", "--detach",
|
||||
"--name", self._infra_name,
|
||||
"--label", self._infra_label,
|
||||
"--label", f"{INFRA_SOURCE_HASH_LABEL}={current_hash}",
|
||||
"--network", self.network,
|
||||
# Host CLI reaches the control plane here (loopback only).
|
||||
"--publish", f"127.0.0.1:{self.port}:{self.port}",
|
||||
# Persist the mitmproxy CA so it survives container recreation.
|
||||
"--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}",
|
||||
# Shared supervise DB (same file the operator reads over HTTP).
|
||||
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
|
||||
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||
# Live control-plane source, mounted to a path that does not
|
||||
# overlay the gateway's baked /app scripts.
|
||||
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
|
||||
# PYTHONPATH lets the orchestrator (and other Python daemons)
|
||||
# import the live source ahead of the installed package.
|
||||
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}",
|
||||
# Orchestrator registry DB on the host (sole writer: control plane).
|
||||
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}",
|
||||
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
|
||||
# Control-plane secret: required by the orchestrator (to enforce)
|
||||
# and by the gateway daemons (to present on /resolve calls).
|
||||
"--env", CONTROL_PLANE_TOKEN_ENV,
|
||||
# Gateway daemons reach the orchestrator over loopback.
|
||||
"--env", f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{self.port}",
|
||||
# Opt the orchestrator into gateway_init's supervise tree.
|
||||
"--env", f"BOT_BOTTLE_GATEWAY_DAEMONS={_INFRA_DAEMONS}",
|
||||
self.image,
|
||||
], env={**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()})
|
||||
if proc.returncode != 0:
|
||||
raise OrchestratorStartError(
|
||||
f"infra container failed to start: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def ensure_running(
|
||||
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
) -> str:
|
||||
"""Ensure the control plane + shared gateway are up; return the host
|
||||
control-plane URL. Idempotent — a healthy control plane running
|
||||
current code and a running gateway are left untouched. Raises
|
||||
`OrchestratorStartError` on timeout."""
|
||||
gateway = self._gateway()
|
||||
gateway.ensure_built() # rebuild the bundle image on a source change
|
||||
gateway.ensure_running() # creates the shared network + (re)starts gateway
|
||||
"""Ensure the infra container (control plane + gateway) is up; return
|
||||
the host control-plane URL. Idempotent — a healthy container on current
|
||||
source is left untouched. Raises `OrchestratorStartError` on timeout."""
|
||||
self._build_images()
|
||||
|
||||
# Recreate the orchestrator container only when its bind-mounted
|
||||
# source has actually changed since it started — its Python process
|
||||
# loaded that code at startup and won't reload, so a stale container
|
||||
# would keep running OLD control-plane code. Recreating on *every*
|
||||
# launch (the prior behaviour) would drop every other active
|
||||
# bottle's in-memory egress tokens each time a new bottle starts,
|
||||
# since the orchestrator process holds them only in memory (#381).
|
||||
current_hash = source_hash(self._repo_root)
|
||||
if self.is_healthy() and self._orchestrator_source_current(current_hash):
|
||||
if self.is_healthy() and self._infra_source_current(current_hash):
|
||||
return self.url
|
||||
|
||||
self._ensure_orchestrator_image()
|
||||
log.info(
|
||||
"starting orchestrator container",
|
||||
context={"name": self._orchestrator_name},
|
||||
)
|
||||
self._run_orchestrator_container(current_hash)
|
||||
log.info("starting infra container", context={"name": self._infra_name})
|
||||
self._run_infra_container(current_hash)
|
||||
|
||||
deadline = time.monotonic() + startup_timeout
|
||||
while time.monotonic() < deadline:
|
||||
if self.is_healthy():
|
||||
log.info("orchestrator healthy", context={"url": self.url})
|
||||
log.info("infra container healthy", context={"url": self.url})
|
||||
return self.url
|
||||
time.sleep(_HEALTH_POLL_SECONDS)
|
||||
raise OrchestratorStartError(
|
||||
f"orchestrator at {self.url} did not become healthy within {startup_timeout:g}s"
|
||||
f"infra container at {self.url} did not become healthy within {startup_timeout:g}s"
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Remove the orchestrator + gateway containers (idempotent)."""
|
||||
run_docker(["docker", "rm", "--force", self._orchestrator_name])
|
||||
self._gateway().stop()
|
||||
"""Remove the infra container (idempotent)."""
|
||||
run_docker(["docker", "rm", "--force", self._infra_name])
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OrchestratorService",
|
||||
"OrchestratorStartError",
|
||||
"ORCHESTRATOR_NAME",
|
||||
"INFRA_NAME",
|
||||
"INFRA_IMAGE",
|
||||
"INFRA_SOURCE_HASH_LABEL",
|
||||
"ORCHESTRATOR_IMAGE",
|
||||
"DEFAULT_PORT",
|
||||
"DEFAULT_STARTUP_TIMEOUT_SECONDS",
|
||||
"source_hash",
|
||||
]
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# PRD prd-new: CI artifact-based coverage and local Firecracker candidate flow
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-07-21
|
||||
- **Issue:** #446
|
||||
|
||||
## Summary
|
||||
|
||||
Restructure the CI test pipeline to run each test suite exactly once, upload
|
||||
small `.coverage.*` artifacts, and combine them in a lightweight aggregation
|
||||
job. Move the infra build onto the KVM runner so the ~194 MB rootfs never
|
||||
crosses the network for PRs. On main-branch pushes, publish the byte-identical
|
||||
rootfs that was tested.
|
||||
|
||||
## Motivation
|
||||
|
||||
The prior pipeline had two redundant costs:
|
||||
|
||||
1. **Duplicate artifact transfers.** `build-infra` (ubuntu-latest) built and
|
||||
uploaded the ~194 MB rootfs; `integration-firecracker` downloaded it; the
|
||||
`coverage` job downloaded it a second time. Combined download overhead: ~83
|
||||
seconds per run, plus the ~70-second upload.
|
||||
|
||||
2. **Duplicate test execution.** `integration-firecracker` ran the Firecracker
|
||||
integration suite; `coverage` ran the entire unit + integration suite again
|
||||
on the same KVM runner to collect coverage data. Every line of Firecracker
|
||||
code was tested twice per CI run.
|
||||
|
||||
## Goals
|
||||
|
||||
- Each test suite (unit, integration-docker, integration-firecracker) executes
|
||||
exactly once per workflow run.
|
||||
- PRs incur no large artifact transfers — the rootfs stays on the KVM runner.
|
||||
- Main-branch pushes publish a byte-for-byte identical rootfs to the one that
|
||||
passed the integration tests.
|
||||
- Concurrent workflow runs cannot cross-publish candidates (naturally enforced
|
||||
by Gitea Actions' per-run artifact scoping).
|
||||
- Failed or cancelled runs block publication (enforced by the `needs:` chain on
|
||||
`publish-infra`).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing test semantics or the coverage policy (ADR 0004).
|
||||
- Removing the KVM runner guard on `integration-firecracker` and `coverage`.
|
||||
- Changing how `publish_infra.py` builds or uploads the rootfs.
|
||||
|
||||
## Design
|
||||
|
||||
### Job graph
|
||||
|
||||
```
|
||||
unit ──────────────────────────────────┐
|
||||
integration-docker ────────────────────┤──► coverage ──► publish-infra (main only)
|
||||
integration-firecracker (KVM) ─────────┘
|
||||
```
|
||||
|
||||
### `unit`
|
||||
|
||||
Unchanged except: `coverage run` writes `--data-file=.coverage.unit`; the file
|
||||
is uploaded as the `coverage-unit` artifact.
|
||||
|
||||
### `integration-docker`
|
||||
|
||||
Adds a `coverage` install step. `coverage run` writes `--data-file=.coverage.docker`;
|
||||
the file is uploaded as `coverage-docker`.
|
||||
|
||||
### `integration-firecracker` (KVM runner)
|
||||
|
||||
Replaces the old `stage-firecracker-inputs` → `build-infra` → download chain:
|
||||
|
||||
1. Builds the infra candidate locally with
|
||||
`BOT_BOTTLE_FC_DROPBEAR=/var/cache/bot-bottle-fc/dropbear`.
|
||||
2. Boots the candidate and runs integration tests with coverage, writing
|
||||
`.coverage.firecracker`.
|
||||
3. Uploads the small `coverage-firecracker` artifact unconditionally.
|
||||
4. On main-branch pushes only, uploads the rootfs as `infra-candidate` and the
|
||||
dropbear as `firecracker-inputs` so `publish-infra` can verify and publish
|
||||
the byte-identical artifact.
|
||||
|
||||
### `coverage`
|
||||
|
||||
Moves from a KVM runner to `ubuntu-latest`. No tests are re-executed:
|
||||
|
||||
1. Downloads `coverage-unit`, `coverage-docker`, and `coverage-firecracker`.
|
||||
2. Runs `scripts/coverage.sh aggregate critical`, which calls
|
||||
`coverage combine` then `coverage report`.
|
||||
3. Runs the diff-coverage gate (`scripts/diff_coverage.py`).
|
||||
|
||||
Coverage files use `relative_files = True` (`.coveragerc`) so they combine
|
||||
cleanly across runners with different absolute workspace paths.
|
||||
|
||||
### `publish-infra`
|
||||
|
||||
Depends on all four predecessor jobs (unchanged gate). Downloads `infra-candidate`
|
||||
and `firecracker-inputs` that were uploaded by `integration-firecracker` on
|
||||
main — the same byte sequence that passed the integration tests.
|
||||
|
||||
### Eliminated jobs
|
||||
|
||||
- `stage-firecracker-inputs`: existed only to copy the dropbear to ubuntu-latest
|
||||
for `build-infra`. No longer needed.
|
||||
- `build-infra`: the infra candidate is now built on the KVM runner in
|
||||
`integration-firecracker`.
|
||||
|
||||
### Script changes
|
||||
|
||||
`scripts/coverage.sh` gains an `aggregate` mode (`coverage.sh aggregate [critical]`)
|
||||
that combines pre-existing `.coverage.*` files instead of re-running tests.
|
||||
The existing run mode (`coverage.sh [critical]`) is preserved for local dev.
|
||||
@@ -0,0 +1,155 @@
|
||||
# PRD prd-new: Consolidate infra backend for Docker
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-07-20
|
||||
- **Issue:** #431
|
||||
|
||||
## Summary
|
||||
|
||||
The Docker backend runs two containers — `bot-bottle-orch-gateway` (gateway
|
||||
data plane) and `bot-bottle-orchestrator` (control plane) — where the
|
||||
macOS and Firecracker backends already run a single combined infra
|
||||
unit. This PRD collapses Docker to the same model: one `bot-bottle-infra`
|
||||
container running both processes under the `gateway_init` supervise tree, a
|
||||
restructured `Dockerfile.infra` as the shared gateway+orchestrator base,
|
||||
and a handful of extracted shared utilities (CA cert polling, teardown
|
||||
sequence, launch skeleton) that are currently duplicated across all three
|
||||
`consolidated_launch.py` files.
|
||||
|
||||
## Goals / success criteria
|
||||
|
||||
- Docker backend starts exactly one infra container instead of two.
|
||||
- `Dockerfile.infra` is the shared base image (gateway + orchestrator, no
|
||||
buildah); the Firecracker image layers buildah on top of it.
|
||||
- The orchestrator process runs under the `gateway_init` supervise tree
|
||||
inside the combined container (one PID-1, one restart/health surface).
|
||||
- CA cert polling, the teardown sequence, and the shared launch skeleton
|
||||
(ensure-infra → register → provision → return context) live in a single
|
||||
shared module; all three backends import from it.
|
||||
- No functional change to macOS or Firecracker launch paths.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing per-bottle isolation — agents stay one-VM/container-each.
|
||||
- Consolidating transport implementations (`DockerGatewayTransport`,
|
||||
`AppleGatewayTransport`, `SshGatewayTransport`) — these are already the
|
||||
right abstraction boundary.
|
||||
- macOS DHCP-inversion of registration order — irreducible backend
|
||||
difference, stays as-is.
|
||||
- Any changes to the orchestrator RPC protocol or the attribution model.
|
||||
|
||||
## Design
|
||||
|
||||
### Dockerfile restructuring
|
||||
|
||||
**Current shape:**
|
||||
|
||||
- `Dockerfile.gateway` — data plane (mitmproxy, gitleaks, git, openssh,
|
||||
supervise daemons)
|
||||
- `Dockerfile.orchestrator` — control plane (python:3.12-slim + bot_bottle
|
||||
package; stdlib-only, no third-party deps)
|
||||
- `Dockerfile.infra` — Firecracker only: `FROM bot-bottle-gateway` +
|
||||
buildah + `COPY --from bot-bottle-orchestrator`
|
||||
|
||||
**New shape:**
|
||||
|
||||
- `Dockerfile.gateway` — unchanged
|
||||
- `Dockerfile.orchestrator` — unchanged (single definition of orchestrator
|
||||
content; both Docker infra and Firecracker infra `COPY --from` it)
|
||||
- `Dockerfile.infra` — **shared base**: `FROM bot-bottle-gateway` + `COPY
|
||||
--from bot-bottle-orchestrator` (no buildah — Docker infra image)
|
||||
- `Dockerfile.infra.fc` — Firecracker only: `FROM bot-bottle-infra` +
|
||||
buildah/crun/netavark/aardvark-dns (layered on the shared base, same net
|
||||
result as today)
|
||||
|
||||
The comment in `Dockerfile.infra` that says "the docker backend keeps
|
||||
orchestrator + gateway as separate images; this combined image exists only
|
||||
for the Firecracker single-VM cut" is removed.
|
||||
|
||||
### Orchestrator in the supervise tree
|
||||
|
||||
`gateway_init` already supervises the data-plane daemons (egress, git-http,
|
||||
supervise-MCP). The orchestrator control plane is added as another supervised
|
||||
process: `python3 -m bot_bottle.orchestrator --host 0.0.0.0 --port <port>
|
||||
--broker stub`.
|
||||
|
||||
The orchestrator source is bind-mounted (`/app` → repo root, as today) so
|
||||
dev live-reload still works. `source_hash`-based container recreation in
|
||||
`OrchestratorService.ensure_running` continues to apply — a code change
|
||||
recreates the combined infra container, which bounces both gateway and
|
||||
orchestrator. This is acceptable: the docker backend is a dev/legacy target
|
||||
where in-flight egress connections across a code deploy are not a hard
|
||||
requirement.
|
||||
|
||||
### `OrchestratorService` changes
|
||||
|
||||
`OrchestratorService` currently starts two containers in sequence: gateway
|
||||
first (`DockerGateway.ensure_running`), then orchestrator. After this PRD:
|
||||
|
||||
- Single `docker run` of `bot-bottle-infra:latest`
|
||||
- Container name: `bot-bottle-infra` (replaces `bot-bottle-orch-gateway` +
|
||||
`bot-bottle-orchestrator`)
|
||||
- Published ports: `127.0.0.1:{port}:{port}` for the control plane (same as
|
||||
today)
|
||||
- Bind mounts: repo root + host root (same as today)
|
||||
- `DockerGateway` becomes an implementation detail of `OrchestratorService`
|
||||
rather than a separately started container; the gateway image name
|
||||
(`GATEWAY_IMAGE`) is no longer referenced at runtime, only at build time
|
||||
for the `Dockerfile.infra` base
|
||||
|
||||
The `_gateway()` / `ensure_running` two-step in `OrchestratorService` is
|
||||
replaced by a single `_run_infra_container()`.
|
||||
|
||||
### Shared backend utilities
|
||||
|
||||
Three items are duplicated across
|
||||
`backend/docker/consolidated_launch.py`,
|
||||
`backend/macos_container/consolidated_launch.py`, and
|
||||
`backend/firecracker/consolidated_launch.py`:
|
||||
|
||||
1. **CA cert polling loop** — `deadline = time.monotonic() + timeout; while
|
||||
...: try fetch CA; sleep` — extracted to
|
||||
`backend/consolidated_util.py:poll_ca_cert(transport, *, timeout)`.
|
||||
|
||||
2. **Teardown sequence** — `OrchestratorClient(url).teardown_bottle(id)` +
|
||||
`deprovision_git_gate(transport, id)` — extracted to
|
||||
`backend/consolidated_util.py:teardown_consolidated(url, transport,
|
||||
bottle_id)`.
|
||||
|
||||
3. **Launch skeleton** — all three follow: ensure-infra → allocate/register
|
||||
→ provision git-gate → fetch CA cert → return launch context. The macOS
|
||||
inversion (agent starts before registration, source IP from DHCP) is the
|
||||
only deviation. Extract a shared `_provision_bottle(transport, bottle_id,
|
||||
plan, orchestrator_url)` helper covering the register → provision →
|
||||
return-token steps; the backends keep their own `launch_consolidated`
|
||||
wrappers for the before/after (infra-ensure + agent-start + IP
|
||||
allocation), calling the shared helper.
|
||||
|
||||
The new `backend/consolidated_util.py` module holds only backend-neutral,
|
||||
transport-agnostic logic. All three backends import from it.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
1. **(this PR)** Dockerfile restructuring: rename current `Dockerfile.infra`
|
||||
content to `Dockerfile.infra.fc`; write new `Dockerfile.infra` as
|
||||
gateway+orchestrator base. Update Firecracker image-build references from
|
||||
`Dockerfile.infra` → `Dockerfile.infra.fc`.
|
||||
|
||||
2. Add orchestrator process to `gateway_init` supervise tree.
|
||||
|
||||
3. Collapse `OrchestratorService` to a single-container start; rename
|
||||
container from `bot-bottle-orch-gateway`/`bot-bottle-orchestrator` →
|
||||
`bot-bottle-infra`; update image name constant.
|
||||
|
||||
4. Extract `backend/consolidated_util.py` with `poll_ca_cert`,
|
||||
`teardown_consolidated`, and `_provision_bottle`; update all three
|
||||
`consolidated_launch.py` files to import from it.
|
||||
|
||||
5. Update tests that reference the old container names or two-container
|
||||
startup sequence.
|
||||
|
||||
## Open questions
|
||||
|
||||
None — the supervise-tree approach and shared Dockerfile layering were
|
||||
confirmed in issue #431.
|
||||
+8
-28
@@ -1,19 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Combined unit + integration coverage (see docs/decisions/0004-coverage-policy.md).
|
||||
#
|
||||
# Two modes:
|
||||
# Runs the unit suite, then appends the integration suite (which skips
|
||||
# cleanly when Docker / the backend CLIs are unavailable), and prints one
|
||||
# combined report. The integration suite is what scores the subprocess /
|
||||
# backend orchestration modules, so the number here is the policy's
|
||||
# yardstick — not the unit-only badge.
|
||||
#
|
||||
# scripts/coverage.sh [critical]
|
||||
# Run mode (default, for local dev): executes the unit suite then the
|
||||
# integration suite under coverage and prints a combined report.
|
||||
#
|
||||
# scripts/coverage.sh aggregate [critical]
|
||||
# Aggregate mode (used by CI): combines pre-existing .coverage.* files
|
||||
# produced by individual test jobs and prints a combined report. No tests
|
||||
# are re-executed; no KVM or Docker dependency.
|
||||
#
|
||||
# Pass "critical" as the last argument in either mode to also report just the
|
||||
# critical modules (ADR 0004 target: 90%).
|
||||
# Usage:
|
||||
# scripts/coverage.sh # combined report
|
||||
# scripts/coverage.sh critical # also report just the critical modules
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
@@ -25,22 +21,6 @@ PY="${PYTHON:-python3}"
|
||||
# README "core coverage" badge can't drift; comma-join it for --include.
|
||||
CRITICAL=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
|
||||
|
||||
if [ "${1:-}" = "aggregate" ]; then
|
||||
# Aggregate mode: combine .coverage.* artifacts already in the workspace.
|
||||
echo "== combining coverage artifacts ==" >&2
|
||||
"$PY" -m coverage combine
|
||||
|
||||
echo "== combined report ==" >&2
|
||||
"$PY" -m coverage report -m
|
||||
|
||||
if [ "${2:-}" = "critical" ]; then
|
||||
echo "== critical modules (ADR 0004 target: 90%) ==" >&2
|
||||
"$PY" -m coverage report --include="$CRITICAL"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run mode (default): execute both suites under coverage in this process.
|
||||
rm -f .coverage
|
||||
|
||||
echo "== unit ==" >&2
|
||||
|
||||
@@ -34,6 +34,7 @@ from tests._docker import skip_unless_docker
|
||||
# image instead of leaking a new dangling tag on every invocation.
|
||||
_TEST_ORCHESTRATOR_IMAGE = "bot-bottle-orchestrator:itest"
|
||||
_TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest"
|
||||
_TEST_INFRA_IMAGE = "bot-bottle-infra:itest"
|
||||
|
||||
|
||||
@skip_unless_docker()
|
||||
@@ -69,20 +70,17 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
|
||||
os.environ["BOT_BOTTLE_ROOT"] = cls._tmp.name
|
||||
cls.addClassCleanup(_restore_root)
|
||||
|
||||
orchestrator_name = f"bot-bottle-orch-itest-{suffix}"
|
||||
gateway_name = f"bot-bottle-gw-itest-{suffix}"
|
||||
infra_name = f"bot-bottle-infra-itest-{suffix}"
|
||||
network = f"bot-bottle-net-itest-{suffix}"
|
||||
host_root = Path(cls._tmp.name)
|
||||
cls.addClassCleanup(
|
||||
cls._teardown_docker, orchestrator_name, gateway_name, network, host_root
|
||||
cls._teardown_docker, infra_name, network, host_root
|
||||
)
|
||||
|
||||
cls.svc = OrchestratorService(
|
||||
orchestrator_name=orchestrator_name,
|
||||
gateway_name=gateway_name,
|
||||
infra_name=infra_name,
|
||||
network=network,
|
||||
image=_TEST_ORCHESTRATOR_IMAGE,
|
||||
gateway_image=_TEST_GATEWAY_IMAGE,
|
||||
image=_TEST_INFRA_IMAGE,
|
||||
port=20000 + secrets.randbelow(10000),
|
||||
host_root=host_root,
|
||||
)
|
||||
@@ -91,23 +89,23 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def _teardown_docker(
|
||||
orchestrator_name: str, gateway_name: str, network: str, host_root: Path
|
||||
infra_name: str, network: str, host_root: Path
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
["docker", "rm", "--force", orchestrator_name, gateway_name],
|
||||
["docker", "rm", "--force", infra_name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "network", "rm", network],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
# The orchestrator container (no USER directive) wrote the registry
|
||||
# The infra container (no USER directive) wrote the registry
|
||||
# DB as root into the throwaway host_root; chown it back so the
|
||||
# (non-root) tempdir cleanup can remove it. Same workaround
|
||||
# test_multitenant_isolation.py uses for the identical bind mount.
|
||||
subprocess.run(
|
||||
["docker", "run", "--rm", "-v", f"{host_root}:/r",
|
||||
"--entrypoint", "chown", _TEST_GATEWAY_IMAGE, "-R",
|
||||
"--entrypoint", "chown", _TEST_INFRA_IMAGE, "-R",
|
||||
f"{os.getuid()}:{os.getgid()}", "/r"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.orchestrator.client import RegisteredBottle
|
||||
|
||||
_MOD = "bot_bottle.backend.docker.consolidated_launch"
|
||||
_UTIL = "bot_bottle.backend.consolidated_util"
|
||||
|
||||
|
||||
def _egress_plan() -> EgressPlan:
|
||||
@@ -49,7 +50,7 @@ class TestLaunchConsolidated(unittest.TestCase):
|
||||
patch(f"{_MOD}._container_ip", return_value="172.18.0.2"), \
|
||||
patch(f"{_MOD}._network_container_ips", return_value=list(on_network)), \
|
||||
patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_MOD}.provision_git_gate", provision or Mock()):
|
||||
patch(f"{_UTIL}.provision_git_gate", provision or Mock()):
|
||||
return launch_consolidated(_egress_plan(), _git_plan(), service=service)
|
||||
|
||||
def test_allocates_ip_registers_and_provisions(self) -> None:
|
||||
@@ -84,8 +85,8 @@ class TestLaunchConsolidated(unittest.TestCase):
|
||||
class TestTeardownConsolidated(unittest.TestCase):
|
||||
def test_deregisters_and_deprovisions(self) -> None:
|
||||
client = Mock()
|
||||
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_MOD}.deprovision_git_gate") as deprov:
|
||||
with patch(f"{_UTIL}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_UTIL}.deprovision_git_gate") as deprov:
|
||||
teardown_consolidated("b1", orchestrator_url="http://orch:8080")
|
||||
client.teardown_bottle.assert_called_once_with("b1")
|
||||
deprov.assert_called_once()
|
||||
|
||||
@@ -96,7 +96,7 @@ class TestVersionInputs(unittest.TestCase):
|
||||
(pkg / "app.py").write_text("print('hi')\n")
|
||||
(pkg / "egress_entrypoint.sh").write_text("#!/bin/sh\nexec mitmdump\n")
|
||||
(pkg / "netpool.defaults.env").write_text("FOO=1\n")
|
||||
for name in ("Dockerfile.orchestrator", "Dockerfile.gateway", "Dockerfile.infra"):
|
||||
for name in ("Dockerfile.orchestrator", "Dockerfile.gateway", "Dockerfile.infra", "Dockerfile.infra.fc"):
|
||||
(root / name).write_text(f"FROM scratch # {name}\n")
|
||||
(root / "pyproject.toml").write_text("[project]\nname = 'bot-bottle'\n")
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.orchestrator.client import RegisteredBottle
|
||||
|
||||
_MOD = "bot_bottle.backend.macos_container.consolidated_launch"
|
||||
_UTIL = "bot_bottle.backend.consolidated_util"
|
||||
|
||||
|
||||
def _egress_plan() -> EgressPlan:
|
||||
@@ -87,7 +88,7 @@ class TestRegisterAgent(unittest.TestCase):
|
||||
*, source_ip: str = "192.168.128.9",
|
||||
):
|
||||
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_MOD}.provision_git_gate", provision or Mock()):
|
||||
patch(f"{_UTIL}.provision_git_gate", provision or Mock()):
|
||||
return register_agent(
|
||||
_egress_plan(), _git_plan(),
|
||||
source_ip=source_ip, endpoint=_endpoint(), image_ref="img:1",
|
||||
@@ -125,8 +126,8 @@ class TestTeardown(unittest.TestCase):
|
||||
def test_deregisters_and_deprovisions(self) -> None:
|
||||
client = Mock()
|
||||
deprovision = Mock()
|
||||
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_MOD}.deprovision_git_gate", deprovision):
|
||||
with patch(f"{_UTIL}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_UTIL}.deprovision_git_gate", deprovision):
|
||||
teardown_consolidated("b1", orchestrator_url="http://o:8099")
|
||||
client.teardown_bottle.assert_called_once_with("b1")
|
||||
self.assertEqual("b1", deprovision.call_args.args[1])
|
||||
|
||||
@@ -17,10 +17,10 @@ from unittest.mock import patch
|
||||
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle
|
||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||
from bot_bottle.backend.macos_container.consolidated_launch import GatewayEndpoint
|
||||
from bot_bottle.backend.macos_container.gateway_hosts import GATEWAY_HOSTNAME
|
||||
from bot_bottle.backend.macos_container.launch import (
|
||||
_agent_run_argv,
|
||||
_identity_proxy_env,
|
||||
_proxy_url,
|
||||
)
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
@@ -104,38 +104,19 @@ class TestAgentRunArgv(unittest.TestCase):
|
||||
read back after start."""
|
||||
self.assertNotIn("--ip", self.argv)
|
||||
|
||||
def test_run_time_env_sets_no_proxy_vars_at_all(self) -> None:
|
||||
"""Regression: the proxy vars must exist *only* in the exec-time env.
|
||||
|
||||
`container exec --env` appends rather than replaces, so a token-less
|
||||
`HTTPS_PROXY` here would survive next to the token-bearing one and
|
||||
leave two entries in the agent's `environ`. Resolution is then
|
||||
runtime-specific — Node reads the last, Rust's `std::env::var` reads
|
||||
the first — so Codex proxied without its identity token and every
|
||||
request fail-closed at `/resolve`.
|
||||
"""
|
||||
for entry in self.argv:
|
||||
self.assertFalse(
|
||||
entry.upper().startswith(("HTTP_PROXY=", "HTTPS_PROXY=")),
|
||||
f"run-time env must not set a proxy var, got {entry!r}",
|
||||
)
|
||||
|
||||
def test_run_time_env_carries_no_identity_token(self) -> None:
|
||||
"""The token is minted by registration, which happens after this run,
|
||||
so it cannot be here under any name."""
|
||||
self.assertNotIn("bottle:", " ".join(self.argv))
|
||||
def test_run_time_proxy_carries_no_identity_token(self) -> None:
|
||||
"""The token is minted by registration, which happens after this run —
|
||||
so it cannot be here. `/resolve` denies the token-less pair (#366),
|
||||
which is the safe direction; the real value arrives at exec time."""
|
||||
joined = " ".join(self.argv)
|
||||
self.assertIn(f"HTTP_PROXY={_proxy_url('192.168.128.3')}", joined)
|
||||
self.assertNotIn("bottle:", joined)
|
||||
|
||||
def test_gateway_bypasses_the_proxy(self) -> None:
|
||||
"""git-http + supervise live on the gateway and must be reached
|
||||
directly, not through its own egress proxy."""
|
||||
entry = next(a for a in self.argv if a.startswith("NO_PROXY="))
|
||||
self.assertIn(GATEWAY_HOSTNAME, entry)
|
||||
|
||||
def test_no_proxy_names_the_gateway_and_never_addresses_it(self) -> None:
|
||||
"""NO_PROXY is baked into the run-time env, so an address here is as
|
||||
unfixable as the proxy URL if the gateway moves."""
|
||||
entry = next(a for a in self.argv if a.startswith("NO_PROXY="))
|
||||
self.assertNotIn("192.168.128.3", entry)
|
||||
self.assertIn("192.168.128.3", entry)
|
||||
|
||||
def test_forwarded_secrets_stay_off_argv(self) -> None:
|
||||
"""Bare name → inherited from the run process env, so the value never
|
||||
@@ -153,7 +134,7 @@ class TestIdentityTokenDelivery(unittest.TestCase):
|
||||
def test_exec_env_carries_the_token_as_proxy_credentials(self) -> None:
|
||||
env = _identity_proxy_env(_endpoint(), "s3cret")
|
||||
self.assertEqual(
|
||||
f"http://bottle:s3cret@{GATEWAY_HOSTNAME}:9099", env["HTTP_PROXY"],
|
||||
"http://bottle:s3cret@192.168.128.3:9099", env["HTTP_PROXY"],
|
||||
)
|
||||
self.assertEqual(env["HTTP_PROXY"], env["https_proxy"])
|
||||
|
||||
@@ -221,7 +202,7 @@ class TestPlanIdentityToken(unittest.TestCase):
|
||||
self.assertIn("HTTP_PROXY", argv)
|
||||
self.assertNotIn("s3cret", " ".join(argv))
|
||||
self.assertEqual(
|
||||
f"http://bottle:s3cret@{GATEWAY_HOSTNAME}:9099", kwargs["env"]["HTTP_PROXY"],
|
||||
"http://bottle:s3cret@192.168.128.3:9099", kwargs["env"]["HTTP_PROXY"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -272,30 +272,6 @@ resolver #2
|
||||
),
|
||||
)
|
||||
|
||||
def test_exec_container_as_root_selects_root_user(self):
|
||||
completed = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="", stderr="",
|
||||
)
|
||||
with patch.object(util, "_run_container_op", return_value=completed) as run:
|
||||
util.exec_container_as_root("bot-bottle-demo", ["true"])
|
||||
|
||||
run.assert_called_once_with([
|
||||
"container", "exec", "--user", "root", "bot-bottle-demo", "true",
|
||||
])
|
||||
|
||||
def test_exec_container_as_root_reports_failure(self):
|
||||
failed = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr="permission denied\n",
|
||||
)
|
||||
with patch.object(util, "_run_container_op", return_value=failed), \
|
||||
patch.object(util, "die", side_effect=SystemExit("die")) as die:
|
||||
with self.assertRaises(SystemExit):
|
||||
util.exec_container_as_root("bot-bottle-demo", ["true"])
|
||||
|
||||
die.assert_called_once_with(
|
||||
"container exec (root) in bot-bottle-demo failed: permission denied",
|
||||
)
|
||||
|
||||
|
||||
def _completed(stdout: str, returncode: int = 0):
|
||||
return util.subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="")
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Unit: stable gateway name via each bottle's /etc/hosts (issue #443).
|
||||
|
||||
The gateway's address moves whenever the infra container is recreated. Agents
|
||||
name it instead of addressing it, and the name resolves through `/etc/hosts` —
|
||||
a file, so it stays rewritable while the bottle runs, unlike the `environ` the
|
||||
proxy URL is delivered in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.macos_container.gateway_hosts import (
|
||||
GATEWAY_HOSTNAME,
|
||||
refresh_gateway_host,
|
||||
set_gateway_host,
|
||||
)
|
||||
|
||||
_MOD = "bot_bottle.backend.macos_container.gateway_hosts"
|
||||
|
||||
|
||||
class TestSetGatewayHost(unittest.TestCase):
|
||||
def _script(self, exec_root: object) -> str:
|
||||
argv = exec_root.call_args.args[1] # type: ignore[attr-defined]
|
||||
self.assertEqual(["sh", "-c"], argv[:2])
|
||||
return argv[2]
|
||||
|
||||
def test_writes_the_address_against_the_stable_name(self) -> None:
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "192.168.128.19")
|
||||
self.assertEqual("bot-bottle-demo", ex.call_args.args[0])
|
||||
script = self._script(ex)
|
||||
self.assertIn("192.168.128.19", script)
|
||||
self.assertIn(GATEWAY_HOSTNAME, script)
|
||||
|
||||
def test_runs_as_root_so_the_agent_cannot_repoint_itself(self) -> None:
|
||||
"""The agent runs as `node`. If it could rewrite /etc/hosts it could
|
||||
aim its own gateway name elsewhere, so the write must go through the
|
||||
root-only helper."""
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "10.0.0.1")
|
||||
ex.assert_called_once()
|
||||
|
||||
def test_is_idempotent_by_removing_its_own_line_first(self) -> None:
|
||||
"""Re-pointing must replace the managed entry, not append a second one
|
||||
— two entries for the same name would resolve by luck of ordering."""
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "10.0.0.1")
|
||||
self.assertIn("grep -v", self._script(ex))
|
||||
|
||||
def test_preserves_the_rest_of_the_hosts_file(self) -> None:
|
||||
"""localhost and the container's own name must survive the rewrite."""
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "10.0.0.1")
|
||||
script = self._script(ex)
|
||||
# Filter-and-append, never a truncating write of just our line.
|
||||
self.assertIn("/etc/hosts >", script)
|
||||
self.assertIn(">> /tmp/.bb-hosts", script)
|
||||
|
||||
def test_keeps_the_original_inode(self) -> None:
|
||||
"""`cat >` rather than `mv`: a pre-created /etc/hosts must keep its
|
||||
ownership and mode, not be replaced by a root-owned copy."""
|
||||
script = None
|
||||
with patch(f"{_MOD}.container_mod.exec_container_as_root") as ex:
|
||||
set_gateway_host("bot-bottle-demo", "10.0.0.1")
|
||||
script = self._script(ex)
|
||||
self.assertIn("cat /tmp/.bb-hosts > /etc/hosts", script)
|
||||
self.assertNotIn("mv ", script)
|
||||
|
||||
|
||||
class TestRefreshGatewayHost(unittest.TestCase):
|
||||
"""The re-attach sweep: bottles stranded by an earlier gateway restart get
|
||||
re-pointed in place instead of needing a relaunch."""
|
||||
|
||||
def _agents(self, *slugs: str) -> list[SimpleNamespace]:
|
||||
return [SimpleNamespace(slug=s) for s in slugs]
|
||||
|
||||
def test_repoints_every_running_bottle(self) -> None:
|
||||
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
|
||||
patch(f"{_MOD}.set_gateway_host") as setter:
|
||||
updated = refresh_gateway_host("192.168.128.19")
|
||||
self.assertEqual(["bot-bottle-a", "bot-bottle-b"], updated)
|
||||
self.assertEqual(
|
||||
[("bot-bottle-a", "192.168.128.19"), ("bot-bottle-b", "192.168.128.19")],
|
||||
[c.args for c in setter.call_args_list],
|
||||
)
|
||||
|
||||
def test_one_failing_bottle_does_not_stop_the_sweep(self) -> None:
|
||||
"""A container that is already exiting must not block the repair of
|
||||
its neighbours, nor fail the launch that triggered the sweep."""
|
||||
def _flaky(name: str, _ip: str) -> None:
|
||||
if name == "bot-bottle-a":
|
||||
raise RuntimeError("container is exiting")
|
||||
|
||||
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
|
||||
patch(f"{_MOD}.set_gateway_host", side_effect=_flaky), \
|
||||
patch(f"{_MOD}.warn") as warn:
|
||||
updated = refresh_gateway_host("10.0.0.1")
|
||||
self.assertEqual(["bot-bottle-b"], updated)
|
||||
warn.assert_called_once()
|
||||
|
||||
def test_no_running_bottles_is_a_clean_no_op(self) -> None:
|
||||
with patch(f"{_MOD}.enumerate_active", return_value=[]), \
|
||||
patch(f"{_MOD}.set_gateway_host") as setter:
|
||||
self.assertEqual([], refresh_gateway_host("10.0.0.1"))
|
||||
setter.assert_not_called()
|
||||
|
||||
|
||||
|
||||
class TestLaunchWiring(unittest.TestCase):
|
||||
"""Ordering matters: the name must resolve before anything execs, and the
|
||||
stranded-bottle sweep must run once the gateway is known to be up."""
|
||||
|
||||
def test_launch_sets_the_host_entry_before_reading_the_source_ip(self) -> None:
|
||||
"""The agent's every URL names the gateway, so the entry has to exist
|
||||
before the first connection — which means before the agent execs."""
|
||||
import inspect
|
||||
|
||||
from bot_bottle.backend.macos_container import launch
|
||||
|
||||
src = inspect.getsource(launch)
|
||||
set_at = src.index("set_gateway_host(plan.container_name")
|
||||
exec_at = src.index("wait_container_ipv4_on_network")
|
||||
self.assertLess(set_at, exec_at)
|
||||
|
||||
def test_launch_refreshes_stranded_bottles_after_ensure_gateway(self) -> None:
|
||||
import inspect
|
||||
|
||||
from bot_bottle.backend.macos_container import launch
|
||||
|
||||
src = inspect.getsource(launch)
|
||||
ensure_at = src.index("endpoint = ensure_gateway()")
|
||||
refresh_at = src.index("refresh_gateway_host(endpoint.gateway_ip)")
|
||||
self.assertLess(ensure_at, refresh_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit: orchestrator+gateway container lifecycle — idempotent singleton (PRD 0070)."""
|
||||
"""Unit: infra container lifecycle — idempotent singleton (PRD 0070)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,10 +8,10 @@ import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from bot_bottle.orchestrator.gateway import GatewayError
|
||||
from bot_bottle.orchestrator.lifecycle import (
|
||||
ORCHESTRATOR_IMAGE,
|
||||
ORCHESTRATOR_NAME,
|
||||
ORCHESTRATOR_SOURCE_HASH_LABEL,
|
||||
INFRA_NAME,
|
||||
INFRA_SOURCE_HASH_LABEL,
|
||||
OrchestratorService,
|
||||
OrchestratorStartError,
|
||||
source_hash,
|
||||
@@ -20,7 +20,6 @@ from tests.unit import use_bottle_root
|
||||
|
||||
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
|
||||
_RUN = "bot_bottle.orchestrator.lifecycle.run_docker"
|
||||
_GATEWAY = "bot_bottle.orchestrator.lifecycle.DockerGateway"
|
||||
_SLEEP = "bot_bottle.orchestrator.lifecycle.time.sleep"
|
||||
_MONOTONIC = "bot_bottle.orchestrator.lifecycle.time.monotonic"
|
||||
|
||||
@@ -42,10 +41,8 @@ class TestOrchestratorService(unittest.TestCase):
|
||||
self.addCleanup(use_bottle_root(Path(self._tmp.name)))
|
||||
self.svc = OrchestratorService(port=8099)
|
||||
|
||||
def test_urls(self) -> None:
|
||||
def test_url(self) -> None:
|
||||
self.assertEqual("http://127.0.0.1:8099", self.svc.url)
|
||||
# The gateway reaches the control plane by container name over docker DNS.
|
||||
self.assertEqual(f"http://{ORCHESTRATOR_NAME}:8099", self.svc.internal_url)
|
||||
|
||||
def test_is_healthy(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_health(200)):
|
||||
@@ -54,126 +51,147 @@ class TestOrchestratorService(unittest.TestCase):
|
||||
self.assertFalse(self.svc.is_healthy())
|
||||
|
||||
def test_ensure_running_noop_when_healthy_and_source_unchanged(self) -> None:
|
||||
# A healthy control plane already running the *current* bind-mounted
|
||||
# source is left alone — recreating it on every launch would drop
|
||||
# every other active bottle's in-memory egress tokens (#381).
|
||||
# A healthy container on current source is left alone — recreating it
|
||||
# on every launch drops in-memory egress tokens (#381).
|
||||
current = source_hash(self.svc._repo_root)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout=ORCHESTRATOR_NAME)
|
||||
return _proc(stdout=INFRA_NAME)
|
||||
if argv[:2] == ["docker", "inspect"]:
|
||||
return _proc(stdout=current)
|
||||
return _proc()
|
||||
|
||||
with patch(_URLOPEN, return_value=_health(200)), \
|
||||
patch(_GATEWAY) as gw_cls, patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
||||
gw_cls.return_value.ensure_running.assert_called() # gateway kept up
|
||||
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||
rms = [c for c in calls if c[:3] == ["docker", "rm", "--force"] and ORCHESTRATOR_NAME in c]
|
||||
self.assertEqual([], runs) # not recreated
|
||||
rms = [c for c in calls if c[:3] == ["docker", "rm", "--force"] and INFRA_NAME in c]
|
||||
self.assertEqual([], runs)
|
||||
self.assertEqual([], rms)
|
||||
|
||||
def test_ensure_running_recreates_when_source_changed(self) -> None:
|
||||
# Healthy, but the running container's label doesn't match the
|
||||
# current source hash (a real code change) — recreate so it takes
|
||||
# effect, same as the gateway's image-staleness check.
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout=ORCHESTRATOR_NAME)
|
||||
return _proc(stdout=INFRA_NAME)
|
||||
if argv[:2] == ["docker", "inspect"]:
|
||||
return _proc(stdout="stale-hash")
|
||||
return _proc()
|
||||
|
||||
with patch(_URLOPEN, return_value=_health(200)), \
|
||||
patch(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
||||
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||
self.assertEqual(1, len(runs))
|
||||
self.assertIn(ORCHESTRATOR_NAME, runs[0])
|
||||
# the fresh container is labeled with the current hash, not the stale one
|
||||
self.assertIn(INFRA_NAME, runs[0])
|
||||
current = source_hash(self.svc._repo_root)
|
||||
self.assertIn(f"{ORCHESTRATOR_SOURCE_HASH_LABEL}={current}", runs[0])
|
||||
self.assertIn(f"{INFRA_SOURCE_HASH_LABEL}={current}", runs[0])
|
||||
|
||||
def test_ensure_running_starts_orchestrator_container_when_absent(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout="") # not running
|
||||
return _proc()
|
||||
|
||||
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
||||
patch(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
||||
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||
self.assertEqual(1, len(runs))
|
||||
argv = runs[0]
|
||||
self.assertIn(ORCHESTRATOR_NAME, argv)
|
||||
self.assertIn("--broker", argv)
|
||||
self.assertEqual("stub", argv[argv.index("--broker") + 1]) # register-only, no socket
|
||||
self.assertIn("bot_bottle.orchestrator", argv)
|
||||
self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1])
|
||||
|
||||
def test_ensure_running_builds_lean_orchestrator_image_when_missing(self) -> None:
|
||||
# The control plane runs its own lean image (#384), distinct from the
|
||||
# gateway data plane — built from Dockerfile.orchestrator when absent.
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout="") # orchestrator not running
|
||||
if argv[:3] == ["docker", "image", "inspect"]:
|
||||
return _proc(returncode=1) # image absent -> build
|
||||
return _proc()
|
||||
|
||||
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
||||
patch(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
self.svc.ensure_running()
|
||||
builds = [c for c in calls if c[:2] == ["docker", "build"]]
|
||||
self.assertEqual(1, len(builds))
|
||||
self.assertIn(ORCHESTRATOR_IMAGE, builds[0])
|
||||
self.assertTrue(any(a.endswith("Dockerfile.orchestrator") for a in builds[0]))
|
||||
# It is NOT the gateway image/dockerfile — the split is the point.
|
||||
self.assertFalse(any("Dockerfile.gateway" in a for a in builds[0]))
|
||||
|
||||
def test_ensure_running_skips_orchestrator_image_build_when_present(self) -> None:
|
||||
def test_ensure_running_starts_infra_container_when_absent(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout="")
|
||||
if argv[:3] == ["docker", "image", "inspect"]:
|
||||
return _proc(returncode=0) # image present -> no build
|
||||
return _proc()
|
||||
|
||||
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
||||
patch(_GATEWAY), patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
self.assertEqual(self.svc.url, self.svc.ensure_running())
|
||||
runs = [c for c in calls if c[:2] == ["docker", "run"]]
|
||||
self.assertEqual(1, len(runs))
|
||||
argv = runs[0]
|
||||
self.assertIn(INFRA_NAME, argv)
|
||||
# Published on loopback — not exposed on external interfaces.
|
||||
self.assertEqual("127.0.0.1:8099:8099", argv[argv.index("--publish") + 1])
|
||||
# Both processes in one container — no separate entrypoint override.
|
||||
self.assertNotIn("--entrypoint", argv)
|
||||
# Gateway daemons + orchestrator explicitly opted in.
|
||||
daemons_flag = "BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise,orchestrator"
|
||||
self.assertIn("orchestrator", argv[argv.index(daemons_flag)])
|
||||
|
||||
def test_ensure_running_builds_both_images(self) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout="")
|
||||
return _proc()
|
||||
|
||||
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
||||
patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
self.svc.ensure_running()
|
||||
self.assertEqual([], [c for c in calls if c[:2] == ["docker", "build"]])
|
||||
builds = [c for c in calls if c[:2] == ["docker", "build"]]
|
||||
# Orchestrator (build intermediate) + infra image both built.
|
||||
self.assertEqual(2, len(builds))
|
||||
dockerfiles = [next(a for a in b if "Dockerfile" in a) for b in builds]
|
||||
self.assertIn("Dockerfile.orchestrator", dockerfiles[0])
|
||||
self.assertIn("Dockerfile.infra", dockerfiles[1])
|
||||
# Images are distinct — the point of the split.
|
||||
tags = [b[b.index("-t") + 1] for b in builds]
|
||||
self.assertNotEqual(tags[0], tags[1])
|
||||
|
||||
def test_ensure_running_raises_on_timeout(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \
|
||||
patch(_GATEWAY), patch(_RUN, return_value=Mock(returncode=0, stderr="")), \
|
||||
patch(_RUN, return_value=Mock(returncode=0, stdout="", stderr="")), \
|
||||
patch(_SLEEP), patch(_MONOTONIC, side_effect=[0.0, 0.5, 2.0]):
|
||||
with self.assertRaises(OrchestratorStartError):
|
||||
self.svc.ensure_running(startup_timeout=1.0)
|
||||
|
||||
def test_stop_removes_orchestrator_and_gateway(self) -> None:
|
||||
with patch(_RUN) as run, patch(_GATEWAY) as gw_cls:
|
||||
def test_noop_when_healthy_and_inspect_fails(self) -> None:
|
||||
"""If docker inspect fails (e.g. docker daemon hiccup), leave the
|
||||
working container alone rather than churning it."""
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout=INFRA_NAME)
|
||||
if argv[:2] == ["docker", "inspect"]:
|
||||
return _proc(returncode=1, stderr="daemon error")
|
||||
return _proc()
|
||||
|
||||
with patch(_URLOPEN, return_value=_health(200)), \
|
||||
patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
self.svc.ensure_running()
|
||||
# no docker run — the working container was left alone
|
||||
|
||||
def test_build_failure_raises(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=urllib.error.URLError("down")), \
|
||||
patch(_RUN, return_value=_proc(returncode=1, stderr="no space left on device")):
|
||||
with self.assertRaises(GatewayError):
|
||||
self.svc.ensure_running()
|
||||
|
||||
def test_ensure_network_creates_if_missing(self) -> None:
|
||||
"""If the gateway network doesn't exist yet, create it."""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:3] == ["docker", "network", "inspect"]:
|
||||
return _proc(returncode=1, stderr="not found")
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout="")
|
||||
return _proc()
|
||||
|
||||
with patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
|
||||
patch(_RUN, side_effect=fake), patch(_SLEEP):
|
||||
self.svc.ensure_running()
|
||||
creates = [c for c in calls if c[:3] == ["docker", "network", "create"]]
|
||||
self.assertEqual(1, len(creates))
|
||||
|
||||
def test_stop_removes_infra_container(self) -> None:
|
||||
with patch(_RUN) as run:
|
||||
self.svc.stop()
|
||||
rms = [c.args[0] for c in run.call_args_list if c.args[0][:3] == ["docker", "rm", "--force"]]
|
||||
self.assertTrue(any(ORCHESTRATOR_NAME in a for a in rms))
|
||||
gw_cls.return_value.stop.assert_called_once()
|
||||
rms = [
|
||||
c.args[0] for c in run.call_args_list
|
||||
if c.args[0][:3] == ["docker", "rm", "--force"]
|
||||
]
|
||||
self.assertTrue(any(INFRA_NAME in a for a in rms))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user