From 313249b8fce3d4af46d9a6222ed2d69cd3833591 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 18:26:04 +0200 Subject: [PATCH] Exercise packaged runtime topology --- ...NSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md | 3 + tests/test_runtime_image_smoke.py | 24 +++++++ tools/checks/runtime-image-smoke.py | 63 ++++++++++++++++++- 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md index d84a54c..c9e49c9 100644 --- a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md +++ b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md @@ -157,6 +157,9 @@ migrations against the pinned PostgreSQL image, reach API and WebUI readiness as non-root/read-only processes, and complete a task through the pinned Redis image and packaged worker. Sanitized per-platform smoke receipts are retained as immutable release assets. +The smoke also proves a bounded post-migration table contract and aborts as +soon as a required container exits, rather than allowing a dead process to +consume the full readiness timeout. Ingress acceptance streams generated configuration into Docker-managed volumes before starting the read-only containers. It therefore also works when diff --git a/tests/test_runtime_image_smoke.py b/tests/test_runtime_image_smoke.py index c74b9c9..d73f6cc 100644 --- a/tests/test_runtime_image_smoke.py +++ b/tests/test_runtime_image_smoke.py @@ -3,9 +3,11 @@ from __future__ import annotations import importlib.util import json from pathlib import Path +import subprocess import sys import tempfile import unittest +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] @@ -52,6 +54,28 @@ class RuntimeImageSmokeTests(unittest.TestCase): with self.assertRaisesRegex(MODULE.SmokeError, "exact sha256"): MODULE.platform_image(path, "linux/arm64", "API") + def test_readiness_fails_immediately_when_container_exits(self) -> None: + exited = subprocess.CompletedProcess([], 0, "false\n", "") + logs = subprocess.CompletedProcess([], 0, "fatal startup error\n", "") + with patch.object(MODULE, "_run", side_effect=(exited, logs)): + with self.assertRaisesRegex( + MODULE.SmokeError, + "container exited before readiness: fatal startup error", + ): + MODULE._wait_for( + "WebUI", + lambda: self.fail("probe must not run for an exited container"), + timeout=60, + container="web", + ) + + def test_smoke_supplies_the_packaged_web_upstream_and_schema_contract(self) -> None: + source = SCRIPT.read_text(encoding="utf-8") + + self.assertIn('"--network-alias",\n "load-balancer"', source) + self.assertIn("'core_system_settings'", source) + self.assertIn("'core_runtime_nodes'", source) + if __name__ == "__main__": unittest.main() diff --git a/tools/checks/runtime-image-smoke.py b/tools/checks/runtime-image-smoke.py index e05cf62..1bbbf45 100644 --- a/tools/checks/runtime-image-smoke.py +++ b/tools/checks/runtime-image-smoke.py @@ -104,10 +104,28 @@ def _wait_for( probe: Callable[[], subprocess.CompletedProcess[str]], *, timeout: float, + container: str | None = None, ) -> None: deadline = time.monotonic() + timeout last = "" while time.monotonic() < deadline: + if container is not None: + state = _run( + ("docker", "inspect", "--format", "{{.State.Running}}", container), + check=False, + timeout=30, + ) + if state.returncode != 0 or state.stdout.strip() != "true": + logs = _run( + ("docker", "logs", "--tail", "100", container), + check=False, + timeout=30, + ) + detail = _tail(logs.stdout + logs.stderr, limit=8000) + raise SmokeError( + f"{label} container exited before readiness: " + f"{detail or 'no diagnostic output'}" + ) result = probe() if result.returncode == 0: return @@ -212,13 +230,15 @@ def run_smoke( started = time.monotonic() def record(check_id: str, began: float) -> None: + duration = round(time.monotonic() - began, 3) checks.append( { "id": check_id, "state": "passed", - "duration_seconds": round(time.monotonic() - began, 3), + "duration_seconds": duration, } ) + print(f"PASS {platform} {check_id} ({duration}s)", flush=True) common_runtime = [ "--platform", @@ -309,6 +329,7 @@ def run_smoke( timeout=30, ), timeout=timeout, + container=names["postgres"], ) _wait_for( "Redis", @@ -318,6 +339,7 @@ def run_smoke( timeout=30, ), timeout=timeout, + container=names["redis"], ) record("managed_dependencies_ready", began) @@ -347,6 +369,40 @@ def run_smoke( ) record("release_migrations", began) + began = time.monotonic() + _run( + ( + "docker", + "run", + "--rm", + "--name", + f"{prefix}-schema", + *common_runtime, + *_env_arguments( + environment, + role="migration", + node_id=f"runtime-smoke-{slug}-schema", + ), + api_image, + "python", + "-c", + ( + "import os;" + "from sqlalchemy import create_engine,inspect;" + "engine=create_engine(os.environ['DATABASE_URL']);" + "tables=set(inspect(engine).get_table_names());" + "required={'alembic_version','core_scopes','core_system_settings'," + "'core_runtime_nodes'};" + "missing=required-tables;" + "assert not missing, f'missing release tables: {sorted(missing)}';" + "engine.dispose()" + ), + ), + timeout=timeout, + redactions=redactions, + ) + record("release_schema_contract", began) + began = time.monotonic() _run( ( @@ -357,6 +413,8 @@ def run_smoke( names["api"], "--network-alias", "api", + "--network-alias", + "load-balancer", *common_runtime, *_env_arguments( environment, @@ -388,6 +446,7 @@ def run_smoke( timeout=30, ), timeout=timeout, + container=names["api"], ) _run( ( @@ -446,6 +505,7 @@ def run_smoke( timeout=30, ), timeout=timeout, + container=names["web"], ) _run( ("docker", "exec", names["web"], "sh", "-c", "test \"$(id -u)\" = 101"), @@ -507,6 +567,7 @@ def run_smoke( timeout=30, ), timeout=timeout, + container=names["worker"], ) _run(("docker", "stop", "--time", "20", names["worker"]), timeout=30) record("worker_delivery_and_shutdown", began)