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
+11 -13
View File
@@ -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)
@@ -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()
@@ -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,
)
-5
View File
@@ -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)
+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()
@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"
)
+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(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()
+16
View File
@@ -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)
+16
View File
@@ -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
+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()