From f3fbfb3cc3ed61a07789d420c270d54c4dbd57b6 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 08:30:57 +0000 Subject: [PATCH] fix(ci): join control planes to the runner network --- .gitea/workflows/pre-release-test.yml | 13 +++-- .gitea/workflows/test.yml | 15 +++--- bot_bottle/backend/docker/orchestrator.py | 34 ++++++++++--- docs/ci.md | 8 +-- scripts/docker_host_address.py | 39 --------------- tests/unit/test_docker_host_address.py | 59 ----------------------- tests/unit/test_docker_orchestrator.py | 28 +++++++++++ 7 files changed, 76 insertions(+), 120 deletions(-) delete mode 100644 scripts/docker_host_address.py delete mode 100644 tests/unit/test_docker_host_address.py diff --git a/.gitea/workflows/pre-release-test.yml b/.gitea/workflows/pre-release-test.yml index 3cbb9248..73571b2b 100644 --- a/.gitea/workflows/pre-release-test.yml +++ b/.gitea/workflows/pre-release-test.yml @@ -86,12 +86,15 @@ jobs: COVERAGE_FILE: ${{ github.workspace }}/.coverage.docker run: | set -euo pipefail - DOCKER_HOST_ADDRESS=$(python3 scripts/docker_host_address.py) - test -n "$DOCKER_HOST_ADDRESS" + DOCKER_CLIENT_NETWORK=$( + docker inspect "$(hostname)" | + python3 -c 'import json,sys; n=json.load(sys.stdin)[0]["NetworkSettings"]["Networks"]; print(next(iter(n)))' + ) + test -n "$DOCKER_CLIENT_NETWORK" RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}" - export NO_PROXY="${NO_PROXY:+$NO_PROXY,}$DOCKER_HOST_ADDRESS" - export no_proxy="${no_proxy:+$no_proxy,}$DOCKER_HOST_ADDRESS" - export BOT_BOTTLE_DOCKER_HOST_ADDRESS="$DOCKER_HOST_ADDRESS" + export NO_PROXY="*" + export no_proxy="*" + export BOT_BOTTLE_DOCKER_CLIENT_NETWORK="$DOCKER_CLIENT_NETWORK" 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 \ diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index dd08c9f9..26a60e36 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -100,14 +100,17 @@ jobs: 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 + # Docker socket. Attach control-plane siblings to the job's network, # 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" + DOCKER_CLIENT_NETWORK=$( + docker inspect "$(hostname)" | + python3 -c 'import json,sys; n=json.load(sys.stdin)[0]["NetworkSettings"]["Networks"]; print(next(iter(n)))' + ) + test -n "$DOCKER_CLIENT_NETWORK" RUN_KEY="${GITHUB_RUN_ID:-${GITHUB_RUN_NUMBER:-0}}" - export NO_PROXY="${NO_PROXY:+$NO_PROXY,}$DOCKER_HOST_ADDRESS" - export no_proxy="${no_proxy:+$no_proxy,}$DOCKER_HOST_ADDRESS" - export BOT_BOTTLE_DOCKER_HOST_ADDRESS="$DOCKER_HOST_ADDRESS" + export NO_PROXY="*" + export no_proxy="*" + export BOT_BOTTLE_DOCKER_CLIENT_NETWORK="$DOCKER_CLIENT_NETWORK" 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 \ diff --git a/bot_bottle/backend/docker/orchestrator.py b/bot_bottle/backend/docker/orchestrator.py index 44a8da8f..b293c9c1 100644 --- a/bot_bottle/backend/docker/orchestrator.py +++ b/bot_bottle/backend/docker/orchestrator.py @@ -70,6 +70,7 @@ class DockerOrchestrator(Orchestrator): host_root: Path | None = None, root_mount_source: str | Path | None = None, client_host: str | None = None, + client_network: str | None = None, bind_host: str | None = None, dockerfile: str | None = ORCHESTRATOR_DOCKERFILE, ) -> None: @@ -87,21 +88,31 @@ class DockerOrchestrator(Orchestrator): self._root_mount_source = str( root_mount_source or configured_root or host_root or bot_bottle_root() ) + configured_network = os.environ.get( + "BOT_BOTTLE_DOCKER_CLIENT_NETWORK", "" + ).strip() + self._client_network = client_network or configured_network or None configured_host = os.environ.get( "BOT_BOTTLE_DOCKER_HOST_ADDRESS", "" ).strip() - self._client_host = client_host or configured_host or "127.0.0.1" + self._client_host = ( + client_host or configured_host + or (self.name if self._client_network else "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. + # network 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" + "0.0.0.0" + if not self._client_network and self._client_host != "127.0.0.1" + else "127.0.0.1" ) self._dockerfile = dockerfile def url(self) -> str: """Control-plane URL reachable by this Docker client.""" - return f"http://{self._client_host}:{self.port}" + port = DEFAULT_PORT if self._client_network else self.port + return f"http://{self._client_host}:{port}" def gateway_url(self) -> str: """The URL the gateway's data plane resolves policy against — the @@ -195,8 +206,8 @@ class DockerOrchestrator(Orchestrator): # route to the control plane (the L3 block, not just the JWT). "--network", self.control_network, # 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 + # Socket-shared CI joins the container directly to the job network; + # the host-side mapping remains loopback-only in that topology. The # orchestrator listens on the fixed DEFAULT_PORT inside the # container; self.port is the host-side published port. "--publish", f"{self._bind_host}:{self.port}:{DEFAULT_PORT}", @@ -221,6 +232,15 @@ class DockerOrchestrator(Orchestrator): raise OrchestratorStartError( f"orchestrator container failed to start: {proc.stderr.strip()}" ) + if self._client_network: + proc = run_docker([ + "docker", "network", "connect", self._client_network, self.name, + ]) + if proc.returncode != 0: + raise OrchestratorStartError( + f"orchestrator container failed to join client network " + f"{self._client_network}: {proc.stderr.strip()}" + ) def stop(self) -> None: """Remove the control-plane container (idempotent).""" diff --git a/docs/ci.md b/docs/ci.md index 4eeb5ae7..6b9e23fe 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -8,10 +8,10 @@ gate when tested package/build inputs change on a pull request or on `main`. The Docker job preflights the backend before discovery. Gitea's `act_runner` runs the job in a container with the host Docker socket, so the test process -reaches host-published ports through its bridge gateway and uses named Docker -volumes for orchestrator/CA state the host daemon must mount. The orchestrator -runs the package baked into the image built from the checkout; it does not bind -the job container's invisible workspace into a sibling container. +reaches control-plane siblings through the job's Docker network and uses named +Docker volumes for orchestrator/CA state the host daemon must mount. The +orchestrator runs the package baked into the image built from the checkout; it +does not bind the job container's invisible workspace into a sibling container. `scripts.unittest_gate` enforces the Docker job's contract: all 22 integration tests must execute and none may skip. This includes the real gateway-image, diff --git a/scripts/docker_host_address.py b/scripts/docker_host_address.py deleted file mode 100644 index 6b05bd32..00000000 --- a/scripts/docker_host_address.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -"""Print the Docker host address seen by a socket-shared Linux CI container.""" - -from __future__ import annotations - -import socket -import struct -import sys -from pathlib import Path - - -def default_ipv4_gateway(route_table: str) -> str: - """Return the active default gateway from Linux ``/proc/net/route``.""" - - for line in route_table.splitlines()[1:]: - fields = line.split() - if len(fields) < 4 or fields[1] != "00000000": - continue - try: - gateway = int(fields[2], 16) - flags = int(fields[3], 16) - except ValueError: - continue - if flags & 0x2: # RTF_GATEWAY - return socket.inet_ntoa(struct.pack(" int: - try: - print(default_ipv4_gateway(Path("/proc/net/route").read_text(encoding="utf-8"))) - except (OSError, ValueError) as exc: - print(f"docker-host-address: {exc}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/unit/test_docker_host_address.py b/tests/unit/test_docker_host_address.py deleted file mode 100644 index 0c1b13c4..00000000 --- a/tests/unit/test_docker_host_address.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Tests for the socket-shared Docker host address helper.""" - -from __future__ import annotations - -import unittest -from contextlib import redirect_stderr, redirect_stdout -from io import StringIO -from unittest.mock import patch - -from scripts.docker_host_address import default_ipv4_gateway, main - - -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) - - def test_ignores_short_malformed_and_inactive_routes(self) -> None: - routes = ( - "Iface Destination Gateway Flags\n" - "short row\n" - "eth0 00000000 nope 0003\n" - "eth0 00000000 010011AC 0001\n" - ) - with self.assertRaisesRegex(ValueError, "no active"): - default_ipv4_gateway(routes) - - def test_main_prints_gateway(self) -> None: - routes = ( - "Iface Destination Gateway Flags\n" - "eth0 00000000 010011AC 0003\n" - ) - output = StringIO() - with patch("pathlib.Path.read_text", return_value=routes), \ - redirect_stdout(output): - self.assertEqual(0, main()) - self.assertEqual("172.17.0.1\n", output.getvalue()) - - def test_main_reports_route_error(self) -> None: - error = StringIO() - with patch("pathlib.Path.read_text", side_effect=OSError("denied")), \ - redirect_stderr(error): - self.assertEqual(1, main()) - self.assertIn("docker-host-address: denied", error.getvalue()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_docker_orchestrator.py b/tests/unit/test_docker_orchestrator.py index 208b2e0b..82fafed2 100644 --- a/tests/unit/test_docker_orchestrator.py +++ b/tests/unit/test_docker_orchestrator.py @@ -69,6 +69,34 @@ class TestDockerOrchestrator(unittest.TestCase): root_mount_source="state-volume", ) + def test_socket_shared_job_network_uses_container_dns(self) -> None: + orch = DockerOrchestrator( + name="orchestrator-itest", + port=22001, + client_network="runner-job-network", + 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://orchestrator-itest:8099", orch.url()) + calls = [call.args[0] for call in run.call_args_list] + self.assertIn( + [ + "docker", "network", "connect", + "runner-job-network", "orchestrator-itest", + ], + calls, + ) + argv = next(call for call in calls if call[:2] == ["docker", "run"]) + self.assertEqual( + "127.0.0.1:22001:8099", + argv[argv.index("--publish") + 1], + ) + 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())