fix(ci): join control planes to the runner network
prd-number-check / require-numbered-prds (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 8s
test / integration-docker (pull_request) Failing after 35s
test / unit (pull_request) Successful in 52s
test / coverage (pull_request) Has been skipped
lint / lint (push) Successful in 1m7s

This commit is contained in:
2026-07-26 08:30:57 +00:00
parent b2245ae1f3
commit f3fbfb3cc3
7 changed files with 76 additions and 120 deletions
+8 -5
View File
@@ -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 \
+9 -6
View File
@@ -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 \
+27 -7
View File
@@ -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)."""
+4 -4
View File
@@ -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,
-39
View File
@@ -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("<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())
-59
View File
@@ -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()
+28
View File
@@ -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())