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
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:
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user