ci(docker): require the full integration suite

This commit is contained in:
2026-07-26 08:00:15 +00:00
parent fafb828bb7
commit 583ff98b27
17 changed files with 341 additions and 109 deletions
+19 -1
View File
@@ -84,7 +84,25 @@ jobs:
env: env:
BOT_BOTTLE_BACKEND: docker BOT_BOTTLE_BACKEND: docker
COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v run: |
set -euo pipefail
DOCKER_HOST_ADDRESS=$(python3 scripts/docker_host_address.py)
test -n "$DOCKER_HOST_ADDRESS"
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
export BOT_BOTTLE_DOCKER_HOST_ADDRESS="$DOCKER_HOST_ADDRESS"
export BOT_BOTTLE_DOCKER_ROOT_MOUNT="bot-bottle-ci-root-$RUN_KEY"
export BOT_BOTTLE_DOCKER_CA_MOUNT="bot-bottle-ci-ca-$RUN_KEY"
python3 -m coverage run -m scripts.unittest_gate \
-t . -s tests/integration -v \
--minimum-executed 22 --fail-on-skip
- name: Clean Docker integration volumes
if: always()
run: |
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
docker volume rm --force \
"bot-bottle-ci-root-$RUN_KEY" \
"bot-bottle-ci-ca-$RUN_KEY" 2>/dev/null || true
# Non-dot name so upload-artifact's dotfile-skipping glob picks it up. # Non-dot name so upload-artifact's dotfile-skipping glob picks it up.
- name: Stage docker coverage for upload - name: Stage docker coverage for upload
+36 -5
View File
@@ -12,10 +12,14 @@ on:
- 'bot_bottle/**' - 'bot_bottle/**'
- 'tests/**/*.py' - 'tests/**/*.py'
- 'cli.py' - 'cli.py'
- 'install.sh'
- 'setup.py'
- 'MANIFEST.in'
- 'flake.nix'
- 'nix/firecracker-netpool.nix'
- 'scripts/coverage.sh' - 'scripts/coverage.sh'
- 'scripts/critical-modules.txt' - 'scripts/critical-modules.txt'
- 'scripts/diff_coverage.py' - 'scripts/**/*.py'
- 'scripts/tracker_policy.py'
- 'scripts/firecracker-netpool.sh' - 'scripts/firecracker-netpool.sh'
- 'Dockerfile*' - 'Dockerfile*'
- 'pyproject.toml' - 'pyproject.toml'
@@ -23,15 +27,20 @@ on:
- '.coveragerc' - '.coveragerc'
- '.dockerignore' - '.dockerignore'
- '.gitea/workflows/test.yml' - '.gitea/workflows/test.yml'
- '.gitea/workflows/pre-release-test.yml'
pull_request: pull_request:
paths: paths:
- 'bot_bottle/**' - 'bot_bottle/**'
- 'tests/**/*.py' - 'tests/**/*.py'
- 'cli.py' - 'cli.py'
- 'install.sh'
- 'setup.py'
- 'MANIFEST.in'
- 'flake.nix'
- 'nix/firecracker-netpool.nix'
- 'scripts/coverage.sh' - 'scripts/coverage.sh'
- 'scripts/critical-modules.txt' - 'scripts/critical-modules.txt'
- 'scripts/diff_coverage.py' - 'scripts/**/*.py'
- 'scripts/tracker_policy.py'
- 'scripts/firecracker-netpool.sh' - 'scripts/firecracker-netpool.sh'
- 'Dockerfile*' - 'Dockerfile*'
- 'pyproject.toml' - 'pyproject.toml'
@@ -39,6 +48,7 @@ on:
- '.coveragerc' - '.coveragerc'
- '.dockerignore' - '.dockerignore'
- '.gitea/workflows/test.yml' - '.gitea/workflows/test.yml'
- '.gitea/workflows/pre-release-test.yml'
jobs: jobs:
unit: unit:
@@ -87,7 +97,28 @@ jobs:
env: env:
BOT_BOTTLE_BACKEND: docker BOT_BOTTLE_BACKEND: docker
COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker
run: python3 -m coverage run -m unittest discover -t . -s tests/integration -v run: |
set -euo pipefail
# act_runner executes this job in a container while sharing the host
# Docker socket. Reach host-published ports through the bridge gateway
# and use named volumes for state the host daemon must mount.
DOCKER_HOST_ADDRESS=$(python3 scripts/docker_host_address.py)
test -n "$DOCKER_HOST_ADDRESS"
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
export BOT_BOTTLE_DOCKER_HOST_ADDRESS="$DOCKER_HOST_ADDRESS"
export BOT_BOTTLE_DOCKER_ROOT_MOUNT="bot-bottle-ci-root-$RUN_KEY"
export BOT_BOTTLE_DOCKER_CA_MOUNT="bot-bottle-ci-ca-$RUN_KEY"
python3 -m coverage run -m scripts.unittest_gate \
-t . -s tests/integration -v \
--minimum-executed 22 --fail-on-skip
- name: Clean Docker integration volumes
if: always()
run: |
RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}"
docker volume rm --force \
"bot-bottle-ci-root-$RUN_KEY" \
"bot-bottle-ci-ca-$RUN_KEY" 2>/dev/null || true
- name: Stage docker coverage for upload - name: Stage docker coverage for upload
run: cp .coverage.docker coverage-docker.dat run: cp .coverage.docker coverage-docker.dat
+7 -2
View File
@@ -35,6 +35,7 @@ class DockerGateway(Gateway):
build_context: Path | None = None, build_context: Path | None = None,
dockerfile: str | None = GATEWAY_DOCKERFILE, dockerfile: str | None = GATEWAY_DOCKERFILE,
host_port_bindings: tuple[int, ...] = (), host_port_bindings: tuple[int, ...] = (),
ca_mount_source: str | Path | None = None,
) -> None: ) -> None:
self.image_ref = image_ref self.image_ref = image_ref
self.name = name self.name = name
@@ -59,6 +60,10 @@ class DockerGateway(Gateway):
# backend's dev-harness gateway so VMs can reach it via their TAP link; # backend's dev-harness gateway so VMs can reach it via their TAP link;
# Docker's DNAT + the nft `ct status dnat accept` rule handle the rest. # Docker's DNAT + the nft `ct status dnat accept` rule handle the rest.
self._host_port_bindings = host_port_bindings self._host_port_bindings = host_port_bindings
configured_ca = os.environ.get("BOT_BOTTLE_DOCKER_CA_MOUNT", "").strip()
self._ca_mount_source = str(
ca_mount_source or configured_ca or host_gateway_ca_dir()
)
def image_exists(self) -> bool: def image_exists(self) -> bool:
return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0 return run_docker(["docker", "image", "inspect", self.image_ref]).returncode == 0
@@ -158,7 +163,7 @@ class DockerGateway(Gateway):
# Persist the self-generated CA on the host so it survives both # Persist the self-generated CA on the host so it survives both
# container recreation AND docker volume pruning (agents trust it) # container recreation AND docker volume pruning (agents trust it)
# — see host_gateway_ca_dir / issue #450. # — see host_gateway_ca_dir / issue #450.
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}", "--volume", f"{self._ca_mount_source}:{MITMPROXY_HOME}",
# No DB mount: the data plane (egress / supervise / git-gate) reaches # No DB mount: the data plane (egress / supervise / git-gate) reaches
# the supervise queue over the control-plane RPC and never opens # the supervise queue over the control-plane RPC and never opens
# bot-bottle.db, so the gateway container gets no file handle on it # bot-bottle.db, so the gateway container gets no file handle on it
@@ -253,4 +258,4 @@ class DockerGateway(Gateway):
def provisioning_transport(self) -> GatewayTransport: def provisioning_transport(self) -> GatewayTransport:
"""The exec/cp transport git-gate provisioning stages per-bottle repos + """The exec/cp transport git-gate provisioning stages per-bottle repos +
deploy keys through (over the docker socket).""" deploy keys through (over the docker socket)."""
return DockerGatewayTransport(self.name) return DockerGatewayTransport(self.name)
+11 -4
View File
@@ -33,7 +33,6 @@ from .orchestrator import (
ORCHESTRATOR_NAME, ORCHESTRATOR_NAME,
ORCHESTRATOR_NETWORK, ORCHESTRATOR_NETWORK,
) )
from ...paths import bot_bottle_root
from ... import resources from ... import resources
from ...gateway import ( from ...gateway import (
GATEWAY_IMAGE, GATEWAY_IMAGE,
@@ -69,6 +68,8 @@ class DockerInfraService(InfraService):
gateway_image: str = GATEWAY_IMAGE, gateway_image: str = GATEWAY_IMAGE,
repo_root: Path | None = None, repo_root: Path | None = None,
host_root: Path | None = None, host_root: Path | None = None,
root_mount_source: str | Path | None = None,
gateway_ca_mount_source: str | Path | None = None,
orchestrator_name: str = ORCHESTRATOR_NAME, orchestrator_name: str = ORCHESTRATOR_NAME,
orchestrator_label: str = ORCHESTRATOR_LABEL, orchestrator_label: str = ORCHESTRATOR_LABEL,
gateway_name: str = GATEWAY_NAME, gateway_name: str = GATEWAY_NAME,
@@ -78,10 +79,14 @@ class DockerInfraService(InfraService):
self.control_network = control_network self.control_network = control_network
self.orchestrator_image = orchestrator_image self.orchestrator_image = orchestrator_image
self.gateway_image = gateway_image self.gateway_image = gateway_image
# Build context / bind-mount source: the repo root in a checkout, a # Build context: the repo root in a checkout, a staged copy from the
# staged copy from the installed wheel otherwise (bot_bottle.resources). # installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root() self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._host_root = host_root or bot_bottle_root() if host_root is not None and root_mount_source is not None:
raise ValueError("pass host_root or root_mount_source, not both")
self._host_root = host_root
self._root_mount_source = root_mount_source
self._gateway_ca_mount_source = gateway_ca_mount_source
self._orchestrator_name = orchestrator_name self._orchestrator_name = orchestrator_name
self._orchestrator_label = orchestrator_label self._orchestrator_label = orchestrator_label
self._gateway_name = gateway_name self._gateway_name = gateway_name
@@ -98,6 +103,7 @@ class DockerInfraService(InfraService):
control_network=self.control_network, control_network=self.control_network,
repo_root=self._repo_root, repo_root=self._repo_root,
host_root=self._host_root, host_root=self._host_root,
root_mount_source=self._root_mount_source,
) )
def gateway(self) -> DockerGateway: def gateway(self) -> DockerGateway:
@@ -112,6 +118,7 @@ class DockerInfraService(InfraService):
network=self.network, network=self.network,
control_network=self.control_network, control_network=self.control_network,
build_context=self._repo_root, build_context=self._repo_root,
ca_mount_source=self._gateway_ca_mount_source,
) )
def ensure_running( def ensure_running(
+35 -19
View File
@@ -42,13 +42,9 @@ ORCHESTRATOR_IMAGE = os.environ.get(
) )
ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator" ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator"
# Baked as a container label so `ensure_running` can detect whether the running # Baked as a container label so `ensure_running` can detect whether the running
# orchestrator is executing the current bind-mounted source. # orchestrator image was built from the current source.
ORCHESTRATOR_SOURCE_HASH_LABEL = "bot-bottle-orchestrator-source-hash" ORCHESTRATOR_SOURCE_HASH_LABEL = "bot-bottle-orchestrator-source-hash"
# The bind-mount path for the live control-plane source inside the container.
# PYTHONPATH points here so a code change takes effect on the next launch
# without an image rebuild.
_SRC_IN_CONTAINER = "/bot-bottle-src"
# Bot-bottle host-root bind-mount (DB + state) inside the orchestrator. The # Bot-bottle host-root bind-mount (DB + state) inside the orchestrator. The
# control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT -> # control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT ->
# host_db_path()); it is the ONLY container with a handle on it (issue #469). # host_db_path()); it is the ONLY container with a handle on it (issue #469).
@@ -72,23 +68,40 @@ class DockerOrchestrator(Orchestrator):
control_network: str = ORCHESTRATOR_NETWORK, control_network: str = ORCHESTRATOR_NETWORK,
repo_root: Path | None = None, repo_root: Path | None = None,
host_root: Path | None = None, host_root: Path | None = None,
root_mount_source: str | Path | None = None,
client_host: str | None = None,
bind_host: str | None = None,
dockerfile: str | None = ORCHESTRATOR_DOCKERFILE, dockerfile: str | None = ORCHESTRATOR_DOCKERFILE,
) -> None: ) -> None:
if host_root is not None and root_mount_source is not None:
raise ValueError("pass host_root or root_mount_source, not both")
self.image_ref = image_ref self.image_ref = image_ref
self.name = name self.name = name
self.label = label self.label = label
self.port = port self.port = port
self.control_network = control_network self.control_network = control_network
# Build context / bind-mount source: the repo root in a checkout, a # Build context: the repo root in a checkout, a staged copy from the
# staged copy from the installed wheel otherwise (bot_bottle.resources). # installed wheel otherwise (bot_bottle.resources).
self._repo_root = repo_root if repo_root is not None else resources.build_root() self._repo_root = repo_root if repo_root is not None else resources.build_root()
self._host_root = host_root or bot_bottle_root() configured_root = os.environ.get("BOT_BOTTLE_DOCKER_ROOT_MOUNT", "").strip()
self._root_mount_source = str(
root_mount_source or configured_root or host_root or bot_bottle_root()
)
configured_host = os.environ.get(
"BOT_BOTTLE_DOCKER_HOST_ADDRESS", ""
).strip()
self._client_host = client_host or configured_host or "127.0.0.1"
# A socket-shared CI runner reaches published ports through its Docker
# bridge gateway rather than its own loopback. Production stays bound
# to host loopback unless a caller explicitly selects another client.
self._bind_host = bind_host or (
"0.0.0.0" if self._client_host != "127.0.0.1" else "127.0.0.1"
)
self._dockerfile = dockerfile self._dockerfile = dockerfile
def url(self) -> str: def url(self) -> str:
"""Host-side control-plane URL — the orchestrator's published loopback, """Control-plane URL reachable by this Docker client."""
which the CLI reaches.""" return f"http://{self._client_host}:{self.port}"
return f"http://127.0.0.1:{self.port}"
def gateway_url(self) -> str: def gateway_url(self) -> str:
"""The URL the gateway's data plane resolves policy against — the """The URL the gateway's data plane resolves policy against — the
@@ -119,8 +132,7 @@ class DockerOrchestrator(Orchestrator):
return self.name in proc.stdout.split() return self.name in proc.stdout.split()
def _source_current(self, current_hash: str) -> bool: def _source_current(self, current_hash: str) -> bool:
"""True iff the running orchestrator was started from the current """True iff the running orchestrator image matches current source."""
bind-mounted source."""
if not self.is_running(): if not self.is_running():
return False return False
proc = run_docker([ proc = run_docker([
@@ -182,15 +194,19 @@ class DockerOrchestrator(Orchestrator):
# Control network only — agents are never on it, so they have no # Control network only — agents are never on it, so they have no
# route to the control plane (the L3 block, not just the JWT). # route to the control plane (the L3 block, not just the JWT).
"--network", self.control_network, "--network", self.control_network,
# Host CLI reaches the control plane here (loopback only). The # Host CLI reaches the control plane here (loopback by default).
# Socket-shared CI binds all host interfaces so its job container
# can use the Docker bridge gateway selected by `client_host`. The
# orchestrator listens on the fixed DEFAULT_PORT inside the # orchestrator listens on the fixed DEFAULT_PORT inside the
# container; self.port is the host-side published port. # container; self.port is the host-side published port.
"--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}", "--publish", f"{self._bind_host}:{self.port}:{DEFAULT_PORT}",
# Live control-plane source (code changes without an image rebuild). # The image was rebuilt from `_repo_root` immediately before this
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro", # launch. Running its baked package avoids a host-path bind mount,
"--env", f"PYTHONPATH={_SRC_IN_CONTAINER}", # which is both more production-like and works with socket-shared
# CI where the daemon cannot see the job container's workspace.
# Orchestrator registry DB on the host (sole writer: control plane). # Orchestrator registry DB on the host (sole writer: control plane).
"--volume", f"{self._host_root}:{_ROOT_IN_CONTAINER}", # `root_mount_source` may be a host path or a named Docker volume.
"--volume", f"{self._root_mount_source}:{_ROOT_IN_CONTAINER}",
"--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}", "--env", f"BOT_BOTTLE_ROOT={_ROOT_IN_CONTAINER}",
# The signing key — held ONLY by the orchestrator (it verifies # The signing key — held ONLY by the orchestrator (it verifies
# tokens); the gateway gets the pre-minted `gateway` JWT, never the # tokens); the gateway gets the pre-minted `gateway` JWT, never the
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Print the Docker host address seen by a socket-shared Linux CI container."""
from __future__ import annotations
import socket
import struct
import sys
from pathlib import Path
def default_ipv4_gateway(route_table: str) -> str:
"""Return the active default gateway from Linux ``/proc/net/route``."""
for line in route_table.splitlines()[1:]:
fields = line.split()
if len(fields) < 4 or fields[1] != "00000000":
continue
try:
gateway = int(fields[2], 16)
flags = int(fields[3], 16)
except ValueError:
continue
if flags & 0x2: # RTF_GATEWAY
return socket.inet_ntoa(struct.pack("<I", gateway))
raise ValueError("no active IPv4 default gateway in /proc/net/route")
def main() -> int:
try:
print(default_ipv4_gateway(Path("/proc/net/route").read_text(encoding="utf-8")))
except (OSError, ValueError) as exc:
print(f"docker-host-address: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Run unittest discovery with explicit execution-count assurances.
The standard unittest CLI exits successfully when a suite contains skipped
tests. That is normally useful, but it let the Docker integration job stay
green while its security-boundary classes were all skipped under act_runner.
This wrapper keeps normal unittest output and adds opt-in minimum-executed and
no-skip gates for jobs that promise a concrete integration surface.
"""
from __future__ import annotations
import argparse
import sys
import unittest
def assurance_errors(
*, tests_run: int, skipped: int, minimum_executed: int, fail_on_skip: bool
) -> list[str]:
"""Return human-readable assurance failures for a completed suite."""
executed = tests_run - skipped
errors: list[str] = []
if executed < minimum_executed:
errors.append(
f"executed {executed} test(s), below required minimum "
f"{minimum_executed} (discovered {tests_run}, skipped {skipped})"
)
if fail_on_skip and skipped:
errors.append(f"{skipped} test(s) skipped in a no-skip suite")
return errors
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="unittest discovery with execution-count assurance"
)
parser.add_argument("-s", "--start-directory", default=".")
parser.add_argument("-t", "--top-level-directory", default=None)
parser.add_argument("-p", "--pattern", default="test*.py")
parser.add_argument("--minimum-executed", type=int, default=0)
parser.add_argument("--fail-on-skip", action="store_true")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv)
suite = unittest.defaultTestLoader.discover(
args.start_directory,
pattern=args.pattern,
top_level_dir=args.top_level_directory,
)
result = unittest.TextTestRunner(
verbosity=2 if args.verbose else 1,
).run(suite)
failures = assurance_errors(
tests_run=result.testsRun,
skipped=len(result.skipped),
minimum_executed=args.minimum_executed,
fail_on_skip=args.fail_on_skip,
)
for failure in failures:
print(f"unittest-gate: {failure}", file=sys.stderr)
return 0 if result.wasSuccessful() and not failures else 1
if __name__ == "__main__":
raise SystemExit(main())
+11 -13
View File
@@ -14,9 +14,7 @@ the chunk-1 contract:
expected "no daemons selected" line when the supervisor is expected "no daemons selected" line when the supervisor is
pointed at an empty daemon set. pointed at an empty daemon set.
Skips cleanly when docker is unavailable, or under act_runner Skips cleanly only when the selected Docker backend is unavailable.
where the host bind-mount topology breaks multi-stage builds
that pull large bases.
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,12 +31,6 @@ _DOCKERFILE = "Dockerfile.gateway"
@skip_unless_backend("docker") @skip_unless_backend("docker")
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: multi-stage build pulls a 200+MB "
"mitmproxy base + two upstream gateway images; runner storage "
"+ time budget make this an interactive-only test",
)
class TestGatewayImage(unittest.TestCase): class TestGatewayImage(unittest.TestCase):
"""Builds the image once for the class, then runs a few """Builds the image once for the class, then runs a few
`docker run` probes against it.""" `docker run` probes against it."""
@@ -51,10 +43,11 @@ class TestGatewayImage(unittest.TestCase):
"-f", _DOCKERFILE, "."], "-f", _DOCKERFILE, "."],
cwd=repo_root, cwd=repo_root,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
check=False,
) )
if proc.returncode != 0: if proc.returncode != 0:
raise unittest.SkipTest( raise AssertionError(
f"docker build failed; skipping image probes.\n" f"docker build failed; image probes cannot run.\n"
f"{proc.stdout.decode('utf-8', errors='replace')[-2000:]}" f"{proc.stdout.decode('utf-8', errors='replace')[-2000:]}"
) )
@@ -63,14 +56,16 @@ class TestGatewayImage(unittest.TestCase):
subprocess.run( subprocess.run(
["docker", "image", "rm", "-f", _IMAGE], ["docker", "image", "rm", "-f", _IMAGE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
) )
def _run_in_image(self, *cmd: str, timeout: float = 30.0) -> tuple[int, str]: def _run_in_image(self, *cmd: str, timeout: float = 30.0) -> tuple[int, str]:
proc = subprocess.run( proc = subprocess.run(
["docker", "run", "--rm", "--entrypoint", cmd[0], _IMAGE, ["docker", "run", "--rm", "--entrypoint", cmd[0], _IMAGE,
*cmd[1:]], *cmd[1:]],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout, timeout=timeout,
check=False,
) )
return proc.returncode, proc.stdout.decode("utf-8", errors="replace") return proc.returncode, proc.stdout.decode("utf-8", errors="replace")
@@ -91,7 +86,9 @@ class TestGatewayImage(unittest.TestCase):
# Probe that the package imports resolve inside the image. # Probe that the package imports resolve inside the image.
rc, out = self._run_in_image( rc, out = self._run_in_image(
"python3", "-c", "python3", "-c",
"from bot_bottle.supervisor import types; from bot_bottle.gateway.supervisor import server as supervise_server; print('ok')", "from bot_bottle.supervisor import types; "
"from bot_bottle.gateway.supervisor import server as supervise_server; "
"print('ok')",
) )
self.assertEqual(0, rc, msg=out) self.assertEqual(0, rc, msg=out)
self.assertIn("ok", out) self.assertIn("ok", out)
@@ -106,6 +103,7 @@ class TestGatewayImage(unittest.TestCase):
_IMAGE], _IMAGE],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=10.0, timeout=10.0,
check=False,
) )
out = proc.stdout.decode("utf-8", errors="replace") out = proc.stdout.decode("utf-8", errors="replace")
self.assertEqual(0, proc.returncode, msg=out) self.assertEqual(0, proc.returncode, msg=out)
@@ -16,11 +16,9 @@ throwaway BOT_BOTTLE_ROOT for a clean registry and tears everything down.
from __future__ import annotations from __future__ import annotations
import os import secrets
import subprocess import subprocess
import tempfile
import unittest import unittest
from pathlib import Path
from bot_bottle.backend.docker.consolidated_launch import ( from bot_bottle.backend.docker.consolidated_launch import (
_network_cidr, _network_cidr,
@@ -73,19 +71,12 @@ _PROBE_SRC = (
@skip_unless_backend("docker") @skip_unless_backend("docker")
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: the orchestrator container bind-mounts the repo "
"path into a container on the socket-shared host daemon, which can't see the "
"runner's /workspace — same host-bind-mount constraint as the other "
"bottle-bringup integration tests",
)
class TestMultitenantIsolation(unittest.TestCase): class TestMultitenantIsolation(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory() # Named volume → a clean registry DB that is also visible to a
self.addCleanup(self._tmp.cleanup) # socket-shared host daemon when the test process runs in act_runner.
# Throwaway root → a clean registry DB, independent of the host's. self._root_volume = "bot-bottle-mtitest-root-" + secrets.token_hex(4)
self.svc = DockerInfraService(host_root=Path(self._tmp.name)) self.svc = DockerInfraService(root_mount_source=self._root_volume)
self.addCleanup(self._teardown_docker) self.addCleanup(self._teardown_docker)
# ensure_running builds the bundle image (slow on a cold cache) and # ensure_running builds the bundle image (slow on a cold cache) and
# brings up the shared network + gateway + orchestrator. # brings up the shared network + gateway + orchestrator.
@@ -100,13 +91,8 @@ class TestMultitenantIsolation(unittest.TestCase):
self.svc.stop() self.svc.stop()
subprocess.run(["docker", "network", "rm", GATEWAY_NETWORK], subprocess.run(["docker", "network", "rm", GATEWAY_NETWORK],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
# The orchestrator container wrote the registry DB as root into the
# throwaway root; chown it back so the (non-root) tempdir cleanup can
# remove it.
subprocess.run( subprocess.run(
["docker", "run", "--rm", "-v", f"{self._tmp.name}:/r", ["docker", "volume", "rm", "--force", self._root_volume],
"--entrypoint", "chown", GATEWAY_IMAGE, "-R",
f"{os.getuid()}:{os.getgid()}", "/r"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
@staticmethod @staticmethod
@@ -137,7 +123,8 @@ class TestMultitenantIsolation(unittest.TestCase):
["docker", "run", "--rm", "--network", GATEWAY_NETWORK, "--ip", source_ip, ["docker", "run", "--rm", "--network", GATEWAY_NETWORK, "--ip", source_ip,
"--entrypoint", "python3", GATEWAY_IMAGE, "-c", _PROBE_SRC, "--entrypoint", "python3", GATEWAY_IMAGE, "-c", _PROBE_SRC,
f"http://{self.gw_ip}:{EGRESS_PORT}", host], f"http://{self.gw_ip}:{EGRESS_PORT}", host],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, timeout=90, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
check=False, timeout=90,
) )
return proc.stdout.strip() return proc.stdout.strip()
@@ -23,7 +23,6 @@ import secrets
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
from pathlib import Path
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint
from bot_bottle.orchestrator.client import OrchestratorClient from bot_bottle.orchestrator.client import OrchestratorClient
@@ -38,13 +37,6 @@ _TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest"
@skip_unless_backend("docker") @skip_unless_backend("docker")
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: the orchestrator container bind-mounts the repo "
"path into a container on the socket-shared host daemon, which can't see the "
"runner's /workspace — same host-bind-mount constraint as the other "
"bottle-bringup integration tests",
)
class TestDockerOrchestratorAuthIntegration(unittest.TestCase): class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
@@ -74,10 +66,10 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
gateway_name = f"bot-bottle-gateway-itest-{suffix}" gateway_name = f"bot-bottle-gateway-itest-{suffix}"
network = f"bot-bottle-net-itest-{suffix}" network = f"bot-bottle-net-itest-{suffix}"
control_network = f"bot-bottle-ctrl-itest-{suffix}" control_network = f"bot-bottle-ctrl-itest-{suffix}"
host_root = Path(cls._tmp.name) root_volume = f"bot-bottle-root-itest-{suffix}"
cls.addClassCleanup( cls.addClassCleanup(
cls._teardown_docker, cls._teardown_docker,
orchestrator_name, gateway_name, network, control_network, host_root, orchestrator_name, gateway_name, network, control_network, root_volume,
) )
cls.svc = DockerInfraService( cls.svc = DockerInfraService(
@@ -88,7 +80,7 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
orchestrator_image=_TEST_ORCHESTRATOR_IMAGE, orchestrator_image=_TEST_ORCHESTRATOR_IMAGE,
gateway_image=_TEST_GATEWAY_IMAGE, gateway_image=_TEST_GATEWAY_IMAGE,
port=20000 + secrets.randbelow(10000), port=20000 + secrets.randbelow(10000),
host_root=host_root, root_mount_source=root_volume,
) )
cls.svc.ensure_running() cls.svc.ensure_running()
# The control plane now verifies role-scoped signed tokens, not the raw # The control plane now verifies role-scoped signed tokens, not the raw
@@ -100,7 +92,7 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
@staticmethod @staticmethod
def _teardown_docker( def _teardown_docker(
orchestrator_name: str, gateway_name: str, orchestrator_name: str, gateway_name: str,
network: str, control_network: str, host_root: Path, network: str, control_network: str, root_volume: str,
) -> None: ) -> None:
for name in (gateway_name, orchestrator_name): for name in (gateway_name, orchestrator_name):
subprocess.run( subprocess.run(
@@ -112,14 +104,8 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase):
["docker", "network", "rm", net], ["docker", "network", "rm", net],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
) )
# The orchestrator container (no USER directive) wrote the registry
# DB as root into the throwaway host_root; chown it back so the
# (non-root) tempdir cleanup can remove it. Same workaround
# test_multitenant_isolation.py uses for the identical bind mount.
subprocess.run( subprocess.run(
["docker", "run", "--rm", "-v", f"{host_root}:/r", ["docker", "volume", "rm", "--force", root_volume],
"--entrypoint", "chown", _TEST_ORCHESTRATOR_IMAGE, "-R",
f"{os.getuid()}:{os.getgid()}", "/r"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
) )
-5
View File
@@ -42,11 +42,6 @@ class TestOrphanCleanup(unittest.TestCase):
# Returning True == idempotent success. # Returning True == idempotent success.
self.assertTrue(network_remove(f"bot-bottle-net-{self.slug}-does-not-exist")) self.assertTrue(network_remove(f"bot-bottle-net-{self.slug}-does-not-exist"))
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: docker socket mount topology breaks "
"in-process visibility of networks created on the host daemon",
)
def test_create_and_remove(self): def test_create_and_remove(self):
self.internal_name = network_create_internal(self.slug) self.internal_name = network_create_internal(self.slug)
self.egress_name = network_create_egress(self.slug) self.egress_name = network_create_egress(self.slug)
+1 -20
View File
@@ -67,26 +67,7 @@ _DUMMY_HOST_KEY = (
) )
# Backends whose CI runner is HOST-mode (self-hosted), so the test process
# and the backend share a host. The containerized act_runner (docker on
# ubuntu-latest) is the one that can't see the host bind mount egress_tls_init
# uses and hides sibling-gateway network topology; host-mode runners
# (firecracker/KVM, macos-container) don't have those constraints, so the test
# runs there. Keep this in sync with the `runs-on` labels in
# .gitea/workflows/test.yml.
_HOST_MODE_CI_BACKENDS = frozenset({"firecracker", "macos-container"})
@skip_unless_selected_backend_available() @skip_unless_selected_backend_available()
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true"
and os.environ.get("BOT_BOTTLE_BACKEND") not in _HOST_MODE_CI_BACKENDS,
"skipped under the containerized act_runner (docker on ubuntu-latest): "
"egress_tls_init uses a host bind mount the runner container can't "
"see, and the network topology hides sibling-gateway visibility — "
"these constraints don't apply on the self-hosted host-mode runners "
"(firecracker/KVM, macos-container)",
)
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
up for the whole class — bringup is ~10-30s, so per-test up for the whole class — bringup is ~10-30s, so per-test
@@ -189,7 +170,7 @@ class TestSandboxEscape(unittest.TestCase):
missing.append(tool) missing.append(tool)
if missing: if missing:
cls._teardown_resources() cls._teardown_resources()
raise unittest.SkipTest( raise AssertionError(
f"agent missing required tools: {', '.join(missing)}" f"agent missing required tools: {', '.join(missing)}"
f"add them to the backend's base image" f"add them to the backend's base image"
) )
+28
View File
@@ -0,0 +1,28 @@
"""Tests for the socket-shared Docker host address helper."""
from __future__ import annotations
import unittest
from scripts.docker_host_address import default_ipv4_gateway
class TestDefaultIpv4Gateway(unittest.TestCase):
def test_decodes_linux_little_endian_gateway(self) -> None:
routes = (
"Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT\n"
"eth0 00000000 010011AC 0003 0 0 0 00000000 0 0 0\n"
)
self.assertEqual("172.17.0.1", default_ipv4_gateway(routes))
def test_rejects_table_without_default_gateway(self) -> None:
routes = (
"Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT\n"
"eth0 000011AC 00000000 0001 0 0 0 00FFFFFF 0 0 0\n"
)
with self.assertRaisesRegex(ValueError, "no active"):
default_ipv4_gateway(routes)
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -67,6 +67,14 @@ class TestDockerInfraService(unittest.TestCase):
self.assertTrue(any(GATEWAY_NAME in a for a in rms)) self.assertTrue(any(GATEWAY_NAME in a for a in rms))
self.assertTrue(any(ORCHESTRATOR_NAME in a for a in rms)) self.assertTrue(any(ORCHESTRATOR_NAME in a for a in rms))
def test_named_mounts_propagate_to_both_planes(self) -> None:
svc = DockerInfraService(
root_mount_source="registry-volume",
gateway_ca_mount_source="ca-volume",
)
self.assertEqual("registry-volume", svc.orchestrator()._root_mount_source)
self.assertEqual("ca-volume", svc.gateway()._ca_mount_source)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+16
View File
@@ -47,6 +47,20 @@ class TestDockerOrchestrator(unittest.TestCase):
def test_url_is_host_loopback(self) -> None: def test_url_is_host_loopback(self) -> None:
self.assertEqual("http://127.0.0.1:8099", self.orch.url()) self.assertEqual("http://127.0.0.1:8099", self.orch.url())
def test_socket_shared_client_uses_explicit_host_and_open_bind(self) -> None:
orch = DockerOrchestrator(
port=8099, client_host="172.17.0.1", root_mount_source="state-volume"
)
with patch(_TOKEN, return_value="k"), \
patch(_URLOPEN, side_effect=[urllib.error.URLError("down"), _health(200)]), \
patch(_RUN, return_value=_proc()) as run, patch(_SLEEP):
orch.ensure_running()
self.assertEqual("http://172.17.0.1:8099", orch.url())
argv = next(c.args[0] for c in run.call_args_list
if c.args[0][:2] == ["docker", "run"])
self.assertEqual("0.0.0.0:8099:8099", argv[argv.index("--publish") + 1])
self.assertIn("state-volume:/bot-bottle-root", argv)
def test_gateway_url_is_the_container_dns_name(self) -> None: def test_gateway_url_is_the_container_dns_name(self) -> None:
# The gateway reaches the orchestrator by name on the control network. # The gateway reaches the orchestrator by name on the control network.
self.assertEqual(f"http://{ORCHESTRATOR_NAME}:8099", self.orch.gateway_url()) self.assertEqual(f"http://{ORCHESTRATOR_NAME}:8099", self.orch.gateway_url())
@@ -110,6 +124,8 @@ class TestDockerOrchestrator(unittest.TestCase):
# The lean control plane: no mitmproxy CA mount, no gateway daemons. # The lean control plane: no mitmproxy CA mount, no gateway daemons.
self.assertFalse([a for a in argv if a.endswith(":/home/mitmproxy/.mitmproxy")]) self.assertFalse([a for a in argv if a.endswith(":/home/mitmproxy/.mitmproxy")])
self.assertNotIn("BOT_BOTTLE_GATEWAY_DAEMONS", " ".join(argv)) self.assertNotIn("BOT_BOTTLE_GATEWAY_DAEMONS", " ".join(argv))
self.assertNotIn("/bot-bottle-src", " ".join(argv))
self.assertNotIn("PYTHONPATH", " ".join(argv))
# Orchestrator entrypoint args (image ENTRYPOINT is `-m bot_bottle.orchestrator`). # Orchestrator entrypoint args (image ENTRYPOINT is `-m bot_bottle.orchestrator`).
self.assertIn("--broker", argv) self.assertIn("--broker", argv)
self.assertIn("stub", argv) self.assertIn("stub", argv)
+16
View File
@@ -142,6 +142,22 @@ class TestDockerGateway(unittest.TestCase):
# Data plane resolves policy against the orchestrator control plane. # Data plane resolves policy against the orchestrator control plane.
self.assertIn(f"BOT_BOTTLE_ORCHESTRATOR_URL={_ORCH_URL}", runs[0]) self.assertIn(f"BOT_BOTTLE_ORCHESTRATOR_URL={_ORCH_URL}", runs[0])
def test_named_ca_volume_supports_socket_shared_runner(self) -> None:
sc = DockerGateway(
"bot-bottle-gateway:latest", ca_mount_source="ci-ca-volume"
)
def fake(argv: list[str], **_kw: object) -> Mock:
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
with patch(_RUN_DOCKER, side_effect=fake) as run:
sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
argv = next(c.args[0] for c in run.call_args_list
if c.args[0][:2] == ["docker", "run"])
self.assertIn(
"ci-ca-volume:/home/mitmproxy/.mitmproxy", argv
)
def test_connect_injects_the_pre_minted_gateway_token(self) -> None: def test_connect_injects_the_pre_minted_gateway_token(self) -> None:
# The gateway presents the token the orchestrator handed it — it never # The gateway presents the token the orchestrator handed it — it never
# mints (holds no signing key). The value rides the env (bare `--env # mints (holds no signing key). The value rides the env (bare `--env
+34
View File
@@ -0,0 +1,34 @@
"""Unit tests for CI's unittest execution-count gate."""
from __future__ import annotations
import unittest
from scripts.unittest_gate import assurance_errors
class TestAssuranceErrors(unittest.TestCase):
def test_accepts_suite_that_meets_minimum_without_skips(self) -> None:
self.assertEqual(
[],
assurance_errors(
tests_run=22, skipped=0, minimum_executed=22, fail_on_skip=True
),
)
def test_rejects_green_suite_below_execution_minimum(self) -> None:
errors = assurance_errors(
tests_run=22, skipped=18, minimum_executed=22, fail_on_skip=False
)
self.assertEqual(1, len(errors))
self.assertIn("executed 4", errors[0])
def test_rejects_any_skip_when_required(self) -> None:
errors = assurance_errors(
tests_run=23, skipped=1, minimum_executed=22, fail_on_skip=True
)
self.assertEqual(["1 test(s) skipped in a no-skip suite"], errors)
if __name__ == "__main__":
unittest.main()