diff --git a/Dockerfile.unraid b/Dockerfile.unraid new file mode 100644 index 0000000..35f3b00 --- /dev/null +++ b/Dockerfile.unraid @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1.7 +FROM python:3.13-slim-trixie + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_DEFAULT_INDEX=https://pypi.org/simple \ + PATH="/app/.venv/bin:$PATH" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates libpq5 postgresql redis-server supervisor \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:0.10.0 /uv /uvx /bin/ +WORKDIR /app + +RUN addgroup --system app \ + && adduser --system --ingroup app --home /app --no-create-home app + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +COPY . . +RUN uv sync --frozen --no-dev \ + && chmod +x scripts/*.sh \ + && python manage.py collectstatic --noinput \ + && mkdir -p /app/media /app/logs /app/local /var/log/supervisor \ + && chown -R app:app /app + +EXPOSE 8080 +ENTRYPOINT ["/app/scripts/unraid_aio_entrypoint.sh"] diff --git a/deployment/unraid/README.md b/deployment/unraid/README.md index 9c2cc1f..cc9049b 100644 --- a/deployment/unraid/README.md +++ b/deployment/unraid/README.md @@ -1,5 +1,5 @@ # Unraid-installatiebestanden -De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Volg eerst [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md). +De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Deze bouwt één Dockerman-container via `Dockerfile.unraid`; interne processen worden door Supervisor bewaakt. Volg eerst [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md). Deze directory is gereserveerd voor toekomstige Unraid-template-XML en release-specifieke voorbeelden. Commit hier geen ingevulde `.env`, app-password, registrypulltoken of databaseback-up. diff --git a/deployment/unraid/supervisord.conf b/deployment/unraid/supervisord.conf new file mode 100644 index 0000000..d9d2bfa --- /dev/null +++ b/deployment/unraid/supervisord.conf @@ -0,0 +1,58 @@ +[supervisord] +nodaemon=true +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/tmp/supervisord.pid + +[program:postgres] +command=/usr/lib/postgresql/17/bin/postgres -D /app/local/postgres-aio17 +user=postgres +priority=10 +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:redis] +command=/usr/bin/redis-server --dir /app/local/redis-aio --save 60 1 --loglevel warning --protected-mode yes --bind 127.0.0.1 +user=redis +priority=20 +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:web] +command=/app/.venv/bin/gunicorn config.wsgi:application --bind 0.0.0.0:8080 --workers 2 --threads 4 --timeout 90 --no-control-socket +directory=/app +user=app +priority=40 +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +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 +directory=/app +user=app +priority=50 +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:scheduler] +command=/app/.venv/bin/celery -A config beat -l INFO --schedule /tmp/celerybeat-schedule +directory=/app +user=app +priority=60 +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index c07280b..b899ba7 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -1,76 +1,38 @@ -# Compose-bron van waarheid met paden voor een Unraid-installatie. -# Lees deployment/unraid/README.md vóór gebruik en wijzig secrets. +# Eén Dockerman-container met intern bewaakte PostgreSQL-, Redis-, web- en Celery-processen. name: vacatureradar -x-app: &app - image: ghcr.io/CHANGE_ME/vacatureradar:latest - user: "10001:10001" - read_only: true - tmpfs: - - /tmp:rw,noexec,nosuid,size=128m - - /var/tmp:rw,noexec,nosuid,size=128m - - /app/.cache:rw,noexec,nosuid,size=128m - env_file: - - /mnt/user/appdata/vacatureradar/config/.env - restart: unless-stopped - security_opt: - - no-new-privileges:true - volumes: - - /mnt/user/appdata/vacatureradar/media:/app/media - - /mnt/user/appdata/vacatureradar/logs:/app/logs - - /mnt/user/appdata/vacatureradar/local:/app/local - networks: [internal] - depends_on: - postgres: {condition: service_healthy} - redis: {condition: service_healthy} - services: - web: - <<: *app - ports: ["1226:8080"] - worker: - <<: *app + app: + build: + context: . + dockerfile: Dockerfile.unraid + image: vacatureradar:unraid + container_name: VacatureRadar + env_file: + - /mnt/user/appdata/vacatureradar/source/.env environment: - VACATURERADAR_RUN_MIGRATIONS: "0" - cap_drop: - - ALL - command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"] - scheduler: - <<: *app - environment: - VACATURERADAR_RUN_MIGRATIONS: "0" - cap_drop: - - ALL - command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"] - postgres: - image: postgres:17.5-bookworm + DATABASE_URL: postgresql://vacatureradar:${POSTGRES_PASSWORD}@127.0.0.1:5432/vacatureradar + POSTGRES_HOST: 127.0.0.1 + REDIS_URL: redis://127.0.0.1:6379/0 + VACATURERADAR_RESTORE_DUMP: /app/local/backups/aio-migration.dump restart: unless-stopped - env_file: [/mnt/user/appdata/vacatureradar/config/.env] - volumes: [/mnt/user/appdata/vacatureradar/postgres:/var/lib/postgresql/data] - networks: [internal] + security_opt: + - no-new-privileges:true + ports: + - "1226:8080" + volumes: + - /mnt/user/appdata/vacatureradar/source/local/media:/app/media + - /mnt/user/appdata/vacatureradar/source/local/logs:/app/logs + - /mnt/user/appdata/vacatureradar/source/local:/app/local + labels: + net.unraid.docker.managed: dockerman + net.unraid.docker.webui: http://[IP]:[PORT:8080]/ + net.unraid.docker.icon: http://[IP]:[PORT:8080]/static/favicon.svg + org.opencontainers.image.title: VacatureRadar + org.opencontainers.image.description: Persoonlijke vacature-intelligence voor de Belgische arbeidsmarkt healthcheck: - test: ["CMD-SHELL", "pg_isready -U vacatureradar -d vacatureradar"] - interval: 10s + test: ["CMD", "python", "scripts/healthcheck.py", "http://127.0.0.1:8080/health/ready/"] + interval: 30s timeout: 5s - retries: 10 - redis: - image: redis:7.4.2-alpine - restart: unless-stopped - command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] - volumes: [/mnt/user/appdata/vacatureradar/redis:/data] - networks: [internal] - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 10 - ollama: - image: ollama/ollama:0.9.6 - profiles: ["ai"] - restart: unless-stopped - volumes: [/mnt/user/appdata/vacatureradar/ollama:/root/.ollama] - networks: [internal] - -networks: - internal: - driver: bridge + retries: 5 + start_period: 60s diff --git a/docs/ai/PROJECT_STATE.md b/docs/ai/PROJECT_STATE.md index b80255c..0ca359c 100644 --- a/docs/ai/PROJECT_STATE.md +++ b/docs/ai/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Projectstatus -- Laatst bijgewerkt: 2026-07-22 +- Laatst bijgewerkt: 2026-07-25 - Repositoryversie: 0.3.0 volledige Stitch Intelligence Cockpit - Uitvoeringsmodus: autonome backlog - Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next` @@ -184,7 +184,8 @@ VR-208-herverificatie op 2026-07-22: ## Deploymentstatus - `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/` met PostgreSQL, Redis, web, worker en scheduler. +- 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 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 self-hosted Gitea Actions-runner is niet actief. Deze sessie gebruikte daarom een directe, commitgebonden SSH-deploy; push-to-deploy vereist nog runnerherstel/registratie. - De huidige uitrol is alleen geschikt voor het beheerde LAN. Publieke productie vereist eerst een gekozen domein, reverse proxy/TLS en `DJANGO_DEBUG=0` met secure cookies. diff --git a/docs/operations/UNRAID_DEPLOYMENT.md b/docs/operations/UNRAID_DEPLOYMENT.md index 56fb3e3..45c9fbd 100644 --- a/docs/operations/UNRAID_DEPLOYMENT.md +++ b/docs/operations/UNRAID_DEPLOYMENT.md @@ -1,6 +1,6 @@ # Unraid-deployment -Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van waarheid. Unraid-installaties verschillen in gebruikte Compose Manager, reverse proxy en shares; behoud de hieronder genoemde volumes, secrets en netwerkgrenzen ook wanneer de UI de services afzonderlijk aanmaakt. +Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van waarheid. De Unraid-variant bundelt web, worker, scheduler, PostgreSQL en Redis bewust in één Dockerman-container met Supervisor als procesbewaker. De gewone `docker-compose.yml` behoudt de gescheiden productieprocessen voor andere hosts. ## Vooraf @@ -10,17 +10,25 @@ Maak deze directories: /mnt/user/appdata/vacatureradar/config /mnt/user/appdata/vacatureradar/media /mnt/user/appdata/vacatureradar/logs -/mnt/user/appdata/vacatureradar/postgres -/mnt/user/appdata/vacatureradar/redis +/mnt/user/appdata/vacatureradar/source/local/postgres-aio17 +/mnt/user/appdata/vacatureradar/source/local/redis-aio /mnt/user/appdata/vacatureradar/ollama # alleen bij lokale AI /mnt/user/backups/vacatureradar ``` Gebruik bij voorkeur een cache-backed `appdata`-share en neem die afzonderlijk in je back-upregime op. De PostgreSQL-datadir mag niet gelijktijdig door file-syncsoftware worden gemuteerd. +## Dockerman + +De container verschijnt als **VacatureRadar** en bevat metadata voor: + +- WebUI: `http://[IP]:1226/`; +- icoon: het lokale VacatureRadar-favicon op `/static/favicon.svg`; +- één herstart- en healthcheckpunt voor alle interne processen. + ## Imagekeuze -De Unraid-compose bevat bewust `ghcr.io/CHANGE_ME/vacatureradar:latest`. Codex kan code, Dockerfile en CI afwerken zonder registrycredentials, maar voor productie moet een van deze paden worden gekozen: +De Unraid-compose bouwt lokaal met `Dockerfile.unraid` en tagt de image als `vacatureradar:unraid`. Pin voor reproduceerbare releases aanvullend een commitgebonden tag. 1. bouw lokaal op de Unraid-host en vervang `image:` door een vaste lokale tag; 2. publiceer vanuit CI naar een private registry en pin op een immutable digest; @@ -61,9 +69,7 @@ SESSION_COOKIE_SECURE=1 CSRF_COOKIE_SECURE=1 SECURE_SSL_REDIRECT=1 \ bash scripts/deploy_docker.sh ``` -3. Start eerst PostgreSQL en Redis. -4. Start web; de entrypoint voert migraties uit. -5. Start worker en scheduler. +3. Start de ene appcontainer; de entrypoint initialiseert PostgreSQL, voert migraties uit en start daarna alle processen onder Supervisor. 6. Controleer `GET /health/live/` en `GET /health/ready/`. 6. Voer eenmalig in de webcontainer uit: @@ -144,10 +150,10 @@ Pin een modelnaam in `OLLAMA_MODEL`; zonder model blijft `OLLAMA_ENABLED=0`. Ste 2. Lees `CHANGELOG.md` en de releaseartefacten. 3. Als de release nog niet geverifieerd is, voer `./scripts/release_verify.sh` lokaal uit. 4. Pull/bouw de nieuwe immutable image. -5. Stop scheduler en worker, daarna web. -6. Start web en laat migraties uitvoeren. +5. Stop de appcontainer. +6. Start de nieuwe appcontainer en laat de entrypoint migraties uitvoeren. 7. Controleer health, login en release-smoke-rapport. -8. Start worker en scheduler. +8. Controleer in de containerlogs dat web, worker en scheduler actief zijn. 9. Bewaar de vorige image/digest totdat één volledige schedulercyclus goed verliep. ## Rollback diff --git a/scripts/unraid_aio_entrypoint.sh b/scripts/unraid_aio_entrypoint.sh new file mode 100644 index 0000000..51ea866 --- /dev/null +++ b/scripts/unraid_aio_entrypoint.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +pg_data=/app/local/postgres-aio17 +redis_data=/app/local/redis-aio +pg_bin="$(dirname "$(readlink -f "$(command -v pg_config)")")" +fresh_database=0 + +mkdir -p "$pg_data" "$redis_data" /app/media /app/logs /tmp/celery +chown -R postgres:postgres "$pg_data" +chown -R redis:redis "$redis_data" +chown -R app:app /app/media /app/logs /tmp/celery + +if [[ ! -s "$pg_data/PG_VERSION" ]]; then + runuser -u postgres -- "$pg_bin/initdb" -D "$pg_data" --auth-local=trust --auth-host=scram-sha-256 + fresh_database=1 +fi + +runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start +db_name="${POSTGRES_DB:-vacatureradar}" +db_user="${POSTGRES_USER:-vacatureradar}" +db_password="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD ontbreekt}" +export DATABASE_URL="postgresql://${db_user}:${db_password}@127.0.0.1:5432/${db_name}" +export POSTGRES_HOST=127.0.0.1 +export REDIS_URL=redis://127.0.0.1:6379/0 + +runuser -u postgres -- psql --set=ON_ERROR_STOP=1 --set=db_name="$db_name" --set=db_user="$db_user" --set=db_password="$db_password" postgres <<'SQL' +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'db_user', :'db_password') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'db_user') \gexec +SELECT format('ALTER ROLE %I PASSWORD %L', :'db_user', :'db_password') \gexec +SELECT format('CREATE DATABASE %I OWNER %I', :'db_name', :'db_user') +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'db_name') \gexec +SQL +if [[ "$fresh_database" == "1" && -n "${VACATURERADAR_RESTORE_DUMP:-}" && -s "${VACATURERADAR_RESTORE_DUMP}" ]]; then + runuser -u postgres -- pg_restore --no-owner --role="$db_user" --dbname="$db_name" "${VACATURERADAR_RESTORE_DUMP}" +fi +runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop + +runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start +runuser -u app -- python manage.py migrate --noinput +if [[ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" == "1" ]]; then + runuser -u app -- python manage.py bootstrap_instance || true +fi +runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop + +exec /usr/bin/supervisord -c /app/deployment/unraid/supervisord.conf diff --git a/scripts/validate_repository.py b/scripts/validate_repository.py index d92355e..92a1765 100644 --- a/scripts/validate_repository.py +++ b/scripts/validate_repository.py @@ -83,7 +83,12 @@ def validate_yaml_contracts() -> None: compose = parse_yaml(relative) if not isinstance(compose, dict) or not isinstance(compose.get("services"), dict): raise ValidationError(f"Composebestand mist services: {relative}") - for service in ("web", "worker", "scheduler", "postgres", "redis"): + required_services = ( + ("app",) + if relative == "docker-compose.unraid.yml" + else ("web", "worker", "scheduler", "postgres", "redis") + ) + for service in required_services: if service not in compose["services"]: raise ValidationError(f"{relative} mist service {service}") diff --git a/tests/unit/test_deployment_configuration.py b/tests/unit/test_deployment_configuration.py index 528fc46..5c7ef60 100644 --- a/tests/unit/test_deployment_configuration.py +++ b/tests/unit/test_deployment_configuration.py @@ -7,15 +7,28 @@ ROOT = Path(__file__).resolve().parents[2] def test_only_web_runs_migrations_in_compose() -> None: - for filename in ("docker-compose.yml", "docker-compose.unraid.yml"): - compose = yaml.safe_load((ROOT / filename).read_text(encoding="utf-8")) - services = compose["services"] + compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text(encoding="utf-8")) + services = compose["services"] - assert ( - services["web"].get("environment", {}).get("VACATURERADAR_RUN_MIGRATIONS", "1") == "1" - ) - assert services["worker"]["environment"]["VACATURERADAR_RUN_MIGRATIONS"] == "0" - assert services["scheduler"]["environment"]["VACATURERADAR_RUN_MIGRATIONS"] == "0" + assert services["web"].get("environment", {}).get("VACATURERADAR_RUN_MIGRATIONS", "1") == "1" + assert services["worker"]["environment"]["VACATURERADAR_RUN_MIGRATIONS"] == "0" + assert services["scheduler"]["environment"]["VACATURERADAR_RUN_MIGRATIONS"] == "0" + + +def test_unraid_uses_one_dockerman_container_with_ui_metadata() -> None: + compose = yaml.safe_load((ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8")) + + assert list(compose["services"]) == ["app"] + labels = compose["services"]["app"]["labels"] + assert labels["net.unraid.docker.managed"] == "dockerman" + assert labels["net.unraid.docker.webui"].endswith(":8080]/") + assert labels["net.unraid.docker.icon"].endswith("/static/favicon.svg") + + dockerfile = (ROOT / "Dockerfile.unraid").read_text(encoding="utf-8") + entrypoint = (ROOT / "scripts" / "unraid_aio_entrypoint.sh").read_text(encoding="utf-8") + assert "python:3.13-slim-trixie" in dockerfile + assert "postgres-aio17" in entrypoint + assert "command -v pg_config" in entrypoint def test_runtime_is_compatible_with_read_only_container() -> None: