From 3e637a842986932637e2b98a8b426d0611b1c458 Mon Sep 17 00:00:00 2001 From: Jens Date: Wed, 9 Sep 2026 22:24:32 +0200 Subject: [PATCH 1/3] ops: define clone-only verified legacy migration --- docker-compose.migration.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docker-compose.migration.yml diff --git a/docker-compose.migration.yml b/docker-compose.migration.yml new file mode 100644 index 0000000..9bfbf44 --- /dev/null +++ b/docker-compose.migration.yml @@ -0,0 +1,34 @@ +# Append after docker-compose.unraid.yml. Operator-only migration prerequisite; +# every path must be a verified cold clone, never the original live data. +# Also pass MIGRATION_ENV_FILE as Compose --env-file for interpolation parity. +name: vacatureradar-managed +services: + app: + labels: + io.itworx.migration-attempt: ${MIGRATION_ATTEMPT:?journaled migration attempt required} + image: ${MIGRATION_IMAGE:?set an attempt-specific candidate image} + env_file: + - ${VACATURERADAR_ENV_FILE:?set the protected external runtime env file} + build: + labels: + org.opencontainers.image.source: jens/vacatureradar + org.opencontainers.image.revision: ${MIGRATION_SOURCE_REVISION:?full verified Git commit required} + org.opencontainers.image.source-tree: ${MIGRATION_SOURCE_TREE:?verified Git tree required} + org.opencontainers.image.version: ${MIGRATION_BUILD_ID:?broker build ID required} + org.opencontainers.image.created: ${MIGRATION_BUILD_DATE:?broker UTC build date required} + volumes: + - type: bind + source: ${MIGRATION_LOCAL_ROOT:?verified cold clone of the complete local tree required}/media + target: /app/media + bind: + create_host_path: false + - type: bind + source: ${MIGRATION_LOCAL_ROOT:?verified cold clone of the complete local tree required}/logs + target: /app/logs + bind: + create_host_path: false + - type: bind + source: ${MIGRATION_LOCAL_ROOT:?verified cold clone of the complete local tree required} + target: /app/local + bind: + create_host_path: false -- 2.54.0 From a0c0ee26ca8138fde281aa1eae6360c497b06c50 Mon Sep 17 00:00:00 2001 From: Jens Date: Wed, 9 Sep 2026 22:25:49 +0200 Subject: [PATCH 2/3] docs: record safe legacy migration data contract --- docs/LEGACY_DEPLOYMENT_MIGRATION.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/LEGACY_DEPLOYMENT_MIGRATION.md diff --git a/docs/LEGACY_DEPLOYMENT_MIGRATION.md b/docs/LEGACY_DEPLOYMENT_MIGRATION.md new file mode 100644 index 0000000..39a9164 --- /dev/null +++ b/docs/LEGACY_DEPLOYMENT_MIGRATION.md @@ -0,0 +1,19 @@ +# Gecontroleerde migratie van legacy deployments + +`docker-compose.migration.yml` is een optionele operatoroverride, geen wijziging +aan de standaarddeployment. Gebruik deze alleen na de expliciete migratieprocedure +in ProjectBrain `scripts/legacy-deployment-migration/`. + +De operator valideert de exacte Git-revisie en imageprovenance, stopt de oude +container en maakt een gecontroleerde koude kopie van de volledige `local`-map. +`MIGRATION_LOCAL_ROOT` verwijst uitsluitend naar die kopie. Media, logs en de +PostgreSQL-data houden hun geneste mountrelatie. De originele data, image en +container blijven bewaard voor rollback; startupmigraties mogen ze niet wijzigen. + +Alle migratievariabelen zijn verplicht, paden worden niet automatisch aangemaakt, +en een attemptlabel bindt de nieuwe container aan precies één hersteltransactie. +De operator controleert gemergde Compose-mounts, gezondheid, HTTP en de echte +brokerreceipt voordat de configuratie definitief wordt overgezet. + +De override is offline getest met echte Compose-rendering en ontbrekende +invoervariabelen. Toevoeging van dit bestand bewijst geen uitgevoerde productiemigratie. -- 2.54.0 From be0fbaa53c152aff3f0c75a32479efea7986966b Mon Sep 17 00:00:00 2001 From: Jens Date: Wed, 9 Sep 2026 23:21:53 +0200 Subject: [PATCH 3/3] fix: hold candidate traffic and workers until migration commit --- apps/core/middleware.py | 24 ++++++++++ config/settings.py | 1 + deployment/unraid/supervisord.conf | 4 +- docker-compose.migration.yml | 1 + docs/LEGACY_DEPLOYMENT_MIGRATION.md | 21 +++++++++ docs/ai/BACKLOG.yaml | 26 ++++++++++ docs/ai/PROJECT_STATE.md | 12 +++++ scripts/migration_worker_gate.py | 60 ++++++++++++++++++++++++ tests/unit/test_migration_maintenance.py | 45 ++++++++++++++++++ tests/unit/test_migration_worker_gate.py | 44 +++++++++++++++++ 10 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 scripts/migration_worker_gate.py create mode 100644 tests/unit/test_migration_maintenance.py create mode 100644 tests/unit/test_migration_worker_gate.py diff --git a/apps/core/middleware.py b/apps/core/middleware.py index 1091b80..7251e62 100644 --- a/apps/core/middleware.py +++ b/apps/core/middleware.py @@ -9,6 +9,30 @@ from django.shortcuts import redirect from django.urls import reverse from django.utils.http import url_has_allowed_host_and_scheme +from scripts.migration_worker_gate import hold_active + + +class MigrationMaintenanceMiddleware: + """Block candidate traffic before durable migration activation, including GET writes.""" + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + try: + held = hold_active() + except (OSError, RuntimeError): + held = True + health = request.method in {"GET", "HEAD"} and request.path_info in { + "/health/ready/", "/health/live/", + } + if held and not health: + response = JsonResponse({"error": "migration_maintenance"}, status=503) + response["Retry-After"] = "30" + response["Cache-Control"] = "no-store" + return response + return self.get_response(request) + class DemoReadOnlyMiddleware: """Prevent a shared public demo account from mutating application data.""" diff --git a/config/settings.py b/config/settings.py index afbfcd6..afe905a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -159,6 +159,7 @@ INSTALLED_APPS = [ ] MIDDLEWARE = [ + "apps.core.middleware.MigrationMaintenanceMiddleware", "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", diff --git a/deployment/unraid/supervisord.conf b/deployment/unraid/supervisord.conf index d9d2bfa..4e935d2 100644 --- a/deployment/unraid/supervisord.conf +++ b/deployment/unraid/supervisord.conf @@ -36,7 +36,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:worker] -command=/app/.venv/bin/celery -A config worker -l INFO -Q high,default,low --concurrency=2 +command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py worker directory=/app user=app priority=50 @@ -47,7 +47,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:scheduler] -command=/app/.venv/bin/celery -A config beat -l INFO --schedule /tmp/celerybeat-schedule +command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py scheduler directory=/app user=app priority=60 diff --git a/docker-compose.migration.yml b/docker-compose.migration.yml index 9bfbf44..25f7eeb 100644 --- a/docker-compose.migration.yml +++ b/docker-compose.migration.yml @@ -4,6 +4,7 @@ name: vacatureradar-managed services: app: + network_mode: ${MIGRATION_NETWORK:?exact original network required} labels: io.itworx.migration-attempt: ${MIGRATION_ATTEMPT:?journaled migration attempt required} image: ${MIGRATION_IMAGE:?set an attempt-specific candidate image} diff --git a/docs/LEGACY_DEPLOYMENT_MIGRATION.md b/docs/LEGACY_DEPLOYMENT_MIGRATION.md index 39a9164..cc89ef9 100644 --- a/docs/LEGACY_DEPLOYMENT_MIGRATION.md +++ b/docs/LEGACY_DEPLOYMENT_MIGRATION.md @@ -17,3 +17,24 @@ brokerreceipt voordat de configuratie definitief wordt overgezet. De override is offline getest met echte Compose-rendering en ontbrekende invoervariabelen. Toevoeging van dit bestand bewijst geen uitgevoerde productiemigratie. +# Outbound worker hold + +Before starting a migration candidate, create `.migration-worker-hold` in its +cloned `/app/local` directory. The supervised Celery worker and scheduler wait +without consuming tasks until the operator removes that exact attempt-owned +file after the deployment commit. Normal startup is unchanged when it is absent. +Do not put the marker into the original data. This is a startup gate, not a +control for pausing an already-running worker. Recovery before commit retains +the hold and candidate data; recovery after commit resumes activation and must +never revert to stale original data after outbound work has been released. + +The first Django middleware also returns 503 (no-store) for every ordinary +request while held, including GET requests. Only exact GET/HEAD requests to +`/health/ready/` and `/health/live/` pass. Marker inspection errors fail closed. +The migration helper creates a nonce-bound marker only in the cold clone and +removes it with directory fsync after a durable `committed` journal. Recovery +after that boundary may resume activation but can never restore old data. + +`MIGRATION_NETWORK` is required and must equal the inspected existing +`vacatureradar_default` network. The managed Compose project does not move the +application to a newly-created network. diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml index 8dc6e6e..7bc1e6f 100644 --- a/docs/ai/BACKLOG.yaml +++ b/docs/ai/BACKLOG.yaml @@ -2445,6 +2445,32 @@ tasks: note: Releasebootstrap gepind op immutable setup-uv v8.1.0-commit en uv 0.11.12 via Astral-mirror; Gitea Actions-run 3062 publiceerde digest sha256:98f4b33d met checksum-geldige SBOM/release-evidence; Unraid gepind op volledige digest en live health/readiness groen. +- id: VR-232 + title: Houd uitgaand werk vast tijdens gecontroleerde datamigratie + status: ready + priority: P0 + requirement_ids: [NFR-009] + depends_on: [VR-231] + summary: Door de eigenaar gevraagde migratie met rollback; Celery start pas na vrijgave van de kandidaatkopie. + acceptance_criteria: + - Een marker in de kandidaatkopie blokkeert worker en scheduler voor hun eerste externe actie. + - Zonder marker blijft normaal opstartgedrag ongewijzigd. + - Onbekende procesrollen en ongeldige markers falen gesloten. + - De migratie verwijdert uitsluitend haar eigen marker na geverifieerde commit. + - Gewone webverzoeken blijven geblokkeerd tot commit; alleen exacte GET/HEAD-healthroutes zijn beschikbaar. + - De migratie behoudt het bestaande Docker-netwerk via een verplichte gevalideerde variabele. + verification: + - uv run pytest tests/unit/test_migration_worker_gate.py + - uv run pytest tests/unit/test_migration_maintenance.py + - ./scripts/codex_verify.sh + primary_paths: + - scripts/migration_worker_gate.py + - deployment/unraid/supervisord.conf + - tests/unit/test_migration_worker_gate.py + - tests/unit/test_migration_maintenance.py + - apps/core/middleware.py + - config/settings.py + - docker-compose.migration.yml - id: VR-231 title: Sluit de operationele 0.3.17-restpunten status: done diff --git a/docs/ai/PROJECT_STATE.md b/docs/ai/PROJECT_STATE.md index 8022f41..f125a44 100644 --- a/docs/ai/PROJECT_STATE.md +++ b/docs/ai/PROJECT_STATE.md @@ -1,5 +1,17 @@ # Projectstatus +## Gecontroleerde migratie — 2026-09-09 + +VR-232 voegt een startup-hold voor Celery worker/beat toe tijdens de expliciet +goedgekeurde legacy-migratie. Alleen de kandidaatdatakopie krijgt een marker; +uitgaand werk wordt pas na een geverifieerde deploymentcommit vrijgegeven. +De eerste middleware blokkeert ook gewone webverzoeken tot commit; alleen exacte +GET/HEAD-healthroutes blijven beschikbaar. De netwerkoverride behoudt het bestaande +netwerk. Lokaal slagen 344 tests inclusief browsercontroles (84,07% coverage). +De volledige `scripts/codex_verify.sh`-gate is groen: Ruff, Django-checks, +migratiecontrole, tests, taakledger en repositoryvalidatie zijn geslaagd. +Live migratie is niet uitgevoerd. Bestaande productietaken zijn niet gewijzigd. + - Laatst bijgewerkt: 2026-08-12 - Repositoryversie: 0.3.17 immutable releaseherstel - Uitvoeringsmodus: autonome backlog diff --git a/scripts/migration_worker_gate.py b/scripts/migration_worker_gate.py new file mode 100644 index 0000000..08cec00 --- /dev/null +++ b/scripts/migration_worker_gate.py @@ -0,0 +1,60 @@ +"""Keep outbound Celery work paused during a verified cold-data migration. + +The operator creates the hold file in the candidate data clone before startup +and removes it only after committing the verified deployment. Normal startups +without a hold file behave unchanged. This does not pause an existing worker. +""" + +import os +import stat +import sys +import time +from pathlib import Path + +HOLD_FILE = Path("/app/local/.migration-worker-hold") +COMMANDS = { + "worker": [ + "/app/.venv/bin/celery", + "-A", + "config", + "worker", + "-l", + "INFO", + "-Q", + "high,default,low", + "--concurrency=2", + ], + "scheduler": [ + "/app/.venv/bin/celery", + "-A", + "config", + "beat", + "-l", + "INFO", + "--schedule", + "/tmp/celerybeat-schedule", # noqa: S108 - existing supervised container-local schedule path + ], +} + + +def hold_active(path=HOLD_FILE): + try: + mode = path.lstat().st_mode + except FileNotFoundError: + return False + if not stat.S_ISREG(mode): + raise RuntimeError("Migration hold must be a regular file") + return True + + +def start(role, *, check=hold_active, sleep=time.sleep, execute=os.execv): + if role not in COMMANDS: + raise ValueError("Unsupported supervised process") + while check(): + sleep(1) + command = COMMANDS[role] + execute(command[0], command) + + +if __name__ == "__main__": + start(sys.argv[1] if len(sys.argv) == 2 else "") diff --git a/tests/unit/test_migration_maintenance.py b/tests/unit/test_migration_maintenance.py new file mode 100644 index 0000000..08ea5d8 --- /dev/null +++ b/tests/unit/test_migration_maintenance.py @@ -0,0 +1,45 @@ +from unittest.mock import Mock, patch + +import pytest +from django.http import HttpResponse +from django.test import RequestFactory + +from apps.core.middleware import MigrationMaintenanceMiddleware + + +@pytest.mark.parametrize("method,path,allowed", [ + ("GET", "/health/ready/", True), + ("HEAD", "/health/live/", True), + ("POST", "/health/ready/", False), + ("GET", "/health/ready", False), + ("GET", "/", False), + ("POST", "/accounts/login/", False), + ("OPTIONS", "/health/live/", False), +]) +def test_held_candidate_only_allows_exact_safe_health(method, path, allowed): + downstream = Mock(return_value=HttpResponse("ok")) + with patch("apps.core.middleware.hold_active", return_value=True): + response = MigrationMaintenanceMiddleware(downstream)( + RequestFactory().generic(method, path) + ) + assert response.status_code == (200 if allowed else 503) + assert downstream.called is allowed + if not allowed: + assert response["Cache-Control"] == "no-store" + + +@pytest.mark.parametrize("failure", [PermissionError(), RuntimeError()]) +def test_marker_errors_block_ordinary_traffic(failure): + downstream = Mock() + with patch("apps.core.middleware.hold_active", side_effect=failure): + response = MigrationMaintenanceMiddleware(downstream)(RequestFactory().get("/")) + assert response.status_code == 503 + downstream.assert_not_called() + + +def test_normal_runtime_unchanged_and_middleware_is_first(settings): + assert settings.MIDDLEWARE[0] == "apps.core.middleware.MigrationMaintenanceMiddleware" + downstream = Mock(return_value=HttpResponse("ok")) + with patch("apps.core.middleware.hold_active", return_value=False): + response = MigrationMaintenanceMiddleware(downstream)(RequestFactory().post("/")) + assert response.status_code == 200 diff --git a/tests/unit/test_migration_worker_gate.py b/tests/unit/test_migration_worker_gate.py new file mode 100644 index 0000000..1f929d6 --- /dev/null +++ b/tests/unit/test_migration_worker_gate.py @@ -0,0 +1,44 @@ +import importlib.util +from pathlib import Path + +import pytest + +spec = importlib.util.spec_from_file_location( + "migration_worker_gate", + Path(__file__).resolve().parents[2] / "scripts/migration_worker_gate.py", +) +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +@pytest.mark.parametrize("role", ["worker", "scheduler"]) +def test_hold_prevents_outbound_process_until_release(role): + states = iter([True, True, False]) + events = [] + gate.start( + role, + check=lambda: next(states), + sleep=lambda seconds: events.append("wait"), + execute=lambda executable, args: events.append(args), + ) + assert events == ["wait", "wait", gate.COMMANDS[role]] + + +def test_normal_startup_and_missing_hold(tmp_path): + assert not gate.hold_active(tmp_path / "absent") + events = [] + gate.start("worker", check=lambda: False, execute=lambda executable, args: events.append(args)) + assert events == [gate.COMMANDS["worker"]] + + +def test_hold_exists_and_unsupported_marker_fails_closed(tmp_path): + marker = tmp_path / "hold" + marker.write_text("fixture-attempt") + assert gate.hold_active(marker) + with pytest.raises(RuntimeError): + gate.hold_active(tmp_path) + + +def test_unknown_role_never_executes(): + with pytest.raises(ValueError): + gate.start("shell", execute=lambda *args: pytest.fail("unreviewed command")) -- 2.54.0