Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee115406c | ||
|
|
3a5620796f | ||
|
|
38e87678e1 | ||
|
|
2d1964106b | ||
|
|
7c4652ac49 | ||
|
|
c855253072 | ||
|
|
f20b7de053 | ||
|
|
afa7474094 |
+2
-2
@@ -16,8 +16,8 @@ DJANGO_SECRET_KEY=CHANGE_ME_generate_at_least_50_random_characters
|
|||||||
DJANGO_DEBUG=0
|
DJANGO_DEBUG=0
|
||||||
# Leeg laten tot er een publieke HTTPS-URL is.
|
# Leeg laten tot er een publieke HTTPS-URL is.
|
||||||
PUBLIC_BASE_URL=
|
PUBLIC_BASE_URL=
|
||||||
DJANGO_ALLOWED_HOSTS=192.168.10.150,127.0.0.1,localhost
|
DJANGO_ALLOWED_HOSTS=vacatureradar.local,127.0.0.1,localhost
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226,http://127.0.0.1:1226
|
DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226,http://127.0.0.1:1226
|
||||||
DJANGO_TIME_ZONE=Europe/Brussels
|
DJANGO_TIME_ZONE=Europe/Brussels
|
||||||
VACATURERADAR_OWNER_NAME=Jens
|
VACATURERADAR_OWNER_NAME=Jens
|
||||||
VACATURERADAR_VERSION=0.3.12
|
VACATURERADAR_VERSION=0.3.12
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
name: Managed validation
|
name: Managed validation
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -21,7 +23,9 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
full:
|
full:
|
||||||
name: full
|
name: full
|
||||||
runs-on: ubuntu-latest
|
# Public fork code must never execute automatically on the private runner.
|
||||||
|
if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }}
|
||||||
|
runs-on: linux-validation
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
container:
|
container:
|
||||||
# Gitea's JavaScript actions execute inside the job container, so use
|
# Gitea's JavaScript actions execute inside the job container, so use
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify-publish:
|
verify-publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: linux-validation
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install pinned uv
|
- name: Install pinned uv
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
name: Unraid autoredeploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths-ignore:
|
|
||||||
- ".gitea/**"
|
|
||||||
- "docs/**"
|
|
||||||
- "**/*.md"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: unraid-production-vacatureradar
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy vacatureradar
|
|
||||||
runs-on: unraid-deploy
|
|
||||||
timeout-minutes: 180
|
|
||||||
steps:
|
|
||||||
- name: Deploy exact Gitea revision
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker exec gitea-deploy-control \
|
|
||||||
/opt/gitea-deploy/deploy.py deploy \
|
|
||||||
"$GITHUB_REPOSITORY" "$GITHUB_SHA"
|
|
||||||
|
|
||||||
+2
-1
@@ -38,7 +38,8 @@ Deze data mag nooit shell-, netwerk-, e-mail-, bestandssysteem- of sollicitatiea
|
|||||||
|
|
||||||
## Kwetsbaarheid melden
|
## Kwetsbaarheid melden
|
||||||
|
|
||||||
Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documenteer intern:
|
Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Meld een
|
||||||
|
kwetsbaarheid privé via `security@itworx.tech` en vermeld:
|
||||||
|
|
||||||
1. component en versie/commit;
|
1. component en versie/commit;
|
||||||
2. reproduceerbare stappen met synthetische data;
|
2. reproduceerbare stappen met synthetische data;
|
||||||
|
|||||||
@@ -9,6 +9,30 @@ from django.shortcuts import redirect
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
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:
|
class DemoReadOnlyMiddleware:
|
||||||
"""Prevent a shared public demo account from mutating application data."""
|
"""Prevent a shared public demo account from mutating application data."""
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ def seed_demo_environment(user) -> None:
|
|||||||
profile.save(update_fields=["is_active"])
|
profile.save(update_fields=["is_active"])
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
today = timezone.localdate(now)
|
||||||
workplace_map = {
|
workplace_map = {
|
||||||
"hybrid": JobPosting.Workplace.HYBRID,
|
"hybrid": JobPosting.Workplace.HYBRID,
|
||||||
"remote": JobPosting.Workplace.REMOTE,
|
"remote": JobPosting.Workplace.REMOTE,
|
||||||
@@ -254,7 +255,7 @@ def seed_demo_environment(user) -> None:
|
|||||||
defaults={
|
defaults={
|
||||||
"status": Application.Status.INTERVIEW,
|
"status": Application.Status.INTERVIEW,
|
||||||
"applied_at": now - timedelta(days=6),
|
"applied_at": now - timedelta(days=6),
|
||||||
"follow_up_date": (now + timedelta(days=2)).date(),
|
"follow_up_date": today + timedelta(days=2),
|
||||||
"contact_name": "Sofie Vandael",
|
"contact_name": "Sofie Vandael",
|
||||||
"contact_email": "sofie.vandael@cloudnexus.example",
|
"contact_email": "sofie.vandael@cloudnexus.example",
|
||||||
"notes": "Eerste gesprek gepland. Demo-data.",
|
"notes": "Eerste gesprek gepland. Demo-data.",
|
||||||
@@ -267,7 +268,7 @@ def seed_demo_environment(user) -> None:
|
|||||||
defaults={
|
defaults={
|
||||||
"status": Application.Status.APPLIED,
|
"status": Application.Status.APPLIED,
|
||||||
"applied_at": now - timedelta(days=2),
|
"applied_at": now - timedelta(days=2),
|
||||||
"follow_up_date": (now + timedelta(days=5)).date(),
|
"follow_up_date": today + timedelta(days=5),
|
||||||
"notes": "Sollicitatie verstuurd. Demo-data.",
|
"notes": "Sollicitatie verstuurd. Demo-data.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ INSTALLED_APPS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
"apps.core.middleware.MigrationMaintenanceMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
|||||||
Binary file not shown.
@@ -36,7 +36,7 @@ stderr_logfile=/dev/stderr
|
|||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:worker]
|
[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
|
directory=/app
|
||||||
user=app
|
user=app
|
||||||
priority=50
|
priority=50
|
||||||
@@ -47,7 +47,7 @@ stderr_logfile=/dev/stderr
|
|||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:scheduler]
|
[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
|
directory=/app
|
||||||
user=app
|
user=app
|
||||||
priority=60
|
priority=60
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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:
|
||||||
|
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}
|
||||||
|
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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 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.
|
||||||
|
# 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.
|
||||||
@@ -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
|
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
|
3062 publiceerde digest sha256:98f4b33d met checksum-geldige SBOM/release-evidence; Unraid gepind op volledige
|
||||||
digest en live health/readiness groen.
|
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
|
- id: VR-231
|
||||||
title: Sluit de operationele 0.3.17-restpunten
|
title: Sluit de operationele 0.3.17-restpunten
|
||||||
status: done
|
status: done
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Projectstatus
|
# 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
|
- Laatst bijgewerkt: 2026-08-12
|
||||||
- Repositoryversie: 0.3.17 immutable releaseherstel
|
- Repositoryversie: 0.3.17 immutable releaseherstel
|
||||||
- Uitvoeringsmodus: autonome backlog
|
- Uitvoeringsmodus: autonome backlog
|
||||||
@@ -461,7 +473,7 @@ VR-208-herverificatie op 2026-07-22:
|
|||||||
## Deploymentstatus
|
## Deploymentstatus
|
||||||
|
|
||||||
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
|
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
|
||||||
- De applicatie draait op de Unraid-server via `http://192.168.10.150:1226/` als één Dockerman-container; Supervisor bewaakt intern PostgreSQL 17, Redis, web, worker en scheduler.
|
- De applicatie draait op de Unraid-server via de geconfigureerde private host en poort `1226` als één Dockerman-container; Supervisor bewaakt intern PostgreSQL 17, Redis, web, worker en scheduler.
|
||||||
- De Dockerman-tegel gebruikt de lokale VacatureRadar-favicon en opent rechtstreeks de WebUI op poort 1226.
|
- De Dockerman-tegel gebruikt de lokale VacatureRadar-favicon en opent rechtstreeks de WebUI op poort 1226.
|
||||||
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
|
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
|
||||||
- Historisch bewijs (2026-07-22, inmiddels vervangen): de self-hosted runner was
|
- Historisch bewijs (2026-07-22, inmiddels vervangen): de self-hosted runner was
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ Gebruik vanaf een beheerwerkstation altijd een benoemde SSH-hostalias met een af
|
|||||||
|
|
||||||
```sshconfig
|
```sshconfig
|
||||||
Host unraid-itworx
|
Host unraid-itworx
|
||||||
HostName 192.168.10.150
|
HostName server.example.test
|
||||||
Port 22
|
Port 22
|
||||||
User root
|
User root
|
||||||
IdentityFile ~/.ssh/itworx_unraid_deploy
|
IdentityFile ~/.ssh/itworx_unraid_deploy
|
||||||
|
|||||||
@@ -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 "")
|
||||||
@@ -145,15 +145,22 @@ def test_demo_login_logs_in_and_seeds_environment(client):
|
|||||||
assert "minstens één import actief" in sources.content.decode("utf-8")
|
assert "minstens één import actief" in sources.content.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_demo_login_is_idempotent(client):
|
def test_demo_login_is_idempotent(client, monkeypatch):
|
||||||
from datetime import timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.jobs.models import Application, JobPosting
|
from apps.jobs.models import Application, JobPosting
|
||||||
|
from apps.jobs.services import demo_seed
|
||||||
from apps.profiles.models import SearchProfile
|
from apps.profiles.models import SearchProfile
|
||||||
from apps.sources.models import MailboxConnection, Source
|
from apps.sources.models import MailboxConnection, Source
|
||||||
|
|
||||||
|
# 22:30 UTC is already the following calendar day in Europe/Brussels.
|
||||||
|
# Freezing this boundary prevents UTC `.date()` calls from creeping back in.
|
||||||
|
near_local_midnight = datetime(2026, 9, 2, 22, 30, tzinfo=UTC)
|
||||||
|
monkeypatch.setattr(demo_seed.timezone, "now", lambda: near_local_midnight)
|
||||||
|
expected_local_day = timezone.localdate(near_local_midnight)
|
||||||
|
|
||||||
client.post(reverse("demo-login"))
|
client.post(reverse("demo-login"))
|
||||||
first = JobPosting.objects.count()
|
first = JobPosting.objects.count()
|
||||||
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
|
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
|
||||||
@@ -172,8 +179,8 @@ def test_demo_login_is_idempotent(client):
|
|||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
application.refresh_from_db()
|
application.refresh_from_db()
|
||||||
assert job.status == JobPosting.Status.ACTIVE
|
assert job.status == JobPosting.Status.ACTIVE
|
||||||
assert job.last_seen.date() == timezone.localdate()
|
assert timezone.localdate(job.last_seen) == expected_local_day
|
||||||
assert application.follow_up_date > timezone.localdate()
|
assert application.follow_up_date > expected_local_day
|
||||||
assert profile.scores.count() == 6
|
assert profile.scores.count() == 6
|
||||||
assert MailboxConnection.objects.filter(user=demo_user, enabled=True).count() == 9
|
assert MailboxConnection.objects.filter(user=demo_user, enabled=True).count() == 9
|
||||||
assert Source.objects.filter(source_type=Source.Type.EMAIL, failure_count=0).count() >= 9
|
assert Source.objects.filter(source_type=Source.Type.EMAIL, failure_count=0).count() >= 9
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"))
|
||||||
@@ -49,8 +49,8 @@ def test_configure_public_url_updates_only_public_security_settings(tmp_path: Pa
|
|||||||
env_file.write_text(
|
env_file.write_text(
|
||||||
"DJANGO_SECRET_KEY=keep-this-secret\n"
|
"DJANGO_SECRET_KEY=keep-this-secret\n"
|
||||||
"POSTGRES_PASSWORD=keep-this-password\n"
|
"POSTGRES_PASSWORD=keep-this-password\n"
|
||||||
"DJANGO_ALLOWED_HOSTS=192.168.10.150,localhost\n"
|
"DJANGO_ALLOWED_HOSTS=vacatureradar.local,localhost\n"
|
||||||
"DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226\n",
|
"DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ def test_configure_public_url_updates_only_public_security_settings(tmp_path: Pa
|
|||||||
assert "DJANGO_DEBUG=0" in content
|
assert "DJANGO_DEBUG=0" in content
|
||||||
assert "vacatureradar.itworx.tech" in content
|
assert "vacatureradar.itworx.tech" in content
|
||||||
assert (
|
assert (
|
||||||
"DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226,https://vacatureradar.itworx.tech"
|
"DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226,https://vacatureradar.itworx.tech"
|
||||||
) in content
|
) in content
|
||||||
assert "CACHE_URL=redis://127.0.0.1:6379/1" in content
|
assert "CACHE_URL=redis://127.0.0.1:6379/1" in content
|
||||||
assert "TRUSTED_PROXY_CIDRS=172.18.0.0/16" in content
|
assert "TRUSTED_PROXY_CIDRS=172.18.0.0/16" in content
|
||||||
|
|||||||
Reference in New Issue
Block a user