From 583ff98b27fd1ef071483f9b41db9ec70d8136c1 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 08:00:15 +0000 Subject: [PATCH] ci(docker): require the full integration suite --- .gitea/workflows/pre-release-test.yml | 20 +++++- .gitea/workflows/test.yml | 41 ++++++++++-- bot_bottle/backend/docker/gateway.py | 9 ++- bot_bottle/backend/docker/infra.py | 15 +++-- bot_bottle/backend/docker/orchestrator.py | 54 +++++++++------ scripts/docker_host_address.py | 39 +++++++++++ scripts/unittest_gate.py | 67 +++++++++++++++++++ tests/integration/test_gateway_image.py | 24 +++---- .../integration/test_multitenant_isolation.py | 29 +++----- .../test_orchestrator_docker_auth.py | 24 ++----- tests/integration/test_orphan_cleanup.py | 5 -- tests/integration/test_sandbox_escape.py | 21 +----- tests/unit/test_docker_host_address.py | 28 ++++++++ tests/unit/test_docker_infra.py | 8 +++ tests/unit/test_docker_orchestrator.py | 16 +++++ tests/unit/test_orchestrator_gateway.py | 16 +++++ tests/unit/test_unittest_gate.py | 34 ++++++++++ 17 files changed, 341 insertions(+), 109 deletions(-) create mode 100644 scripts/docker_host_address.py create mode 100644 scripts/unittest_gate.py create mode 100644 tests/unit/test_docker_host_address.py create mode 100644 tests/unit/test_unittest_gate.py diff --git a/.gitea/workflows/pre-release-test.yml b/.gitea/workflows/pre-release-test.yml index 99f87bad..8d894abf 100644 --- a/.gitea/workflows/pre-release-test.yml +++ b/.gitea/workflows/pre-release-test.yml @@ -84,7 +84,25 @@ jobs: env: BOT_BOTTLE_BACKEND: 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. - name: Stage docker coverage for upload diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 23a00e3b..d91421b9 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -12,10 +12,14 @@ on: - 'bot_bottle/**' - 'tests/**/*.py' - 'cli.py' + - 'install.sh' + - 'setup.py' + - 'MANIFEST.in' + - 'flake.nix' + - 'nix/firecracker-netpool.nix' - 'scripts/coverage.sh' - 'scripts/critical-modules.txt' - - 'scripts/diff_coverage.py' - - 'scripts/tracker_policy.py' + - 'scripts/**/*.py' - 'scripts/firecracker-netpool.sh' - 'Dockerfile*' - 'pyproject.toml' @@ -23,15 +27,20 @@ on: - '.coveragerc' - '.dockerignore' - '.gitea/workflows/test.yml' + - '.gitea/workflows/pre-release-test.yml' pull_request: paths: - 'bot_bottle/**' - 'tests/**/*.py' - 'cli.py' + - 'install.sh' + - 'setup.py' + - 'MANIFEST.in' + - 'flake.nix' + - 'nix/firecracker-netpool.nix' - 'scripts/coverage.sh' - 'scripts/critical-modules.txt' - - 'scripts/diff_coverage.py' - - 'scripts/tracker_policy.py' + - 'scripts/**/*.py' - 'scripts/firecracker-netpool.sh' - 'Dockerfile*' - 'pyproject.toml' @@ -39,6 +48,7 @@ on: - '.coveragerc' - '.dockerignore' - '.gitea/workflows/test.yml' + - '.gitea/workflows/pre-release-test.yml' jobs: unit: @@ -87,7 +97,28 @@ jobs: env: BOT_BOTTLE_BACKEND: 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 run: cp .coverage.docker coverage-docker.dat diff --git a/bot_bottle/backend/docker/gateway.py b/bot_bottle/backend/docker/gateway.py index 612b3d77..6212c63b 100644 --- a/bot_bottle/backend/docker/gateway.py +++ b/bot_bottle/backend/docker/gateway.py @@ -35,6 +35,7 @@ class DockerGateway(Gateway): build_context: Path | None = None, dockerfile: str | None = GATEWAY_DOCKERFILE, host_port_bindings: tuple[int, ...] = (), + ca_mount_source: str | Path | None = None, ) -> None: self.image_ref = image_ref self.name = name @@ -59,6 +60,10 @@ class DockerGateway(Gateway): # 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. 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: 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 # container recreation AND docker volume pruning (agents trust it) # — 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 # the supervise queue over the control-plane RPC and never opens # 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: """The exec/cp transport git-gate provisioning stages per-bottle repos + deploy keys through (over the docker socket).""" - return DockerGatewayTransport(self.name) \ No newline at end of file + return DockerGatewayTransport(self.name) diff --git a/bot_bottle/backend/docker/infra.py b/bot_bottle/backend/docker/infra.py index 66e26837..b0a74484 100644 --- a/bot_bottle/backend/docker/infra.py +++ b/bot_bottle/backend/docker/infra.py @@ -33,7 +33,6 @@ from .orchestrator import ( ORCHESTRATOR_NAME, ORCHESTRATOR_NETWORK, ) -from ...paths import bot_bottle_root from ... import resources from ...gateway import ( GATEWAY_IMAGE, @@ -69,6 +68,8 @@ class DockerInfraService(InfraService): gateway_image: str = GATEWAY_IMAGE, repo_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_label: str = ORCHESTRATOR_LABEL, gateway_name: str = GATEWAY_NAME, @@ -78,10 +79,14 @@ class DockerInfraService(InfraService): self.control_network = control_network self.orchestrator_image = orchestrator_image self.gateway_image = gateway_image - # Build context / bind-mount source: the repo root in a checkout, a - # staged copy from the installed wheel otherwise (bot_bottle.resources). + # Build context: the repo root in a checkout, a staged copy from the + # installed wheel otherwise (bot_bottle.resources). 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_label = orchestrator_label self._gateway_name = gateway_name @@ -98,6 +103,7 @@ class DockerInfraService(InfraService): control_network=self.control_network, repo_root=self._repo_root, host_root=self._host_root, + root_mount_source=self._root_mount_source, ) def gateway(self) -> DockerGateway: @@ -112,6 +118,7 @@ class DockerInfraService(InfraService): network=self.network, control_network=self.control_network, build_context=self._repo_root, + ca_mount_source=self._gateway_ca_mount_source, ) def ensure_running( diff --git a/bot_bottle/backend/docker/orchestrator.py b/bot_bottle/backend/docker/orchestrator.py index 6197fe87..44a8da8f 100644 --- a/bot_bottle/backend/docker/orchestrator.py +++ b/bot_bottle/backend/docker/orchestrator.py @@ -42,13 +42,9 @@ ORCHESTRATOR_IMAGE = os.environ.get( ) ORCHESTRATOR_DOCKERFILE = "Dockerfile.orchestrator" # 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" -# 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 # 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). @@ -72,23 +68,40 @@ class DockerOrchestrator(Orchestrator): control_network: str = ORCHESTRATOR_NETWORK, repo_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, ) -> 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.name = name self.label = label self.port = port self.control_network = control_network - # Build context / bind-mount source: the repo root in a checkout, a - # staged copy from the installed wheel otherwise (bot_bottle.resources). + # Build context: the repo root in a checkout, a staged copy from the + # installed wheel otherwise (bot_bottle.resources). 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 def url(self) -> str: - """Host-side control-plane URL — the orchestrator's published loopback, - which the CLI reaches.""" - return f"http://127.0.0.1:{self.port}" + """Control-plane URL reachable by this Docker client.""" + return f"http://{self._client_host}:{self.port}" def gateway_url(self) -> str: """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() def _source_current(self, current_hash: str) -> bool: - """True iff the running orchestrator was started from the current - bind-mounted source.""" + """True iff the running orchestrator image matches current source.""" if not self.is_running(): return False proc = run_docker([ @@ -182,15 +194,19 @@ class DockerOrchestrator(Orchestrator): # Control network only — agents are never on it, so they have no # route to the control plane (the L3 block, not just the JWT). "--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 # container; self.port is the host-side published port. - "--publish", f"127.0.0.1:{self.port}:{DEFAULT_PORT}", - # Live control-plane source (code changes without an image rebuild). - "--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro", - "--env", f"PYTHONPATH={_SRC_IN_CONTAINER}", + "--publish", f"{self._bind_host}:{self.port}:{DEFAULT_PORT}", + # The image was rebuilt from `_repo_root` immediately before this + # launch. Running its baked package avoids a host-path bind mount, + # 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). - "--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}", # The signing key — held ONLY by the orchestrator (it verifies # tokens); the gateway gets the pre-minted `gateway` JWT, never the diff --git a/scripts/docker_host_address.py b/scripts/docker_host_address.py new file mode 100644 index 00000000..6b05bd32 --- /dev/null +++ b/scripts/docker_host_address.py @@ -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(" 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()) diff --git a/scripts/unittest_gate.py b/scripts/unittest_gate.py new file mode 100644 index 00000000..fa942452 --- /dev/null +++ b/scripts/unittest_gate.py @@ -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()) diff --git a/tests/integration/test_gateway_image.py b/tests/integration/test_gateway_image.py index 9d27c8de..a0e7b878 100644 --- a/tests/integration/test_gateway_image.py +++ b/tests/integration/test_gateway_image.py @@ -14,9 +14,7 @@ the chunk-1 contract: expected "no daemons selected" line when the supervisor is pointed at an empty daemon set. -Skips cleanly when docker is unavailable, or under act_runner -where the host bind-mount topology breaks multi-stage builds -that pull large bases. +Skips cleanly only when the selected Docker backend is unavailable. """ from __future__ import annotations @@ -33,12 +31,6 @@ _DOCKERFILE = "Dockerfile.gateway" @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): """Builds the image once for the class, then runs a few `docker run` probes against it.""" @@ -51,10 +43,11 @@ class TestGatewayImage(unittest.TestCase): "-f", _DOCKERFILE, "."], cwd=repo_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + check=False, ) if proc.returncode != 0: - raise unittest.SkipTest( - f"docker build failed; skipping image probes.\n" + raise AssertionError( + f"docker build failed; image probes cannot run.\n" f"{proc.stdout.decode('utf-8', errors='replace')[-2000:]}" ) @@ -63,14 +56,16 @@ class TestGatewayImage(unittest.TestCase): subprocess.run( ["docker", "image", "rm", "-f", _IMAGE], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + check=False, ) def _run_in_image(self, *cmd: str, timeout: float = 30.0) -> tuple[int, str]: proc = subprocess.run( ["docker", "run", "--rm", "--entrypoint", cmd[0], _IMAGE, - *cmd[1:]], + *cmd[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout, + check=False, ) 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. rc, out = self._run_in_image( "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.assertIn("ok", out) @@ -106,6 +103,7 @@ class TestGatewayImage(unittest.TestCase): _IMAGE], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=10.0, + check=False, ) out = proc.stdout.decode("utf-8", errors="replace") self.assertEqual(0, proc.returncode, msg=out) diff --git a/tests/integration/test_multitenant_isolation.py b/tests/integration/test_multitenant_isolation.py index 78082fdf..64ac9931 100644 --- a/tests/integration/test_multitenant_isolation.py +++ b/tests/integration/test_multitenant_isolation.py @@ -16,11 +16,9 @@ throwaway BOT_BOTTLE_ROOT for a clean registry and tears everything down. from __future__ import annotations -import os +import secrets import subprocess -import tempfile import unittest -from pathlib import Path from bot_bottle.backend.docker.consolidated_launch import ( _network_cidr, @@ -73,19 +71,12 @@ _PROBE_SRC = ( @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): def setUp(self) -> None: - self._tmp = tempfile.TemporaryDirectory() - self.addCleanup(self._tmp.cleanup) - # Throwaway root → a clean registry DB, independent of the host's. - self.svc = DockerInfraService(host_root=Path(self._tmp.name)) + # Named volume → a clean registry DB that is also visible to a + # socket-shared host daemon when the test process runs in act_runner. + self._root_volume = "bot-bottle-mtitest-root-" + secrets.token_hex(4) + self.svc = DockerInfraService(root_mount_source=self._root_volume) self.addCleanup(self._teardown_docker) # ensure_running builds the bundle image (slow on a cold cache) and # brings up the shared network + gateway + orchestrator. @@ -100,13 +91,8 @@ class TestMultitenantIsolation(unittest.TestCase): self.svc.stop() subprocess.run(["docker", "network", "rm", GATEWAY_NETWORK], 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( - ["docker", "run", "--rm", "-v", f"{self._tmp.name}:/r", - "--entrypoint", "chown", GATEWAY_IMAGE, "-R", - f"{os.getuid()}:{os.getgid()}", "/r"], + ["docker", "volume", "rm", "--force", self._root_volume], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) @staticmethod @@ -137,7 +123,8 @@ class TestMultitenantIsolation(unittest.TestCase): ["docker", "run", "--rm", "--network", GATEWAY_NETWORK, "--ip", source_ip, "--entrypoint", "python3", GATEWAY_IMAGE, "-c", _PROBE_SRC, 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() diff --git a/tests/integration/test_orchestrator_docker_auth.py b/tests/integration/test_orchestrator_docker_auth.py index 23f4d8dc..24db3728 100644 --- a/tests/integration/test_orchestrator_docker_auth.py +++ b/tests/integration/test_orchestrator_docker_auth.py @@ -23,7 +23,6 @@ import secrets import subprocess import tempfile import unittest -from pathlib import Path from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, mint from bot_bottle.orchestrator.client import OrchestratorClient @@ -38,13 +37,6 @@ _TEST_GATEWAY_IMAGE = "bot-bottle-gateway:itest" @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): @classmethod def setUpClass(cls) -> None: @@ -74,10 +66,10 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase): gateway_name = f"bot-bottle-gateway-itest-{suffix}" network = f"bot-bottle-net-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._teardown_docker, - orchestrator_name, gateway_name, network, control_network, host_root, + orchestrator_name, gateway_name, network, control_network, root_volume, ) cls.svc = DockerInfraService( @@ -88,7 +80,7 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase): orchestrator_image=_TEST_ORCHESTRATOR_IMAGE, gateway_image=_TEST_GATEWAY_IMAGE, port=20000 + secrets.randbelow(10000), - host_root=host_root, + root_mount_source=root_volume, ) cls.svc.ensure_running() # The control plane now verifies role-scoped signed tokens, not the raw @@ -100,7 +92,7 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase): @staticmethod def _teardown_docker( orchestrator_name: str, gateway_name: str, - network: str, control_network: str, host_root: Path, + network: str, control_network: str, root_volume: str, ) -> None: for name in (gateway_name, orchestrator_name): subprocess.run( @@ -112,14 +104,8 @@ class TestDockerOrchestratorAuthIntegration(unittest.TestCase): ["docker", "network", "rm", net], 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( - ["docker", "run", "--rm", "-v", f"{host_root}:/r", - "--entrypoint", "chown", _TEST_ORCHESTRATOR_IMAGE, "-R", - f"{os.getuid()}:{os.getgid()}", "/r"], + ["docker", "volume", "rm", "--force", root_volume], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) diff --git a/tests/integration/test_orphan_cleanup.py b/tests/integration/test_orphan_cleanup.py index d1702730..a6756948 100644 --- a/tests/integration/test_orphan_cleanup.py +++ b/tests/integration/test_orphan_cleanup.py @@ -42,11 +42,6 @@ class TestOrphanCleanup(unittest.TestCase): # Returning True == idempotent success. 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): self.internal_name = network_create_internal(self.slug) self.egress_name = network_create_egress(self.slug) diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index 69758bd1..c6ac8dae 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -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() -@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): """End-to-end attacks against a real bottle. The bottle stays up for the whole class — bringup is ~10-30s, so per-test @@ -189,7 +170,7 @@ class TestSandboxEscape(unittest.TestCase): missing.append(tool) if missing: cls._teardown_resources() - raise unittest.SkipTest( + raise AssertionError( f"agent missing required tools: {', '.join(missing)} — " f"add them to the backend's base image" ) diff --git a/tests/unit/test_docker_host_address.py b/tests/unit/test_docker_host_address.py new file mode 100644 index 00000000..260aa11d --- /dev/null +++ b/tests/unit/test_docker_host_address.py @@ -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() diff --git a/tests/unit/test_docker_infra.py b/tests/unit/test_docker_infra.py index bbd41bfe..2dca468b 100644 --- a/tests/unit/test_docker_infra.py +++ b/tests/unit/test_docker_infra.py @@ -67,6 +67,14 @@ class TestDockerInfraService(unittest.TestCase): self.assertTrue(any(GATEWAY_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__": unittest.main() diff --git a/tests/unit/test_docker_orchestrator.py b/tests/unit/test_docker_orchestrator.py index fea4e845..d5167ea3 100644 --- a/tests/unit/test_docker_orchestrator.py +++ b/tests/unit/test_docker_orchestrator.py @@ -47,6 +47,20 @@ class TestDockerOrchestrator(unittest.TestCase): def test_url_is_host_loopback(self) -> None: 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: # The gateway reaches the orchestrator by name on the control network. 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. 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-src", " ".join(argv)) + self.assertNotIn("PYTHONPATH", " ".join(argv)) # Orchestrator entrypoint args (image ENTRYPOINT is `-m bot_bottle.orchestrator`). self.assertIn("--broker", argv) self.assertIn("stub", argv) diff --git a/tests/unit/test_orchestrator_gateway.py b/tests/unit/test_orchestrator_gateway.py index a2aa1ddc..20d7039a 100644 --- a/tests/unit/test_orchestrator_gateway.py +++ b/tests/unit/test_orchestrator_gateway.py @@ -142,6 +142,22 @@ class TestDockerGateway(unittest.TestCase): # Data plane resolves policy against the orchestrator control plane. 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: # The gateway presents the token the orchestrator handed it — it never # mints (holds no signing key). The value rides the env (bare `--env diff --git a/tests/unit/test_unittest_gate.py b/tests/unit/test_unittest_gate.py new file mode 100644 index 00000000..4b234522 --- /dev/null +++ b/tests/unit/test_unittest_gate.py @@ -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()