Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c527841d55 | |||
| 5940b75bb7 |
@@ -1,6 +1,10 @@
|
||||
[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.
|
||||
|
||||
+99
-112
@@ -9,10 +9,12 @@
|
||||
# tests/canaries/ — upstream regression canaries; run on a separate
|
||||
# schedule (see canaries.yml), not here
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
name: test
|
||||
|
||||
@@ -40,53 +42,6 @@ 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:
|
||||
@@ -101,11 +56,17 @@ jobs:
|
||||
- name: Install dev requirements
|
||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
||||
|
||||
- name: Run unit tests
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
||||
- name: Run unit tests with coverage
|
||||
run: python3 -m coverage run --data-file=.coverage.unit -m unittest discover -t . -s tests/unit -v
|
||||
|
||||
- name: Report unit coverage
|
||||
run: python3 -m coverage report -m
|
||||
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
|
||||
|
||||
integration-docker:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -115,6 +76,9 @@ 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
|
||||
@@ -124,10 +88,16 @@ jobs:
|
||||
echo "docker not on PATH — integration tests will skip"
|
||||
fi
|
||||
|
||||
- name: Run integration tests (docker)
|
||||
- name: Run integration tests (docker) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: docker
|
||||
run: python3 -m unittest discover -t . -s tests/integration -v
|
||||
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
|
||||
|
||||
# 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.
|
||||
@@ -137,9 +107,16 @@ jobs:
|
||||
#
|
||||
# Runner prerequisites (provision once; see README "Firecracker on Linux"):
|
||||
# `firecracker` on PATH, `/dev/kvm` accessible, cached kernel +
|
||||
# static dropbear, and the pool as a persistent systemd unit.
|
||||
# static dropbear at /var/cache/bot-bottle-fc/dropbear, and the pool as a
|
||||
# persistent systemd unit.
|
||||
#
|
||||
# The infra candidate is built here directly (no artifact download) to
|
||||
# eliminate the ~70 s ubuntu-latest upload + ~83 s combined download that
|
||||
# the old build-infra → integration-firecracker + coverage chain incurred.
|
||||
# For main-branch pushes the tested rootfs and matching dropbear are
|
||||
# uploaded so publish-infra can publish the byte-identical artifact; PRs
|
||||
# skip those uploads entirely.
|
||||
integration-firecracker:
|
||||
needs: build-infra
|
||||
runs-on: [self-hosted, kvm]
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
@@ -159,49 +136,58 @@ jobs:
|
||||
# range overlap; it prints the exact `backend setup` fix.
|
||||
python3 cli.py backend status --backend=firecracker
|
||||
|
||||
- name: Download the candidate built from this checkout
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate
|
||||
- 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: 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: 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)
|
||||
# No dev-requirements install: `coverage` is already provided by the
|
||||
# self-hosted runner's Nix python env, and that env has no `pip`
|
||||
# module to install into anyway.
|
||||
- name: Run integration tests (firecracker) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: firecracker
|
||||
BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
|
||||
run: python3 -m unittest discover -t . -s tests/integration -v
|
||||
run: python3 -m coverage run --data-file=.coverage.firecracker -m unittest discover -t . -s tests/integration -v
|
||||
|
||||
# Combined unit+integration coverage + the diff-coverage gate (the hard
|
||||
# gate: new/changed lines >= 90%). See docs/decisions/0004-coverage-policy.md.
|
||||
- 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%).
|
||||
#
|
||||
# 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.
|
||||
# Runs on ubuntu-latest — no KVM needed, no test reruns. Coverage files use
|
||||
# relative_files = True (.coveragerc) so they combine cleanly across runners.
|
||||
#
|
||||
# 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.
|
||||
# Restricted to the same events as integration-firecracker: it depends on
|
||||
# that job's coverage artifact and skips for fork PRs alongside it.
|
||||
coverage:
|
||||
needs: [build-infra, integration-firecracker]
|
||||
needs: [unit, integration-docker, integration-firecracker]
|
||||
timeout-minutes: 15
|
||||
runs-on: [self-hosted, kvm]
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
@@ -213,29 +199,29 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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: Install coverage
|
||||
run: python3 -m pip install --break-system-packages coverage
|
||||
|
||||
- name: Download the candidate already exercised by integration
|
||||
- name: Download unit coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
path: infra-candidate
|
||||
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 }}
|
||||
|
||||
# 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)
|
||||
env:
|
||||
BOT_BOTTLE_CI_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
|
||||
run: PYTHON=python3 bash scripts/coverage.sh critical
|
||||
run: PYTHON=python3 bash scripts/coverage.sh aggregate critical
|
||||
|
||||
- name: Diff-coverage gate (changed lines >= 90%)
|
||||
run: |
|
||||
@@ -243,14 +229,14 @@ jobs:
|
||||
python3 scripts/diff_coverage.py --base origin/main --min 90
|
||||
|
||||
publish-infra:
|
||||
needs: [stage-firecracker-inputs, build-infra, unit, integration-docker, integration-firecracker, coverage]
|
||||
needs: [unit, integration-docker, integration-firecracker, coverage]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout the tested revision
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download the tested candidate
|
||||
- name: Download the tested rootfs
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: infra-candidate
|
||||
@@ -258,9 +244,10 @@ jobs:
|
||||
|
||||
# publish_infra re-derives the version from the checkout to confirm the
|
||||
# bundle matches before uploading, and the version hashes the dropbear
|
||||
# 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)
|
||||
# bytes. Download the SAME dropbear integration-firecracker used, or
|
||||
# the recheck computes a "<missing>"-dropbear version and rejects the
|
||||
# candidate.
|
||||
- name: Download the staged dropbear (matches build's version)
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: firecracker-inputs
|
||||
|
||||
@@ -75,22 +75,6 @@ On compatible macOS hosts, the default backend requires Apple's `container` CLI
|
||||
|
||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
|
||||
|
||||
> **Experimental containers-in-bottle spike (#392):** a bottle may set
|
||||
> `docker_access: true`. On the macOS backend this starts a guest-local,
|
||||
> rootless **podman** service after the bottle is registered, exposing its
|
||||
> Docker-compatible API socket — the agent still uses `docker` and `docker
|
||||
> compose`. It does not mount Docker Desktop's socket or add outer VM
|
||||
> capabilities. Rootless Docker was tried first and does not work here at
|
||||
> all: Apple Container's capability bounding set omits `CAP_SYS_ADMIN`,
|
||||
> which the kernel requires to write a multi-range `uid_map`. See
|
||||
> [`docs/research/rootless-docker-in-apple-container-spike.md`](docs/research/rootless-docker-in-apple-container-spike.md).
|
||||
>
|
||||
> The tradeoff to understand before enabling it: podman avoids that
|
||||
> requirement by falling back to a single-UID mapping, so nested containers
|
||||
> provide **no isolation from the agent itself** — `root` inside a nested
|
||||
> container is the agent user outside it. Nested containers are a build/test
|
||||
> convenience, not a security boundary. The bottle remains the boundary.
|
||||
|
||||
### Firecracker on Linux
|
||||
|
||||
On Linux, a KVM-capable host defaults to the Firecracker backend. It needs:
|
||||
|
||||
@@ -142,19 +142,11 @@ def launch_consolidated(
|
||||
|
||||
|
||||
def teardown_consolidated(
|
||||
bottle_id: str,
|
||||
*,
|
||||
orchestrator_url: str,
|
||||
gateway_name: str = GATEWAY_NAME,
|
||||
timeout: float | None = None,
|
||||
bottle_id: str, *, orchestrator_url: str, gateway_name: str = GATEWAY_NAME,
|
||||
) -> None:
|
||||
"""Deregister the bottle and remove its git-gate state from the gateway.
|
||||
Both steps are idempotent so this is safe from a cleanup trap."""
|
||||
from ...orchestrator.config_store import DEFAULT_TEARDOWN_TIMEOUT_SECONDS
|
||||
OrchestratorClient(
|
||||
orchestrator_url,
|
||||
timeout=timeout if timeout is not None else DEFAULT_TEARDOWN_TIMEOUT_SECONDS,
|
||||
).teardown_bottle(bottle_id)
|
||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||
deprovision_git_gate(DockerGatewayTransport(gateway_name), bottle_id)
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ from .compose import (
|
||||
write_compose_file,
|
||||
)
|
||||
from .consolidated_compose import consolidated_agent_compose
|
||||
from ...orchestrator.config_store import resolve_teardown_timeout
|
||||
from .consolidated_launch import launch_consolidated, teardown_consolidated
|
||||
from ...orchestrator.gateway import DockerGateway
|
||||
|
||||
@@ -134,14 +133,11 @@ def launch(
|
||||
token_values = egress_resolve_token_values(
|
||||
plan.egress_plan.token_env_map, effective_env,
|
||||
)
|
||||
teardown_timeout = resolve_teardown_timeout()
|
||||
ctx = launch_consolidated(
|
||||
plan.egress_plan, git_gate_plan, image_ref=plan.image, tokens=token_values,
|
||||
)
|
||||
stack.callback(
|
||||
teardown_consolidated, ctx.bottle_id,
|
||||
orchestrator_url=ctx.orchestrator_url,
|
||||
timeout=teardown_timeout,
|
||||
teardown_consolidated, ctx.bottle_id, orchestrator_url=ctx.orchestrator_url,
|
||||
)
|
||||
|
||||
# Step 4: install the SHARED gateway CA into the agent (replaces the
|
||||
|
||||
@@ -91,18 +91,12 @@ def launch_consolidated(
|
||||
)
|
||||
|
||||
|
||||
def teardown_consolidated(
|
||||
bottle_id: str, *, orchestrator_url: str, timeout: float | None = None,
|
||||
) -> None:
|
||||
def teardown_consolidated(bottle_id: str, *, orchestrator_url: str) -> None:
|
||||
"""Deregister the bottle and remove its git-gate state from the gateway
|
||||
VM. Both steps are idempotent so this is safe from a cleanup trap. Does
|
||||
NOT stop the infra VM — it's a persistent per-host singleton shared by
|
||||
every bottle."""
|
||||
from ...orchestrator.config_store import DEFAULT_TEARDOWN_TIMEOUT_SECONDS
|
||||
OrchestratorClient(
|
||||
orchestrator_url,
|
||||
timeout=timeout if timeout is not None else DEFAULT_TEARDOWN_TIMEOUT_SECONDS,
|
||||
).teardown_bottle(bottle_id)
|
||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||
deprovision_git_gate(infra_vm.gateway_transport(), bottle_id)
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from . import firecracker_vm, image_builder, isolation_probe, netpool, util
|
||||
from .bottle import FirecrackerBottle
|
||||
from .bottle_plan import FirecrackerBottlePlan
|
||||
from ...orchestrator.config_store import resolve_teardown_timeout
|
||||
from .consolidated_launch import (
|
||||
launch_consolidated,
|
||||
teardown_consolidated,
|
||||
@@ -113,7 +112,6 @@ def launch(
|
||||
token_values = egress_resolve_token_values(
|
||||
plan.egress_plan.token_env_map, effective_env,
|
||||
)
|
||||
teardown_timeout = resolve_teardown_timeout()
|
||||
ctx = launch_consolidated(
|
||||
plan.egress_plan, git_gate_plan,
|
||||
guest_ip=slot.guest_ip,
|
||||
@@ -123,7 +121,6 @@ def launch(
|
||||
stack.callback(
|
||||
teardown_consolidated, ctx.bottle_id,
|
||||
orchestrator_url=ctx.orchestrator_url,
|
||||
timeout=teardown_timeout,
|
||||
)
|
||||
|
||||
# Step 5: install the SHARED gateway CA (replaces the per-bottle CA).
|
||||
|
||||
@@ -20,7 +20,6 @@ class MacosContainerBottlePlan(BottlePlan):
|
||||
# bottle is registered. See launch.py's stamp for why it lives here and not
|
||||
# only in the exec-time proxy env.
|
||||
identity_token: str = ""
|
||||
docker_access: bool = False
|
||||
|
||||
@property
|
||||
def container_name(self) -> str:
|
||||
|
||||
@@ -36,12 +36,9 @@ from dataclasses import dataclass
|
||||
|
||||
from ...egress import EgressPlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...log import info
|
||||
from ...orchestrator.client import OrchestratorClient, OrchestratorClientError
|
||||
from ...orchestrator.client import OrchestratorClient
|
||||
from ...orchestrator.registration import registration_inputs
|
||||
from ..docker.gateway_provision import deprovision_git_gate, provision_git_gate
|
||||
from . import util as container_mod
|
||||
from .enumerate import CONTAINER_NAME_PREFIX, EnumerationError, enumerate_active
|
||||
from .gateway import GATEWAY_NETWORK
|
||||
from .gateway_provision import AppleGatewayTransport
|
||||
from .infra import MacosInfraService, OrchestratorStartError
|
||||
@@ -92,32 +89,6 @@ def ensure_gateway(
|
||||
)
|
||||
|
||||
|
||||
def live_source_ips(network: str) -> list[str]:
|
||||
"""Every running agent container's address on `network`.
|
||||
|
||||
The reconciliation input: the orchestrator lives inside the infra
|
||||
container and cannot enumerate the host's containers, so the host has to
|
||||
tell it which bottles are actually up. Containers that have not been
|
||||
assigned an address yet contribute nothing — the reap's grace window, not
|
||||
this list, is what protects an in-flight launch.
|
||||
|
||||
Raises `EnumerationError` when the live set cannot be determined
|
||||
authoritatively: either the container listing fails or any individual
|
||||
inspect fails. Callers must skip reconciliation in that case to avoid
|
||||
unregistering healthy bottles."""
|
||||
ips: list[str] = []
|
||||
for agent in enumerate_active():
|
||||
name = f"{CONTAINER_NAME_PREFIX}{agent.slug}"
|
||||
ip = container_mod.inspect_container_network_ip(name, network)
|
||||
if ip is None:
|
||||
raise EnumerationError(
|
||||
f"container inspect {name!r} failed; live set is not authoritative"
|
||||
)
|
||||
if ip:
|
||||
ips.append(ip)
|
||||
return ips
|
||||
|
||||
|
||||
def register_agent(
|
||||
egress_plan: EgressPlan,
|
||||
git_gate_plan: GitGatePlan,
|
||||
@@ -132,16 +103,6 @@ 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)
|
||||
# Self-heal before registering: a launcher that died hard (SIGKILL, closed
|
||||
# terminal, host sleep) never ran its teardown callback, leaving an active
|
||||
# row with no container. vmnet recycles addresses, so such a row can
|
||||
# collide with this bottle's — and `by_source_ip` fail-closes on ambiguity,
|
||||
# which would resolve no policy at all and deny every host. Best-effort: a
|
||||
# reconciliation failure must not block an otherwise-fine launch.
|
||||
try:
|
||||
client.reconcile(live_source_ips(endpoint.network))
|
||||
except (OrchestratorClientError, EnumerationError) as e:
|
||||
info(f"registry reconciliation skipped: {e}")
|
||||
inputs = registration_inputs(egress_plan)
|
||||
reg = client.register_bottle(
|
||||
source_ip, image_ref=image_ref, policy=inputs.policy,
|
||||
@@ -163,17 +124,11 @@ def register_agent(
|
||||
)
|
||||
|
||||
|
||||
def teardown_consolidated(
|
||||
bottle_id: str, *, orchestrator_url: str, timeout: float | None = None,
|
||||
) -> None:
|
||||
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."""
|
||||
from ...orchestrator.config_store import DEFAULT_TEARDOWN_TIMEOUT_SECONDS
|
||||
OrchestratorClient(
|
||||
orchestrator_url,
|
||||
timeout=timeout if timeout is not None else DEFAULT_TEARDOWN_TIMEOUT_SECONDS,
|
||||
).teardown_bottle(bottle_id)
|
||||
OrchestratorClient(orchestrator_url).teardown_bottle(bottle_id)
|
||||
deprovision_git_gate(AppleGatewayTransport(), bottle_id)
|
||||
|
||||
|
||||
@@ -181,7 +136,6 @@ __all__ = [
|
||||
"GatewayEndpoint",
|
||||
"LaunchContext",
|
||||
"ensure_gateway",
|
||||
"live_source_ips",
|
||||
"register_agent",
|
||||
"teardown_consolidated",
|
||||
"ConsolidatedLaunchError",
|
||||
|
||||
@@ -19,10 +19,6 @@ CONTAINER_NAME_PREFIX = "bot-bottle-"
|
||||
_INFRA_NAMES = frozenset({INFRA_NAME})
|
||||
|
||||
|
||||
class EnumerationError(RuntimeError):
|
||||
"""container list failed; the resulting live set is not authoritative."""
|
||||
|
||||
|
||||
def enumerate_active() -> list[ActiveAgent]:
|
||||
result = subprocess.run(
|
||||
["container", "list", "--quiet"],
|
||||
@@ -31,10 +27,7 @@ def enumerate_active() -> list[ActiveAgent]:
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise EnumerationError(
|
||||
f"container list failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -64,9 +64,7 @@ from .gateway_hosts import (
|
||||
refresh_gateway_host,
|
||||
set_gateway_host,
|
||||
)
|
||||
from . import rootless_podman
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
from ...orchestrator.config_store import resolve_teardown_timeout
|
||||
from .consolidated_launch import (
|
||||
GatewayEndpoint,
|
||||
ensure_gateway,
|
||||
@@ -144,7 +142,6 @@ def launch(
|
||||
token_values = egress_resolve_token_values(
|
||||
plan.egress_plan.token_env_map, effective_env,
|
||||
)
|
||||
teardown_timeout = resolve_teardown_timeout()
|
||||
ctx = register_agent(
|
||||
plan.egress_plan,
|
||||
plan.git_gate_plan,
|
||||
@@ -156,7 +153,6 @@ def launch(
|
||||
stack.callback(
|
||||
teardown_consolidated, ctx.bottle_id,
|
||||
orchestrator_url=ctx.orchestrator_url,
|
||||
timeout=teardown_timeout,
|
||||
)
|
||||
info(
|
||||
f"agent {plan.container_name} registered "
|
||||
@@ -172,10 +168,6 @@ def launch(
|
||||
# token above, so — unlike the run-time env — the plan CAN carry it.
|
||||
plan = dataclasses.replace(plan, identity_token=ctx.identity_token)
|
||||
|
||||
exec_env = {
|
||||
**_identity_proxy_env(endpoint, ctx.identity_token),
|
||||
**rootless_podman.guest_env(plan.docker_access),
|
||||
}
|
||||
bottle = MacosContainerBottle(
|
||||
plan.container_name,
|
||||
teardown,
|
||||
@@ -189,16 +181,10 @@ def launch(
|
||||
),
|
||||
terminal_color=plan.spec.color,
|
||||
agent_workdir=plan.workspace_plan.workdir,
|
||||
exec_env=exec_env,
|
||||
exec_env=_identity_proxy_env(endpoint, ctx.identity_token),
|
||||
)
|
||||
bottle.prompt_path = provision(plan, bottle)
|
||||
|
||||
if plan.docker_access:
|
||||
rootless_podman.prepare_guest_devices(
|
||||
plan.container_name, container_mod.exec_container_as_root,
|
||||
)
|
||||
rootless_podman.start(bottle)
|
||||
|
||||
yield bottle
|
||||
finally:
|
||||
teardown()
|
||||
@@ -210,22 +196,15 @@ def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and container_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
plan = dataclasses.replace(
|
||||
return dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(
|
||||
plan.agent_provision, image=committed,
|
||||
),
|
||||
)
|
||||
else:
|
||||
container_mod.build_image(
|
||||
plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
if plan.docker_access:
|
||||
image = rootless_podman.build_image(plan.image, container_mod.build_image)
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=image),
|
||||
)
|
||||
container_mod.build_image(
|
||||
plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
|
||||
@@ -44,5 +44,4 @@ def resolve_plan(
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
agent_provision=agent_provision_plan,
|
||||
docker_access=manifest.bottle.docker_access,
|
||||
)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
uid="$(id -u)"
|
||||
if [ "$uid" -eq 0 ]; then
|
||||
echo "refusing to run rootless podman as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in podman docker fuse-overlayfs slirp4netns; do
|
||||
command -v "$command" >/dev/null 2>&1 || {
|
||||
echo "missing rootless podman prerequisite: $command" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
# The inverse of the rootless-Docker check, and the whole point of the podman
|
||||
# variant: a subordinate range would push podman onto newuidmap, which cannot
|
||||
# write a multi-range uid_map without CAP_SYS_ADMIN in this guest. An empty
|
||||
# range keeps it on the single-UID self-mapping an unprivileged process may
|
||||
# write itself.
|
||||
if grep -q "^$(id -un):" /etc/subuid 2>/dev/null; then
|
||||
echo "unexpected subordinate UID range for $(id -un): podman would" >&2
|
||||
echo "require CAP_SYS_ADMIN via newuidmap in this guest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for device in /dev/fuse /dev/net/tun; do
|
||||
[ -r "$device" ] && [ -w "$device" ] || {
|
||||
echo "device $device is not readable/writable by $(id -un)" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/bot-bottle-podman-run}"
|
||||
config="$HOME/.config/containers"
|
||||
mkdir -p "$XDG_RUNTIME_DIR" "$config"
|
||||
chmod 700 "$XDG_RUNTIME_DIR"
|
||||
|
||||
# ignore_chown_errors is required, not incidental: with a single-UID mapping
|
||||
# there is no second UID for image layers to be chowned to, so layers that
|
||||
# record other owners would otherwise fail to extract.
|
||||
cat > "$config/storage.conf" <<'CONF'
|
||||
[storage]
|
||||
driver="overlay"
|
||||
[storage.options.overlay]
|
||||
mount_program="/usr/bin/fuse-overlayfs"
|
||||
ignore_chown_errors="true"
|
||||
CONF
|
||||
|
||||
# No cgroup delegation reaches this guest, so asking podman to manage cgroups
|
||||
# fails; events_logger=file avoids the journald socket that is equally absent.
|
||||
cat > "$config/containers.conf" <<'CONF'
|
||||
[containers]
|
||||
cgroups="disabled"
|
||||
[engine]
|
||||
cgroup_manager="cgroupfs"
|
||||
events_logger="file"
|
||||
CONF
|
||||
|
||||
# Registry pulls egress through the bottle's proxy like everything else. The
|
||||
# token-bearing proxy URL is already in the agent's environment; persisting it
|
||||
# inside this disposable VM does not broaden its authority.
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy", "")
|
||||
no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy", "")
|
||||
config = {"proxies": {"default": {
|
||||
"httpProxy": proxy,
|
||||
"httpsProxy": proxy,
|
||||
"noProxy": no_proxy,
|
||||
}}}
|
||||
path = Path.home() / ".docker" / "config.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
PY
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log=/tmp/bot-bottle-rootless-podman.log
|
||||
nohup podman system service --time=0 \
|
||||
"unix://$XDG_RUNTIME_DIR/podman.sock" \
|
||||
>"$log" 2>&1 </dev/null &
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Experimental rootless podman bootstrap for Apple-container bottles.
|
||||
|
||||
The service and every nested container remain inside the existing per-bottle
|
||||
VM. This module refuses to compensate for missing prerequisites with outer
|
||||
capabilities, a privileged container, or a host Docker socket.
|
||||
|
||||
Podman is used rather than rootless Docker for one specific reason: Apple
|
||||
Container's capability bounding set omits `CAP_SYS_ADMIN`, which the kernel
|
||||
requires to write a multi-range `uid_map` via `newuidmap`. Rootless Docker
|
||||
has no path that avoids that write. Podman does — with no subordinate UID
|
||||
range configured it falls back to a single-UID self-mapping, which an
|
||||
unprivileged process may write itself. See
|
||||
`docs/research/rootless-docker-in-apple-container-spike.md`.
|
||||
|
||||
That fallback is why `build_image` *removes* the agent user's `/etc/subuid`
|
||||
and `/etc/subgid` entries instead of adding them: their presence is precisely
|
||||
what would send podman down the `newuidmap` path that cannot work here.
|
||||
|
||||
The agent still talks to `docker` and `docker compose`; those speak to
|
||||
podman's Docker-compatible API socket, so nothing in the agent's habits
|
||||
changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from ...log import die, info
|
||||
|
||||
_INIT = "/usr/local/libexec/bot-bottle/rootless-podman-init"
|
||||
_RUNTIME_DIR = "/tmp/bot-bottle-podman-run"
|
||||
_SOCKET = f"{_RUNTIME_DIR}/podman.sock"
|
||||
_LOG = "/tmp/bot-bottle-rootless-podman.log"
|
||||
READY_RETRIES = 30
|
||||
|
||||
# Apple Container creates both device nodes 0600 root:root, so the agent user
|
||||
# cannot open them: /dev/fuse blocks the fuse-overlayfs storage driver and
|
||||
# /dev/net/tun blocks slirp4netns, which rootless podman uses for the default
|
||||
# bridge network that stock compose files expect. Relaxing the modes needs no
|
||||
# capability the bottle does not already hold — unlike CAP_SYS_ADMIN, which is
|
||||
# what killed the rootless-Docker approach.
|
||||
_GUEST_DEVICES = ("/dev/fuse", "/dev/net/tun")
|
||||
|
||||
|
||||
def build_image(
|
||||
base_image: str,
|
||||
build: Callable[..., None],
|
||||
) -> str:
|
||||
"""Layer spike-only tooling on an already-built provider image."""
|
||||
image = f"{base_image}-rootless-podman"
|
||||
init_script = Path(__file__).with_name("rootless-podman-init.sh")
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-rootless-podman.") as tmp:
|
||||
context = Path(tmp)
|
||||
shutil.copy2(init_script, context / "rootless-podman-init.sh")
|
||||
(context / "Dockerfile").write_text(
|
||||
"FROM docker:28-cli AS docker_cli\n"
|
||||
f"FROM {base_image}\n"
|
||||
"USER root\n"
|
||||
"COPY --from=docker_cli /usr/local/bin/docker /usr/local/bin/docker\n"
|
||||
"COPY --from=docker_cli /usr/local/libexec/docker/cli-plugins/"
|
||||
"docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose\n"
|
||||
"RUN apt-get update \\\n"
|
||||
" && apt-get install -y --no-install-recommends podman "
|
||||
"fuse-overlayfs slirp4netns uidmap \\\n"
|
||||
" && rm -rf /var/lib/apt/lists/* \\\n"
|
||||
# Deliberate: an empty subordinate range keeps podman on the
|
||||
# single-UID mapping that needs no CAP_SYS_ADMIN. Adding ranges
|
||||
# here would reintroduce the newuidmap failure this spike exists
|
||||
# to route around.
|
||||
" && sed -i '/^node:/d' /etc/subuid /etc/subgid\n"
|
||||
"COPY rootless-podman-init.sh "
|
||||
"/usr/local/libexec/bot-bottle/rootless-podman-init\n"
|
||||
"RUN chmod 0755 /usr/local/libexec/bot-bottle/rootless-podman-init\n"
|
||||
"USER node\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
build(image, str(context), dockerfile=str(context / "Dockerfile"))
|
||||
return image
|
||||
|
||||
|
||||
def guest_env(enabled: bool) -> dict[str, str]:
|
||||
"""Environment consumed by the Docker CLI inside an enabled bottle."""
|
||||
if not enabled:
|
||||
return {}
|
||||
return {
|
||||
"DOCKER_HOST": f"unix://{_SOCKET}",
|
||||
"XDG_RUNTIME_DIR": _RUNTIME_DIR,
|
||||
}
|
||||
|
||||
|
||||
def prepare_guest_devices(container_name: str, exec_as_root: Callable[..., None]) -> None:
|
||||
"""Make /dev/fuse and /dev/net/tun openable by the agent user.
|
||||
|
||||
Runs as root inside the bottle because the agent must not be able to
|
||||
re-mode device nodes itself. No outer capability is involved.
|
||||
"""
|
||||
exec_as_root(
|
||||
container_name,
|
||||
["sh", "-c", f"chmod 0666 {' '.join(_GUEST_DEVICES)}"],
|
||||
)
|
||||
|
||||
|
||||
def start(bottle: object) -> None:
|
||||
"""Start and verify the unprivileged service through the bottle exec API."""
|
||||
info("starting experimental rootless podman service")
|
||||
result = bottle.exec(shlex.quote(_INIT)) # type: ignore[attr-defined]
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
die(f"rootless podman bootstrap failed: {detail or '<no output>'}")
|
||||
|
||||
for _ in range(READY_RETRIES):
|
||||
result = bottle.exec("docker info >/dev/null 2>&1") # type: ignore[attr-defined]
|
||||
if result.returncode == 0:
|
||||
info("rootless podman service is ready")
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
logs = bottle.exec( # type: ignore[attr-defined]
|
||||
f"tail -n 80 {_LOG} 2>/dev/null || true"
|
||||
)
|
||||
die(
|
||||
"rootless podman did not become ready without additional outer "
|
||||
f"privileges:\n{(logs.stdout or logs.stderr or '<no log>').strip()}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["build_image", "guest_env", "prepare_guest_devices", "start"]
|
||||
@@ -572,41 +572,6 @@ def try_container_ipv4_on_network(name: str, network: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def inspect_container_network_ip(name: str, network: str) -> str | None:
|
||||
"""IP of `name` on `network`, distinguishing inspect failure from "not yet".
|
||||
|
||||
Returns:
|
||||
- the IP string when the container has one on `network`
|
||||
- "" when inspect succeeds but no address is assigned yet (in-flight DHCP)
|
||||
- None when the inspect command itself fails (authoritative list impossible)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "inspect", name],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if isinstance(data, list):
|
||||
data = data[0] if data else {}
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
status = data.get("status")
|
||||
networks = status.get("networks") if isinstance(status, dict) else None
|
||||
if not isinstance(networks, list):
|
||||
return ""
|
||||
for entry in networks:
|
||||
if not isinstance(entry, dict) or entry.get("network") != network:
|
||||
continue
|
||||
raw = entry.get("ipv4Address")
|
||||
if isinstance(raw, str) and raw:
|
||||
return raw.split("/", 1)[0]
|
||||
return ""
|
||||
|
||||
|
||||
def wait_container_ipv4_on_network(
|
||||
name: str, network: str, *, timeout: float = 15.0, poll: float = 0.25,
|
||||
) -> str:
|
||||
|
||||
@@ -379,7 +379,6 @@ class EgressAddon:
|
||||
env,
|
||||
request_method=flow.request.method,
|
||||
request_headers=req_headers,
|
||||
deny_reason=config.deny_reason,
|
||||
)
|
||||
|
||||
if decision.action == "block":
|
||||
|
||||
@@ -89,14 +89,6 @@ LOG_FULL = 2 # log block/warn events + full request and response bodies
|
||||
class Config:
|
||||
routes: tuple[Route, ...]
|
||||
log: int = LOG_OFF
|
||||
# Why this Config is a deny-all, when it is one for a reason *other* than
|
||||
# the bottle's own policy genuinely not listing the host. A deny-all is
|
||||
# indistinguishable from "policy loaded, host not allowed" at the decision
|
||||
# point — both are simply "no matching route" — so without this the
|
||||
# operator sees `host X is not in the allowlist` and goes hunting for a
|
||||
# missing route that was never the problem. Empty for a normally-parsed
|
||||
# policy; `decide` prefers it over the allowlist wording when set.
|
||||
deny_reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -413,40 +405,16 @@ class PolicyResolverLike(typing.Protocol):
|
||||
...
|
||||
|
||||
|
||||
# Deny-all explanations. Each names the *actual* failure so an operator isn't
|
||||
# sent looking for a missing egress route when the bottle never had a policy
|
||||
# to begin with — the failure mode that made a bricked registration read like
|
||||
# a misconfigured allowlist.
|
||||
DENY_UNATTRIBUTED = (
|
||||
"egress: this request was not attributed to any bottle, so no egress "
|
||||
"policy applies and every host is denied. Either the bottle's registry "
|
||||
"row is missing/ambiguous (torn down, or another bottle claimed its "
|
||||
"source IP), or the request carried no matching identity token — check "
|
||||
"that the caller's proxy URL includes it. This is not an allowlist problem."
|
||||
)
|
||||
DENY_UNPARSEABLE = (
|
||||
"egress: this bottle's egress policy could not be parsed, so it is being "
|
||||
"treated as deny-all. Fix the bottle's egress.routes; every host is denied "
|
||||
"until it loads."
|
||||
)
|
||||
DENY_RESOLVER_ERROR = (
|
||||
"egress: the orchestrator could not be reached to resolve this bottle's "
|
||||
"egress policy, so every host is denied (fail-closed). Check that the "
|
||||
"control plane is up; this is not an allowlist problem."
|
||||
)
|
||||
|
||||
|
||||
def _config_from_policy(policy: "str | None") -> "Config":
|
||||
"""Parse a resolved policy blob into a Config, fail-closed: None / empty /
|
||||
unparseable all become a deny-all Config (no routes → every request
|
||||
blocked). Each deny-all carries the reason it is one, so the block message
|
||||
names the real fault instead of blaming the allowlist."""
|
||||
blocked)."""
|
||||
if not policy:
|
||||
return Config(routes=(), deny_reason=DENY_UNATTRIBUTED)
|
||||
return Config(routes=()) # unattributed or empty → deny-all
|
||||
try:
|
||||
return load_config(policy)
|
||||
except ValueError:
|
||||
return Config(routes=(), deny_reason=DENY_UNPARSEABLE)
|
||||
return Config(routes=()) # unparseable policy → deny
|
||||
|
||||
|
||||
def resolve_client_config(
|
||||
@@ -460,7 +428,7 @@ def resolve_client_config(
|
||||
try:
|
||||
policy = resolver.resolve(client_ip, identity_token)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR)
|
||||
return Config(routes=()) # orchestrator unreachable/errored → deny
|
||||
return _config_from_policy(policy)
|
||||
|
||||
|
||||
@@ -489,7 +457,7 @@ def resolve_client_context(
|
||||
client_ip, identity_token,
|
||||
)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR), "", {}
|
||||
return Config(routes=()), "", {} # orchestrator unreachable/errored → deny
|
||||
return _config_from_policy(policy), (bottle_id or ""), tokens
|
||||
|
||||
|
||||
@@ -604,16 +572,12 @@ def decide(
|
||||
*,
|
||||
request_method: str = "GET",
|
||||
request_headers: typing.Mapping[str, str] | None = None,
|
||||
deny_reason: str = "",
|
||||
) -> Decision:
|
||||
"""`deny_reason` is `Config.deny_reason`: when the deny-all came from a
|
||||
missing/unparseable policy rather than the bottle's own allowlist, report
|
||||
that instead of implying a route is merely absent."""
|
||||
route = match_route(routes, request_host)
|
||||
if route is None:
|
||||
return Decision(
|
||||
action="block",
|
||||
reason=deny_reason or (
|
||||
reason=(
|
||||
f"egress: host {request_host!r} is not in the "
|
||||
f"bottle's egress.routes allowlist. Declare a "
|
||||
f"route for it or remove the request."
|
||||
@@ -888,9 +852,6 @@ __all__ = [
|
||||
"is_git_push_request",
|
||||
"is_git_fetch_request",
|
||||
"load_config",
|
||||
"DENY_UNATTRIBUTED",
|
||||
"DENY_UNPARSEABLE",
|
||||
"DENY_RESOLVER_ERROR",
|
||||
"resolve_client_config",
|
||||
"resolve_client_context",
|
||||
"PolicyResolverLike",
|
||||
|
||||
@@ -44,9 +44,6 @@ class ManifestBottle:
|
||||
# daemon that exposes egress MCP tools to the agent. Set
|
||||
# `supervise: false` to skip the gateway.
|
||||
supervise: bool = True
|
||||
# Experimental guest-local container engine (issue #392). Backends must
|
||||
# implement this without granting access to a host/shared daemon.
|
||||
docker_access: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
||||
@@ -126,15 +123,7 @@ class ManifestBottle:
|
||||
f"(was {type(supervise_raw).__name__})"
|
||||
)
|
||||
|
||||
docker_access_raw = d.get("docker_access", False)
|
||||
if not isinstance(docker_access_raw, bool):
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' docker_access must be a boolean "
|
||||
f"(was {type(docker_access_raw).__name__})"
|
||||
)
|
||||
|
||||
return cls(
|
||||
env=env, agent_provider=agent_provider, git=git,
|
||||
git_user=git_user, egress=egress, supervise=supervise_raw,
|
||||
docker_access=docker_access_raw,
|
||||
)
|
||||
|
||||
@@ -54,7 +54,6 @@ def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle
|
||||
git_user=merged_git_user,
|
||||
egress=merged_egress,
|
||||
supervise=override.supervise,
|
||||
docker_access=override.docker_access,
|
||||
)
|
||||
|
||||
|
||||
@@ -207,7 +206,6 @@ def _fold_two_bottles(
|
||||
git_user=merged_git_user,
|
||||
egress=merged_egress,
|
||||
supervise=later.supervise,
|
||||
docker_access=later.docker_access,
|
||||
), merged_repos_raw
|
||||
|
||||
|
||||
@@ -268,11 +266,6 @@ def _merge_bottles(
|
||||
merged_supervise = (
|
||||
child.supervise if "supervise" in child_raw else parent.supervise
|
||||
)
|
||||
merged_docker_access = (
|
||||
child.docker_access
|
||||
if "docker_access" in child_raw
|
||||
else parent.docker_access
|
||||
)
|
||||
validate_egress_routes(name, merged_egress.routes)
|
||||
|
||||
return ManifestBottle(
|
||||
@@ -282,7 +275,6 @@ def _merge_bottles(
|
||||
git_user=merged_git_user,
|
||||
egress=merged_egress,
|
||||
supervise=merged_supervise,
|
||||
docker_access=merged_docker_access,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,10 +16,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
# sets dies with a "did you mean" pointer: typos should not silently
|
||||
# ghost into an empty config.
|
||||
BOTTLE_KEYS = frozenset(
|
||||
{
|
||||
"env", "extends", "agent_provider", "git-gate", "egress", "supervise",
|
||||
"docker_access",
|
||||
}
|
||||
{"env", "extends", "agent_provider", "git-gate", "egress", "supervise"}
|
||||
)
|
||||
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
|
||||
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"})
|
||||
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..paths import host_control_plane_token
|
||||
@@ -148,20 +147,6 @@ class OrchestratorClient:
|
||||
raise OrchestratorClientError(f"teardown {bottle_id}: HTTP {status}")
|
||||
return True
|
||||
|
||||
def reconcile(
|
||||
self, live_source_ips: Iterable[str], *, grace_seconds: float | None = None,
|
||||
) -> list[str]:
|
||||
"""Drop registry rows for bottles that are no longer running
|
||||
(`POST /reconcile`), returning the reaped bottle ids. `live_source_ips`
|
||||
is the caller's enumeration of its live bottles — the orchestrator
|
||||
can't see the backend from inside the infra container."""
|
||||
body: dict[str, object] = {"live_source_ips": list(live_source_ips)}
|
||||
if grace_seconds is not None:
|
||||
body["grace_seconds"] = grace_seconds
|
||||
payload = self._ok("POST", "/reconcile", body)
|
||||
reaped = payload.get("reaped")
|
||||
return [r for r in reaped if isinstance(r, str)] if isinstance(reaped, list) else []
|
||||
|
||||
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
||||
"""Live-reload a bottle's policy (`PUT /bottles/<id>/policy`). False on
|
||||
404 (unknown bottle)."""
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Per-host orchestrator configuration store (settings in bot-bottle.db).
|
||||
|
||||
Co-tenants the shared `bot-bottle.db` via the `DbStore` framework. Settings
|
||||
are readable by the host launch path directly (no HTTP round-trip to the
|
||||
orchestrator), so they take effect even before the orchestrator is reachable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from ..db_store import DbStore
|
||||
from ..migrations import TableMigrations
|
||||
from ..paths import host_db_path
|
||||
|
||||
TEARDOWN_TIMEOUT_ENV = "BOT_BOTTLE_ORCHESTRATOR_TEARDOWN_TIMEOUT_SECONDS"
|
||||
DEFAULT_TEARDOWN_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
_MIGRATIONS = TableMigrations(
|
||||
"orchestrator_config",
|
||||
[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS orchestrator_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
teardown_timeout_seconds REAL
|
||||
)
|
||||
""",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class OrchestratorConfigStore(DbStore):
|
||||
"""Orchestrator settings in the shared host DB."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
super().__init__(db_path or host_db_path(), _MIGRATIONS)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = super()._connect()
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
return conn
|
||||
|
||||
def get_teardown_timeout_seconds(self) -> float | None:
|
||||
"""Return the configured teardown timeout, or None if not set."""
|
||||
try:
|
||||
with self._connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT teardown_timeout_seconds FROM orchestrator_config WHERE id = 1"
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
return row["teardown_timeout_seconds"] if row else None
|
||||
|
||||
def set_teardown_timeout_seconds(self, value: float) -> None:
|
||||
"""Persist the teardown timeout."""
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO orchestrator_config"
|
||||
" (id, teardown_timeout_seconds) VALUES (1, ?)",
|
||||
(value,),
|
||||
)
|
||||
self._chmod()
|
||||
|
||||
def delete_teardown_timeout_seconds(self) -> bool:
|
||||
"""Clear the stored teardown timeout. Returns True if a value existed."""
|
||||
with self._connection() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE orchestrator_config SET teardown_timeout_seconds = NULL"
|
||||
" WHERE id = 1 AND teardown_timeout_seconds IS NOT NULL"
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def resolve_teardown_timeout(db_path: Path | None = None) -> float:
|
||||
"""Return the teardown timeout to use, in priority order:
|
||||
|
||||
1. ``BOT_BOTTLE_ORCHESTRATOR_TEARDOWN_TIMEOUT_SECONDS`` env var
|
||||
2. ``teardown_timeout_seconds`` in the orchestrator config DB
|
||||
3. ``DEFAULT_TEARDOWN_TIMEOUT_SECONDS`` (30 s)
|
||||
"""
|
||||
raw = os.environ.get(TEARDOWN_TIMEOUT_ENV, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
value = float(raw)
|
||||
if value > 0:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
store = OrchestratorConfigStore(db_path)
|
||||
if not store.is_migrated():
|
||||
store.migrate()
|
||||
db_value = store.get_teardown_timeout_seconds()
|
||||
if db_value is not None and db_value > 0:
|
||||
return db_value
|
||||
|
||||
return DEFAULT_TEARDOWN_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OrchestratorConfigStore",
|
||||
"resolve_teardown_timeout",
|
||||
"TEARDOWN_TIMEOUT_ENV",
|
||||
"DEFAULT_TEARDOWN_TIMEOUT_SECONDS",
|
||||
]
|
||||
@@ -13,9 +13,6 @@ vsock / unix-socket portability caveats):
|
||||
PUT /bottles/<bottle_id>/policy -> 200 {"updated": true} | 404 (live reload)
|
||||
body: {"policy"}
|
||||
DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown)
|
||||
POST /reconcile -> 200 {"reaped": [bottle_id, ...]}
|
||||
body: {"live_source_ips": [...],
|
||||
["grace_seconds"]}
|
||||
POST /attribute -> 200 {"bottle_id"} | 403
|
||||
POST /resolve -> 200 {"bottle_id","policy"} | 403
|
||||
body: {"source_ip","identity_token"}
|
||||
@@ -144,27 +141,6 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
||||
return 200, {"torn_down": True}
|
||||
return 404, {"error": "no such bottle"}
|
||||
|
||||
if method == "POST" and route == "/reconcile":
|
||||
# Host-driven self-heal: the caller enumerates its live bottles (only
|
||||
# the host can see the backend) and the orchestrator drops rows for
|
||||
# every other active bottle. Trusted-caller only — an agent that could
|
||||
# reach this would be able to unregister its neighbours.
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
raw_ips = data.get("live_source_ips")
|
||||
if not isinstance(raw_ips, list):
|
||||
return 400, {"error": "live_source_ips (list of strings) is required"}
|
||||
live = [ip for ip in raw_ips if isinstance(ip, str) and ip]
|
||||
grace = data.get("grace_seconds")
|
||||
kwargs = (
|
||||
{"grace_seconds": float(grace)}
|
||||
if isinstance(grace, (int, float)) and not isinstance(grace, bool)
|
||||
else {}
|
||||
)
|
||||
return 200, {"reaped": orch.reconcile(live, **kwargs)}
|
||||
|
||||
if method == "POST" and route == "/attribute":
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
|
||||
@@ -32,7 +32,6 @@ import hmac
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -43,12 +42,6 @@ from ..paths import host_db_path
|
||||
# 256 bits of urandom, URL-safe — unguessable per-bottle identity token.
|
||||
IDENTITY_TOKEN_BYTES = 32
|
||||
|
||||
# How recently a row must have been registered to be exempt from
|
||||
# `reap_absent`. Covers the window between `container run` and the address
|
||||
# becoming visible to another launch's enumeration, so reconciliation never
|
||||
# reaps a bottle that is still coming up.
|
||||
DEFAULT_REAP_GRACE_SECONDS = 120.0
|
||||
|
||||
|
||||
def new_identity_token() -> str:
|
||||
"""A fresh per-bottle identity token (PRD 0070 attribution defence)."""
|
||||
@@ -232,70 +225,6 @@ class RegistryStore(DbStore):
|
||||
).fetchall()
|
||||
return [_row_to_record(r) for r in rows]
|
||||
|
||||
def reap_absent(
|
||||
self,
|
||||
live_source_ips: Iterable[str],
|
||||
*,
|
||||
grace_seconds: float = DEFAULT_REAP_GRACE_SECONDS,
|
||||
now: float | None = None,
|
||||
) -> list[BottleRecord]:
|
||||
"""Delete active rows whose source IP is not held by a live bottle.
|
||||
|
||||
A row only ever leaves the registry two ways: an explicit
|
||||
`teardown_bottle` (the launcher's cleanup callback) or the supersede
|
||||
sweep in `register`. Neither runs when the launching CLI dies hard —
|
||||
SIGKILL, a closed terminal, a host sleep/crash — so the row outlives
|
||||
its container. That orphan is not inert: source IPs are recycled by
|
||||
the backend's DHCP, and `by_source_ip` fail-closes on ambiguity, so a
|
||||
leftover row at a reused address can brick the *next* bottle that
|
||||
lands on it (no policy resolved -> every host denied, reported to the
|
||||
agent as "not in the allowlist"). Reconciling against the live set at
|
||||
launch keeps the registry from accumulating those landmines.
|
||||
|
||||
Restores the invariant the data plane needs: **at most one active row
|
||||
per live address, and none at all for a dead one.** Two cases, because
|
||||
a dead bottle's address may already have been handed to a live one:
|
||||
|
||||
* no live bottle holds the address — every row there is an orphan;
|
||||
* a live bottle holds it but several rows claim it — the newest
|
||||
registration is authoritative and the rest are orphans, the same
|
||||
rule `register`'s same-IP supersede sweep applies. Without this
|
||||
second case a recycled address stays ambiguous, which is exactly
|
||||
the state that resolves no policy.
|
||||
|
||||
`grace_seconds` protects an in-flight launch: registration happens
|
||||
moments after `container run`, and a concurrent launch's address may
|
||||
not be visible to the caller's enumeration yet. Rows younger than the
|
||||
grace window are never reaped, so reconciliation can't race a bottle
|
||||
that is still coming up. Returns the deleted records."""
|
||||
live = {ip for ip in live_source_ips if ip}
|
||||
cutoff = (time.time() if now is None else now) - grace_seconds
|
||||
with self._connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM orchestrator_bottles WHERE state = 'active'",
|
||||
).fetchall()
|
||||
by_ip: dict[str, list[BottleRecord]] = {}
|
||||
for row in rows:
|
||||
rec = _row_to_record(row)
|
||||
by_ip.setdefault(rec.source_ip, []).append(rec)
|
||||
candidates: list[BottleRecord] = []
|
||||
for ip, recs in by_ip.items():
|
||||
if ip not in live:
|
||||
candidates.extend(recs)
|
||||
continue
|
||||
# Keep the newest claim on a live address; supersede the rest.
|
||||
recs.sort(key=lambda r: r.created_at)
|
||||
candidates.extend(recs[:-1])
|
||||
doomed = [r for r in candidates if r.created_at <= cutoff]
|
||||
for rec in doomed:
|
||||
conn.execute(
|
||||
"DELETE FROM orchestrator_bottles WHERE bottle_id = ?",
|
||||
(rec.bottle_id,),
|
||||
)
|
||||
if doomed:
|
||||
self._chmod()
|
||||
return doomed
|
||||
|
||||
def by_source_ip(self, source_ip: str) -> BottleRecord | None:
|
||||
"""Network-layer attribution: the single active bottle at this source
|
||||
IP, or None if unknown or ambiguous (more than one — a
|
||||
@@ -333,5 +262,4 @@ __all__ = [
|
||||
"new_identity_token",
|
||||
"default_db_path",
|
||||
"IDENTITY_TOKEN_BYTES",
|
||||
"DEFAULT_REAP_GRACE_SECONDS",
|
||||
]
|
||||
|
||||
@@ -13,20 +13,15 @@ Launch lifecycle:
|
||||
and returns the record. If the broker rejects/fails, the registry entry
|
||||
is rolled back so a failed launch leaves no orphan.
|
||||
* `teardown_bottle` sends a signed teardown request, then deregisters.
|
||||
* `reconcile` sweeps rows whose bottle is no longer running — the
|
||||
self-heal for the teardown paths that never got to run (a hard-killed
|
||||
launcher), since an orphan row at a recycled source IP bricks the next
|
||||
bottle that lands on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .broker import LaunchBroker, LaunchRequest, sign_request
|
||||
from .registry import DEFAULT_REAP_GRACE_SECONDS, BottleRecord, RegistryStore
|
||||
from .registry import BottleRecord, RegistryStore
|
||||
from .gateway import Gateway
|
||||
from ..supervise import (
|
||||
AuditEntry,
|
||||
@@ -122,30 +117,6 @@ class Orchestrator:
|
||||
self._tokens.pop(bottle_id, None)
|
||||
return True
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
live_source_ips: Iterable[str],
|
||||
*,
|
||||
grace_seconds: float = DEFAULT_REAP_GRACE_SECONDS,
|
||||
) -> list[str]:
|
||||
"""Drop registry rows for bottles that are no longer running, and
|
||||
forget their in-memory egress tokens. Returns the reaped bottle ids.
|
||||
|
||||
The caller supplies the live set because only the host can enumerate
|
||||
its own containers — the orchestrator runs *inside* the infra
|
||||
container and has no view of the backend. Deliberately does not
|
||||
broker a teardown: the container is already gone, so there is nothing
|
||||
to stop, and a broker error must not stop the sweep from clearing
|
||||
the row that would otherwise brick the next bottle at that address.
|
||||
|
||||
See `RegistryStore.reap_absent` for why orphans accumulate and why
|
||||
they are harmful rather than merely untidy."""
|
||||
reaped = self.registry.reap_absent(
|
||||
live_source_ips, grace_seconds=grace_seconds)
|
||||
for rec in reaped:
|
||||
self._tokens.pop(rec.bottle_id, None)
|
||||
return [rec.bottle_id for rec in reaped]
|
||||
|
||||
def tokens_for(self, bottle_id: str) -> dict[str, str]:
|
||||
"""The bottle's in-memory egress auth tokens (env_name -> value), or
|
||||
empty. The gateway injects these per request; they are never
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# 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.
|
||||
@@ -1,86 +0,0 @@
|
||||
# Egress proxy OOMs on large downloads
|
||||
|
||||
Found on 2026-07-21 while running the rootless-podman spike
|
||||
(`docs/research/rootless-docker-in-apple-container-spike.md`). Recorded
|
||||
rather than fixed — the fix is a security-relevant decision, not a
|
||||
mechanical patch.
|
||||
|
||||
## Summary
|
||||
|
||||
A single large HTTPS download through the gateway kills the egress
|
||||
proxy. `mitmdump` buffers whole response bodies so the DLP detectors can
|
||||
scan them, grows past the gateway container's memory limit, and is
|
||||
OOM-killed by the cgroup. Nothing restarts it.
|
||||
|
||||
Two properties make this worse than a failed download:
|
||||
|
||||
- **The gateway is a per-host singleton.** Every bottle shares it, so
|
||||
one bottle's download takes egress away from all of them.
|
||||
- **There is no restart on death.** The gateway supervisor is
|
||||
`while : ; do wait ; done`; a killed daemon stays dead until the infra
|
||||
container is recreated.
|
||||
|
||||
So ordinary agent activity — pulling a container image, downloading a
|
||||
model or dataset, fetching a large tarball — is a denial of service
|
||||
against every other bottle on the host. No malice required, though it is
|
||||
trivially reachable on purpose.
|
||||
|
||||
## Evidence
|
||||
|
||||
Triggered by `docker compose up` pulling `quay.io/fedora/python-312`
|
||||
(two layers, ~82MB and ~83MB) inside a bottle. The pull itself
|
||||
succeeded; the *next* request failed:
|
||||
|
||||
```
|
||||
initializing source docker://quay.io/fedora/python-312:latest:
|
||||
pinging container registry quay.io: Get "https://quay.io/v2/":
|
||||
proxyconnect tcp: dial tcp 192.168.128.39:9099: connect: connection refused
|
||||
```
|
||||
|
||||
From the gateway's `dmesg`:
|
||||
|
||||
```
|
||||
python3 invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0
|
||||
oom-kill:constraint=CONSTRAINT_MEMCG,
|
||||
oom_memcg=/container/bot-bottle-mac-infra,
|
||||
task_memcg=/container/bot-bottle-mac-infra,task=mitmdump,pid=118
|
||||
Memory cgroup out of memory: Killed process 118 (mitmdump)
|
||||
total-vm:1391936kB, anon-rss:997768kB
|
||||
```
|
||||
|
||||
~1GB RSS against a 1024MB container. Note the amplification: ~165MB of
|
||||
layers produced ~1GB of resident memory, so the buffering is several
|
||||
copies deep (encoded body, decoded body, and the text conversion the
|
||||
regex detectors scan).
|
||||
|
||||
Afterwards the gateway container was still running and healthy-looking —
|
||||
orchestrator, supervise, and git-http all alive — with no `mitmdump`
|
||||
process at all, and it stayed that way until the container was
|
||||
recreated. A liveness check on the container would not have caught this.
|
||||
|
||||
## Reproduction
|
||||
|
||||
1. Launch any bottle with an egress route to a host serving a large file.
|
||||
2. Download >~150MB over HTTPS through the proxy.
|
||||
3. `dmesg | grep -i oom` inside `bot-bottle-mac-infra`, and note that no
|
||||
`mitmdump` process remains.
|
||||
|
||||
Beware a false negative when checking: truncating the process listing
|
||||
(`cut -c1-45`) cuts before the binary name, because `mitmdump` runs as
|
||||
`/usr/local/bin/python3.12 /usr/local/bin/mitmdump …`.
|
||||
|
||||
## Fix options, not yet chosen
|
||||
|
||||
1. **Restart dead daemons.** Smallest change and strictly an
|
||||
improvement: an OOM then degrades one download instead of removing
|
||||
egress for every bottle. Does not stop the OOM.
|
||||
2. **Cap the scanned body size.** Above a threshold, stop buffering —
|
||||
either skip the scan or stream it. This is the root-cause fix and a
|
||||
security decision: a size threshold is exactly the hole an exfiltrator
|
||||
would aim for, so "skip above N" trades a DoS for a covert channel.
|
||||
Streaming with a bounded window keeps coverage, at more complexity.
|
||||
3. **Raise the gateway's memory limit.** Moves the threshold; does not
|
||||
remove it.
|
||||
|
||||
Worth noting that (1) and (2) are complementary — the restart gap is
|
||||
worth closing regardless of how the memory behaviour is resolved.
|
||||
@@ -1,353 +0,0 @@
|
||||
# Rootless Docker inside Apple Container bottles
|
||||
|
||||
Spike branch: `spike/rootless-docker-macos` (`a4d8461`)
|
||||
|
||||
## Summary
|
||||
|
||||
**Negative result.** Rootless Docker cannot run inside an Apple
|
||||
Container bottle without granting the bottle `CAP_SYS_ADMIN`. This is a
|
||||
kernel constraint on writing multi-range `uid_map`, not a packaging gap
|
||||
we can close with a better init script, a different base image, or more
|
||||
careful `/etc/subuid` handling.
|
||||
|
||||
The spike was built on the premise — stated in
|
||||
`bot_bottle/backend/macos_container/rootless_docker.py` — that it would
|
||||
*"deliberately refuse to compensate for missing prerequisites with outer
|
||||
capabilities, a privileged container, or a host Docker socket."* That
|
||||
premise is exactly what the experiment falsified. The two ways forward
|
||||
are to abandon the premise (add `CAP_SYS_ADMIN` to the bottle, and with
|
||||
it most of the isolation the bottle exists to provide) or to abandon
|
||||
rootless Docker.
|
||||
|
||||
Recommendation: abandon rootless Docker. Podman does not have this
|
||||
problem — see [Podman is not blocked by
|
||||
this](#podman-is-not-blocked-by-this) below.
|
||||
|
||||
## Local environment
|
||||
|
||||
Tested on 2026-07-21:
|
||||
|
||||
```console
|
||||
$ sw_vers
|
||||
ProductName: macOS
|
||||
ProductVersion: 26.5.1
|
||||
BuildVersion: 25F80
|
||||
|
||||
$ container --version
|
||||
container CLI version 1.0.0 (build: release, commit: ee848e3)
|
||||
|
||||
$ uname -a # inside the bottle
|
||||
Linux ... 6.18.15 #1 SMP Tue Mar 17 01:36:53 UTC 2026 aarch64 GNU/Linux
|
||||
```
|
||||
|
||||
## The failure
|
||||
|
||||
`tests/integration/test_macos_rootless_docker_spike.py` builds the
|
||||
image, launches the bottle, and dies in `rootless_docker.start`:
|
||||
|
||||
```
|
||||
+ exec rootlesskit --net=slirp4netns --mtu=65520 ... dockerd-rootless.sh
|
||||
[rootlesskit:parent] error: failed to setup UID/GID map:
|
||||
newuidmap 1100 [0 1000 1 1 100000 65536] failed:
|
||||
newuidmap: write to uid_map failed: Operation not permitted
|
||||
```
|
||||
|
||||
## Why it fails
|
||||
|
||||
Every prerequisite you would normally suspect is present and correct in
|
||||
the guest:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `/usr/bin/newuidmap` | `-rwsr-xr-x root root` — setuid bit intact, survived the OCI export |
|
||||
| `/` mount options | `rw,relatime` — **not** `nosuid` |
|
||||
| `NoNewPrivs` | `0` |
|
||||
| `Seccomp` | `0`, no filters |
|
||||
| `/etc/subuid`, `/etc/subgid` | `node:100000:65536` in both |
|
||||
| user namespace | `user:[4026531837]`, identical to pid 1 — the *initial* userns |
|
||||
| `unshare -U -r true` | succeeds |
|
||||
| `/proc/sys/user/max_user_namespaces` | `4505` |
|
||||
|
||||
The one thing that is missing is in the capability bounding set that
|
||||
Apple Container gives the container:
|
||||
|
||||
```
|
||||
CapBnd: 00000000a80425fb
|
||||
= chown, dac_override, fowner, fsetid, kill, setgid, setuid, setpcap,
|
||||
net_bind_service, net_raw, sys_chroot, mknod, audit_write, setfcap
|
||||
```
|
||||
|
||||
No `CAP_SYS_ADMIN`. That is the whole story, and the chain is:
|
||||
|
||||
1. The kernel's `map_write()` gates writing a `uid_map` on
|
||||
`file_ns_capable(file, ns, CAP_SYS_ADMIN)` — capability over the
|
||||
**new** user namespace, evaluated against the credentials that opened
|
||||
`/proc/<pid>/uid_map`.
|
||||
2. `newuidmap` is setuid-root, so it runs with euid 0 — but its
|
||||
capability sets are clamped by the bounding set, which has no
|
||||
`CAP_SYS_ADMIN`.
|
||||
3. `cap_capable()` has a shortcut that grants *all* capabilities when
|
||||
the caller's userns is the new namespace's parent **and**
|
||||
`ns->owner == cred->euid`. It does not apply: the namespace was
|
||||
created by `node` (uid 1000) while `newuidmap` runs as euid 0.
|
||||
4. So the check falls through to the effective-set test in the initial
|
||||
userns, which fails. `EPERM`.
|
||||
|
||||
Note that the single-line unprivileged path (`unshare -U -r`) works
|
||||
precisely because it does not go through `newuidmap` and does not need
|
||||
`CAP_SYS_ADMIN`. Only the multi-range subuid mapping that rootless
|
||||
Docker requires does.
|
||||
|
||||
This is the same constraint that makes upstream's `dind-rootless` image
|
||||
require `--privileged`. It is not specific to Apple Container, except
|
||||
that Apple Container gives us no bounding set that includes
|
||||
`CAP_SYS_ADMIN` by default.
|
||||
|
||||
## It does work with the capability — which is the point
|
||||
|
||||
Adding the capability clears the failure immediately, and exposes one
|
||||
further, much smaller blocker: `/dev/net/tun` exists (the kernel has
|
||||
tun; `/proc/misc` lists `200 tun`) but Apple Container creates it
|
||||
`crw------- root root`, so uid 1000 cannot open it and `slirp4netns`
|
||||
fails with `open: Permission denied`. A `chmod 0666 /dev/net/tun` as
|
||||
root inside the bottle fixes that, and needs no capability beyond what
|
||||
the bottle already has.
|
||||
|
||||
With both applied by hand, the daemon comes up completely:
|
||||
|
||||
```console
|
||||
$ container run --rm -u root --cap-add CAP_SYS_ADMIN \
|
||||
bot-bottle-claude:latest-rootless-docker sh -c '...'
|
||||
Server Version: 20.10.24+dfsg1
|
||||
Storage Driver: fuse-overlayfs
|
||||
Cgroup Driver: none
|
||||
Cgroup Version: 2
|
||||
API listen on /tmp/rt/docker.sock
|
||||
```
|
||||
|
||||
So `rootless-docker-init.sh` and `rootless_docker.py` are *correct*.
|
||||
The spike did not fail on a bug. It failed on its own premise.
|
||||
|
||||
Two secondary findings from that run, relevant if anyone revisits this:
|
||||
|
||||
- Debian's `docker.io` package pins Docker **20.10** (EOL), not the 28.x
|
||||
implied by the `docker:28-cli` compose plugin the image copies in.
|
||||
- `Cgroup Driver: none` — no resource limits on nested containers.
|
||||
|
||||
## Why we should not just add the capability
|
||||
|
||||
`CAP_SYS_ADMIN` is close to a superset of "root" in practical terms —
|
||||
mount, `pivot_root`, namespace manipulation, and a long tail of
|
||||
subsystem-specific powers. Granting it to the agent bottle would
|
||||
undercut the containment argument the rest of the backend is built
|
||||
around, including the deliberately narrow choices immediately adjacent
|
||||
to it in `launch.py` (`--cap-drop CAP_NET_RAW`, no `NET_ADMIN`, a
|
||||
host-only agent network). Trading all of that for nested `docker
|
||||
compose` is a bad exchange.
|
||||
|
||||
## Podman is not blocked by this
|
||||
|
||||
Sanity-checked on the same host, same kernel, same runtime, so the
|
||||
comparison is apples to apples:
|
||||
|
||||
| Scenario | Result |
|
||||
| --- | --- |
|
||||
| Podman rootless, `/etc/subuid` populated | **Fails identically** — `newuidmap: write to uid_map failed: Operation not permitted` |
|
||||
| Podman rootless, no subuid ranges, `--network=host` | **Works**, no added capabilities |
|
||||
| Podman rootless, no subuid ranges, default netns, `/dev/net/tun` at `0600` | Fails — `slirp4netns: open("/dev/net/tun"): Permission denied` |
|
||||
| Podman rootless, no subuid ranges, default netns, `/dev/net/tun` at `0666` | **Works**, no added capabilities |
|
||||
|
||||
The difference is that podman degrades gracefully when no subuid range
|
||||
is available: it falls back to a single-UID self-mapping, which an
|
||||
unprivileged process may write itself, so `newuidmap` is never invoked
|
||||
and `CAP_SYS_ADMIN` is never needed. Docker's rootless mode has no
|
||||
equivalent fallback.
|
||||
|
||||
The cost of that fallback is real and should be weighed before building
|
||||
on it: with a single-UID mapping, every UID inside a nested container
|
||||
collapses onto the bottle's own uid 1000. There is no UID separation
|
||||
between the agent and anything it runs — `root` in a nested container is
|
||||
the agent user outside it. It also requires `ignore_chown_errors` on the
|
||||
storage driver. Whether that is acceptable depends on whether the bottle
|
||||
boundary (which is unchanged) or the nested-container boundary (which is
|
||||
effectively nil) is the one we are relying on.
|
||||
|
||||
## What the podman spike then needed
|
||||
|
||||
The podman implementation that replaced the Docker one on this branch
|
||||
turned up two more device-node blockers of the same shape as
|
||||
`/dev/net/tun` — Apple Container creates the node, but 0600 root:root:
|
||||
|
||||
- **`/dev/fuse`** — blocks the `fuse-overlayfs` storage driver
|
||||
(`fuse: failed to open /dev/fuse: Permission denied`). Without it the
|
||||
only working driver is `vfs`, which copies whole layers per container.
|
||||
- **`/dev/net/tun`** — blocks `slirp4netns`, which rootless podman uses
|
||||
for the default bridge network.
|
||||
|
||||
Both are fixed by `chmod 0666` as root inside the bottle, which needs no
|
||||
capability the bottle does not already hold. This is categorically
|
||||
different from the `CAP_SYS_ADMIN` requirement: it is a permission on a
|
||||
node that already exists, not an outer privilege grant.
|
||||
|
||||
One design note worth recording: the agent-facing surface stays `docker`
|
||||
and `docker compose`, pointed at podman's Docker-compatible API socket
|
||||
via `DOCKER_HOST`. Setting `netns="host"` in `containers.conf` does *not*
|
||||
propagate through that compat API — stock `docker run` and compose files
|
||||
request bridge networking explicitly — so slirp4netns (and therefore the
|
||||
`/dev/net/tun` chmod) is required for ordinary compose files to work at
|
||||
all. Host networking remains available per-workload via
|
||||
`--network=host`.
|
||||
|
||||
Verified working in a bottle with zero added capabilities: fuse-overlayfs
|
||||
storage, the compat API socket, `docker run` on both bridge and host
|
||||
networking, and published ports.
|
||||
|
||||
### Nested pulls collide with our own egress DLP
|
||||
|
||||
The first live run got podman up and `docker compose` running, then
|
||||
failed on the image pull:
|
||||
|
||||
```
|
||||
web Pulling
|
||||
initializing source docker://python:3.12-alpine: reading manifest ...
|
||||
StatusCode: 403, egress DLP: Generic Bearer JWT found in body
|
||||
```
|
||||
|
||||
This is bot-bottle's own egress scanner, not a podman problem. The
|
||||
Docker registry auth flow carries a bearer JWT *by protocol*, and the
|
||||
`token_patterns` detector's `Generic Bearer JWT` rule
|
||||
(`Bearer\s+[A-Za-z0-9._\-]{50,}`) matches it on every pull. Any bottle
|
||||
that pulls images will hit this.
|
||||
|
||||
The fix is per-route detector scoping, which the egress config already
|
||||
supports — drop `token_patterns` on the registry hosts and keep
|
||||
`known_secrets`:
|
||||
|
||||
```json
|
||||
{"host": "registry-1.docker.io",
|
||||
"dlp": {"outbound_detectors": ["known_secrets"]}}
|
||||
```
|
||||
|
||||
That is the right trade rather than a grudging one: `known_secrets`
|
||||
matches the bottle's *actual* credential values, so real exfil through a
|
||||
registry host is still caught. `token_patterns` on a registry route only
|
||||
ever produces protocol noise.
|
||||
|
||||
Worth generalising later: any manifest enabling `docker_access` needs
|
||||
this on its registry routes, so it probably belongs in a shared
|
||||
registry-route snippet rather than being copy-pasted per bottle.
|
||||
|
||||
### And then registry auth collides with the Authorization strip
|
||||
|
||||
With DLP scoped, the pull failed differently: `unauthorized:
|
||||
authentication required`. This one is architectural.
|
||||
|
||||
`egress_addon.py` strips agent-set `Authorization` unconditionally
|
||||
before forwarding — deliberately, so an agent cannot smuggle a
|
||||
credential out in a header the DLP detectors don't recognise. A route
|
||||
may carry gateway-injected auth instead, but only from a *static* token
|
||||
in an env var (`auth_scheme` + `token_env`).
|
||||
|
||||
Docker registry auth doesn't fit that shape. The client fetches a
|
||||
short-lived, per-repository-scope bearer token from `auth.docker.io` and
|
||||
presents it to `registry-1.docker.io`. There is no static token to
|
||||
inject, and the token the client legitimately obtained is stripped.
|
||||
|
||||
Measured inside a bottle, by hand:
|
||||
|
||||
| Step | Result |
|
||||
| --- | --- |
|
||||
| Fetch token from `auth.docker.io` | 200, 5409-byte token body |
|
||||
| Manifest request **with** that valid token | 401 |
|
||||
| Manifest request with **no** Authorization | 401 — identical |
|
||||
|
||||
A valid token behaves exactly like sending none, which is direct
|
||||
evidence the header never arrives. Any nested-container workflow that
|
||||
pulls from a registry is blocked on this, so it is not a detail that can
|
||||
be deferred: pulling base images is most of what nested containers are
|
||||
for.
|
||||
|
||||
### Registries that skip the token dance work today
|
||||
|
||||
Not every registry needs the stripped header. Measured directly:
|
||||
|
||||
| Registry | Manifest request with no `Authorization` |
|
||||
| --- | --- |
|
||||
| `quay.io` | 200 |
|
||||
| `mcr.microsoft.com` | 200 |
|
||||
| `registry.k8s.io` | 307 (redirect, no auth) |
|
||||
| `ghcr.io` | 401 |
|
||||
| `registry-1.docker.io` | 401 |
|
||||
|
||||
So "just add the registry to the bottle config" genuinely works — for
|
||||
quay, MCR, registry.k8s.io, or any unauthenticated internal registry.
|
||||
Docker Hub and GHCR are the ones that need the strip resolved. The
|
||||
acceptance test uses quay for exactly this reason.
|
||||
|
||||
Resolving it for Docker Hub means picking one of:
|
||||
|
||||
1. **Per-route opt-in to preserve client Authorization.** Smallest
|
||||
change. Note the compounding effect on exactly these routes: the DLP
|
||||
scoping above already removed `token_patterns` there, so a
|
||||
preserved-auth registry route is one where the agent may send bearer
|
||||
tokens that neither the strip nor the pattern detector inspects.
|
||||
`known_secrets` still applies, so the bottle's real credentials are
|
||||
still caught.
|
||||
2. **A registry-aware gateway** that performs the token dance itself and
|
||||
injects the result. Preserves the invariant fully; materially more
|
||||
work, and it makes the gateway speak a specific registry protocol.
|
||||
3. **Pre-seed images at provision time** (host-side `container image
|
||||
save` into podman storage), so bottles never pull at runtime.
|
||||
Preserves the invariant, and limits nested containers to
|
||||
pre-approved images — which fits the custody positioning, at the cost
|
||||
of no ad-hoc `docker pull`.
|
||||
4. **Stop.** Nested containers are not supported on this backend.
|
||||
|
||||
### Podman 4.3.1 silently swallows container exit codes
|
||||
|
||||
Debian bookworm — which the current agent base image is built on —
|
||||
ships podman 4.3.1. Through its Docker-compatible API, `docker run`
|
||||
returns 0 no matter what the container did:
|
||||
|
||||
| Command | podman 4.3.1 | podman 5.4.2 |
|
||||
| --- | --- | --- |
|
||||
| `docker run … sh -c 'exit 7'` (compat API) | **0** | 7 |
|
||||
| `docker run … sh -c 'exit 0'` (compat API) | 0 | 0 |
|
||||
| `podman run … sh -c 'exit 7'` (native) | 7 | 7 |
|
||||
|
||||
This is worse than a broken feature: every failing command an agent runs
|
||||
via `docker run` reports success. A test suite, a build step, or a CI
|
||||
script inside a bottle would pass while failing. It also silently
|
||||
defeated the acceptance test's egress-containment assertion, which is
|
||||
why that assertion now checks an in-band marker rather than an exit
|
||||
code.
|
||||
|
||||
Podman 5.4.2 (Debian trixie) fixes it, but needs two packages that
|
||||
bookworm's podman does not: `passt` (podman 5's default network tool)
|
||||
and `nftables` (netavark shells out to `nft`; without it every run fails
|
||||
with `unable to upgrade to tcp, received 500`). With both installed,
|
||||
exit codes propagate correctly and the compat API behaves.
|
||||
|
||||
The open question this leaves is where podman 5 comes from, since the
|
||||
agent base is bookworm-based:
|
||||
|
||||
1. **Move the agent images to Debian trixie.** Trixie is current stable.
|
||||
Correct, and the blast radius is every bottle, not just this feature.
|
||||
2. **Drop the compat socket and use podman natively** (`podman-docker`
|
||||
provides a `docker` shim; compose comes from `podman-compose`).
|
||||
Native podman propagates exit codes correctly even on 4.3.1. Contained
|
||||
to this feature, at the cost of `docker compose` becoming
|
||||
`docker-compose`/`podman-compose`.
|
||||
3. **Ship bookworm's podman 4.3.1 with the compat socket** — not viable.
|
||||
Silent false success is a correctness bug agents cannot see.
|
||||
|
||||
## Recommendation
|
||||
|
||||
1. Do not revive rootless Docker on this backend. This document is the
|
||||
record of why.
|
||||
2. Nested containers, if wanted, come from podman under the
|
||||
single-mapping constraint — with the explicit understanding that the
|
||||
nested-container boundary carries no security weight. `root` in a
|
||||
nested container is the agent user outside it.
|
||||
3. Nested containers are therefore a build/test convenience. The bottle
|
||||
remains the security boundary, exactly as it was.
|
||||
+28
-8
@@ -1,15 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Combined unit + integration coverage (see docs/decisions/0004-coverage-policy.md).
|
||||
#
|
||||
# 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.
|
||||
# Two modes:
|
||||
#
|
||||
# Usage:
|
||||
# scripts/coverage.sh # combined report
|
||||
# scripts/coverage.sh critical # also report just the critical modules
|
||||
# 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%).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
@@ -21,6 +25,22 @@ 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
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Live-Mac acceptance spike for guest-local rootless podman (issue #392).
|
||||
|
||||
Run explicitly on an Apple Silicon/macOS 26 host:
|
||||
|
||||
BOT_BOTTLE_ROOTLESS_PODMAN_SPIKE=1 \
|
||||
python3 -m unittest tests.integration.test_macos_rootless_podman_spike -v
|
||||
|
||||
The opt-in is deliberate: ordinary Linux CI cannot execute Apple Container.
|
||||
|
||||
Podman rather than Docker because Apple Container's capability bounding set
|
||||
omits CAP_SYS_ADMIN; see
|
||||
docs/research/rootless-docker-in-apple-container-spike.md. The agent-facing
|
||||
surface is still `docker` and `docker compose`, which talk to podman's
|
||||
Docker-compatible API socket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
platform.system() == "Darwin"
|
||||
and os.environ.get("BOT_BOTTLE_ROOTLESS_PODMAN_SPIKE") == "1",
|
||||
"requires an explicit live-Mac rootless-podman spike run",
|
||||
)
|
||||
class TestMacosRootlessPodmanSpike(unittest.TestCase):
|
||||
def test_compose_stays_inside_registered_bottle(self) -> None:
|
||||
workspace = Path(tempfile.mkdtemp(prefix="rootless-podman-spike."))
|
||||
stage = Path(tempfile.mkdtemp(prefix="rootless-podman-stage."))
|
||||
try:
|
||||
(workspace / "index.html").write_text("bottle-compose-ok\n")
|
||||
(workspace / "compose.yaml").write_text(
|
||||
"services:\n"
|
||||
" web:\n"
|
||||
" image: quay.io/prometheus/busybox\n"
|
||||
" working_dir: /workspace\n"
|
||||
" command: httpd -f -p 8000 -h /workspace\n"
|
||||
" volumes: ['.:/workspace']\n"
|
||||
" ports: ['18080:8000']\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {
|
||||
"docker_access": True,
|
||||
# A deliberately tiny image. Pulling a ~165MB one
|
||||
# OOM-kills the shared egress proxy, which buffers whole
|
||||
# response bodies to scan them — a real defect, but a
|
||||
# separate one from what this test covers. See the
|
||||
# research note.
|
||||
#
|
||||
# quay.io deliberately, not Docker Hub: the egress proxy
|
||||
# strips agent-set Authorization (so an agent cannot
|
||||
# smuggle a credential out in a header), and Docker Hub
|
||||
# requires a client-fetched, per-scope bearer token that
|
||||
# the strip therefore removes. quay serves manifests with
|
||||
# no Authorization at all, so a plain route is enough.
|
||||
#
|
||||
# token_patterns is still scoped off: registry traffic
|
||||
# carries bearer JWTs by protocol and trips the generic
|
||||
# rule. known_secrets stays on — it matches the bottle's
|
||||
# own credentials, which is the detector that catches
|
||||
# real exfil.
|
||||
"egress": {"routes": [
|
||||
{"host": "quay.io", "dlp": {
|
||||
"outbound_detectors": ["known_secrets"],
|
||||
}},
|
||||
{"host": "cdn01.quay.io", "dlp": {
|
||||
"outbound_detectors": ["known_secrets"],
|
||||
}},
|
||||
]},
|
||||
}},
|
||||
"agents": {"spike": {
|
||||
"bottle": "dev", "skills": [], "prompt": "",
|
||||
}},
|
||||
})
|
||||
spec = BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name="spike",
|
||||
copy_cwd=True,
|
||||
user_cwd=str(workspace),
|
||||
)
|
||||
backend = get_bottle_backend("macos-container")
|
||||
plan = backend.prepare(spec, stage_dir=stage)
|
||||
with backend.launch(plan) as bottle:
|
||||
workdir = plan.workspace_plan.workdir
|
||||
checks = (
|
||||
"docker info >/dev/null && docker compose version && "
|
||||
f"cd {workdir} && docker compose up -d --wait && "
|
||||
"curl --fail --silent http://127.0.0.1:18080/ | "
|
||||
"grep -q bottle-compose-ok"
|
||||
)
|
||||
result = bottle.exec(checks)
|
||||
self.assertEqual(
|
||||
0, result.returncode,
|
||||
f"stdout={result.stdout!r}\nstderr={result.stderr!r}",
|
||||
)
|
||||
# podman's compat API reports rootlessness through its own
|
||||
# native endpoint; the Docker-shaped SecurityOptions field does
|
||||
# not carry it.
|
||||
inspect = bottle.exec(
|
||||
"podman info --format '{{.Host.Security.Rootless}}'"
|
||||
)
|
||||
self.assertIn("true", inspect.stdout.lower())
|
||||
self.assertEqual(
|
||||
0,
|
||||
bottle.exec(
|
||||
"test \"$(id -u)\" -ne 0"
|
||||
).returncode,
|
||||
"the podman service must not be running as bottle root",
|
||||
)
|
||||
self.assertNotEqual(
|
||||
0,
|
||||
bottle.exec("test -S /var/run/docker.sock").returncode,
|
||||
"spike must never expose a host/rootful Docker socket",
|
||||
)
|
||||
# Asserted on an in-band marker, not on `docker run`'s exit
|
||||
# code: podman 4.3.1's Docker-compat API swallows the
|
||||
# container's status and returns 0 for everything, so an
|
||||
# exit-code assertion here passes whether egress was blocked
|
||||
# or wide open. That silent false pass is worse than no check
|
||||
# at all, and it is exactly this check — the one proving a
|
||||
# nested container cannot escape the egress path.
|
||||
#
|
||||
# busybox ships wget, so a failure here means egress was
|
||||
# refused rather than the binary being absent.
|
||||
direct = bottle.exec(
|
||||
"docker run --rm --env HTTP_PROXY= --env HTTPS_PROXY= "
|
||||
"--env http_proxy= --env https_proxy= "
|
||||
"quay.io/prometheus/busybox sh -c "
|
||||
"'wget -T 4 -qO- https://evil.example.com/ "
|
||||
"&& echo ESCAPED || echo CONTAINED'"
|
||||
)
|
||||
self.assertIn(
|
||||
"CONTAINED", direct.stdout,
|
||||
"an inner container obtained direct, unproxied egress: "
|
||||
f"stdout={direct.stdout!r} stderr={direct.stderr!r}",
|
||||
)
|
||||
self.assertNotIn("ESCAPED", direct.stdout)
|
||||
finally:
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,14 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.egress_addon_core import (
|
||||
DENY_RESOLVER_ERROR,
|
||||
DENY_UNATTRIBUTED,
|
||||
DENY_UNPARSEABLE,
|
||||
decide,
|
||||
resolve_client_config,
|
||||
resolve_client_context,
|
||||
)
|
||||
from bot_bottle.egress_addon_core import resolve_client_config, resolve_client_context
|
||||
from bot_bottle.policy_resolver import PolicyResolveError
|
||||
|
||||
|
||||
@@ -115,55 +108,3 @@ class TestResolveClientContext(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestDenyReasonNamesTheRealFault(unittest.TestCase):
|
||||
"""A deny-all must not masquerade as a missing allowlist entry.
|
||||
|
||||
Regression: an unregistered bottle resolves no policy, so *every* host is
|
||||
denied — but the block message said `host X is not in the allowlist`,
|
||||
which reads as a config problem and sends the operator hunting for a route
|
||||
that was never missing. The structural reason wins over that wording.
|
||||
"""
|
||||
|
||||
def _reason(self, resolver: object, host: str = "chatgpt.com") -> str:
|
||||
cfg = resolve_client_config(resolver, "10.243.0.1") # type: ignore[arg-type]
|
||||
return decide(cfg.routes, host, "/v1/x", {}, deny_reason=cfg.deny_reason).reason
|
||||
|
||||
def test_unattributed_says_unattributed_not_allowlist(self) -> None:
|
||||
reason = self._reason(_FakeResolver(result=None))
|
||||
self.assertEqual(DENY_UNATTRIBUTED, reason)
|
||||
# The misleading claim is the one that must be gone: the host was
|
||||
# never "not in the allowlist" — there was no allowlist at all.
|
||||
self.assertNotIn("is not in the bottle's egress.routes allowlist", reason)
|
||||
# Both causes must be named. `/resolve` fail-closes on a missing row
|
||||
# *and* on a token mismatch, and the message pointing only at the row
|
||||
# sent us hunting for a deregistered bottle that was registered fine.
|
||||
self.assertIn("registry row", reason)
|
||||
self.assertIn("identity token", reason)
|
||||
|
||||
def test_resolver_error_says_orchestrator_unreachable(self) -> None:
|
||||
self.assertEqual(DENY_RESOLVER_ERROR, self._reason(_FakeResolver(raises=True)))
|
||||
|
||||
def test_unparseable_policy_says_so(self) -> None:
|
||||
self.assertEqual(
|
||||
DENY_UNPARSEABLE, self._reason(_FakeResolver(result="routes: notalist\n")))
|
||||
|
||||
def test_a_real_allowlist_miss_keeps_the_allowlist_wording(self) -> None:
|
||||
"""The message only changes for structural deny-alls — a loaded policy
|
||||
that genuinely lacks the host still points at the allowlist."""
|
||||
reason = self._reason(_FakeResolver(result='routes:\n - host: "api.example.com"\n'))
|
||||
self.assertIn("is not in the bottle's egress.routes allowlist", reason)
|
||||
self.assertIn("chatgpt.com", reason)
|
||||
|
||||
def test_allowed_host_is_still_forwarded(self) -> None:
|
||||
cfg = resolve_client_config(
|
||||
_FakeResolver(result='routes:\n - host: "api.example.com"\n'), "10.243.0.1")
|
||||
decision = decide(
|
||||
cfg.routes, "api.example.com", "/v1/x", {}, deny_reason=cfg.deny_reason)
|
||||
self.assertEqual("forward", decision.action)
|
||||
|
||||
def test_a_parsed_policy_carries_no_deny_reason(self) -> None:
|
||||
cfg = resolve_client_config(
|
||||
_FakeResolver(result='routes:\n - host: "api.example.com"\n'), "10.243.0.1")
|
||||
self.assertEqual("", cfg.deny_reason)
|
||||
|
||||
@@ -87,8 +87,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"{_MOD}.live_source_ips", return_value=[]):
|
||||
patch(f"{_MOD}.provision_git_gate", provision or Mock()):
|
||||
return register_agent(
|
||||
_egress_plan(), _git_plan(),
|
||||
source_ip=source_ip, endpoint=_endpoint(), image_ref="img:1",
|
||||
@@ -135,104 +134,3 @@ class TestTeardown(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestLiveSourceIps(unittest.TestCase):
|
||||
"""The reconciliation input: the host enumerates its own bottles because
|
||||
the orchestrator, inside the infra container, cannot see the backend."""
|
||||
|
||||
def _agents(self, *slugs: str) -> list[Mock]:
|
||||
return [Mock(slug=s) for s in slugs]
|
||||
|
||||
def test_maps_slugs_to_container_addresses(self) -> None:
|
||||
from bot_bottle.backend.macos_container.consolidated_launch import live_source_ips
|
||||
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
|
||||
patch(f"{_MOD}.container_mod.inspect_container_network_ip",
|
||||
side_effect=["10.0.0.1", "10.0.0.2"]) as ip:
|
||||
got = live_source_ips("net0")
|
||||
self.assertEqual(["10.0.0.1", "10.0.0.2"], got)
|
||||
self.assertEqual("bot-bottle-a", ip.call_args_list[0].args[0])
|
||||
|
||||
def test_containers_without_an_address_are_skipped(self) -> None:
|
||||
"""A container that hasn't been given a DHCP address yet contributes
|
||||
nothing — the reap's grace window, not this list, protects it."""
|
||||
from bot_bottle.backend.macos_container.consolidated_launch import live_source_ips
|
||||
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
|
||||
patch(f"{_MOD}.container_mod.inspect_container_network_ip",
|
||||
side_effect=["", "10.0.0.2"]):
|
||||
self.assertEqual(["10.0.0.2"], live_source_ips("net0"))
|
||||
|
||||
def test_container_list_failure_raises(self) -> None:
|
||||
"""If container list fails, the live set is not authoritative and
|
||||
reconciliation must be skipped."""
|
||||
from bot_bottle.backend.macos_container.consolidated_launch import live_source_ips
|
||||
from bot_bottle.backend.macos_container.enumerate import EnumerationError
|
||||
with patch(f"{_MOD}.enumerate_active",
|
||||
side_effect=EnumerationError("container list failed")):
|
||||
with self.assertRaises(EnumerationError):
|
||||
live_source_ips("net0")
|
||||
|
||||
def test_per_container_inspect_failure_raises(self) -> None:
|
||||
"""If any individual inspect fails, the live set is not authoritative."""
|
||||
from bot_bottle.backend.macos_container.consolidated_launch import live_source_ips
|
||||
from bot_bottle.backend.macos_container.enumerate import EnumerationError
|
||||
with patch(f"{_MOD}.enumerate_active", return_value=self._agents("a", "b")), \
|
||||
patch(f"{_MOD}.container_mod.inspect_container_network_ip",
|
||||
side_effect=["10.0.0.1", None]):
|
||||
with self.assertRaises(EnumerationError):
|
||||
live_source_ips("net0")
|
||||
|
||||
|
||||
class TestRegisterAgentReconciles(unittest.TestCase):
|
||||
"""Registration self-heals the registry first: an orphan row at a recycled
|
||||
address makes attribution ambiguous, which resolves no policy at all and
|
||||
denies every host for the bottle being launched."""
|
||||
|
||||
def _register(self, client: Mock) -> None:
|
||||
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_MOD}.provision_git_gate"), \
|
||||
patch(f"{_MOD}.live_source_ips", return_value=["10.0.0.7"]):
|
||||
register_agent(
|
||||
_egress_plan(), _git_plan(),
|
||||
source_ip="10.0.0.7", endpoint=_endpoint(),
|
||||
)
|
||||
|
||||
def test_reconciles_before_registering(self) -> None:
|
||||
client = _client()
|
||||
calls: list[str] = []
|
||||
|
||||
def _reconcile(*_args: object, **_kwargs: object) -> list[str]:
|
||||
calls.append("reconcile")
|
||||
return []
|
||||
|
||||
def _register_bottle(*_args: object, **_kwargs: object) -> RegisteredBottle:
|
||||
calls.append("register")
|
||||
return RegisteredBottle("b1", "tok")
|
||||
|
||||
client.reconcile.side_effect = _reconcile
|
||||
client.register_bottle.side_effect = _register_bottle
|
||||
self._register(client)
|
||||
self.assertEqual(["reconcile", "register"], calls)
|
||||
client.reconcile.assert_called_once_with(["10.0.0.7"])
|
||||
|
||||
def test_a_reconcile_failure_does_not_block_the_launch(self) -> None:
|
||||
from bot_bottle.orchestrator.client import OrchestratorClientError
|
||||
client = _client()
|
||||
client.reconcile.side_effect = OrchestratorClientError("unreachable")
|
||||
self._register(client)
|
||||
client.register_bottle.assert_called_once()
|
||||
|
||||
def test_enumeration_error_does_not_block_the_launch(self) -> None:
|
||||
"""A partial container listing must not abort the launch — skip
|
||||
reconciliation and proceed, just as with an unreachable orchestrator."""
|
||||
from bot_bottle.backend.macos_container.enumerate import EnumerationError
|
||||
client = _client()
|
||||
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_MOD}.provision_git_gate"), \
|
||||
patch(f"{_MOD}.live_source_ips",
|
||||
side_effect=EnumerationError("container list failed")):
|
||||
register_agent(
|
||||
_egress_plan(), _git_plan(),
|
||||
source_ip="10.0.0.7", endpoint=_endpoint(),
|
||||
)
|
||||
client.register_bottle.assert_called_once()
|
||||
|
||||
@@ -66,10 +66,8 @@ class TestMacosContainerEnumerate(unittest.TestCase):
|
||||
agents = self._enumerate("bot-bottle-mac-infra\nbot-bottle-dev-abc\n")
|
||||
self.assertEqual(["dev-abc"], [a.slug for a in agents])
|
||||
|
||||
def test_raises_when_the_cli_fails(self):
|
||||
from bot_bottle.backend.macos_container.enumerate import EnumerationError
|
||||
with self.assertRaises(EnumerationError):
|
||||
self._enumerate("", returncode=1)
|
||||
def test_empty_when_the_cli_fails(self):
|
||||
self.assertEqual([], self._enumerate("", returncode=1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -22,7 +22,6 @@ from bot_bottle.backend.macos_container.launch import (
|
||||
_agent_run_argv,
|
||||
_identity_proxy_env,
|
||||
)
|
||||
from bot_bottle.backend.macos_container.rootless_podman import guest_env
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
_BOTTLE = "bot_bottle.backend.macos_container.bottle"
|
||||
@@ -77,7 +76,6 @@ def _plan(
|
||||
),
|
||||
agent_git_gate_url=agent_git_gate_url,
|
||||
agent_supervise_url=agent_supervise_url,
|
||||
docker_access=False,
|
||||
))
|
||||
|
||||
|
||||
@@ -180,18 +178,6 @@ class TestIdentityTokenDelivery(unittest.TestCase):
|
||||
self.assertNotIn("--env", argv)
|
||||
|
||||
|
||||
class TestRootlessPodmanEnvironment(unittest.TestCase):
|
||||
def test_disabled_bottle_gets_no_docker_environment(self) -> None:
|
||||
self.assertEqual({}, guest_env(False))
|
||||
|
||||
def test_enabled_bottle_uses_only_guest_local_socket(self) -> None:
|
||||
env = guest_env(True)
|
||||
self.assertEqual(
|
||||
"unix:///tmp/bot-bottle-podman-run/podman.sock", env["DOCKER_HOST"],
|
||||
)
|
||||
self.assertNotIn("/var/run/docker.sock", " ".join(env.values()))
|
||||
|
||||
|
||||
class TestPlanIdentityToken(unittest.TestCase):
|
||||
"""git-gate's gitconfig extraHeader and the supervise MCP --header read
|
||||
`getattr(plan, "identity_token", "")` at provision time and both bypass the
|
||||
|
||||
@@ -334,50 +334,6 @@ class TestInspectDigests(unittest.TestCase):
|
||||
self.assertEqual({}, util.container_env("x"))
|
||||
|
||||
|
||||
class TestInspectContainerNetworkIp(unittest.TestCase):
|
||||
"""inspect_container_network_ip must distinguish inspect failure (None)
|
||||
from 'no DHCP address yet' (""), which is the invariant live_source_ips
|
||||
relies on to skip reconciliation on partial snapshots."""
|
||||
|
||||
_NETWORK = "bot-bottle-mac-gateway"
|
||||
|
||||
def _inspect(self, stdout: str, returncode: int = 0) -> str | None:
|
||||
cp = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=returncode, stdout=stdout, stderr="",
|
||||
)
|
||||
with patch.object(util.subprocess, "run", return_value=cp):
|
||||
return util.inspect_container_network_ip("bot-bottle-abc", self._NETWORK)
|
||||
|
||||
def _entry(self, ip: str = "192.168.128.5") -> str:
|
||||
return (
|
||||
f'[{{"status":{{"networks":['
|
||||
f'{{"network":"{self._NETWORK}","ipv4Address":"{ip}"}}'
|
||||
f']}}}}]'
|
||||
)
|
||||
|
||||
def test_returns_ip_when_inspect_succeeds(self) -> None:
|
||||
self.assertEqual("192.168.128.5", self._inspect(self._entry()))
|
||||
|
||||
def test_strips_cidr_prefix(self) -> None:
|
||||
self.assertEqual("192.168.128.5", self._inspect(self._entry("192.168.128.5/24")))
|
||||
|
||||
def test_returns_empty_string_when_no_address_assigned_yet(self) -> None:
|
||||
no_ip = f'[{{"status":{{"networks":[{{"network":"{self._NETWORK}","ipv4Address":""}}]}}}}]'
|
||||
self.assertEqual("", self._inspect(no_ip))
|
||||
|
||||
def test_returns_empty_string_when_network_list_absent(self) -> None:
|
||||
self.assertEqual("", self._inspect('[{"status":{}}]'))
|
||||
|
||||
def test_returns_none_on_nonzero_exit(self) -> None:
|
||||
self.assertIsNone(self._inspect("", returncode=1))
|
||||
|
||||
def test_returns_none_on_malformed_json(self) -> None:
|
||||
self.assertIsNone(self._inspect("not-json"))
|
||||
|
||||
def test_returns_none_on_unexpected_json_shape(self) -> None:
|
||||
self.assertIsNone(self._inspect("null"))
|
||||
|
||||
|
||||
class TestWaitContainerIpv4(unittest.TestCase):
|
||||
def test_returns_address_once_dhcp_assigns_it(self):
|
||||
with patch.object(util, "try_container_ipv4_on_network", side_effect=["", "", "192.168.128.4"]), \
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Unit coverage for the fail-closed macOS rootless-podman spike."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.macos_container import rootless_podman
|
||||
from bot_bottle.backend.macos_container import launch as launch_mod
|
||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||
|
||||
|
||||
class _Bottle:
|
||||
def __init__(self, results: list[SimpleNamespace]) -> None:
|
||||
self.results = results
|
||||
self.commands: list[str] = []
|
||||
|
||||
def exec(self, command: str) -> SimpleNamespace:
|
||||
self.commands.append(command)
|
||||
return self.results.pop(0)
|
||||
|
||||
|
||||
def _result(returncode: int, *, stdout: str = "", stderr: str = "") -> SimpleNamespace:
|
||||
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AgentProvision:
|
||||
image: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Plan:
|
||||
slug: str
|
||||
image: str
|
||||
dockerfile_path: str
|
||||
docker_access: bool
|
||||
agent_provision: _AgentProvision
|
||||
|
||||
|
||||
class TestRootlessPodmanStart(unittest.TestCase):
|
||||
def test_bootstraps_then_waits_for_guest_local_service(self) -> None:
|
||||
bottle = _Bottle([_result(0), _result(1), _result(0)])
|
||||
with patch.object(rootless_podman.time, "sleep"):
|
||||
rootless_podman.start(bottle)
|
||||
self.assertIn("rootless-podman-init", bottle.commands[0])
|
||||
self.assertEqual(2, bottle.commands.count("docker info >/dev/null 2>&1"))
|
||||
|
||||
def test_bootstrap_failure_is_fatal_without_privilege_fallback(self) -> None:
|
||||
bottle = _Bottle([_result(1, stderr="slirp4netns missing")])
|
||||
with patch.object(rootless_podman, "die", side_effect=RuntimeError) as die:
|
||||
with self.assertRaises(RuntimeError):
|
||||
rootless_podman.start(bottle)
|
||||
self.assertIn("slirp4netns missing", die.call_args.args[0])
|
||||
self.assertEqual(1, len(bottle.commands))
|
||||
|
||||
def test_timeout_reports_guest_log(self) -> None:
|
||||
bottle = _Bottle(
|
||||
[_result(0)]
|
||||
+ [_result(1) for _ in range(rootless_podman.READY_RETRIES)]
|
||||
+ [_result(0, stdout="operation not permitted")]
|
||||
)
|
||||
with patch.object(rootless_podman.time, "sleep"), \
|
||||
patch.object(rootless_podman, "die", side_effect=RuntimeError) as die:
|
||||
with self.assertRaises(RuntimeError):
|
||||
rootless_podman.start(bottle)
|
||||
self.assertIn("operation not permitted", die.call_args.args[0])
|
||||
|
||||
|
||||
class TestRootlessPodmanDevices(unittest.TestCase):
|
||||
def test_relaxes_only_the_two_blocked_device_nodes_as_root(self) -> None:
|
||||
calls: list[tuple[str, list[str]]] = []
|
||||
rootless_podman.prepare_guest_devices(
|
||||
"bottle-1", lambda name, argv: calls.append((name, argv)),
|
||||
)
|
||||
self.assertEqual(1, len(calls))
|
||||
name, argv = calls[0]
|
||||
self.assertEqual("bottle-1", name)
|
||||
self.assertIn("chmod 0666 /dev/fuse /dev/net/tun", argv[-1])
|
||||
|
||||
|
||||
class TestRootlessPodmanImage(unittest.TestCase):
|
||||
def test_layers_tooling_without_changing_base_image(self) -> None:
|
||||
calls: list[tuple[str, str, str]] = []
|
||||
|
||||
def build(image: str, context: str, *, dockerfile: str) -> None:
|
||||
calls.append((image, context, dockerfile))
|
||||
text = Path(dockerfile).read_text(encoding="utf-8")
|
||||
self.assertIn("FROM agent:base", text)
|
||||
self.assertIn("podman fuse-overlayfs slirp4netns uidmap", text)
|
||||
self.assertIn("USER node", text)
|
||||
self.assertTrue((Path(context) / "rootless-podman-init.sh").is_file())
|
||||
|
||||
image = rootless_podman.build_image("agent:base", build)
|
||||
self.assertEqual("agent:base-rootless-podman", image)
|
||||
self.assertEqual("agent:base-rootless-podman", calls[0][0])
|
||||
|
||||
def test_strips_subordinate_ranges_so_podman_avoids_newuidmap(self) -> None:
|
||||
"""The single-UID fallback is the entire reason podman works here.
|
||||
|
||||
A subordinate range would send podman down the newuidmap path, which
|
||||
cannot write a multi-range uid_map without CAP_SYS_ADMIN in an Apple
|
||||
Container guest — the failure that killed the rootless-Docker spike.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
|
||||
def build(image: str, context: str, *, dockerfile: str) -> None:
|
||||
seen.append(Path(dockerfile).read_text(encoding="utf-8"))
|
||||
|
||||
rootless_podman.build_image("agent:base", build)
|
||||
text = seen[0]
|
||||
self.assertIn("sed -i '/^node:/d' /etc/subuid /etc/subgid", text)
|
||||
self.assertNotIn("subuid", text.replace(
|
||||
"sed -i '/^node:/d' /etc/subuid /etc/subgid", "",
|
||||
))
|
||||
|
||||
def test_launch_builds_base_then_rootless_variant(self) -> None:
|
||||
plan = cast(MacosContainerBottlePlan, cast(object, _Plan(
|
||||
slug="dev-abc",
|
||||
image="agent:base",
|
||||
dockerfile_path="/repo/Dockerfile",
|
||||
docker_access=True,
|
||||
agent_provision=_AgentProvision(image="agent:base"),
|
||||
)))
|
||||
with patch.object(launch_mod, "read_committed_image", return_value=None), \
|
||||
patch.object(launch_mod.container_mod, "build_image") as build, \
|
||||
patch.object(
|
||||
launch_mod.rootless_podman,
|
||||
"build_image",
|
||||
return_value="agent:base-rootless-podman",
|
||||
) as build_rootless:
|
||||
result = launch_mod._build_images(plan) # pylint: disable=protected-access
|
||||
|
||||
build.assert_called_once_with(
|
||||
"agent:base", launch_mod._REPO_DIR, # pylint: disable=protected-access
|
||||
dockerfile="/repo/Dockerfile",
|
||||
)
|
||||
build_rootless.assert_called_once_with("agent:base", build)
|
||||
self.assertEqual("agent:base-rootless-podman", result.agent_provision.image)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -56,12 +56,6 @@ class TestMergeBottlesRuntime(unittest.TestCase):
|
||||
result = merge_bottles_runtime([base, override])
|
||||
self.assertFalse(result.supervise)
|
||||
|
||||
def test_docker_access_later_wins(self):
|
||||
result = merge_bottles_runtime([
|
||||
_bottle(docker_access=False), _bottle(docker_access=True),
|
||||
])
|
||||
self.assertTrue(result.docker_access)
|
||||
|
||||
def test_three_bottles_merged_left_to_right(self):
|
||||
b1 = _bottle(env={"A": "1", "B": "1", "C": "1"})
|
||||
b2 = _bottle(env={"B": "2", "C": "2"})
|
||||
|
||||
@@ -44,20 +44,13 @@ class TestBottleValidation(unittest.TestCase):
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestBottle.from_dict("b", {"supervise": "yes"})
|
||||
|
||||
def test_docker_access_not_bool(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestBottle.from_dict("b", {"docker_access": "yes"})
|
||||
|
||||
def test_removed_runtime_field(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestBottle.from_dict("b", {"runtime": "runsc"})
|
||||
|
||||
def test_valid_minimal(self) -> None:
|
||||
b = ManifestBottle.from_dict(
|
||||
"b", {"supervise": False, "docker_access": True, "env": {"X": "1"}},
|
||||
)
|
||||
b = ManifestBottle.from_dict("b", {"supervise": False, "env": {"X": "1"}})
|
||||
self.assertFalse(b.supervise)
|
||||
self.assertTrue(b.docker_access)
|
||||
self.assertEqual({"X": "1"}, dict(b.env))
|
||||
|
||||
|
||||
|
||||
@@ -104,32 +104,3 @@ class TestHealthAndPolicy(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestReconcile(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.c = OrchestratorClient("http://orch:8080")
|
||||
|
||||
def test_posts_live_ips_and_returns_reaped(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"reaped": ["b1", "b2"]})) as m:
|
||||
got = self.c.reconcile(["10.0.0.2", "10.0.0.3"])
|
||||
self.assertEqual(["b1", "b2"], got)
|
||||
sent = json.loads(m.call_args.args[0].data)
|
||||
self.assertEqual(["10.0.0.2", "10.0.0.3"], sent["live_source_ips"])
|
||||
self.assertNotIn("grace_seconds", sent) # omitted -> server default
|
||||
|
||||
def test_grace_seconds_is_forwarded_when_given(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"reaped": []})) as m:
|
||||
self.c.reconcile([], grace_seconds=30)
|
||||
self.assertEqual(30, json.loads(m.call_args.args[0].data)["grace_seconds"])
|
||||
|
||||
def test_malformed_reaped_is_tolerated(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"reaped": ["ok", 5, None]})):
|
||||
self.assertEqual(["ok"], self.c.reconcile([]))
|
||||
with patch(_URLOPEN, return_value=_resp(200, {})):
|
||||
self.assertEqual([], self.c.reconcile([]))
|
||||
|
||||
def test_error_status_raises(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=_http_error(500)):
|
||||
with self.assertRaises(OrchestratorClientError):
|
||||
self.c.reconcile([])
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Unit: OrchestratorConfigStore and resolve_teardown_timeout.
|
||||
|
||||
Also verifies the lifecycle ordering invariant: resolve_teardown_timeout()
|
||||
must be called before launch_consolidated() / register_agent() so that a
|
||||
resolver failure cannot leave an orphaned registration with no teardown
|
||||
callback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
from bot_bottle.orchestrator.config_store import (
|
||||
DEFAULT_TEARDOWN_TIMEOUT_SECONDS,
|
||||
TEARDOWN_TIMEOUT_ENV,
|
||||
OrchestratorConfigStore,
|
||||
resolve_teardown_timeout,
|
||||
)
|
||||
|
||||
|
||||
class TestOrchestratorConfigStore(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db = Path(self._tmp.name) / "test.db"
|
||||
self.store = OrchestratorConfigStore(self.db)
|
||||
self.store.migrate()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_get_returns_none_when_not_set(self) -> None:
|
||||
self.assertIsNone(self.store.get_teardown_timeout_seconds())
|
||||
|
||||
def test_set_and_get_roundtrip(self) -> None:
|
||||
self.store.set_teardown_timeout_seconds(42.5)
|
||||
self.assertEqual(42.5, self.store.get_teardown_timeout_seconds())
|
||||
|
||||
def test_set_overwrites_existing_value(self) -> None:
|
||||
self.store.set_teardown_timeout_seconds(10.0)
|
||||
self.store.set_teardown_timeout_seconds(20.0)
|
||||
self.assertEqual(20.0, self.store.get_teardown_timeout_seconds())
|
||||
|
||||
def test_delete_clears_value_and_returns_true(self) -> None:
|
||||
self.store.set_teardown_timeout_seconds(30.0)
|
||||
deleted = self.store.delete_teardown_timeout_seconds()
|
||||
self.assertTrue(deleted)
|
||||
self.assertIsNone(self.store.get_teardown_timeout_seconds())
|
||||
|
||||
def test_delete_absent_returns_false(self) -> None:
|
||||
self.assertFalse(self.store.delete_teardown_timeout_seconds())
|
||||
|
||||
def test_is_migrated_true_after_migrate(self) -> None:
|
||||
self.assertTrue(self.store.is_migrated())
|
||||
|
||||
def test_is_migrated_false_before_migrate(self) -> None:
|
||||
store = OrchestratorConfigStore(Path(self._tmp.name) / "new.db")
|
||||
self.assertFalse(store.is_migrated())
|
||||
|
||||
|
||||
class TestResolveTeardownTimeout(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db = Path(self._tmp.name) / "cfg.db"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
os.environ.pop(TEARDOWN_TIMEOUT_ENV, None)
|
||||
|
||||
def test_returns_default_when_nothing_configured(self) -> None:
|
||||
self.assertEqual(
|
||||
DEFAULT_TEARDOWN_TIMEOUT_SECONDS,
|
||||
resolve_teardown_timeout(self.db),
|
||||
)
|
||||
|
||||
def test_env_var_overrides_default(self) -> None:
|
||||
os.environ[TEARDOWN_TIMEOUT_ENV] = "99"
|
||||
self.assertEqual(99.0, resolve_teardown_timeout(self.db))
|
||||
|
||||
def test_env_var_overrides_db_value(self) -> None:
|
||||
store = OrchestratorConfigStore(self.db)
|
||||
store.migrate()
|
||||
store.set_teardown_timeout_seconds(55.0)
|
||||
os.environ[TEARDOWN_TIMEOUT_ENV] = "77"
|
||||
self.assertEqual(77.0, resolve_teardown_timeout(self.db))
|
||||
|
||||
def test_db_value_overrides_default(self) -> None:
|
||||
store = OrchestratorConfigStore(self.db)
|
||||
store.migrate()
|
||||
store.set_teardown_timeout_seconds(42.0)
|
||||
self.assertEqual(42.0, resolve_teardown_timeout(self.db))
|
||||
|
||||
def test_invalid_env_var_falls_through_to_default(self) -> None:
|
||||
os.environ[TEARDOWN_TIMEOUT_ENV] = "not-a-number"
|
||||
self.assertEqual(DEFAULT_TEARDOWN_TIMEOUT_SECONDS, resolve_teardown_timeout(self.db))
|
||||
|
||||
def test_non_positive_env_var_falls_through_to_default(self) -> None:
|
||||
os.environ[TEARDOWN_TIMEOUT_ENV] = "0"
|
||||
self.assertEqual(DEFAULT_TEARDOWN_TIMEOUT_SECONDS, resolve_teardown_timeout(self.db))
|
||||
|
||||
def test_non_positive_db_value_falls_through_to_default(self) -> None:
|
||||
store = OrchestratorConfigStore(self.db)
|
||||
store.migrate()
|
||||
store.set_teardown_timeout_seconds(0.0)
|
||||
self.assertEqual(DEFAULT_TEARDOWN_TIMEOUT_SECONDS, resolve_teardown_timeout(self.db))
|
||||
|
||||
def test_migrates_db_on_first_call(self) -> None:
|
||||
result = resolve_teardown_timeout(self.db)
|
||||
self.assertEqual(DEFAULT_TEARDOWN_TIMEOUT_SECONDS, result)
|
||||
self.assertTrue(OrchestratorConfigStore(self.db).is_migrated())
|
||||
|
||||
|
||||
class TestTeardownTimeoutResolvedBeforeRegistration(unittest.TestCase):
|
||||
"""Ordering invariant: if resolve_teardown_timeout() raises, the bottle
|
||||
must not yet be registered — no orphaned state can result."""
|
||||
|
||||
def _src(self, module: ModuleType) -> str:
|
||||
return inspect.getsource(module)
|
||||
|
||||
def test_docker_resolves_timeout_before_launch_consolidated(self) -> None:
|
||||
from bot_bottle.backend.docker import launch
|
||||
src = self._src(launch)
|
||||
resolve_at = src.index("teardown_timeout = resolve_teardown_timeout()")
|
||||
launch_at = src.index("ctx = launch_consolidated(")
|
||||
self.assertLess(resolve_at, launch_at)
|
||||
|
||||
def test_firecracker_resolves_timeout_before_launch_consolidated(self) -> None:
|
||||
from bot_bottle.backend.firecracker import launch
|
||||
src = self._src(launch)
|
||||
resolve_at = src.index("teardown_timeout = resolve_teardown_timeout()")
|
||||
launch_at = src.index("ctx = launch_consolidated(")
|
||||
self.assertLess(resolve_at, launch_at)
|
||||
|
||||
def test_macos_resolves_timeout_before_register_agent(self) -> None:
|
||||
from bot_bottle.backend.macos_container import launch
|
||||
src = self._src(launch)
|
||||
resolve_at = src.index("teardown_timeout = resolve_teardown_timeout()")
|
||||
register_at = src.index("ctx = register_agent(")
|
||||
self.assertLess(resolve_at, register_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,13 +8,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -266,7 +264,6 @@ class TestControlPlaneAuth(unittest.TestCase):
|
||||
("POST", "/bottles", _body({"source_ip": "10.0.0.1"})),
|
||||
("PUT", "/bottles/x/policy", _body({"policy": "routes: []"})),
|
||||
("DELETE", "/bottles/x", b""),
|
||||
("POST", "/reconcile", _body({"live_source_ips": []})),
|
||||
("POST", "/resolve", _body({"source_ip": "10.0.0.1", "identity_token": "t"})),
|
||||
("POST", "/attribute", _body({"source_ip": "10.0.0.1", "identity_token": "t"})),
|
||||
("GET", "/supervise/proposals", b""),
|
||||
@@ -390,56 +387,3 @@ class TestDispatchSupervise(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestReconcileRoute(unittest.TestCase):
|
||||
"""`POST /reconcile` — the host tells the orchestrator which bottles are
|
||||
actually up, since the orchestrator can't see the backend from inside the
|
||||
infra container."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.orch = _orchestrator(Path(self._tmp.name) / "r.db")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _old(self, source_ip: str) -> str:
|
||||
rec = self.orch.registry.register(source_ip)
|
||||
with closing(sqlite3.connect(self.orch.registry.db_path)) as conn:
|
||||
conn.execute(
|
||||
"UPDATE orchestrator_bottles SET created_at = 0.0 WHERE bottle_id = ?",
|
||||
(rec.bottle_id,))
|
||||
conn.commit()
|
||||
return rec.bottle_id
|
||||
|
||||
def test_reaps_absent_and_reports_ids(self) -> None:
|
||||
dead = self._old("10.0.0.1")
|
||||
alive = self._old("10.0.0.2")
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile", _body({"live_source_ips": ["10.0.0.2"]}))
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([dead], payload["reaped"])
|
||||
self.assertIsNone(self.orch.registry.get(dead))
|
||||
self.assertIsNotNone(self.orch.registry.get(alive))
|
||||
|
||||
def test_missing_live_source_ips_is_400(self) -> None:
|
||||
status, _ = dispatch(self.orch, "POST", "/reconcile", _body({}))
|
||||
self.assertEqual(400, status)
|
||||
|
||||
def test_grace_seconds_is_honoured(self) -> None:
|
||||
"""A grace window wide enough to cover the row protects it."""
|
||||
self.orch.registry.register("10.0.0.3")
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile",
|
||||
_body({"live_source_ips": [], "grace_seconds": 3600}))
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([], payload["reaped"])
|
||||
|
||||
def test_non_string_entries_are_ignored(self) -> None:
|
||||
dead = self._old("10.0.0.4")
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile",
|
||||
_body({"live_source_ips": [None, 7, "10.0.0.9"]}))
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([dead], payload["reaped"])
|
||||
|
||||
@@ -4,9 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.orchestrator.registry import (
|
||||
@@ -171,81 +169,3 @@ class TestRegistryStore(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestReapAbsent(unittest.TestCase):
|
||||
"""`reap_absent` — the self-heal for rows whose bottle is gone.
|
||||
|
||||
An orphan is not merely untidy: source IPs get recycled, and
|
||||
`by_source_ip` fail-closes on ambiguity, so a leftover row at a reused
|
||||
address resolves *no* policy for the next bottle that lands there and
|
||||
every host it asks for is denied.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.db = Path(self._tmp.name) / "registry.db"
|
||||
self.store = RegistryStore(self.db)
|
||||
self.store.migrate()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _aged(self, source_ip: str, *, age: float) -> BottleRecord:
|
||||
"""Register a bottle and backdate it past the grace window."""
|
||||
rec = self.store.register(source_ip)
|
||||
with closing(sqlite3.connect(self.db)) as conn:
|
||||
conn.execute(
|
||||
"UPDATE orchestrator_bottles SET created_at = ? WHERE bottle_id = ?",
|
||||
(time.time() - age, rec.bottle_id),
|
||||
)
|
||||
conn.commit()
|
||||
return rec
|
||||
|
||||
def test_reaps_row_with_no_live_container(self) -> None:
|
||||
gone = self._aged("10.243.0.9", age=600)
|
||||
reaped = self.store.reap_absent([])
|
||||
self.assertEqual([gone.bottle_id], [r.bottle_id for r in reaped])
|
||||
self.assertIsNone(self.store.get(gone.bottle_id))
|
||||
|
||||
def test_keeps_row_whose_ip_is_live(self) -> None:
|
||||
alive = self._aged("10.243.0.9", age=600)
|
||||
self.assertEqual([], self.store.reap_absent(["10.243.0.9"]))
|
||||
self.assertIsNotNone(self.store.get(alive.bottle_id))
|
||||
|
||||
def test_grace_window_protects_an_in_flight_launch(self) -> None:
|
||||
"""A bottle registered moments ago is never reaped, even though the
|
||||
caller's enumeration didn't see its address yet."""
|
||||
fresh = self.store.register("10.243.0.10")
|
||||
self.assertEqual([], self.store.reap_absent([]))
|
||||
self.assertIsNotNone(self.store.get(fresh.bottle_id))
|
||||
|
||||
def test_reaping_the_orphan_unbricks_the_reused_address(self) -> None:
|
||||
"""The regression this exists for: an orphan at an address that vmnet
|
||||
later hands to a new bottle makes `by_source_ip` ambiguous, so the new
|
||||
bottle resolves no policy at all."""
|
||||
orphan = self._aged("10.243.0.11", age=600)
|
||||
# A new bottle lands on the recycled address. Force the row in directly
|
||||
# so `register`'s own supersede sweep doesn't mask the ambiguity.
|
||||
with closing(sqlite3.connect(self.db)) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO orchestrator_bottles "
|
||||
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) "
|
||||
"VALUES ('newbottle', '10.243.0.11', 'tok-new', 'active', ?, '', 'routes: []')",
|
||||
(time.time(),),
|
||||
)
|
||||
conn.commit()
|
||||
self.assertIsNone(self.store.by_source_ip("10.243.0.11")) # bricked
|
||||
|
||||
reaped = self.store.reap_absent(["10.243.0.11"], grace_seconds=60)
|
||||
self.assertEqual([orphan.bottle_id], [r.bottle_id for r in reaped])
|
||||
rec = self.store.by_source_ip("10.243.0.11")
|
||||
assert rec is not None
|
||||
self.assertEqual("newbottle", rec.bottle_id)
|
||||
|
||||
def test_ignores_empty_ips_in_the_live_set(self) -> None:
|
||||
gone = self._aged("10.243.0.12", age=600)
|
||||
self.assertEqual(
|
||||
[gone.bottle_id],
|
||||
[r.bottle_id for r in self.store.reap_absent(["", "10.243.0.99"])],
|
||||
)
|
||||
|
||||
@@ -4,10 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -306,55 +304,3 @@ class TestOrchestratorSupervise(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestOrchestratorReconcile(unittest.TestCase):
|
||||
"""`reconcile` — drop rows for bottles that are no longer running."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.secret = secrets.token_bytes(16)
|
||||
self.db = Path(self._tmp.name) / "r.db"
|
||||
self.store = RegistryStore(self.db)
|
||||
self.store.migrate()
|
||||
self.broker = StubBroker(self.secret)
|
||||
self.orch = Orchestrator(self.store, self.broker, self.secret)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _age_all(self, seconds: float) -> None:
|
||||
"""Backdate every row past the reap grace window."""
|
||||
with closing(sqlite3.connect(self.db)) as conn:
|
||||
conn.execute(
|
||||
"UPDATE orchestrator_bottles SET created_at = created_at - ?", (seconds,))
|
||||
conn.commit()
|
||||
|
||||
def test_reaps_dead_bottle_and_forgets_its_tokens(self) -> None:
|
||||
dead = self.orch.launch_bottle("10.243.0.1", tokens={"EGRESS_TOKEN_0": "s3cret"})
|
||||
live = self.orch.launch_bottle("10.243.0.2", tokens={"EGRESS_TOKEN_0": "keep"})
|
||||
self._age_all(600)
|
||||
|
||||
self.assertEqual([dead.bottle_id], self.orch.reconcile(["10.243.0.2"]))
|
||||
self.assertIsNone(self.store.get(dead.bottle_id))
|
||||
self.assertIsNotNone(self.store.get(live.bottle_id))
|
||||
# The in-memory egress credential goes with the row.
|
||||
self.assertEqual({}, self.orch.tokens_for(dead.bottle_id))
|
||||
self.assertEqual({"EGRESS_TOKEN_0": "keep"}, self.orch.tokens_for(live.bottle_id))
|
||||
|
||||
def test_reconcile_does_not_broker_a_teardown(self) -> None:
|
||||
"""The container is already gone — there is nothing to stop, and a
|
||||
broker error must not stop the sweep clearing the row."""
|
||||
self.orch.launch_bottle("10.243.0.1")
|
||||
self._age_all(600)
|
||||
self.broker.launched.clear()
|
||||
self.orch.reconcile([])
|
||||
self.assertEqual([], self.broker.torn_down)
|
||||
|
||||
def test_reconcile_keeps_everything_when_all_are_live(self) -> None:
|
||||
a = self.orch.launch_bottle("10.243.0.1")
|
||||
b = self.orch.launch_bottle("10.243.0.2")
|
||||
self._age_all(600)
|
||||
self.assertEqual([], self.orch.reconcile(["10.243.0.1", "10.243.0.2"]))
|
||||
self.assertIsNotNone(self.store.get(a.bottle_id))
|
||||
self.assertIsNotNone(self.store.get(b.bottle_id))
|
||||
|
||||
Reference in New Issue
Block a user