Compare commits

..

10 Commits

Author SHA1 Message Date
didericis-codex fa3e45cc54 fix(firecracker): honor cached image policy
test / integration (pull_request) Successful in 9s
test / unit (pull_request) Successful in 32s
test / coverage (pull_request) Successful in 36s
lint / lint (push) Successful in 47s
2026-07-18 21:18:50 +00:00
didericis-claude 9004f3eb28 fix: correct host_db_path import and stale-check test expectations
test / integration (pull_request) Successful in 11s
test / unit (pull_request) Successful in 36s
lint / lint (push) Successful in 48s
tracker-policy-pr / check-pr (pull_request) Successful in 6s
test / coverage (pull_request) Successful in 36s
- config_store.py imported host_db_path from supervise_types (wrong);
  it lives in paths.py, matching all other stores (audit, queue, etc.)
- test_stale_checks: two tests expected check_stale called twice
  (agent + sidecar) — consolidated arch has no sidecar, so once is
  correct; update assertions and remove unused Path import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 17:02:48 -04:00
didericis-claude 899193f91b fix(lint): remove unused BottleImages imports from two test files
pyright strict reportUnusedImport flagged BottleImages in
test_docker_launch_committed_image.py and test_macos_container_launch.py;
neither file references the type by name (they only use the returned
value's attributes).
2026-07-18 17:02:48 -04:00
didericis-claude 46a6f79ec3 fix: remove redundant quoted annotations in BottleImages and abstract methods
`from __future__ import annotations` already defers all annotation
evaluation, so quoting `str | Path`, `BottleImages` inside the same
module was redundant and tripped pyright strict mode.
2026-07-18 17:02:48 -04:00
didericis-claude 665cd869fa refactor: BottleImages dataclass, prelaunch_checks, build_or_load_images
Addresses review comments 3098, 3099, 3100 on PR #336:

- Add BottleImages(agent, sidecar) dataclass to backend/__init__.py.
  Docker/macOS backends use str image refs; smolmachines uses Path
  artifacts. Replaces the singular `image` variable from the canonical
  pattern in comment 3100.

- Replace _image_stale_checks/skip_stale with public prelaunch_checks().
  CLI now calls backend.prelaunch_checks(plan) before backend.launch(plan);
  if StaleImageError is raised and the operator confirms, launch proceeds
  without re-checking. Removes the while-True/skip_stale retry loop.

- Add abstract _build_or_load_images(plan) -> BottleImages to
  BottleBackend. launch() calls it then passes images to _launch_impl.
  Each backend implements both methods.

- Fix comment 3098 (macos-container): _build_images is removed.
  build_or_load_images() has separate fresh/cached code paths — the
  cached path never calls a build helper.

- Update _start_bundle (smolmachines) to accept sidecar_artifact: Path
  directly. Sidecar artifact resolution moves to _sidecar_from_path(),
  called by build_or_load_images alongside _agent_from_path().
2026-07-18 17:02:48 -04:00
didericis-claude 3d08b102ac fix(tests): resolve pyright errors in stale-check test files
Remove unused imports, add missing type annotations, fix Die()
constructor calls (int code, not str), replace fake backend
subclass with patch.object approach to avoid reportMissingParameterType
and override-incompatibility errors in strict pyright mode.
2026-07-18 17:02:48 -04:00
didericis-claude dd547980e5 test: add unit tests for stale-image checks to reach ≥90% diff-coverage
Covers StaleImageError / check_stale / check_stale_path, the
BottleBackend.launch template method (skip_stale flag), stale_checks
functions across docker / smolmachines / macos-container backends,
_build_images cached paths in the macOS backend, image_created_at edge
cases in both docker and container util modules, the CLI stale loop
(headless die, interactive decline, interactive confirm + retry), and
ConfigStore.cached_image_stale_warning_days fallback paths.

Diff-coverage: 488/507 changed lines covered (96.3%).
2026-07-18 17:02:48 -04:00
didericis-claude e6ebbbfa97 refactor(image-cache): replace warn_if_stale with StaleImageError; add launch template
- image_cache: StaleImageError exception + check_stale/check_stale_path (raise instead of warn)
- BottleBackend.launch: template method (skip_stale flag) that calls _image_stale_checks then _launch_impl
- Each backend: _image_stale_checks delegates to a stale_checks() function in its launch module; _launch_impl replaces launch override
- macos_container: adds image_created_at to util, cached-image support in _build_images, stale_checks
- cli/start.py: catches StaleImageError, prompts interactively, retries with skip_stale=True; headless mode dies on it
2026-07-18 17:02:48 -04:00
didericis-codex 634d2a9bd6 fix: make config store schema explicit 2026-07-18 17:02:48 -04:00
didericis-codex 0acafb10c1 Add cached image quickstart 2026-07-18 17:02:48 -04:00
34 changed files with 1238 additions and 211 deletions
+26 -95
View File
@@ -4,15 +4,16 @@
# dependencies are required to execute it. Tests are split by directory: # dependencies are required to execute it. Tests are split by directory:
# #
# tests/unit/ — pure unit tests; always run # tests/unit/ — pure unit tests; always run
# tests/integration/ — need a reachable backend; skip cleanly when # tests/integration/ — need a reachable Docker daemon; skip cleanly
# the backend isn't available on the runner # (via tests/_docker.py:skip_unless_docker) when
# Docker isn't available on the runner
# tests/canaries/ — upstream regression canaries; run on a separate # tests/canaries/ — upstream regression canaries; run on a separate
# schedule (see canaries.yml), not here # schedule (see canaries.yml), not here
# #
# Integration tests run once per backend in separate jobs. Each job sets # This workflow assumes the Gitea Actions runner exposes the host Docker
# BOT_BOTTLE_BACKEND explicitly so the test suite uses the right backend. # socket to the job container so `docker` commands inside the job can
# Backends that aren't available on the runner fail the preflight step # reach the daemon. If that's not yet configured on the runner the
# rather than silently skipping inside the test output. # integration tests will skip rather than fail.
name: test name: test
@@ -22,16 +23,9 @@ on:
- main - main
paths: paths:
- '**.py' - '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
pull_request: pull_request:
paths: paths:
- '**.py' - '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
workflow_dispatch:
jobs: jobs:
unit: unit:
@@ -54,7 +48,7 @@ jobs:
- name: Report unit coverage - name: Report unit coverage
run: python3 -m coverage report -m run: python3 -m coverage report -m
integration-docker: integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -71,96 +65,33 @@ jobs:
echo "docker not on PATH — integration tests will skip" echo "docker not on PATH — integration tests will skip"
fi fi
- name: Run integration tests (docker) - name: Run integration tests
env:
BOT_BOTTLE_BACKEND: docker
run: python3 -m unittest discover -t . -s tests/integration -v run: python3 -m unittest discover -t . -s tests/integration -v
# Integration tests against the Firecracker backend. Runs on a self-hosted # Combined unit+integration coverage report (informational). See
# KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available. # docs/decisions/0004-coverage-policy.md.
# #
# Restricted to same-repo PRs, push to main, and workflow_dispatch — fork # The hard diff-coverage gate (changed lines >= 90%) is DEFERRED: the
# PRs don't execute untrusted code on the privileged runner. # Firecracker backend's VM/SSH orchestration is covered by the integration
# # suite, which needs /dev/kvm + the provisioned TAP/nft pool — a
# Runner prerequisites (provision once; see README "Firecracker on Linux"): # container-based runner skips it and those lines read uncovered, so the
# `firecracker` on PATH, `/dev/kvm` accessible, Docker, cached kernel + # gate can't pass here. Re-enabling it on a self-hosted KVM runner is
# static dropbear, and the pool as a persistent systemd unit. # tracked separately (see PRD 0069 / #348 and the ci-runner branch).
integration-firecracker:
runs-on: [self-hosted, kvm]
if: >-
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Preflight — Firecracker host is ready
run: |
command -v firecracker >/dev/null || {
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
# `backend status` exits non-zero unless the TAP pool is up + no
# range overlap; it prints the exact `backend setup` fix.
python3 cli.py backend status --backend=firecracker
# No dev-requirements install: the integration suite runs on stdlib
# `unittest` (pylint/pyright are lint.yml's concern, not this job's),
# and the self-hosted runner's Nix python env has no `pip` module
# (`python3 -m pip` → "No module named pip"). Nothing to install.
- name: Run integration tests (firecracker)
env:
BOT_BOTTLE_BACKEND: firecracker
run: python3 -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.
#
# This runs on a self-hosted KVM runner (label `kvm`), NOT ubuntu-latest,
# because the Firecracker backend's subprocess/VM orchestration
# (launch/boot/SSH/isolation-probe) is covered by the integration suite,
# and that suite needs `/dev/kvm` + the provisioned TAP/nft pool — which a
# container-based runner doesn't have. On such a runner the firecracker
# integration test skips and its ~230 orchestration lines read as
# uncovered, so the gate can't pass there.
#
# Restricted to the same events as integration-firecracker (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).
coverage: coverage:
timeout-minutes: 15 runs-on: ubuntu-latest
runs-on: [self-hosted, kvm]
if: >-
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Preflight — Firecracker host is ready # No actions/setup-python: the runner image already ships Python 3.12,
run: | # and older act_runner engines mishandle setup-python's PATH (coverage
command -v firecracker >/dev/null || { # lands in one interpreter, `python3` resolves to another). Install
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; } # straight into the ephemeral job container's system Python —
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; } # --break-system-packages is safe because the container is disposable.
# `backend status` exits non-zero unless the TAP pool is up + no - name: Install dev requirements
# range overlap; it prints the exact `backend setup` fix. run: python3 -m pip install --break-system-packages -r requirements-dev.txt
python3 cli.py backend status --backend=firecracker
# No dev-requirements install: `coverage` is already provided by the - name: Combined coverage report (unit + integration)
# self-hosted runner's Nix python env, and that env has no `pip`
# module to install into anyway. `scripts/coverage.sh` +
# `diff_coverage.py` need only `coverage` (not pylint/pyright).
- name: Combined coverage (unit + integration, incl. firecracker)
run: PYTHON=python3 bash scripts/coverage.sh critical run: PYTHON=python3 bash scripts/coverage.sh critical
- name: Diff-coverage gate (changed lines >= 90%)
run: |
git fetch --no-tags origin main:refs/remotes/origin/main
python3 scripts/diff_coverage.py --base origin/main --min 90
-2
View File
@@ -90,8 +90,6 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`. > **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
> **CI:** the coverage gate (`.gitea/workflows/test.yml` → `coverage` job) runs on a self-hosted runner labelled `kvm`, because the Firecracker backend's VM/SSH orchestration is exercised only by the integration suite, which needs `/dev/kvm` + the provisioned pool (a container runner would skip it and read as uncovered). Provision that runner exactly like a normal Firecracker host — `firecracker` on `PATH`, `/dev/kvm`, Docker, the cached guest kernel + static dropbear, and the pool installed as the persistent systemd unit — then register it with the `kvm` label. The unit/lint jobs still run on `ubuntu-latest`.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
``` ```
+2 -2
View File
@@ -42,7 +42,7 @@ class AuditStore(DbStore):
super().__init__(db_path or host_db_path(), migrations) super().__init__(db_path or host_db_path(), migrations)
def write_audit_entry(self, entry: AuditEntry) -> Path: def write_audit_entry(self, entry: AuditEntry) -> Path:
with self._connection() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO supervise_audit_entries ( INSERT INTO supervise_audit_entries (
@@ -66,7 +66,7 @@ class AuditStore(DbStore):
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]: def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
if not self.db_path.is_file(): if not self.db_path.is_file():
return [] return []
with self._connection() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT * FROM supervise_audit_entries SELECT * FROM supervise_audit_entries
+37 -4
View File
@@ -37,10 +37,10 @@ import os
import shlex import shlex
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar from typing import TYPE_CHECKING, Any, Generator, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan from ..egress import EgressPlan
@@ -82,6 +82,9 @@ class BottleSpec:
# True when launched via --headless (no TTY, no interactive prompts). # True when launched via --headless (no TTY, no interactive prompts).
# The git-gate host-key preflight uses this to error rather than prompt. # The git-gate host-key preflight uses this to error rather than prompt.
headless: bool = False headless: bool = False
# Image startup policy. "fresh" preserves the normal build path;
# "cached" reuses the current local image/artifact without rebuilding.
image_policy: str = "fresh"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -277,6 +280,18 @@ PlanT = TypeVar("PlanT", bound=BottlePlan)
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan) CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
@dataclass(frozen=True)
class BottleImages:
"""Resolved image references (or artifact paths) for a bottle launch.
For Docker/macOS-container backends, `agent` and `sidecar` are string
image refs. For the smolmachines backend they are Path objects pointing
to pre-built `.smolmachine` artifacts."""
agent: str | Path
sidecar: str | Path = ""
class BottleBackend(ABC, Generic[PlanT, CleanupT]): class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Abstract base for selectable bottle backends. Concrete subclasses """Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls. (e.g. DockerBottleBackend) own their own prepare/launch impls.
@@ -436,9 +451,27 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
prompt file, Dockerfile path, and guest home all live on prompt file, Dockerfile path, and guest home all live on
`agent_provision_plan` — the source of truth.""" `agent_provision_plan` — the source of truth."""
def prelaunch_checks(self, plan: PlanT) -> None:
"""Raise StaleImageError if any cached image used by this plan is stale.
No-op default; backends override to call the shared check_stale*
helpers on their image/artifact timestamps. Called by the CLI before
launch so the operator can be prompted outside the launch context."""
@contextmanager
def launch(self, plan: PlanT) -> Generator[Bottle, None, None]:
"""Template: build or load images, then delegate to _launch_impl."""
images = self._build_or_load_images(plan)
with self._launch_impl(plan, images) as bottle:
yield bottle
@abstractmethod @abstractmethod
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]: def _build_or_load_images(self, plan: PlanT) -> BottleImages:
"""Build/run the bottle and yield a handle; tear down on exit.""" """Return the agent and sidecar image references (or artifact paths)
for this plan, building fresh images when the policy requires it."""
@abstractmethod
def _launch_impl(self, plan: PlanT, images: BottleImages) -> AbstractContextManager[Bottle]:
"""Bring up the bottle using pre-resolved images; yield a handle; tear down on exit."""
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None: def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
"""Copy host-side files (CA cert, prompt, skills, .git) into """Copy host-side files (CA cert, prompt, skills, .git) into
+9 -3
View File
@@ -31,7 +31,7 @@ from ...env import ResolvedEnv
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from ...manifest import Manifest from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -100,9 +100,15 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
stage_dir=stage_dir, stage_dir=stage_dir,
) )
def prelaunch_checks(self, plan: DockerBottlePlan) -> None:
_launch.stale_checks(plan)
def _build_or_load_images(self, plan: DockerBottlePlan) -> BottleImages:
return _launch.build_or_load_images(plan)
@contextmanager @contextmanager
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]: def _launch_impl(self, plan: DockerBottlePlan, images: BottleImages) -> Generator[DockerBottle, None, None]:
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, images, provision=self.provision) as bottle:
yield bottle yield bottle
def ensure_orchestrator(self) -> str: def ensure_orchestrator(self) -> str:
+50 -22
View File
@@ -42,7 +42,9 @@ from ...git_gate import (
provision_git_gate_dynamic_keys, provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import info, warn from ...image_cache import check_stale
from ...log import die, info, warn
from .. import BottleImages
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -70,16 +72,47 @@ from ...orchestrator.gateway import DockerGateway
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
"""Resolve the agent image ref for this plan.
Returns the committed snapshot if one exists, the cached image when the
policy is 'cached', or builds a fresh image and returns that."""
committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}")
return BottleImages(agent=committed)
if plan.spec.image_policy == "cached":
if not docker_mod.image_exists(plan.image):
die(
f"cached agent image {plan.image!r} not found; "
"run without --cached-images to build it"
)
info(f"using cached agent image {plan.image!r}")
return BottleImages(agent=plan.image)
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
return BottleImages(agent=plan.image)
@contextmanager @contextmanager
def launch( def launch(
plan: DockerBottlePlan, plan: DockerBottlePlan,
images: BottleImages,
*, *,
provision: Callable[[DockerBottlePlan, "DockerBottle"], str | None], provision: Callable[[DockerBottlePlan, "DockerBottle"], str | None],
) -> Generator[DockerBottle, None, None]: ) -> Generator[DockerBottle, None, None]:
"""Build, launch, and provision a Docker bottle via compose. """Launch and provision a Docker bottle via compose. Teardown on exit."""
Teardown on exit."""
stack = ExitStack() stack = ExitStack()
# Stamp the resolved agent image ref into the plan so compose rendering
# picks up the right image (may be a committed snapshot or cached ref).
plan = dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=str(images.agent)),
)
_bottle_for_revoke = plan.manifest.bottle _bottle_for_revoke = plan.manifest.bottle
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) _git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
@@ -96,25 +129,6 @@ def launch(
) )
try: try:
# Step 1: agent image. Use a committed snapshot when one exists
# and is present in the local daemon; otherwise build from the
# Dockerfile. (The gateway image is built by the orchestrator.)
committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}")
plan = dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
)
else:
docker_mod.build_image(
plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path,
)
docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any, before # Step 2: mint the git-gate dynamic (gitea) deploy keys, if any, before
# provisioning the bottle's repos into the shared gateway. # provisioning the bottle's repos into the shared gateway.
git_gate_plan = plan.git_gate_plan git_gate_plan = plan.git_gate_plan
@@ -207,3 +221,17 @@ def launch(
yield bottle yield bottle
finally: finally:
teardown() teardown()
def stale_checks(plan: DockerBottlePlan) -> None:
"""Raise StaleImageError if a cached image is older than the configured
threshold. Only runs when image_policy is 'cached'. Called by the backend
class's _image_stale_checks before _launch_impl starts any resources."""
if plan.spec.image_policy != "cached":
return
committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed):
check_stale(f"agent image {committed!r}", docker_mod.image_created_at(committed))
return
if docker_mod.image_exists(plan.image):
check_stale(f"agent image {plan.image!r}", docker_mod.image_created_at(plan.image))
+40
View File
@@ -5,6 +5,7 @@ existence, and building images."""
from __future__ import annotations from __future__ import annotations
import os import os
from datetime import datetime, timezone
import re import re
import shutil import shutil
import subprocess import subprocess
@@ -197,3 +198,42 @@ def commit_container(container_name: str, image_tag: str) -> None:
f"{(result.stderr or '').strip() or '<no stderr>'}" f"{(result.stderr or '').strip() or '<no stderr>'}"
) )
info(f"committed {container_name!r}{image_tag!r}") info(f"committed {container_name!r}{image_tag!r}")
def image_created_at(ref: str) -> datetime:
"""Return Docker's image Created timestamp as an aware UTC datetime."""
r = subprocess.run(
["docker", "image", "inspect", "--format", "{{.Created}}", ref],
capture_output=True,
text=True,
check=False,
)
if r.returncode != 0:
die(
f"docker image inspect for {ref!r} failed: "
f"{(r.stderr or '').strip() or '<no stderr>'}"
)
raw = r.stdout.strip()
try:
return _parse_docker_timestamp(raw)
except ValueError:
die(f"docker image inspect for {ref!r} returned invalid Created timestamp: {raw!r}")
def _parse_docker_timestamp(raw: str) -> datetime:
text = raw.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
dot = text.find(".")
if dot != -1:
tz_plus = text.find("+", dot)
tz_minus = text.find("-", dot)
tz_candidates = [pos for pos in (tz_plus, tz_minus) if pos != -1]
if tz_candidates:
tz_pos = min(tz_candidates)
frac = text[dot + 1:tz_pos]
text = text[:dot + 1] + frac[:6].ljust(6, "0") + text[tz_pos:]
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
+11 -4
View File
@@ -18,7 +18,7 @@ from ...env import ResolvedEnv
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...manifest import Manifest from ...manifest import Manifest
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleSpec from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -92,11 +92,18 @@ class FirecrackerBottleBackend(
stage_dir=stage_dir, stage_dir=stage_dir,
) )
def _build_or_load_images(self, plan: FirecrackerBottlePlan) -> BottleImages:
return BottleImages(agent=_launch.build_or_load_agent_base(plan))
def prelaunch_checks(self, plan: FirecrackerBottlePlan) -> None:
_launch.stale_checks(plan)
@contextmanager @contextmanager
def launch( def _launch_impl(
self, plan: FirecrackerBottlePlan self, plan: FirecrackerBottlePlan, images: BottleImages,
) -> Generator[FirecrackerBottle, None, None]: ) -> Generator[FirecrackerBottle, None, None]:
with _launch.launch(plan, provision=self.provision) as bottle: assert isinstance(images.agent, Path)
with _launch.launch(plan, images.agent, provision=self.provision) as bottle:
yield bottle yield bottle
def prepare_cleanup(self) -> FirecrackerBottleCleanupPlan: def prepare_cleanup(self) -> FirecrackerBottleCleanupPlan:
+1 -1
View File
@@ -5,7 +5,7 @@ backend — we stream the guest root filesystem out over the control
channel (SSH here). Unlike the other backends this needs no Docker: the channel (SSH here). Unlike the other backends this needs no Docker: the
tar *is* the resumable artifact. `resume` extracts it and rebuilds a tar *is* the resumable artifact. `resume` extracts it and rebuilds a
fresh per-bottle ext4 with `mke2fs -d` (see `util.build_committed_rootfs_dir` fresh per-bottle ext4 with `mke2fs -d` (see `util.build_committed_rootfs_dir`
and `launch._build_agent_base`). The bottle keeps running after the and `launch.build_or_load_agent_base`). The bottle keeps running after the
snapshot. snapshot.
""" """
@@ -45,6 +45,13 @@ def _dockerfile_hash(dockerfile: Path) -> str:
return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16] return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16]
def cached_agent_rootfs_dir(dockerfile: Path) -> Path | None:
"""Return the ready cached rootfs for ``dockerfile``, if one exists."""
digest = _dockerfile_hash(dockerfile)
base = util.cache_dir() / "rootfs" / f"agent-{digest}"
return base if (base / ".bb-ready").is_file() else None
def build_agent_rootfs_dir( def build_agent_rootfs_dir(
dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (), dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (),
) -> Path: ) -> Path:
@@ -58,7 +65,7 @@ def build_agent_rootfs_dir(
silent-failure image at build time rather than at first agent use.""" silent-failure image at build time rather than at first agent use."""
digest = _dockerfile_hash(dockerfile) digest = _dockerfile_hash(dockerfile)
base = util.cache_dir() / "rootfs" / f"agent-{digest}" base = util.cache_dir() / "rootfs" / f"agent-{digest}"
if (base / ".bb-ready").is_file(): if cached_agent_rootfs_dir(dockerfile) is not None:
info(f"using cached agent rootfs {base.name}") info(f"using cached agent rootfs {base.name}")
return base return base
+34 -13
View File
@@ -45,7 +45,8 @@ from ...git_gate import (
provision_git_gate_dynamic_keys, provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...log import info, warn from ...image_cache import check_stale_path
from ...log import die, info, warn
from ...supervise import SUPERVISE_PORT from ...supervise import SUPERVISE_PORT
from ..docker.egress import EGRESS_PORT from ..docker.egress import EGRESS_PORT
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
@@ -64,6 +65,7 @@ _GIT_HTTP_PORT = 9420
@contextmanager @contextmanager
def launch( def launch(
plan: FirecrackerBottlePlan, plan: FirecrackerBottlePlan,
agent_base: Path,
*, *,
provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None], provision: Callable[[FirecrackerBottlePlan, "FirecrackerBottle"], str | None],
) -> Generator[FirecrackerBottle, None, None]: ) -> Generator[FirecrackerBottle, None, None]:
@@ -85,11 +87,9 @@ def launch(
raise teardown_exc raise teardown_exc
try: try:
# Step 1: agent rootfs. Built from the Dockerfile inside a Firecracker # Step 1 (rootfs resolution/build) runs in BottleBackend.launch before
# builder VM (buildah, no host docker); a committed snapshot is reused # this context starts resources. ``agent_base`` is the selected cache,
# when present. Returns the base dir the per-bottle ext4 is made from. # fresh build, or committed snapshot.
plan, agent_base = _build_agent_base(plan)
# Step 2: mint the git-gate dynamic (gitea) deploy keys, if any. # Step 2: mint the git-gate dynamic (gitea) deploy keys, if any.
git_gate_plan = plan.git_gate_plan git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams: if git_gate_plan.upstreams:
@@ -206,9 +206,7 @@ def launch(
teardown() teardown()
def _build_agent_base( def build_or_load_agent_base(plan: FirecrackerBottlePlan) -> Path:
plan: FirecrackerBottlePlan,
) -> tuple[FirecrackerBottlePlan, Path]:
"""Produce the agent's base rootfs dir. Primary path: build the Dockerfile """Produce the agent's base rootfs dir. Primary path: build the Dockerfile
inside a Firecracker builder VM (buildah, no host docker), smoke-testing inside a Firecracker builder VM (buildah, no host docker), smoke-testing
the image before export. A committed snapshot (freeze/migrate) is resumed the image before export. A committed snapshot (freeze/migrate) is resumed
@@ -217,13 +215,36 @@ def _build_agent_base(
committed_tar = committed_rootfs_path(plan.slug) committed_tar = committed_rootfs_path(plan.slug)
if committed and committed_tar.is_file(): if committed and committed_tar.is_file():
info(f"resuming from committed rootfs {committed_tar}") info(f"resuming from committed rootfs {committed_tar}")
return plan, util.build_committed_rootfs_dir(committed_tar) return util.build_committed_rootfs_dir(committed_tar)
base = image_builder.build_agent_rootfs_dir( dockerfile = Path(plan.dockerfile_path)
Path(plan.dockerfile_path), if plan.spec.image_policy == "cached":
cached = image_builder.cached_agent_rootfs_dir(dockerfile)
if cached is None:
die(
f"cached agent rootfs for {plan.image!r} not found; "
"run without --cached-images to build it"
)
info(f"using cached agent rootfs {cached.name}")
return cached
return image_builder.build_agent_rootfs_dir(
dockerfile,
image_tag=plan.image, image_tag=plan.image,
smoke_test=runtime_for(plan.agent_provider_template).smoke_test, smoke_test=runtime_for(plan.agent_provider_template).smoke_test,
) )
return plan, base
def stale_checks(plan: FirecrackerBottlePlan) -> None:
"""Raise when the cached rootfs selected by this plan is stale."""
if plan.spec.image_policy != "cached":
return
committed = read_committed_image(plan.slug)
committed_tar = committed_rootfs_path(plan.slug)
if committed and committed_tar.is_file():
check_stale_path(f"agent rootfs {committed_tar}", committed_tar)
return
cached = image_builder.cached_agent_rootfs_dir(Path(plan.dockerfile_path))
if cached is not None:
check_stale_path(f"agent rootfs {cached}", cached / ".bb-ready")
# --- agent guest env ------------------------------------------------- # --- agent guest env -------------------------------------------------
+10 -4
View File
@@ -12,7 +12,7 @@ from ...env import ResolvedEnv
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from ...manifest import Manifest from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec from .. import ActiveAgent, BottleBackend, BottleImages, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
@@ -82,11 +82,17 @@ class MacosContainerBottleBackend(
stage_dir=stage_dir, stage_dir=stage_dir,
) )
def prelaunch_checks(self, plan: MacosContainerBottlePlan) -> None:
_launch.stale_checks(plan)
def _build_or_load_images(self, plan: MacosContainerBottlePlan) -> BottleImages:
return _launch.build_or_load_images(plan)
@contextmanager @contextmanager
def launch( def _launch_impl(
self, plan: MacosContainerBottlePlan self, plan: MacosContainerBottlePlan, images: BottleImages
) -> Generator[MacosContainerBottle, None, None]: ) -> Generator[MacosContainerBottle, None, None]:
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, images, provision=self.provision) as bottle:
yield bottle yield bottle
def ensure_orchestrator(self) -> str: def ensure_orchestrator(self) -> str:
+40 -18
View File
@@ -53,7 +53,9 @@ from ...git_gate import (
revoke_git_gate_provisioned_keys, revoke_git_gate_provisioned_keys,
) )
from ...git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT from ...git_http_backend import DEFAULT_PORT as _GIT_HTTP_PORT
from ...image_cache import check_stale
from ...log import die, info, warn from ...log import die, info, warn
from .. import BottleImages
from ...supervise import SUPERVISE_PORT from ...supervise import SUPERVISE_PORT
from ..docker.egress import EGRESS_PORT from ..docker.egress import EGRESS_PORT
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
@@ -71,18 +73,43 @@ _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
_AGENT_SLEEP_SECONDS = "2147483647" _AGENT_SLEEP_SECONDS = "2147483647"
def build_or_load_images(plan: MacosContainerBottlePlan) -> BottleImages:
"""Resolve the agent image ref for this plan. The gateway's own image is
built by `ensure_gateway` — it belongs to the shared singleton."""
committed = read_committed_image(plan.slug)
if committed and container_mod.image_exists(committed):
info(f"using committed image {committed!r}")
return BottleImages(agent=committed)
if plan.spec.image_policy == "cached":
if not container_mod.image_exists(plan.image):
die(
f"cached agent image {plan.image!r} not found; "
"run without --cached-images to build it"
)
info(f"using cached agent image {plan.image!r}")
return BottleImages(agent=plan.image)
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
return BottleImages(agent=plan.image)
@contextmanager @contextmanager
def launch( def launch(
plan: MacosContainerBottlePlan, plan: MacosContainerBottlePlan,
images: BottleImages,
*, *,
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None], provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
) -> Generator[MacosContainerBottle, None, None]: ) -> Generator[MacosContainerBottle, None, None]:
"""Build, run, register, provision, and yield an Apple Container bottle on """Run, register, provision, and yield an Apple Container bottle on the
the shared per-host gateway.""" shared per-host gateway."""
stack = ExitStack() stack = ExitStack()
bottle_for_revoke = plan.manifest.bottle bottle_for_revoke = plan.manifest.bottle
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
plan = dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=str(images.agent)),
)
def teardown() -> None: def teardown() -> None:
teardown_exc: BaseException | None = None teardown_exc: BaseException | None = None
try: try:
@@ -95,8 +122,6 @@ def launch(
raise teardown_exc raise teardown_exc
try: try:
plan = _build_images(plan)
# Step 1: the per-host singletons. Must precede the agent run — its # Step 1: the per-host singletons. Must precede the agent run — its
# proxy env needs the gateway's address at `container run` time. # proxy env needs the gateway's address at `container run` time.
endpoint = ensure_gateway() endpoint = ensure_gateway()
@@ -177,22 +202,19 @@ def launch(
teardown() teardown()
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
"""Build the agent image. The gateway's own image is built by def stale_checks(plan: MacosContainerBottlePlan) -> None:
`ensure_gateway` — it belongs to the shared singleton, not to a bottle.""" """Raise StaleImageError if a cached image is older than the configured
threshold. Only runs when image_policy is 'cached'. Called by the backend
class's _image_stale_checks before _launch_impl starts any resources."""
if plan.spec.image_policy != "cached":
return
committed = read_committed_image(plan.slug) committed = read_committed_image(plan.slug)
if committed and container_mod.image_exists(committed): if committed and container_mod.image_exists(committed):
info(f"using committed image {committed!r}") check_stale(f"agent image {committed!r}", container_mod.image_created_at(committed))
return dataclasses.replace( return
plan, if container_mod.image_exists(plan.image):
agent_provision=dataclasses.replace( check_stale(f"agent image {plan.image!r}", container_mod.image_created_at(plan.image))
plan.agent_provision, image=committed,
),
)
container_mod.build_image(
plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path,
)
return plan
def _provision_git_gate_keys( def _provision_git_gate_keys(
@@ -10,6 +10,7 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
import time import time
from datetime import datetime, timezone
from typing import Iterable from typing import Iterable
from ...log import die, info from ...log import die, info
@@ -611,6 +612,39 @@ def image_id(ref: str) -> str:
raise AssertionError("unreachable") raise AssertionError("unreachable")
def image_created_at(ref: str) -> datetime:
"""Return the image creation timestamp as an aware UTC datetime.
Parses the `created` field from `container image inspect` JSON output."""
result = subprocess.run(
[_CONTAINER, "image", "inspect", ref],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
die(
f"container image inspect for {ref!r} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
try:
data = json.loads(result.stdout or "{}")
except json.JSONDecodeError as exc:
die(f"container image inspect for {ref!r} returned malformed JSON: {exc}")
if isinstance(data, list) and data:
data = data[0]
if isinstance(data, dict):
value = data.get("created") or data.get("Created")
if isinstance(value, str) and value:
try:
ts = value.rstrip("Z")
return datetime.fromisoformat(ts).replace(tzinfo=timezone.utc)
except ValueError:
pass
die(f"container image inspect for {ref!r} did not include a creation timestamp")
raise AssertionError("unreachable")
def save(ref: str, output: str) -> None: def save(ref: str, output: str) -> None:
subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True) subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True)
+33 -4
View File
@@ -36,6 +36,7 @@ from ..bottle_state import (
is_preserved, is_preserved,
mark_preserved, mark_preserved,
) )
from ..image_cache import StaleImageError
from ..log import info, die from ..log import info, die
from ..manifest import Manifest, ManifestIndex from ..manifest import Manifest, ManifestIndex
from ._common import PROG, USER_CWD, read_tty_line from ._common import PROG, USER_CWD, read_tty_line
@@ -74,6 +75,14 @@ def cmd_start(argv: list[str]) -> int:
"skip all prompts. For orchestrators, CI, and webhooks." "skip all prompts. For orchestrators, CI, and webhooks."
), ),
) )
parser.add_argument(
"--cached-images",
action="store_true",
help=(
"quickstart with existing local agent and sidecar images; "
"only valid with --headless"
),
)
parser.add_argument( parser.add_argument(
"--bottle", "--bottle",
action="append", action="append",
@@ -106,6 +115,8 @@ def cmd_start(argv: list[str]) -> int:
help="agent name defined in bot-bottle.json (omit to pick interactively)", help="agent name defined in bot-bottle.json (omit to pick interactively)",
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.cached_images and not args.headless:
die("--cached-images is only supported with --headless")
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1" dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
if args.no_cache or os.environ.get("BOT_BOTTLE_NO_CACHE") == "1": if args.no_cache or os.environ.get("BOT_BOTTLE_NO_CACHE") == "1":
@@ -158,6 +169,10 @@ def cmd_start(argv: list[str]) -> int:
label, color = tui.name_color_modal(default_label=agent_name) label, color = tui.name_color_modal(default_label=agent_name)
label, color = _resolve_unique_label(label, color) label, color = _resolve_unique_label(label, color)
image_policy = _select_image_policy()
if image_policy is None:
return 0
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, manifest=manifest,
agent_name=agent_name, agent_name=agent_name,
@@ -166,6 +181,7 @@ def cmd_start(argv: list[str]) -> int:
label=label, label=label,
color=color, color=color,
bottle_names=bottle_names, bottle_names=bottle_names,
image_policy=image_policy,
) )
return _launch_bottle( return _launch_bottle(
spec, spec,
@@ -226,6 +242,7 @@ def _start_headless(
color=args.color or "", color=args.color or "",
bottle_names=bottle_names, bottle_names=bottle_names,
headless=True, headless=True,
image_policy="cached" if args.cached_images else "fresh",
) )
return _launch_bottle( return _launch_bottle(
spec, spec,
@@ -406,6 +423,13 @@ def _text_prompt_yes() -> bool:
return reply in ("y", "Y", "yes", "YES") return reply in ("y", "Y", "yes", "YES")
def _select_image_policy() -> str | None:
return tui.filter_select(
["fresh", "cached"],
title="Select image startup mode",
)
def _text_render_preflight(): def _text_render_preflight():
def _render(plan: DockerBottlePlan, backend_name: str) -> None: def _render(plan: DockerBottlePlan, backend_name: str) -> None:
print(file=sys.stderr) print(file=sys.stderr)
@@ -548,6 +572,15 @@ def _launch_bottle(
return 0 return 0
backend = get_bottle_backend(backend_name) backend = get_bottle_backend(backend_name)
try:
backend.prelaunch_checks(plan)
except StaleImageError as exc:
if assume_yes:
die(str(exc))
sys.stderr.write(f"bot-bottle: {exc}\nLaunch anyway? [y/N] ")
sys.stderr.flush()
if read_tty_line() not in ("y", "Y", "yes", "YES"):
return 0
with backend.launch(plan) as bottle: with backend.launch(plan) as bottle:
agent_provider_template = getattr(plan, "agent_provider_template", "claude") agent_provider_template = getattr(plan, "agent_provider_template", "claude")
extra_args: tuple[str, ...] = () extra_args: tuple[str, ...] = ()
@@ -566,10 +599,6 @@ def _launch_bottle(
f"session ended (exit {exit_code}); " f"session ended (exit {exit_code}); "
f"container {bottle.name} will be removed" f"container {bottle.name} will be removed"
) )
# While the container is still alive: always snapshot the
# transcript and — if the agent exited non-zero — mark
# the state for preservation. This picks up crashes /
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
if agent_provider_template == "claude": if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code) capture_claude_session_state(identity, exit_code)
return 0 return 0
+71
View File
@@ -0,0 +1,71 @@
"""SQLite-backed bot-bottle configuration store."""
from __future__ import annotations
from pathlib import Path
try:
from .db_store import DbStore
from .migrations import TableMigrations
from .paths import host_db_path
except ImportError:
from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from paths import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS = 1
class ConfigStore(DbStore):
"""SQLite configuration for host-side bot-bottle settings."""
def __init__(self, db_path: Path | None = None) -> None:
migrations = TableMigrations("config_store", [
# v1 — host-side bot-bottle settings
"""
CREATE TABLE IF NOT EXISTS bot_bottle_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
cached_image_stale_warning_days INTEGER NOT NULL DEFAULT 1
)
""",
])
super().__init__(db_path or host_db_path(), migrations)
def cached_image_stale_warning_days(self) -> int:
if not self.db_path.is_file():
return DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS
with self._connect() as conn:
row = conn.execute(
"""
SELECT cached_image_stale_warning_days
FROM bot_bottle_config
WHERE id = 1
""",
).fetchone()
if row is None:
return DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS
try:
return int(row["cached_image_stale_warning_days"])
except (TypeError, ValueError):
return DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS
def set_cached_image_stale_warning_days(self, days: int) -> Path:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO bot_bottle_config (id, cached_image_stale_warning_days)
VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET
cached_image_stale_warning_days = excluded.cached_image_stale_warning_days
""",
(days,),
)
self._chmod()
return self.db_path
__all__ = [
"DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS",
"ConfigStore",
]
+2 -12
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
try: try:
@@ -29,21 +28,12 @@ class DbStore:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
@contextmanager
def _connection(self):
conn = self._connect()
try:
with conn:
yield conn
finally:
conn.close()
def is_migrated(self) -> bool: def is_migrated(self) -> bool:
"""Return True if the DB is fully up-to-date, False if migration is needed.""" """Return True if the DB is fully up-to-date, False if migration is needed."""
if not self.db_path.exists(): if not self.db_path.exists():
return False return False
try: try:
with self._connection() as conn: with self._connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT version FROM schema_versions WHERE module = ?", "SELECT version FROM schema_versions WHERE module = ?",
(self._migrations.schema_key,), (self._migrations.schema_key,),
@@ -55,7 +45,7 @@ class DbStore:
def migrate(self) -> None: def migrate(self) -> None:
"""Apply any pending migrations and set permissions on the DB file.""" """Apply any pending migrations and set permissions on the DB file."""
with self._connection() as conn: with self._connect() as conn:
self._migrations.apply(conn) self._migrations.apply(conn)
self._chmod() self._chmod()
+42
View File
@@ -0,0 +1,42 @@
"""Shared helpers for cached-image quickstart stale checks."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
try:
from .config_store import ConfigStore
except ImportError:
from config_store import ConfigStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
class StaleImageError(Exception):
"""Raised when a cached image or artifact exceeds the configured staleness
threshold. Callers can catch this to prompt interactively; headless paths
let it propagate as a fatal error."""
def check_stale(label: str, created_at: datetime) -> None:
"""Raise StaleImageError if `created_at` is older than the configured
stale-warning threshold. Negative threshold disables the check."""
threshold_days = ConfigStore().cached_image_stale_warning_days()
if threshold_days < 0:
return
now = datetime.now(timezone.utc)
created = created_at.astimezone(timezone.utc)
age = now - created
if age.total_seconds() <= threshold_days * 86400:
return
raise StaleImageError(
f"cached {label} is {age.days} day(s) old; "
"quickstart does not verify it matches the current Dockerfile/context"
)
def check_stale_path(label: str, path: Path) -> None:
"""Raise StaleImageError if `path`'s mtime exceeds the staleness threshold."""
check_stale(label, datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc))
__all__ = ["StaleImageError", "check_stale", "check_stale_path"]
+6 -6
View File
@@ -167,7 +167,7 @@ class RegistryStore(DbStore):
metadata=metadata, metadata=metadata,
policy=policy, policy=policy,
) )
with self._connection() as conn: with self._connect() as conn:
conn.execute( conn.execute(
"DELETE FROM orchestrator_bottles " "DELETE FROM orchestrator_bottles "
"WHERE source_ip = ? AND state = 'active' AND bottle_id != ?", "WHERE source_ip = ? AND state = 'active' AND bottle_id != ?",
@@ -193,7 +193,7 @@ class RegistryStore(DbStore):
def set_policy(self, bottle_id: str, policy: str) -> bool: def set_policy(self, bottle_id: str, policy: str) -> bool:
"""Update a bottle's policy in place (live reload). Returns True if """Update a bottle's policy in place (live reload). Returns True if
the bottle exists.""" the bottle exists."""
with self._connection() as conn: with self._connect() as conn:
cur = conn.execute( cur = conn.execute(
"UPDATE orchestrator_bottles SET policy = ? WHERE bottle_id = ?", "UPDATE orchestrator_bottles SET policy = ? WHERE bottle_id = ?",
(policy, bottle_id), (policy, bottle_id),
@@ -203,7 +203,7 @@ class RegistryStore(DbStore):
def deregister(self, bottle_id: str) -> bool: def deregister(self, bottle_id: str) -> bool:
"""Remove a bottle. Returns True if a row was deleted.""" """Remove a bottle. Returns True if a row was deleted."""
with self._connection() as conn: with self._connect() as conn:
cur = conn.execute( cur = conn.execute(
"DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,) "DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
) )
@@ -211,7 +211,7 @@ class RegistryStore(DbStore):
def get(self, bottle_id: str) -> BottleRecord | None: def get(self, bottle_id: str) -> BottleRecord | None:
"""Return the bottle by id, or None if absent.""" """Return the bottle by id, or None if absent."""
with self._connection() as conn: with self._connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,) "SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
).fetchone() ).fetchone()
@@ -219,7 +219,7 @@ class RegistryStore(DbStore):
def all(self) -> list[BottleRecord]: def all(self) -> list[BottleRecord]:
"""Every registered bottle, oldest first.""" """Every registered bottle, oldest first."""
with self._connection() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM orchestrator_bottles ORDER BY created_at" "SELECT * FROM orchestrator_bottles ORDER BY created_at"
).fetchall() ).fetchall()
@@ -232,7 +232,7 @@ class RegistryStore(DbStore):
source IP is unspoofable (Firecracker `/31` + nft) and the control source IP is unspoofable (Firecracker `/31` + nft) and the control
plane is reachable only by the trusted gateway; pair with the plane is reachable only by the trusted gateway; pair with the
identity token (`attribute`) elsewhere.""" identity token (`attribute`) elsewhere."""
with self._connection() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM orchestrator_bottles " "SELECT * FROM orchestrator_bottles "
"WHERE source_ip = ? AND state = 'active'", "WHERE source_ip = ? AND state = 'active'",
+7 -7
View File
@@ -66,7 +66,7 @@ class QueueStore(DbStore):
super().__init__(resolved, migrations) super().__init__(resolved, migrations)
def write_proposal(self, proposal: Proposal) -> Path: def write_proposal(self, proposal: Proposal) -> Path:
with self._connection() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
INSERT OR REPLACE INTO supervise_proposals ( INSERT OR REPLACE INTO supervise_proposals (
@@ -89,7 +89,7 @@ class QueueStore(DbStore):
return self.db_path return self.db_path
def read_proposal(self, proposal_id: str) -> Proposal: def read_proposal(self, proposal_id: str) -> Proposal:
with self._connection() as conn: with self._connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT * FROM supervise_proposals SELECT * FROM supervise_proposals
@@ -104,7 +104,7 @@ class QueueStore(DbStore):
def list_pending_proposals(self) -> list[Proposal]: def list_pending_proposals(self) -> list[Proposal]:
if not self.db_path.is_file(): if not self.db_path.is_file():
return [] return []
with self._connection() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT p.* FROM supervise_proposals p SELECT p.* FROM supervise_proposals p
@@ -125,7 +125,7 @@ class QueueStore(DbStore):
def list_all_pending_proposals(self) -> list[Proposal]: def list_all_pending_proposals(self) -> list[Proposal]:
if not self.db_path.is_file(): if not self.db_path.is_file():
return [] return []
with self._connection() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT p.* FROM supervise_proposals p SELECT p.* FROM supervise_proposals p
@@ -142,7 +142,7 @@ class QueueStore(DbStore):
return [self._row_to_proposal(row) for row in rows] return [self._row_to_proposal(row) for row in rows]
def write_response(self, response: Response) -> Path: def write_response(self, response: Response) -> Path:
with self._connection() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
INSERT OR REPLACE INTO supervise_responses ( INSERT OR REPLACE INTO supervise_responses (
@@ -161,7 +161,7 @@ class QueueStore(DbStore):
return self.db_path return self.db_path
def read_response(self, proposal_id: str) -> Response: def read_response(self, proposal_id: str) -> Response:
with self._connection() as conn: with self._connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT * FROM supervise_responses SELECT * FROM supervise_responses
@@ -176,7 +176,7 @@ class QueueStore(DbStore):
def archive_proposal(self, proposal_id: str) -> None: def archive_proposal(self, proposal_id: str) -> None:
if not self.db_path.is_file(): if not self.db_path.is_file():
return return
with self._connection() as conn: with self._connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE supervise_proposals SET archived = 1 UPDATE supervise_proposals SET archived = 1
+4
View File
@@ -6,9 +6,11 @@ from pathlib import Path
try: try:
from .audit_store import AuditStore from .audit_store import AuditStore
from .config_store import ConfigStore
from .queue_store import QueueStore from .queue_store import QueueStore
except ImportError: except ImportError:
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from config_store import ConfigStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
_instance: StoreManager | None = None _instance: StoreManager | None = None
@@ -47,11 +49,13 @@ class StoreManager:
return ( return (
QueueStore("", self.db_path).is_migrated() QueueStore("", self.db_path).is_migrated()
and AuditStore(self.db_path).is_migrated() and AuditStore(self.db_path).is_migrated()
and ConfigStore(self.db_path).is_migrated()
) )
def migrate(self) -> None: def migrate(self) -> None:
QueueStore("", self.db_path).migrate() QueueStore("", self.db_path).migrate()
AuditStore(self.db_path).migrate() AuditStore(self.db_path).migrate()
ConfigStore(self.db_path).migrate()
__all__ = ["StoreManager"] __all__ = ["StoreManager"]
+1 -2
View File
@@ -27,8 +27,7 @@ echo "== unit ==" >&2
"$PY" -m coverage run -m unittest discover -t . -s tests/unit "$PY" -m coverage run -m unittest discover -t . -s tests/unit
echo "== integration (skips without Docker) ==" >&2 echo "== integration (skips without Docker) ==" >&2
BOT_BOTTLE_BACKEND=firecracker \ "$PY" -m coverage run --append -m unittest discover -t . -s tests/integration
"$PY" -m coverage run --append -m unittest discover -t . -s tests/integration
echo "== combined report ==" >&2 echo "== combined report ==" >&2
"$PY" -m coverage report -m "$PY" -m coverage report -m
+5 -6
View File
@@ -69,12 +69,11 @@ _DUMMY_HOST_KEY = (
@skip_unless_docker() @skip_unless_docker()
@unittest.skipIf( @unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true" os.environ.get("GITEA_ACTIONS") == "true",
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker", "skipped under act_runner: egress_tls_init uses a host bind mount "
"skipped under act_runner unless BOT_BOTTLE_BACKEND=firecracker: " "the runner container can't see, and the network topology hides "
"egress_tls_init uses a host bind mount the runner container can't " "sibling-gateway visibility — same constraint as the other "
"see, and the network topology hides sibling-gateway visibility — " "bottle-bringup integration tests",
"these constraints don't apply on the self-hosted KVM runner",
) )
class TestSandboxEscape(unittest.TestCase): class TestSandboxEscape(unittest.TestCase):
"""End-to-end attacks against a real bottle. The bottle stays """End-to-end attacks against a real bottle. The bottle stays
+12
View File
@@ -184,6 +184,18 @@ class TestCmdStartHeadless(unittest.TestCase):
) )
self.assertEqual("docker", self._launch_mock.call_args[1]["backend_name"]) self.assertEqual("docker", self._launch_mock.call_args[1]["backend_name"])
def test_cached_images_sets_cached_policy(self):
start_mod.cmd_start(
["--headless", "--cached-images", "researcher", "--bottle", "claude",
"--prompt", "Do it"]
)
self.assertEqual("cached", self._spec().image_policy)
def test_cached_images_requires_headless(self):
with self.assertRaises(Die):
start_mod.cmd_start(["--cached-images", "researcher"])
self._launch_mock.assert_not_called()
class TestPrepareWithPreflight(unittest.TestCase): class TestPrepareWithPreflight(unittest.TestCase):
"""prepare_with_preflight calls render_preflight with the plan and backend name.""" """prepare_with_preflight calls render_preflight with the plan and backend name."""
+21
View File
@@ -57,6 +57,12 @@ class TestCmdStartSelector(unittest.TestCase):
self._bottle_picker_mock = self._bottle_picker_patch.start() self._bottle_picker_mock = self._bottle_picker_patch.start()
self._bottle_picker_mock.return_value = ["claude"] # default: one bottle selected self._bottle_picker_mock.return_value = ["claude"] # default: one bottle selected
self._image_policy_patch = patch(
"bot_bottle.cli.start._select_image_policy",
return_value="fresh",
)
self._image_policy_patch.start()
self._env_patch = patch.dict(os.environ, {}, clear=False) self._env_patch = patch.dict(os.environ, {}, clear=False)
self._env_patch.start() self._env_patch.start()
os.environ.pop("BOT_BOTTLE_BACKEND", None) os.environ.pop("BOT_BOTTLE_BACKEND", None)
@@ -66,6 +72,7 @@ class TestCmdStartSelector(unittest.TestCase):
self._launch_patch.stop() self._launch_patch.stop()
self._agent_picker_patch.stop() self._agent_picker_patch.stop()
self._bottle_picker_patch.stop() self._bottle_picker_patch.stop()
self._image_policy_patch.stop()
self._env_patch.stop() self._env_patch.stop()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -124,6 +131,19 @@ class TestCmdStartSelector(unittest.TestCase):
spec = self._launch_mock.call_args[0][0] spec = self._launch_mock.call_args[0][0]
self.assertEqual(("claude", "dev"), spec.bottle_names) self.assertEqual(("claude", "dev"), spec.bottle_names)
def test_image_policy_forwarded_to_spec(self):
with patch("bot_bottle.cli.start._select_image_policy", return_value="cached"):
start_mod.cmd_start(["researcher"])
self._launch_mock.assert_called_once()
spec = self._launch_mock.call_args[0][0]
self.assertEqual("cached", spec.image_policy)
def test_image_policy_cancel_returns_0(self):
with patch("bot_bottle.cli.start._select_image_policy", return_value=None):
rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc)
self._launch_mock.assert_not_called()
def test_empty_bottle_selection_forwarded(self): def test_empty_bottle_selection_forwarded(self):
self._bottle_picker_mock.return_value = [] self._bottle_picker_mock.return_value = []
start_mod.cmd_start(["researcher"]) start_mod.cmd_start(["researcher"])
@@ -215,6 +235,7 @@ class TestCmdStartLabelCollision(unittest.TestCase):
).start() ).start()
# Stub the bottle picker to always return a selection. # Stub the bottle picker to always return a selection.
patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start() patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start()
patch("bot_bottle.cli.start._select_image_policy", return_value="fresh").start()
self.addCleanup(patch.stopall) self.addCleanup(patch.stopall)
def test_no_collision_proceeds_without_reprompt(self): def test_no_collision_proceeds_without_reprompt(self):
+146
View File
@@ -0,0 +1,146 @@
"""Unit: _launch_bottle StaleImageError handling.
Exercises prelaunch_checks / backend.launch flow:
- headless mode die on stale
- interactive mode, user declines stop without launching
- interactive mode, user confirms skip stale check and launch once
"""
from __future__ import annotations
import io
import tempfile
import unittest
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, patch
from bot_bottle.image_cache import StaleImageError
from bot_bottle.log import Die
def _fake_plan() -> Any:
provision = SimpleNamespace(startup_args=())
return cast(Any, SimpleNamespace(
agent_provision=provision,
agent_provider_template="claude",
slug="dev-abc",
))
def _ok_cm(bottle: Any) -> MagicMock:
"""Return a context-manager mock that yields `bottle`."""
cm = MagicMock()
cm.__enter__ = MagicMock(return_value=bottle)
cm.__exit__ = MagicMock(return_value=False)
return cm
class TestLaunchBottleStaleHandling(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="cli-stale-test.")
def _spec(self) -> Any:
from bot_bottle.backend import BottleSpec
from bot_bottle.manifest import ManifestIndex
idx = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
return BottleSpec(
manifest=idx,
agent_name="demo",
copy_cwd=False,
user_cwd=self._tmp,
identity="dev-abc",
)
def _run_launch(self, **patch_kwargs: Any) -> int:
import bot_bottle.cli.start as start_mod
spec = self._spec()
with patch.object(start_mod, "prepare_with_preflight",
return_value=(_fake_plan(), "dev-abc")), \
patch.object(start_mod, "settle_state"), \
patch.object(start_mod, "info"):
return start_mod._launch_bottle(
spec,
dry_run=False,
backend_name="docker",
**patch_kwargs,
)
def test_headless_stale_calls_die(self) -> None:
"""In headless mode (assume_yes=True), a StaleImageError from prelaunch_checks must call die()."""
import bot_bottle.cli.start as start_mod
backend_mock = MagicMock()
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
patch.object(start_mod, "die", side_effect=Die()):
with self.assertRaises(Die):
self._run_launch(assume_yes=True)
backend_mock.launch.assert_not_called()
def test_interactive_user_declines_stops_before_launch(self) -> None:
"""Interactive user answering 'n' → launch is never called."""
import bot_bottle.cli.start as start_mod
backend_mock = MagicMock()
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
patch.object(start_mod, "read_tty_line", return_value="n"), \
patch("sys.stderr", new_callable=io.StringIO):
rc = self._run_launch(assume_yes=False)
self.assertEqual(0, rc)
backend_mock.launch.assert_not_called()
def test_interactive_user_confirms_launches_once(self) -> None:
"""Interactive user answering 'y' → prelaunch stale error is bypassed; launch called once."""
import bot_bottle.cli.start as start_mod
bottle_mock = MagicMock()
bottle_mock.name = "dev-abc"
backend_mock = MagicMock()
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
backend_mock.launch.return_value = _ok_cm(bottle_mock)
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
patch.object(start_mod, "read_tty_line", return_value="y"), \
patch.object(start_mod, "attach_agent", return_value=0), \
patch.object(start_mod, "capture_claude_session_state"), \
patch("sys.stderr", new_callable=io.StringIO):
rc = self._run_launch(assume_yes=False)
self.assertEqual(0, rc)
backend_mock.prelaunch_checks.assert_called_once()
backend_mock.launch.assert_called_once()
def test_interactive_yes_uppercase_also_accepted(self) -> None:
"""'Y' or 'YES' should also be accepted as confirmation."""
import bot_bottle.cli.start as start_mod
bottle_mock = MagicMock()
bottle_mock.name = "dev-abc"
backend_mock = MagicMock()
backend_mock.prelaunch_checks.side_effect = StaleImageError("image is 5 day(s) old")
backend_mock.launch.return_value = _ok_cm(bottle_mock)
with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \
patch.object(start_mod, "read_tty_line", return_value="YES"), \
patch.object(start_mod, "attach_agent", return_value=0), \
patch.object(start_mod, "capture_claude_session_state"), \
patch("sys.stderr", new_callable=io.StringIO):
rc = self._run_launch(assume_yes=False)
self.assertEqual(0, rc)
backend_mock.launch.assert_called_once()
if __name__ == "__main__":
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""Unit tests for the host-side configuration store."""
from __future__ import annotations
import sqlite3
import tempfile
import unittest
from pathlib import Path
from bot_bottle.config_store import (
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS,
ConfigStore,
)
from bot_bottle.store_manager import StoreManager
class TestConfigStore(unittest.TestCase):
def test_cached_image_warning_days_defaults_to_one(self) -> None:
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
store = ConfigStore(Path(tmp) / "bot-bottle.db")
store.migrate()
self.assertEqual(
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS,
store.cached_image_stale_warning_days(),
)
def test_cached_image_warning_days_reads_value(self) -> None:
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
store = ConfigStore(Path(tmp) / "bot-bottle.db")
store.migrate()
store.set_cached_image_stale_warning_days(7)
self.assertEqual(7, store.cached_image_stale_warning_days())
def test_config_schema_uses_explicit_settings_columns(self) -> None:
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
store = ConfigStore(Path(tmp) / "bot-bottle.db")
store.migrate()
with sqlite3.connect(store.db_path) as conn:
conn.row_factory = sqlite3.Row
columns = [
row["name"]
for row in conn.execute("PRAGMA table_info(bot_bottle_config)")
]
self.assertEqual([
"id",
"cached_image_stale_warning_days",
], columns)
def test_store_manager_includes_config_store(self) -> None:
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
db = Path(tmp) / "bot-bottle.db"
manager = StoreManager(db)
self.assertFalse(manager.is_migrated())
manager.migrate()
self.assertTrue(manager.is_migrated())
def test_cached_image_warning_days_returns_default_when_db_missing(self) -> None:
# When the db file doesn't exist yet (parent exists, file doesn't),
# the store returns the default without touching the file.
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
store = ConfigStore(Path(tmp) / "missing.db")
self.assertEqual(
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS,
store.cached_image_stale_warning_days(),
)
def test_cached_image_warning_days_returns_default_on_null_value(self) -> None:
# If the row exists but the value is NULL (or not castable to int),
# the store falls back to the default.
with tempfile.TemporaryDirectory(prefix="config-store.") as tmp:
db_path = Path(tmp) / "bot-bottle.db"
store = ConfigStore(db_path)
store.migrate()
# Write a NULL value directly.
with sqlite3.connect(db_path) as conn:
conn.execute(
"UPDATE bot_bottle_config SET cached_image_stale_warning_days = NULL WHERE id = 1"
)
self.assertEqual(
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS,
store.cached_image_stale_warning_days(),
)
if __name__ == "__main__":
unittest.main()
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import dataclasses
import io import io
import tempfile import tempfile
import unittest import unittest
@@ -18,6 +19,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.consolidated_launch import LaunchContext from bot_bottle.backend.docker.consolidated_launch import LaunchContext
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.log import Die
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
from tests.unit import use_bottle_root from tests.unit import use_bottle_root
@@ -92,6 +94,7 @@ class TestLaunchCommittedImage(unittest.TestCase):
mock.patch.object(launch_mod.docker_mod, "image_exists", return_value=image_present), \ mock.patch.object(launch_mod.docker_mod, "image_exists", return_value=image_present), \
mock.patch.object(launch_mod.docker_mod, "build_image", side_effect=_build), \ mock.patch.object(launch_mod.docker_mod, "build_image", side_effect=_build), \
mock.patch.object(launch_mod.docker_mod, "verify_agent_image"), \ mock.patch.object(launch_mod.docker_mod, "verify_agent_image"), \
mock.patch.object(launch_mod.docker_mod, "image_created_at"), \
mock.patch.object(launch_mod, "launch_consolidated", return_value=_CTX), \ mock.patch.object(launch_mod, "launch_consolidated", return_value=_CTX), \
mock.patch.object(launch_mod, "teardown_consolidated"), \ mock.patch.object(launch_mod, "teardown_consolidated"), \
mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \ mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \
@@ -112,7 +115,8 @@ class TestLaunchCommittedImage(unittest.TestCase):
with self._patched( with self._patched(
committed_tag=committed_tag, image_present=image_present, compose=compose, committed_tag=committed_tag, image_present=image_present, compose=compose,
) as built: ) as built:
with launch_mod.launch(plan, provision=mock.Mock(return_value=None)): images = launch_mod.build_or_load_images(plan)
with launch_mod.launch(plan, images, provision=mock.Mock(return_value=None)):
pass pass
return built return built
@@ -127,14 +131,34 @@ class TestLaunchCommittedImage(unittest.TestCase):
captured.append(p) captured.append(p)
return {"services": {"agent": {}}} return {"services": {"agent": {}}}
with self._patched(committed_tag=_COMMITTED_TAG, image_present=True, compose=compose): with self._patched(committed_tag=_COMMITTED_TAG, image_present=True, compose=compose) as _:
with launch_mod.launch(_plan(self._tmp), provision=mock.Mock(return_value=None)): plan = _plan(self._tmp)
images = launch_mod.build_or_load_images(plan)
with launch_mod.launch(plan, images, provision=mock.Mock(return_value=None)):
pass pass
self.assertEqual(_COMMITTED_TAG, captured[0].image) self.assertEqual(_COMMITTED_TAG, captured[0].image)
def test_falls_back_to_build_when_no_committed_image(self) -> None: def test_falls_back_to_build_when_no_committed_image(self) -> None:
self.assertEqual([_DEFAULT_IMAGE], self._run_launch(_plan(self._tmp), committed_tag=None)) self.assertEqual([_DEFAULT_IMAGE], self._run_launch(_plan(self._tmp), committed_tag=None))
def test_cached_images_skip_build_when_present(self) -> None:
base = _plan(self._tmp)
plan = dataclasses.replace(
base,
spec=dataclasses.replace(base.spec, image_policy="cached"),
)
built = self._run_launch(plan, committed_tag=None, image_present=True)
self.assertEqual([], built)
def test_cached_images_die_when_agent_missing(self) -> None:
base = _plan(self._tmp)
plan = dataclasses.replace(
base,
spec=dataclasses.replace(base.spec, image_policy="cached"),
)
with self.assertRaises(Die):
self._run_launch(plan, committed_tag=None, image_present=False)
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None: def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=False) built = self._run_launch(_plan(self._tmp), committed_tag=_COMMITTED_TAG, image_present=False)
self.assertEqual([_DEFAULT_IMAGE], built) self.assertEqual([_DEFAULT_IMAGE], built)
+4 -2
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from unittest import mock from unittest import mock
from bot_bottle.agent_provider import AgentProvisionPlan from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleImages, BottleSpec
from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.docker.consolidated_launch import LaunchContext from bot_bottle.backend.docker.consolidated_launch import LaunchContext
@@ -93,6 +93,8 @@ class TestTeardownWarning(unittest.TestCase):
orchestrator_url="http://orch:8099", orchestrator_url="http://orch:8099",
) )
images = BottleImages(agent="bot-bottle-claude:latest", sidecar="bot-bottle-sidecars:latest")
with mock.patch.object(launch_mod.docker_mod, "build_image"), \ with mock.patch.object(launch_mod.docker_mod, "build_image"), \
mock.patch.object(launch_mod.docker_mod, "verify_agent_image"), \ mock.patch.object(launch_mod.docker_mod, "verify_agent_image"), \
mock.patch.object(launch_mod, "launch_consolidated", return_value=ctx), \ mock.patch.object(launch_mod, "launch_consolidated", return_value=ctx), \
@@ -113,7 +115,7 @@ class TestTeardownWarning(unittest.TestCase):
), \ ), \
contextlib.redirect_stderr(buf): contextlib.redirect_stderr(buf):
provision = mock.Mock(return_value=None) provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision): with launch_mod.launch(plan, images, provision=provision):
pass pass
output = buf.getvalue() output = buf.getvalue()
+48
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import subprocess import subprocess
import unittest import unittest
from datetime import timezone
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.backend.docker import util as docker_mod from bot_bottle.backend.docker import util as docker_mod
@@ -26,6 +27,53 @@ def _fail(stderr: str = "boom") -> subprocess.CompletedProcess: # type: ignore
) )
class TestImageCreatedAt(unittest.TestCase):
def test_parses_docker_timestamp_with_nanoseconds(self):
with patch.object(
docker_mod.subprocess, "run",
return_value=_ok(stdout="2026-07-06T15:33:47.123456789Z\n"),
) as run:
created = docker_mod.image_created_at("bot-bottle-claude:latest")
self.assertEqual(2026, created.year)
self.assertEqual(123456, created.microsecond)
self.assertEqual(timezone.utc, created.tzinfo)
self.assertEqual(
["docker", "image", "inspect", "--format", "{{.Created}}", "bot-bottle-claude:latest"],
run.call_args.args[0],
)
def test_dies_on_inspect_failure(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_fail("No such image"),
), patch.object(
docker_mod, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
docker_mod.image_created_at("missing:tag")
die.assert_called_once()
self.assertIn("missing:tag", die.call_args.args[0])
def test_dies_on_invalid_timestamp(self):
with patch.object(
docker_mod.subprocess, "run",
return_value=_ok(stdout="not-a-timestamp\n"),
), patch.object(
docker_mod, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
docker_mod.image_created_at("some:tag")
die.assert_called_once()
self.assertIn("some:tag", die.call_args.args[0])
def test_parse_docker_timestamp_no_tzinfo_defaults_to_utc(self):
# A bare datetime with no tz offset should be treated as UTC.
dt = docker_mod._parse_docker_timestamp("2024-05-01T10:00:00.000000")
self.assertIsNotNone(dt.tzinfo)
self.assertEqual(timezone.utc, dt.tzinfo)
class TestCommitContainer(unittest.TestCase): class TestCommitContainer(unittest.TestCase):
def test_runs_docker_commit(self): def test_runs_docker_commit(self):
with patch.object( with patch.object(
@@ -35,6 +35,15 @@ class TestBuildAgentRootfsDir(unittest.TestCase):
build.assert_not_called() build.assert_not_called()
self.assertEqual(base, out) self.assertEqual(base, out)
def test_cached_lookup_requires_ready_marker(self):
digest = image_builder._dockerfile_hash(self.dockerfile)
base = self.cache / "rootfs" / f"agent-{digest}"
base.mkdir(parents=True)
with patch.object(image_builder.util, "cache_dir", return_value=self.cache):
self.assertIsNone(image_builder.cached_agent_rootfs_dir(self.dockerfile))
(base / ".bb-ready").write_text("ok\n")
self.assertEqual(base, image_builder.cached_agent_rootfs_dir(self.dockerfile))
def test_cache_miss_builds_injects_and_marks_ready(self): def test_cache_miss_builds_injects_and_marks_ready(self):
with patch.object(image_builder.util, "cache_dir", return_value=self.cache), \ with patch.object(image_builder.util, "cache_dir", return_value=self.cache), \
patch.object(image_builder, "_build_in_infra") as build, \ patch.object(image_builder, "_build_in_infra") as build, \
+81
View File
@@ -0,0 +1,81 @@
"""Unit: image_cache.py — check_stale / check_stale_path."""
from __future__ import annotations
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
from bot_bottle.image_cache import StaleImageError, check_stale, check_stale_path
class TestCheckStale(unittest.TestCase):
def _run(self, threshold: int, age_days: float) -> None:
created = datetime.now(tz=timezone.utc) - timedelta(days=age_days)
with patch("bot_bottle.image_cache.ConfigStore") as cs:
cs.return_value.cached_image_stale_warning_days.return_value = threshold
check_stale("test image", created)
def test_negative_threshold_never_raises(self):
# Threshold < 0 means the check is disabled — always passes.
self._run(threshold=-1, age_days=9999)
def test_zero_threshold_raises_immediately(self):
# threshold=0 means any image is stale the moment it exists.
with self.assertRaises(StaleImageError):
self._run(threshold=0, age_days=0.1)
def test_within_threshold_does_not_raise(self):
# Age well under threshold — should pass silently.
self._run(threshold=7, age_days=2)
def test_at_threshold_does_not_raise(self):
# Exactly at the boundary is fine (<=, not <).
self._run(threshold=1, age_days=0.9999)
def test_exceeds_threshold_raises(self):
created = datetime.now(tz=timezone.utc) - timedelta(days=3)
with patch("bot_bottle.image_cache.ConfigStore") as cs:
cs.return_value.cached_image_stale_warning_days.return_value = 1
with self.assertRaises(StaleImageError) as ctx:
check_stale("agent image 'bot-bottle:latest'", created)
self.assertIn("agent image", str(ctx.exception))
self.assertIn("day(s) old", str(ctx.exception))
def test_naive_datetime_treated_as_utc(self):
# check_stale calls .astimezone(utc) on the input; naive datetimes
# that would be interpreted as local time should still work.
# We can't control the local tz in a unit test, so just ensure
# no exception is thrown for a very recent naive datetime.
naive_now = datetime(2099, 1, 1) # far future, always "fresh"
with patch("bot_bottle.image_cache.ConfigStore") as cs:
cs.return_value.cached_image_stale_warning_days.return_value = 1
# Should not raise — the image is brand new.
check_stale("test image", naive_now)
class TestCheckStalePath(unittest.TestCase):
def test_delegates_to_check_stale_with_mtime(self):
with tempfile.NamedTemporaryFile() as f:
path = Path(f.name)
with patch("bot_bottle.image_cache.check_stale") as mock_check:
check_stale_path("some artifact", path)
mock_check.assert_called_once()
label, dt = mock_check.call_args.args
self.assertEqual("some artifact", label)
self.assertIsInstance(dt, datetime)
self.assertIsNotNone(dt.tzinfo)
def test_raises_stale_for_old_file(self):
with tempfile.NamedTemporaryFile() as f:
path = Path(f.name)
with patch("bot_bottle.image_cache.ConfigStore") as cs:
cs.return_value.cached_image_stale_warning_days.return_value = 0
with self.assertRaises(StaleImageError):
check_stale_path("cached artifact", path)
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -323,5 +323,71 @@ class TestWaitContainerIpv4(unittest.TestCase):
self.assertEqual("", util.wait_container_ipv4_on_network("c", "net", timeout=-1)) self.assertEqual("", util.wait_container_ipv4_on_network("c", "net", timeout=-1))
class TestMacosContainerImageCreatedAt(unittest.TestCase):
def _ok(self, stdout: str) -> "util.subprocess.CompletedProcess": # type: ignore
return util.subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr="",
)
def _fail(self, stderr: str = "no such image") -> "util.subprocess.CompletedProcess": # type: ignore
return util.subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
def test_parses_iso_timestamp_from_dict(self):
payload = '[{"created": "2025-06-01T12:00:00"}]'
with patch.object(util.subprocess, "run", return_value=self._ok(payload)):
dt = util.image_created_at("bot-bottle-agent:latest")
self.assertEqual(2025, dt.year)
self.assertEqual(6, dt.month)
self.assertEqual(1, dt.day)
def test_accepts_list_or_dict_input(self):
# Container CLI may return a list; we take the first element.
payload = '[{"created": "2024-01-15T08:30:00"}]'
with patch.object(util.subprocess, "run", return_value=self._ok(payload)):
dt = util.image_created_at("some-image:latest")
self.assertEqual(2024, dt.year)
def test_accepts_uppercase_Created_field(self):
payload = '[{"Created": "2024-03-20T10:00:00"}]'
with patch.object(util.subprocess, "run", return_value=self._ok(payload)):
dt = util.image_created_at("some-image:latest")
self.assertEqual(2024, dt.year)
self.assertEqual(3, dt.month)
def test_dies_on_nonzero_returncode(self):
with patch.object(util.subprocess, "run", return_value=self._fail("not found")), \
patch.object(util, "die", side_effect=SystemExit("die")) as die:
with self.assertRaises(SystemExit):
util.image_created_at("missing:tag")
die.assert_called_once()
self.assertIn("missing:tag", die.call_args.args[0])
def test_dies_on_malformed_json(self):
with patch.object(util.subprocess, "run", return_value=self._ok("not-json {")), \
patch.object(util, "die", side_effect=SystemExit("die")) as die:
with self.assertRaises(SystemExit):
util.image_created_at("some:tag")
die.assert_called_once()
def test_dies_when_no_created_field(self):
payload = '[{"id": "sha256:abc123"}]'
with patch.object(util.subprocess, "run", return_value=self._ok(payload)), \
patch.object(util, "die", side_effect=SystemExit("die")) as die:
with self.assertRaises(SystemExit):
util.image_created_at("some:tag")
die.assert_called_once()
self.assertIn("creation timestamp", die.call_args.args[0])
def test_dies_on_invalid_timestamp_format(self):
payload = '[{"created": "not-a-date"}]'
with patch.object(util.subprocess, "run", return_value=self._ok(payload)), \
patch.object(util, "die", side_effect=SystemExit("die")) as die:
with self.assertRaises(SystemExit):
util.image_created_at("some:tag")
die.assert_called_once()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+265
View File
@@ -0,0 +1,265 @@
"""Unit: stale-image check functions across backends, and the
BottleBackend.launch template method (prelaunch_checks + build_or_load_images).
No real images or containers are used all Docker/container/smolmachine
calls are mocked at the module boundary."""
from __future__ import annotations
import unittest
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, patch
def _bottle_cm(bottle: Any) -> MagicMock:
"""Return a mock context manager that yields `bottle`."""
cm = MagicMock()
cm.__enter__ = MagicMock(return_value=bottle)
cm.__exit__ = MagicMock(return_value=False)
return cm
# ---------------------------------------------------------------------------
# BottleBackend.launch template — prelaunch_checks + _build_or_load_images
# ---------------------------------------------------------------------------
class TestBottleBackendLaunchTemplate(unittest.TestCase):
"""Verify the concrete launch() method on BottleBackend calls
_build_or_load_images and _launch_impl, and that prelaunch_checks is a no-op
on the base class."""
def _make_backend(self) -> Any:
from bot_bottle.backend.docker.backend import DockerBottleBackend
return DockerBottleBackend()
def test_launch_delegates_to_build_or_load_and_launch_impl(self) -> None:
from bot_bottle.backend import BottleImages
backend = self._make_backend()
plan = cast(Any, SimpleNamespace())
bottle = MagicMock()
images = BottleImages(agent="agent:latest", sidecar="sidecar:latest")
with patch.object(
backend, "_build_or_load_images", return_value=images,
) as build_mock, patch.object(
backend, "_launch_impl",
return_value=_bottle_cm(bottle),
) as impl_mock:
with backend.launch(plan):
pass
build_mock.assert_called_once_with(plan)
impl_mock.assert_called_once_with(plan, images)
def test_noop_default_prelaunch_checks(self) -> None:
from bot_bottle.backend.docker.backend import DockerBottleBackend
from bot_bottle.backend import BottleBackend
backend = DockerBottleBackend()
# Base-class prelaunch_checks is a no-op — must not raise.
BottleBackend.prelaunch_checks(backend, cast(Any, SimpleNamespace())) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# Docker backend stale_checks
# ---------------------------------------------------------------------------
class TestDockerStaleChecks(unittest.TestCase):
def _plan(self, policy: str = "cached", slug: str = "dev-abc") -> Any:
spec = SimpleNamespace(image_policy=policy)
provision = SimpleNamespace(image="bot-bottle-agent:latest")
return cast(Any, SimpleNamespace(
spec=spec,
slug=slug,
image="bot-bottle-agent:latest",
agent_provision=provision,
))
def test_fresh_policy_is_noop(self) -> None:
from bot_bottle.backend.docker import launch as mod
with patch.object(mod, "read_committed_image") as rci, \
patch.object(mod, "check_stale") as cs:
mod.stale_checks(self._plan("fresh"))
rci.assert_not_called()
cs.assert_not_called()
def test_committed_image_present_checks_only_committed(self) -> None:
from bot_bottle.backend.docker import launch as mod
from datetime import datetime, timezone
ts = datetime(2025, 1, 1, tzinfo=timezone.utc)
with patch.object(mod, "read_committed_image", return_value="committed:latest"), \
patch.object(mod.docker_mod, "image_exists", return_value=True), \
patch.object(mod.docker_mod, "image_created_at", return_value=ts), \
patch.object(mod, "check_stale") as cs:
mod.stale_checks(self._plan())
cs.assert_called_once()
self.assertIn("committed:latest", cs.call_args.args[0])
def test_no_committed_image_checks_agent(self) -> None:
from bot_bottle.backend.docker import launch as mod
from datetime import datetime, timezone
ts = datetime(2025, 1, 1, tzinfo=timezone.utc)
plan = self._plan()
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(mod.docker_mod, "image_exists", return_value=True), \
patch.object(mod.docker_mod, "image_created_at", return_value=ts), \
patch.object(mod, "check_stale") as cs:
mod.stale_checks(plan)
cs.assert_called_once()
self.assertIn(plan.image, cs.call_args.args[0])
def test_image_not_present_skips_check(self) -> None:
from bot_bottle.backend.docker import launch as mod
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(mod.docker_mod, "image_exists", return_value=False), \
patch.object(mod, "check_stale") as cs:
mod.stale_checks(self._plan())
cs.assert_not_called()
def test_only_sidecar_missing_checks_only_agent(self) -> None:
from bot_bottle.backend.docker import launch as mod
from datetime import datetime, timezone
ts = datetime(2025, 1, 1, tzinfo=timezone.utc)
plan = self._plan()
def image_exists(ref: str) -> bool:
return ref == plan.image # Only agent present; sidecar missing.
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(mod.docker_mod, "image_exists", side_effect=image_exists), \
patch.object(mod.docker_mod, "image_created_at", return_value=ts), \
patch.object(mod, "check_stale") as cs:
mod.stale_checks(plan)
self.assertEqual(1, cs.call_count)
self.assertIn(plan.image, cs.call_args.args[0])
def test_backend_prelaunch_checks_delegates(self) -> None:
from bot_bottle.backend.docker.backend import DockerBottleBackend
from bot_bottle.backend.docker import launch as mod
backend = DockerBottleBackend()
plan = self._plan()
with patch.object(mod, "stale_checks") as sc:
backend.prelaunch_checks(plan) # type: ignore[arg-type]
sc.assert_called_once_with(plan)
# ---------------------------------------------------------------------------
# macOS container backend stale_checks
# ---------------------------------------------------------------------------
class TestMacosContainerStaleChecks(unittest.TestCase):
def _plan(self, policy: str = "cached", slug: str = "dev-abc") -> Any:
return cast(Any, SimpleNamespace(
spec=SimpleNamespace(image_policy=policy),
slug=slug,
image="bot-bottle-agent:latest",
))
def test_fresh_policy_is_noop(self) -> None:
from bot_bottle.backend.macos_container import launch as mod
with patch.object(mod, "read_committed_image") as rci, \
patch.object(mod, "check_stale") as cs:
mod.stale_checks(self._plan("fresh"))
rci.assert_not_called()
cs.assert_not_called()
def test_committed_image_present_checks_only_committed(self) -> None:
from bot_bottle.backend.macos_container import launch as mod
from datetime import datetime, timezone
ts = datetime(2025, 1, 1, tzinfo=timezone.utc)
with patch.object(mod, "read_committed_image", return_value="committed:latest"), \
patch.object(mod.container_mod, "image_exists", return_value=True), \
patch.object(mod.container_mod, "image_created_at", return_value=ts), \
patch.object(mod, "check_stale") as cs:
mod.stale_checks(self._plan())
cs.assert_called_once()
self.assertIn("committed:latest", cs.call_args.args[0])
def test_no_committed_image_checks_agent(self) -> None:
from bot_bottle.backend.macos_container import launch as mod
from datetime import datetime, timezone
ts = datetime(2025, 1, 1, tzinfo=timezone.utc)
plan = self._plan()
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(mod.container_mod, "image_exists", return_value=True), \
patch.object(mod.container_mod, "image_created_at", return_value=ts), \
patch.object(mod, "check_stale") as cs:
mod.stale_checks(plan)
cs.assert_called_once()
self.assertIn(plan.image, cs.call_args.args[0])
def test_image_not_present_skips_check(self) -> None:
from bot_bottle.backend.macos_container import launch as mod
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(mod.container_mod, "image_exists", return_value=False), \
patch.object(mod, "check_stale") as cs:
mod.stale_checks(self._plan())
cs.assert_not_called()
def test_backend_prelaunch_checks_delegates(self) -> None:
from bot_bottle.backend.macos_container.backend import MacosContainerBottleBackend
from bot_bottle.backend.macos_container import launch as mod
backend = MacosContainerBottleBackend()
plan = self._plan()
with patch.object(mod, "stale_checks") as sc:
backend.prelaunch_checks(plan) # type: ignore[arg-type]
sc.assert_called_once_with(plan)
# ---------------------------------------------------------------------------
# Firecracker cached rootfs selection and stale checks
# ---------------------------------------------------------------------------
class TestFirecrackerCachedRootfs(unittest.TestCase):
def _plan(self, policy: str = "cached") -> Any:
return cast(Any, SimpleNamespace(
spec=SimpleNamespace(image_policy=policy),
slug="dev-abc",
image="bot-bottle-agent:latest",
dockerfile_path="/repo/Dockerfile",
agent_provider_template="claude",
))
def test_cached_policy_reuses_ready_rootfs_without_building(self) -> None:
from bot_bottle.backend.firecracker import launch as mod
cached = Path("/cache/rootfs/agent-deadbeef")
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(
mod.image_builder, "cached_agent_rootfs_dir", return_value=cached,
), \
patch.object(mod.image_builder, "build_agent_rootfs_dir") as build:
self.assertEqual(cached, mod.build_or_load_agent_base(self._plan()))
build.assert_not_called()
def test_cached_policy_fails_when_rootfs_is_missing(self) -> None:
from bot_bottle.backend.firecracker import launch as mod
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(
mod.image_builder, "cached_agent_rootfs_dir", return_value=None,
), \
patch.object(mod.image_builder, "build_agent_rootfs_dir") as build, \
self.assertRaises(SystemExit):
mod.build_or_load_agent_base(self._plan())
build.assert_not_called()
def test_stale_checks_use_ready_marker_timestamp(self) -> None:
from bot_bottle.backend.firecracker import launch as mod
cached = Path("/cache/rootfs/agent-deadbeef")
with patch.object(mod, "read_committed_image", return_value=""), \
patch.object(
mod.image_builder, "cached_agent_rootfs_dir", return_value=cached,
), \
patch.object(mod, "check_stale_path") as check:
mod.stale_checks(self._plan())
check.assert_called_once_with(f"agent rootfs {cached}", cached / ".bb-ready")
def test_backend_prelaunch_checks_delegates(self) -> None:
from bot_bottle.backend.firecracker.backend import FirecrackerBottleBackend
from bot_bottle.backend.firecracker import launch as mod
plan = self._plan()
with patch.object(mod, "stale_checks") as checks:
FirecrackerBottleBackend().prelaunch_checks(plan)
checks.assert_called_once_with(plan)
if __name__ == "__main__":
unittest.main()