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