Probe managed ingress across Docker namespaces

This commit is contained in:
2026-08-03 19:41:04 +02:00
parent 909862afdb
commit 2f28f22fd1
4 changed files with 177 additions and 36 deletions
@@ -342,6 +342,7 @@ jobs:
python tools/checks/managed-ingress-drill.py python tools/checks/managed-ingress-drill.py
--caddy-image "$MANAGED_INGRESS_IMAGE" --caddy-image "$MANAGED_INGRESS_IMAGE"
--load-balancer-image "$LOAD_BALANCER_IMAGE" --load-balancer-image "$LOAD_BALANCER_IMAGE"
--probe-image "$(jq -r '.platforms[\"linux/amd64\"]' runtime-output/api-metadata.json)"
- name: Publish immutable Gitea release assets - name: Publish immutable Gitea release assets
working-directory: govoplan working-directory: govoplan
env: env:
@@ -177,7 +177,10 @@ volumes before starting the read-only containers. It therefore also works when
an Actions job reaches a host or remote Docker daemon through a mounted socket; an Actions job reaches a host or remote Docker daemon through a mounted socket;
the drill never assumes that a job-container path is visible to that daemon. the drill never assumes that a job-container path is visible to that daemon.
The drill allocates explicit loopback-only host ports and verifies Docker's The drill allocates explicit loopback-only host ports and verifies Docker's
published mappings, avoiding daemon-specific random-port shorthand behavior. host binding configuration, avoiding daemon-specific random-port shorthand
behavior. Because an Actions job and deployment containers may be Docker
siblings, functional HTTP/TLS checks run from the digest-pinned API image on
the deployment network instead of assuming the Docker host is job-local.
The bounded setup helper writes only generated public configuration as root so The bounded setup helper writes only generated public configuration as root so
it can initialize a new volume; the actual HAProxy process retains the image's it can initialize a new volume; the actual HAProxy process retains the image's
non-root identity and runs read-only with all capabilities dropped. non-root identity and runs read-only with all capabilities dropped.
+44 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib.util import importlib.util
from pathlib import Path from pathlib import Path
import json
import subprocess import subprocess
import sys import sys
import unittest import unittest
@@ -68,7 +69,13 @@ class ManagedIngressDrillTests(unittest.TestCase):
completed = subprocess.CompletedProcess( completed = subprocess.CompletedProcess(
[], [],
0, 0,
"127.0.0.1:49152\n", json.dumps(
{
"8443/tcp": [
{"HostIp": "127.0.0.1", "HostPort": "49152"}
]
}
),
"", "",
) )
with patch.object(INGRESS, "_run", return_value=completed) as run: with patch.object(INGRESS, "_run", return_value=completed) as run:
@@ -76,10 +83,45 @@ class ManagedIngressDrillTests(unittest.TestCase):
self.assertEqual(49152, port) self.assertEqual(49152, port)
self.assertEqual( self.assertEqual(
["docker", "port", "ingress", "8443/tcp"], [
"docker",
"inspect",
"--format",
"{{json .HostConfig.PortBindings}}",
"ingress",
],
run.call_args.args[0], run.call_args.args[0],
) )
def test_published_port_rejects_non_loopback_binding(self) -> None:
completed = subprocess.CompletedProcess(
[],
0,
'{"8443/tcp":[{"HostIp":"0.0.0.0","HostPort":"49152"}]}',
"",
)
with patch.object(INGRESS, "_run", return_value=completed):
with self.assertRaisesRegex(RuntimeError, "loopback binding"):
INGRESS._published_port("ingress", 8443)
def test_probe_runs_as_a_network_sibling_from_a_digest_image(self) -> None:
completed = subprocess.CompletedProcess([], 0, "", "")
image = "registry.example/runtime-api@sha256:" + "1" * 64
with patch.object(INGRESS, "_run", return_value=completed) as run:
INGRESS._probe_ingress(
image=image,
network="deployment-network",
container="ingress",
)
argv = run.call_args.args[0]
self.assertEqual("docker", argv[0])
self.assertIn("deployment-network", argv)
self.assertIn(image, argv)
self.assertIn('(\"ingress\", port)', argv[-1])
self.assertIn("server_hostname=\"localhost\"", argv[-1])
self.assertNotIn("localhost:49152", argv[-1])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+125 -30
View File
@@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
from pathlib import Path from pathlib import Path
import re import re
import shutil import shutil
@@ -11,7 +12,6 @@ import socket
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
from uuid import uuid4 from uuid import uuid4
@@ -80,11 +80,27 @@ def _write_volume_file(
def _published_port(container: str, target: int) -> int: def _published_port(container: str, target: int) -> int:
output = _run(["docker", "port", container, f"{target}/tcp"]).stdout.strip() output = _run(
[
"docker",
"inspect",
"--format",
"{{json .HostConfig.PortBindings}}",
container,
]
).stdout.strip()
try: try:
return int(output.rsplit(":", 1)[1]) bindings = json.loads(output)[f"{target}/tcp"]
except (IndexError, ValueError) as exc: if not isinstance(bindings, list) or len(bindings) != 1:
raise RuntimeError(f"cannot determine published port from {output!r}") from exc raise ValueError("expected exactly one published binding")
binding = bindings[0]
if binding.get("HostIp") != "127.0.0.1":
raise ValueError("published binding is not loopback-only")
return int(binding["HostPort"])
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise RuntimeError(
f"cannot determine loopback binding for {target}/tcp from {output!r}"
) from exc
def _available_loopback_port(*, exclude: frozenset[int] = frozenset()) -> int: def _available_loopback_port(*, exclude: frozenset[int] = frozenset()) -> int:
@@ -97,39 +113,114 @@ def _available_loopback_port(*, exclude: frozenset[int] = frozenset()) -> int:
raise RuntimeError("cannot allocate distinct loopback ports for ingress drill") raise RuntimeError("cannot allocate distinct loopback ports for ingress drill")
def _curl(url: str, *, headers: bool = False) -> str: def _probe_ingress(*, image: str, network: str, container: str) -> None:
argv = ["curl", "--silent", "--show-error", "--insecure"] probe = r'''
if headers: import socket
argv.extend(["--head"]) import ssl
argv.append(url) import time
return _run(argv).stdout
def _wait_for_https(port: int) -> str: def request(port, payload, *, tls):
deadline = time.monotonic() + 30 connection = socket.create_connection(("ingress", port), timeout=3)
last_error = "" if tls:
while time.monotonic() < deadline: connection = ssl._create_unverified_context().wrap_socket(
connection, server_hostname="localhost"
)
with connection:
connection.sendall(payload)
chunks = []
while True:
chunk = connection.recv(65536)
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks)
deadline = time.monotonic() + 30
last_error = ""
while time.monotonic() < deadline:
try: try:
return _curl(f"https://localhost:{port}/health") response = request(
except subprocess.CalledProcessError as exc: 8443,
last_error = exc.stderr.strip() b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
tls=True,
)
head, body_bytes = response.split(b"\r\n\r\n", 1)
status = int(head.split(b" ", 2)[1])
if status != 200:
raise RuntimeError(f"HTTPS returned {status}, expected 200")
body = body_bytes.decode("utf-8").strip()
if body != "proto=https":
raise RuntimeError(f"forwarded protocol was not normalized: {body!r}")
break
except Exception as exc:
last_error = f"{type(exc).__name__}: {exc}"
time.sleep(0.5) time.sleep(0.5)
raise RuntimeError(f"managed ingress did not become ready: {last_error}") else:
raise SystemExit(f"managed ingress did not become ready: {last_error}")
response = request(
8080,
b"HEAD /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
tls=False,
)
head = response.split(b"\r\n\r\n", 1)[0].decode("iso-8859-1")
lines = head.split("\r\n")
status = int(lines[0].split(" ", 2)[1])
if status != 308:
raise SystemExit(f"HTTP returned {status}, expected redirect 308")
headers = {
key.lower(): value.strip()
for key, separator, value in (line.partition(":") for line in lines[1:])
if separator
}
location = headers.get("location", "")
if not location.startswith("https://localhost"):
raise SystemExit(f"HTTP redirect had unexpected location: {location!r}")
'''
try:
_run(
[
"docker",
"run",
"--rm",
"--network",
network,
"--read-only",
"--security-opt",
"no-new-privileges",
"--cap-drop",
"ALL",
"--entrypoint",
"python",
image,
"-c",
probe,
]
)
except subprocess.CalledProcessError:
logs = _run(["docker", "logs", container], check=False).stdout.strip()
if logs:
print(f"managed ingress logs:\n{logs}", file=sys.stderr)
raise
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--caddy-image", required=True) parser.add_argument("--caddy-image", required=True)
parser.add_argument("--load-balancer-image", required=True) parser.add_argument("--load-balancer-image", required=True)
parser.add_argument("--probe-image", required=True)
args = parser.parse_args() args = parser.parse_args()
for label, image in ( for label, image in (
("--caddy-image", args.caddy_image), ("--caddy-image", args.caddy_image),
("--load-balancer-image", args.load_balancer_image), ("--load-balancer-image", args.load_balancer_image),
("--probe-image", args.probe_image),
): ):
if DIGEST_IMAGE.fullmatch(image) is None: if DIGEST_IMAGE.fullmatch(image) is None:
parser.error(f"{label} must be pinned by sha256 digest") parser.error(f"{label} must be pinned by sha256 digest")
if shutil.which("docker") is None or shutil.which("curl") is None: if shutil.which("docker") is None:
parser.error("docker and curl are required") parser.error("docker is required")
suffix = uuid4().hex[:10] suffix = uuid4().hex[:10]
network = f"govoplan-ingress-drill-{suffix}" network = f"govoplan-ingress-drill-{suffix}"
@@ -276,6 +367,8 @@ def main() -> int:
ingress, ingress,
"--network", "--network",
network, network,
"--network-alias",
"ingress",
"--read-only", "--read-only",
"--tmpfs", "--tmpfs",
"/tmp:rw,noexec,nosuid,size=16m", "/tmp:rw,noexec,nosuid,size=16m",
@@ -311,19 +404,21 @@ def main() -> int:
requested_https_port, requested_https_port,
): ):
raise RuntimeError("Docker published unexpected ingress ports") raise RuntimeError("Docker published unexpected ingress ports")
body = _wait_for_https(https_port) _probe_ingress(
if body.strip() != "proto=https": image=args.probe_image,
raise RuntimeError(f"forwarded protocol was not normalized: {body!r}") network=network,
redirect = _curl(f"http://localhost:{http_port}/health", headers=True) container=ingress,
if not redirect.startswith("HTTP/1.1 308"): )
raise RuntimeError(f"HTTP was not redirected to HTTPS: {redirect!r}")
_run(["docker", "rm", "--force", ingress]) _run(["docker", "rm", "--force", ingress])
_run(ingress_command) _run(ingress_command)
https_port = _published_port(ingress, 8443) https_port = _published_port(ingress, 8443)
if _wait_for_https(https_port).strip() != "proto=https": if https_port != requested_https_port:
raise RuntimeError( raise RuntimeError("Docker changed the ingress TLS binding on restart")
"managed ingress did not recover with persistent state" _probe_ingress(
image=args.probe_image,
network=network,
container=ingress,
) )
_run( _run(
[ [