From d107d94fec4261a87b839c99fb6ca0c84373f2ee Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 19:59:12 +0200 Subject: [PATCH] Add standalone managed ingress diagnostics --- .gitea/workflows/runtime-ingress-drill.yml | 44 +++++++++++++++++++ ...NSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md | 4 ++ tests/test_managed_ingress_drill.py | 30 +++++++++++++ tools/checks/managed-ingress-drill.py | 18 ++++++-- 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/runtime-ingress-drill.yml diff --git a/.gitea/workflows/runtime-ingress-drill.yml b/.gitea/workflows/runtime-ingress-drill.yml new file mode 100644 index 0000000..96f91dd --- /dev/null +++ b/.gitea/workflows/runtime-ingress-drill.yml @@ -0,0 +1,44 @@ +name: Runtime Ingress Drill + +on: + workflow_dispatch: + inputs: + caddy_image: + description: Digest-pinned Caddy image + required: true + type: string + load_balancer_image: + description: Digest-pinned HAProxy image + required: true + type: string + probe_image: + description: Digest-pinned amd64 GovOPlaN API image + required: true + type: string + +jobs: + managed-ingress: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + path: govoplan + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - name: Authenticate runtime image pull + env: + REGISTRY_USERNAME: ${{ secrets.GOVOPLAN_REGISTRY_USERNAME }} + REGISTRY_TOKEN: ${{ secrets.GOVOPLAN_REGISTRY_TOKEN }} + run: echo "$REGISTRY_TOKEN" | docker login git.add-ideas.de --username "$REGISTRY_USERNAME" --password-stdin + - name: Exercise the managed ingress boundary + working-directory: govoplan + env: + CADDY_IMAGE: ${{ inputs.caddy_image }} + LOAD_BALANCER_IMAGE: ${{ inputs.load_balancer_image }} + PROBE_IMAGE: ${{ inputs.probe_image }} + run: >- + python tools/checks/managed-ingress-drill.py + --caddy-image "$CADDY_IMAGE" + --load-balancer-image "$LOAD_BALANCER_IMAGE" + --probe-image "$PROBE_IMAGE" diff --git a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md index 9e6092d..7f8cde2 100644 --- a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md +++ b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md @@ -181,6 +181,10 @@ 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 dispatch-only `Runtime Ingress Drill` workflow exposes the same bounded +check independently so ingress changes can be diagnosed before an immutable +runtime publication; it accepts only digest-pinned Caddy, HAProxy, and API +images and has no push trigger. 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 non-root identity and runs read-only with all capabilities dropped. diff --git a/tests/test_managed_ingress_drill.py b/tests/test_managed_ingress_drill.py index 36d7d2f..0e4a8cf 100644 --- a/tests/test_managed_ingress_drill.py +++ b/tests/test_managed_ingress_drill.py @@ -122,6 +122,36 @@ class ManagedIngressDrillTests(unittest.TestCase): self.assertIn("server_hostname=\"localhost\"", argv[-1]) self.assertNotIn("localhost:49152", argv[-1]) + def test_probe_diagnostics_include_container_stderr(self) -> None: + probe_failure = subprocess.CalledProcessError(1, ["docker", "run"]) + state = subprocess.CompletedProcess([], 0, '{"Running":false}', "") + logs = subprocess.CompletedProcess([], 0, "", "caddy startup failed") + with patch.object( + INGRESS, + "_run", + side_effect=[probe_failure, state, logs], + ), patch.object(INGRESS.sys, "stderr") as stderr: + with self.assertRaises(subprocess.CalledProcessError): + INGRESS._probe_ingress( + image="registry.example/runtime-api@sha256:" + "1" * 64, + network="deployment-network", + container="ingress", + ) + + rendered = "".join(call.args[0] for call in stderr.write.call_args_list) + self.assertIn('"Running":false', rendered) + self.assertIn("caddy startup failed", rendered) + + def test_standalone_workflow_is_dispatch_only_and_digest_bounded(self) -> None: + workflow = ( + ROOT / ".gitea/workflows/runtime-ingress-drill.yml" + ).read_text(encoding="utf-8") + + self.assertIn("workflow_dispatch:", workflow) + self.assertNotIn("\n push:", workflow) + self.assertIn("--probe-image \"$PROBE_IMAGE\"", workflow) + self.assertIn("GOVOPLAN_REGISTRY_TOKEN", workflow) + if __name__ == "__main__": unittest.main() diff --git a/tools/checks/managed-ingress-drill.py b/tools/checks/managed-ingress-drill.py index 29e7e1f..d2cccde 100644 --- a/tools/checks/managed-ingress-drill.py +++ b/tools/checks/managed-ingress-drill.py @@ -200,12 +200,24 @@ if not location.startswith("https://localhost"): ] ) except subprocess.CalledProcessError: - logs = _run(["docker", "logs", container], check=False).stdout.strip() - if logs: - print(f"managed ingress logs:\n{logs}", file=sys.stderr) + _print_container_diagnostics(container) raise +def _print_container_diagnostics(container: str) -> None: + state = _run( + ["docker", "inspect", "--format", "{{json .State}}", container], + check=False, + ) + state_detail = (state.stdout + state.stderr).strip() + if state_detail: + print(f"managed ingress state:\n{state_detail}", file=sys.stderr) + logs = _run(["docker", "logs", container], check=False) + log_detail = (logs.stdout + logs.stderr).strip() + if log_detail: + print(f"managed ingress logs:\n{log_detail}", file=sys.stderr) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--caddy-image", required=True)