ci(docker): require the full integration suite
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user