Probe managed ingress across Docker namespaces
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user