ci(docker): require the full integration suite
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
return DockerGatewayTransport(self.name)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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