Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e75fce7cf | |||
| 2738b0fe6c | |||
| d1d5e635ea | |||
| 1b24d72745 | |||
| bc8894469b | |||
| 54589b1cc2 | |||
| 58e450e61f | |||
| bca4713304 | |||
| 8ce78555dc | |||
| fee0a66007 | |||
| ea41d306d8 | |||
| 17daa1c4f7 | |||
| acf9fb4d46 |
+95
-26
@@ -4,16 +4,15 @@
|
|||||||
# 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 Docker daemon; skip cleanly
|
# tests/integration/ — need a reachable backend; skip cleanly when
|
||||||
# (via tests/_docker.py:skip_unless_docker) when
|
# the backend isn't available on the runner
|
||||||
# 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
|
||||||
#
|
#
|
||||||
# This workflow assumes the Gitea Actions runner exposes the host Docker
|
# Integration tests run once per backend in separate jobs. Each job sets
|
||||||
# socket to the job container so `docker` commands inside the job can
|
# BOT_BOTTLE_BACKEND explicitly so the test suite uses the right backend.
|
||||||
# reach the daemon. If that's not yet configured on the runner the
|
# Backends that aren't available on the runner fail the preflight step
|
||||||
# integration tests will skip rather than fail.
|
# rather than silently skipping inside the test output.
|
||||||
|
|
||||||
name: test
|
name: test
|
||||||
|
|
||||||
@@ -23,9 +22,16 @@ 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:
|
||||||
@@ -48,7 +54,7 @@ jobs:
|
|||||||
- name: Report unit coverage
|
- name: Report unit coverage
|
||||||
run: python3 -m coverage report -m
|
run: python3 -m coverage report -m
|
||||||
|
|
||||||
integration:
|
integration-docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -65,33 +71,96 @@ 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
|
- name: Run integration tests (docker)
|
||||||
|
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
|
||||||
|
|
||||||
# Combined unit+integration coverage report (informational). See
|
# Integration tests against the Firecracker backend. Runs on a self-hosted
|
||||||
# docs/decisions/0004-coverage-policy.md.
|
# KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available.
|
||||||
#
|
#
|
||||||
# The hard diff-coverage gate (changed lines >= 90%) is DEFERRED: the
|
# Restricted to same-repo PRs, push to main, and workflow_dispatch — fork
|
||||||
# Firecracker backend's VM/SSH orchestration is covered by the integration
|
# PRs don't execute untrusted code on the privileged runner.
|
||||||
# suite, which needs /dev/kvm + the provisioned TAP/nft pool — a
|
#
|
||||||
# container-based runner skips it and those lines read uncovered, so the
|
# Runner prerequisites (provision once; see README "Firecracker on Linux"):
|
||||||
# gate can't pass here. Re-enabling it on a self-hosted KVM runner is
|
# `firecracker` on PATH, `/dev/kvm` accessible, Docker, cached kernel +
|
||||||
# tracked separately (see PRD 0069 / #348 and the ci-runner branch).
|
# static dropbear, and the pool as a persistent systemd unit.
|
||||||
|
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:
|
||||||
runs-on: ubuntu-latest
|
timeout-minutes: 15
|
||||||
|
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
|
||||||
|
|
||||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
- name: Preflight — Firecracker host is ready
|
||||||
# and older act_runner engines mishandle setup-python's PATH (coverage
|
run: |
|
||||||
# lands in one interpreter, `python3` resolves to another). Install
|
command -v firecracker >/dev/null || {
|
||||||
# straight into the ephemeral job container's system Python —
|
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
|
||||||
# --break-system-packages is safe because the container is disposable.
|
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
|
||||||
- name: Install dev requirements
|
# `backend status` exits non-zero unless the TAP pool is up + no
|
||||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
# range overlap; it prints the exact `backend setup` fix.
|
||||||
|
python3 cli.py backend status --backend=firecracker
|
||||||
|
|
||||||
- name: Combined coverage report (unit + integration)
|
# No dev-requirements install: `coverage` is already provided by the
|
||||||
|
# self-hosted runner's Nix python env, and that env has no `pip`
|
||||||
|
# module to install into anyway. `scripts/coverage.sh` +
|
||||||
|
# `diff_coverage.py` need only `coverage` (not pylint/pyright).
|
||||||
|
- name: Combined coverage (unit + integration, incl. firecracker)
|
||||||
run: PYTHON=python3 bash scripts/coverage.sh 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,7 +2,7 @@ name: tracker-policy-pr
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, edited, reopened, synchronized, labeled, unlabeled]
|
types: [opened, edited, reopened, synchronize, labeled, unlabeled]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-pr:
|
check-pr:
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM supervise_audit_entries
|
SELECT * FROM supervise_audit_entries
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ def _docker_on_path() -> bool:
|
|||||||
def _daemon_reachable() -> bool:
|
def _daemon_reachable() -> bool:
|
||||||
if not _docker_on_path():
|
if not _docker_on_path():
|
||||||
return False
|
return False
|
||||||
return subprocess.run(
|
try:
|
||||||
["docker", "info"],
|
return subprocess.run(
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
["docker", "info"],
|
||||||
).returncode == 0
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
check=False, timeout=5,
|
||||||
|
).returncode == 0
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _print_install_pointer() -> None:
|
def _print_install_pointer() -> None:
|
||||||
|
|||||||
+12
-2
@@ -3,6 +3,7 @@
|
|||||||
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:
|
||||||
@@ -28,12 +29,21 @@ 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._connect() as conn:
|
with self._connection() 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,),
|
||||||
@@ -45,7 +55,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._connect() as conn:
|
with self._connection() as conn:
|
||||||
self._migrations.apply(conn)
|
self._migrations.apply(conn)
|
||||||
self._chmod()
|
self._chmod()
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class RegistryStore(DbStore):
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
policy=policy,
|
policy=policy,
|
||||||
)
|
)
|
||||||
with self._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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'",
|
||||||
|
|||||||
@@ -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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() 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._connect() as conn:
|
with self._connection() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE supervise_proposals SET archived = 1
|
UPDATE supervise_proposals SET archived = 1
|
||||||
|
|||||||
+3
-2
@@ -26,8 +26,9 @@ rm -f .coverage
|
|||||||
echo "== unit ==" >&2
|
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 (firecracker; skips docker tests) ==" >&2
|
||||||
"$PY" -m coverage run --append -m unittest discover -t . -s tests/integration
|
BOT_BOTTLE_BACKEND=firecracker SKIP_DOCKER_TESTS=1 \
|
||||||
|
"$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
|
||||||
|
|||||||
+16
-9
@@ -2,23 +2,30 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
def docker_available() -> bool:
|
def docker_available() -> bool:
|
||||||
|
if os.environ.get("SKIP_DOCKER_TESTS"):
|
||||||
|
return False
|
||||||
if shutil.which("docker") is None:
|
if shutil.which("docker") is None:
|
||||||
return False
|
return False
|
||||||
return (
|
try:
|
||||||
subprocess.run(
|
return (
|
||||||
["docker", "info"],
|
subprocess.run(
|
||||||
stdout=subprocess.DEVNULL,
|
["docker", "info"],
|
||||||
stderr=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
check=False,
|
stderr=subprocess.DEVNULL,
|
||||||
).returncode
|
check=False,
|
||||||
== 0
|
timeout=5,
|
||||||
)
|
).returncode
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def skip_unless_docker(reason: str = "docker unreachable"):
|
def skip_unless_docker(reason: str = "docker unreachable"):
|
||||||
|
|||||||
@@ -69,11 +69,12 @@ _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"
|
||||||
"skipped under act_runner: egress_tls_init uses a host bind mount "
|
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
|
||||||
"the runner container can't see, and the network topology hides "
|
"skipped under act_runner unless BOT_BOTTLE_BACKEND=firecracker: "
|
||||||
"sibling-gateway visibility — same constraint as the other "
|
"egress_tls_init uses a host bind mount the runner container can't "
|
||||||
"bottle-bringup integration tests",
|
"see, and the network topology hides sibling-gateway visibility — "
|
||||||
|
"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
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ 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
|
||||||
|
|
||||||
|
# name_color_modal opens /dev/tty and blocks on keyboard input on
|
||||||
|
# self-hosted runners that have a real controlling terminal. Stub it
|
||||||
|
# out like the other tui pickers so tests don't wait for a keypress.
|
||||||
|
self._modal_patch = patch.object(
|
||||||
|
tui_mod, "name_color_modal", return_value=("researcher", ""),
|
||||||
|
)
|
||||||
|
self._modal_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 +74,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._modal_patch.stop()
|
||||||
self._env_patch.stop()
|
self._env_patch.stop()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
Tests both the helper functions in `bot_bottle.gateway_init`
|
Tests both the helper functions in `bot_bottle.gateway_init`
|
||||||
and the supervisor's end-to-end signal / exit-code behavior. The
|
and the supervisor's end-to-end signal / exit-code behavior. The
|
||||||
end-to-end tests use real subprocesses (`/bin/sleep`,
|
end-to-end tests use real subprocesses — short-lived, no docker required
|
||||||
`/bin/sh -c '...'`) — short-lived, no docker required — so they
|
— so they run under `tests/unit/` rather than `tests/integration/`."""
|
||||||
run under `tests/unit/` rather than `tests/integration/`."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
import warnings
|
import warnings
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.gateway_init import (
|
from bot_bottle.gateway_init import (
|
||||||
@@ -26,6 +24,11 @@ from bot_bottle.gateway_init import (
|
|||||||
_selected_daemons,
|
_selected_daemons,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# /bin/sleep does not exist on FHS-free systems (e.g. NixOS). Use a
|
||||||
|
# portable Python one-liner so supervisor tests run on any platform.
|
||||||
|
_SLEEP_30 = (sys.executable, "-c", "import time; time.sleep(30)")
|
||||||
|
_SLEEP_60 = (sys.executable, "-c", "import time; time.sleep(60)")
|
||||||
|
|
||||||
|
|
||||||
class TestEnvForDaemon(unittest.TestCase):
|
class TestEnvForDaemon(unittest.TestCase):
|
||||||
"""Scope egress-only credential env vars to the egress daemon.
|
"""Scope egress-only credential env vars to the egress daemon.
|
||||||
@@ -182,7 +185,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# up and the supervisor never set shutdown_at.
|
# up and the supervisor never set shutdown_at.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
||||||
_DaemonSpec("longrun", ("/bin/sleep", "30")),
|
_DaemonSpec("longrun", _SLEEP_30),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -214,7 +217,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# signal-killed longrun's negative returncode.
|
# signal-killed longrun's negative returncode.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
||||||
_DaemonSpec("longrun", ("/bin/sleep", "30")),
|
_DaemonSpec("longrun", _SLEEP_30),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -259,7 +262,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("egress", sighup_marker),
|
_DaemonSpec("egress", sighup_marker),
|
||||||
_DaemonSpec("other", ("/bin/sleep", "30")),
|
_DaemonSpec("other", _SLEEP_30),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -282,7 +285,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_forward_signal_unknown_daemon_no_op(self):
|
def test_forward_signal_unknown_daemon_no_op(self):
|
||||||
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("a", _SLEEP_30)]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
delivered = sup.forward_signal(signal.SIGHUP, "ghost")
|
delivered = sup.forward_signal(signal.SIGHUP, "ghost")
|
||||||
@@ -294,8 +297,8 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# Restart one daemon; the other (supervise, the MCP server
|
# Restart one daemon; the other (supervise, the MCP server
|
||||||
# in production) must remain untouched.
|
# in production) must remain untouched.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("git-gate", ("/bin/sleep", "30")),
|
_DaemonSpec("git-gate", _SLEEP_30),
|
||||||
_DaemonSpec("supervise", ("/bin/sleep", "30")),
|
_DaemonSpec("supervise", _SLEEP_30),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -319,8 +322,8 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
|
|
||||||
def test_request_restart_is_drained_by_tick(self):
|
def test_request_restart_is_drained_by_tick(self):
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("git-gate", ("/bin/sleep", "30")),
|
_DaemonSpec("git-gate", _SLEEP_30),
|
||||||
_DaemonSpec("supervise", ("/bin/sleep", "30")),
|
_DaemonSpec("supervise", _SLEEP_30),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -343,7 +346,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_repeated_restart_requests_coalesce(self):
|
def test_repeated_restart_requests_coalesce(self):
|
||||||
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("git-gate", _SLEEP_30)]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -366,7 +369,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_request_restart_unknown_daemon_no_op(self):
|
def test_request_restart_unknown_daemon_no_op(self):
|
||||||
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("a", _SLEEP_30)]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
ok = sup.request_restart("ghost")
|
ok = sup.request_restart("ghost")
|
||||||
@@ -376,7 +379,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_restart_unknown_daemon_no_op(self):
|
def test_restart_unknown_daemon_no_op(self):
|
||||||
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("a", _SLEEP_30)]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
ok = sup.restart_daemon("ghost")
|
ok = sup.restart_daemon("ghost")
|
||||||
@@ -385,7 +388,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_restart_during_shutdown_is_no_op(self):
|
def test_restart_during_shutdown_is_no_op(self):
|
||||||
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("git-gate", _SLEEP_30)]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
sup.request_shutdown(reason="test")
|
sup.request_shutdown(reason="test")
|
||||||
@@ -395,7 +398,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_pending_restart_dropped_during_shutdown(self):
|
def test_pending_restart_dropped_during_shutdown(self):
|
||||||
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("git-gate", _SLEEP_30)]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -413,8 +416,8 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# both should receive SIGTERM and exit. Signal-only
|
# both should receive SIGTERM and exit. Signal-only
|
||||||
# shutdown clamps to a zero supervisor exit code.
|
# shutdown clamps to a zero supervisor exit code.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("a", ("/bin/sleep", "60")),
|
_DaemonSpec("a", _SLEEP_60),
|
||||||
_DaemonSpec("b", ("/bin/sleep", "60")),
|
_DaemonSpec("b", _SLEEP_60),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -449,7 +452,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self.assertEqual(0, rc)
|
self.assertEqual(0, rc)
|
||||||
|
|
||||||
def test_idempotent_shutdown_requests(self):
|
def test_idempotent_shutdown_requests(self):
|
||||||
specs = [_DaemonSpec("a", ("/bin/sleep", "60"))]
|
specs = [_DaemonSpec("a", _SLEEP_60)]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -465,28 +468,22 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
|
|
||||||
class TestMainEndToEnd(unittest.TestCase):
|
class TestMainEndToEnd(unittest.TestCase):
|
||||||
"""Run gateway_init.py as a real subprocess to cover the
|
"""Run gateway_init.py as a real subprocess to cover the
|
||||||
signal-handler installation path. Skipped on platforms
|
signal-handler installation path."""
|
||||||
without /bin/sleep + /bin/sh."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
for p in ("/bin/sh", "/bin/sleep"):
|
|
||||||
if not Path(p).exists():
|
|
||||||
raise unittest.SkipTest(f"missing {p}")
|
|
||||||
|
|
||||||
def _run(self, daemons_csv: str, send_signal: int | None,
|
def _run(self, daemons_csv: str, send_signal: int | None,
|
||||||
wait_before_signal: float = 0.4,
|
wait_before_signal: float = 0.4,
|
||||||
overall_timeout: float = 6.0) -> tuple[int, str]:
|
overall_timeout: float = 6.0) -> tuple[int, str]:
|
||||||
"""Spawn gateway_init.main() in a child process with the
|
"""Spawn gateway_init.main() in a child process with the
|
||||||
DAEMONS list patched to harmless `sleep 30` commands.
|
DAEMONS list patched to harmless long-sleep commands.
|
||||||
Returns (returncode, captured stdout)."""
|
Returns (returncode, captured stdout)."""
|
||||||
|
|
||||||
helper = (
|
helper = (
|
||||||
"import os, runpy, sys\n"
|
"import os, runpy, sys\n"
|
||||||
"from bot_bottle import gateway_init as si\n"
|
"from bot_bottle import gateway_init as si\n"
|
||||||
|
"sleep_cmd = (sys.executable, '-c', 'import time; time.sleep(30)')\n"
|
||||||
"si._DAEMONS = (\n"
|
"si._DAEMONS = (\n"
|
||||||
" si._DaemonSpec('alpha', ('/bin/sleep','30')),\n"
|
" si._DaemonSpec('alpha', sleep_cmd),\n"
|
||||||
" si._DaemonSpec('beta', ('/bin/sleep','30')),\n"
|
" si._DaemonSpec('beta', sleep_cmd),\n"
|
||||||
")\n"
|
")\n"
|
||||||
"sys.exit(si.main([]))\n"
|
"sys.exit(si.main([]))\n"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user