diff --git a/runner/warm_reconcile.py b/runner/warm_reconcile.py index 98f96aa..d3c1998 100644 --- a/runner/warm_reconcile.py +++ b/runner/warm_reconcile.py @@ -55,7 +55,29 @@ def wildcard_secret_version(cert_dir: str = CERT_DIR) -> str: return "v" + digest[:16] -def _traefik_setup(recipe: str, domain: str, version: str) -> None: +def _traefik_requires_certificate_rollout(domain: str, secret_version: str) -> bool: + """Whether Traefik's active service still references an older cert version.""" + stack = lifecycle._stack_name(domain) # noqa: SLF001 + service = f"{stack}_app" + result = _run( + [ + "docker", + "service", + "inspect", + service, + "--format", + "{{range .Spec.TaskTemplate.ContainerSpec.Secrets}}{{.SecretName}} {{end}}", + ], + timeout=30, + ) + expected = { + f"{stack}_ssl_cert_{secret_version}", + f"{stack}_ssl_key_{secret_version}", + } + return not expected.issubset(set(result.stdout.split())) + + +def _traefik_setup(recipe: str, domain: str, version: str) -> bool: """Per-app config for the traefik reverse-proxy reconcile — preserves EXACTLY what the prior proxy.nix bash reconcile did (wildcard/file-provider mode serving the pre-issued cert as ssl_cert/ssl_key swarm secrets; NO ACME). Uses the proven abra.env_set (newline-safe, unlike the @@ -118,6 +140,7 @@ def _traefik_setup(recipe: str, domain: str, version: str) -> None: timeout=120, check=True, ) + return _traefik_requires_certificate_rollout(domain, secret_version) SPECS: dict[str, dict] = { @@ -476,8 +499,9 @@ def reconcile(app: str) -> str: # Per-app config/secrets: a spec may provide its own `setup` (traefik's cert/file-provider wiring); # otherwise the default keycloak-shaped path (app new + DOMAIN/LETS_ENCRYPT + generate secrets). setup = spec.get("setup") + setup_needs_rollout = False if setup: - setup(recipe, domain, latest) + setup_needs_rollout = bool(setup(recipe, domain, latest)) else: ensure_app_config(recipe, domain, latest) ensure_secrets(domain) @@ -495,6 +519,20 @@ def reconcile(app: str) -> str: write_last_good(recipe, target) return f"deployed-fresh:{target}" + # A certificate rotation changes Traefik's immutable Swarm secrets but + # must not be held hostage by an unrelated recipe-major upgrade policy. + # Redeploy the current recipe version so its compose spec references the + # just-created cert/key secret pair, then apply the usual health gate. + if setup_needs_rollout: + if not current: + raise RuntimeError(f"{app} has services but no current version") + print(f"[{app}] certificate changed → redeploy {current}", flush=True) + deploy_version(recipe, domain, current, dt) + if not wait_healthy(spec): + raise RuntimeError(f"{app} certificate rollout {current} did not become healthy") + write_last_good(recipe, current) + return f"certificate-rolled-out:{current}" + # Deployed & already on latest → converge to a no-op (commit last-good if healthy). if current == latest: if wait_healthy(spec, timeout=60): diff --git a/tests/unit/test_warm_reconcile.py b/tests/unit/test_warm_reconcile.py index 54960b2..2ca9b8b 100644 --- a/tests/unit/test_warm_reconcile.py +++ b/tests/unit/test_warm_reconcile.py @@ -9,6 +9,7 @@ from __future__ import annotations import os import sys +from types import SimpleNamespace sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) @@ -115,6 +116,20 @@ def test_traefik_spec_is_stateless_with_setup(): assert "setup" not in wr.SPECS["keycloak"] +def test_traefik_certificate_rollout_detects_active_secret_version(monkeypatch): + stack = "traefik_ci_commoninternet_net" + monkeypatch.setattr(wr.lifecycle, "_stack_name", lambda _domain: stack) + monkeypatch.setattr( + wr, + "_run", + lambda *_args, **_kwargs: SimpleNamespace( + stdout=f"{stack}_ssl_cert_vnew {stack}_ssl_key_vnew" + ), + ) + assert not wr._traefik_requires_certificate_rollout("traefik.ci.commoninternet.net", "vnew") + assert wr._traefik_requires_certificate_rollout("traefik.ci.commoninternet.net", "vold") + + def test_manual_migration_markers(): assert wr.notes_flag_manual_migration("This release requires a MANUAL MIGRATION of the DB.") assert wr.notes_flag_manual_migration("Breaking change: action required before upgrade.")