diff --git a/docs/kb/modules/DECYZJE-do-podjecia.md b/docs/kb/modules/DECYZJE-do-podjecia.md new file mode 100644 index 0000000..f5eeec5 --- /dev/null +++ b/docs/kb/modules/DECYZJE-do-podjecia.md @@ -0,0 +1,103 @@ +# Decyzje do podjęcia — filar dokumentów (moduły 2/3/4) + +> Zbiorcza lista otwartych decyzji z przygotowania configów (2026-07-06, +> branch `task/paperless-nextcloud-config`). Każda pozycja ma odpowiadający +> komentarz `# TODO DECYZJA OSKARA:` w plikach serwisów. Configi są gotowe do +> review — NIC nie zostało zdeployowane. + +## 1. Host Nextclouda: PIHA czy SOLARIA + +Jedyna duża decyzja architektoniczna. Moduł 4 i kb-02 skłaniają się ku +**SOLARIA** (KB czyta własną kopię z archiwum na PIHA, więc dostępność +Nextclouda nie warunkuje zapytań KB; koszt = sync dogania się po wybudzeniu). +Tabela za/przeciw: `services/nextcloud/README.md`. Compose jest przenośne — +decyzja ustawia tylko `owner_node` w `service.yaml` + `LAN_BIND_IP` +i `TRUSTED_PROXIES` w `.env`. Wstępnie wpisane: SOLARIA. + +## 2. Backup Paperlessa (warunek brzegowy hybrydy — musi być przed produkcyjnym użyciem) + +Propozycja w `services/paperless/README.md`: + +- nocny `document_exporter` (oryginały + archiwa + manifest metadanych, + odtwarzalny bez dumpa SQL) do `/opt/homelab/data/paperless/export`, +- kopia eksportu poza hosta: **rsync/borg → SOLARIA** (2 TB, ta sama LAN); + otwarte: czy dokładać offsite (restic → chmura?), +- retencja (propozycja): 7 dziennych + 4 tygodniowe + 6 miesięcznych. + +Do zatwierdzenia: cel kopii (SOLARIA wystarczy? offsite?), retencja, narzędzie. + +## 3. Porty (potwierdzić na żywym PIHA przed deployem) + +Dobrane wg inwentaryzacji 2026-06-30 (snapshot! zweryfikować `ss -tlnp`): + +| Port | Serwis | Uwagi | +|---|---|---| +| 8210 | paperless web | za npm@PIHA | +| 5434 | paperless postgres | 5433 zajęte przez kb-postgres | +| 6380 | paperless redis (broker dla workera) | 6379 zajęte przez agent-system-redis | +| 8220 | nextcloud web | wolny i na PIHA, i na SOLARII | + +Wszystkie bindowane na LAN IP (nie 0.0.0.0); ruch worker↔broker/DB/NFS po +LAN 192.168.31.x, nie Tailscale. + +## 4. Redis brokera bez auth na bindzie LAN + +Broker (6380) i Postgres (5434) są wystawione na LAN IP PIHA dla workera na +SOLARII. Postgres ma auth (scram); **Redis defaultowo nie ma**. Zaufany LAN +domowy — akceptować, czy dołożyć `requirepass` (wtedy `PAPERLESS_REDIS` +z hasłem wędruje do `.env` po obu stronach)? + +## 5. Fallback-worker na PIHA a indeks Whoosh po NFS + +Stockowy obraz na PIHA zawsze ma wbudowany worker (concurrency 1) — to +naturalny fallback „domiela wolno". Ryzyko: gdy SOLARIA pracuje, **dwa hosty +piszą do plikowego indeksu Whoosh** (PIHA lokalnie, SOLARIA po NFS) — znane +upstreamowe objawy wyścigu („This writer is closed", korupcja indeksu). +Indeks jest odtwarzalny (`document_index reindex`), oryginałom nic nie grozi. +Decyzja: zaakceptować i obserwować (rekomendacja), czy od razu wymusić +tryb „kolejka-czeka" (batch-OCR tylko w oknach pracy SOLARII)? +Szczegóły: `services/paperless-worker/README.md`. + +## 6. Domeny + wpisy DNS + vhosty npm + +Założone w configach: `paper.kapala.org` (Paperless), `cloud.kapala.org` +(Nextcloud) — moduły dopuszczały też `*.okit.pl`. Potwierdzić, potem: +A-recordy (DNS Only) → PIHA + vhosty w npm@PIHA + rejestracja OAuth2 apps +w Forgejo (redirect URIs w README serwisów zależą od domeny). + +## 7. Wyłączenie lokalnego loginu w Paperless po weryfikacji OIDC + +Bootstrap idzie przez lokalnego admina (`PAPERLESS_ADMIN_USER`). Po +potwierdzeniu działania Forgejo-OIDC: ustawić +`PAPERLESS_DISABLE_REGULAR_LOGIN=true` + `PAPERLESS_REDIRECT_LOGIN_TO_SSO=true`? +(Vikunja ma dziś oba tryby równolegle.) + +## 8. Pin wersji Nextclouda + +Compose ma ruchomy `nextcloud:stable-apache`; przy deployu przypiąć aktualny +major (np. `:31-apache` — sprawdzić bieżący stable w dniu deployu). Nextcloud +nie wspiera skoków o >1 wersję major, więc ruchomy tag na produkcji = ryzyko +niekontrolowanego skoku. + +## 9. Sizing workera OCR pod batch 70k załączników + +`WORKER_CONCURRENCY=4` × `PAPERLESS_THREADS_PER_WORKER=4` = ~16 wątków na +24 rdzeniach SOLARII. Przed batchem 70k (moduł 5): zmierzyć na próbce i +zdecydować, czy podbić concurrency, czy zostawić zapas na ollama/AI. + +--- + +## Rozstrzygnięte w tym przygotowaniu (dla porządku) + +- **Split-host OCR-worker przez NFS: WYKONALNY** — wzorzec potwierdzony przez + maintainerów paperless-ngx (nieoficjalnie wspierany): ten sam obraz, + `command: celery --app paperless worker`, wspólny Redis+Postgres+storage, + identyczne ścieżki kontenerowe i numeryczny UID po obu stronach. Pełny + wynik badania + ryzyka: `services/paperless-worker/README.md`. +- Storage dokumentów na PIHA; NFS export → SOLARIA po LAN + (192.168.31.5 → 192.168.31.70), nie Tailscale. +- AOF w Redis brokera (kolejka przeżywa restart — zero utraty zadań). +- OIDC: Paperless przez `django-allauth openid_connect` (env), + Nextcloud przez appkę `user_oidc` (kroki `occ` w README). +- Limity RAM na PIHA: `hosts/piha/runtime/paperless/docker-compose.override.yml` + (stack ≤ ~1.9 Gi worst-case). diff --git a/hosts/piha/runtime/paperless/docker-compose.override.yml b/hosts/piha/runtime/paperless/docker-compose.override.yml new file mode 100644 index 0000000..0e3dac3 --- /dev/null +++ b/hosts/piha/runtime/paperless/docker-compose.override.yml @@ -0,0 +1,41 @@ +# PIHA-specific overrides for paperless (KB module 2). +# +# RESOURCE CONTEXT: PIHA is the RAM-bound 8 GB box shared with Home Assistant +# and monitoring. Precondition for this service is KB module 0 (piha-slim); +# after it ~3.8 Gi should be available. Ceilings below keep the whole stack +# ≤ ~1.9 Gi worst-case so paperless can never starve HA — the cgroup OOM +# killer restarts the offending container instead of the host killer picking +# a victim. +services: + paperless: + # Idle expectation ~400–600 Mi (gunicorn + consumer + beat + 1 celery + # worker). The ceiling leaves room for the concurrency-1 FALLBACK OCR of + # a large PDF when SOLARIA is asleep; a pathological document gets the + # container restarted rather than the host swapping. + mem_limit: 1400m + mem_reservation: 512m + + db: + # Metadata-only DB (documents live on disk, not in Postgres). Same tuning + # philosophy as kb-postgres but smaller: bounded backends because the + # SOLARIA worker also holds connections (concurrency 4 + webserver pool). + mem_limit: 384m + command: + - "postgres" + - "-c" + - "shared_buffers=96MB" + - "-c" + - "effective_cache_size=256MB" + - "-c" + - "work_mem=4MB" + - "-c" + - "maintenance_work_mem=32MB" + - "-c" + - "max_connections=50" + + broker: + # Task payloads are tiny (paths + ids): even the 70k-attachment backlog + # is ~tens of MB in-queue. NO maxmemory/eviction — evicting broker keys + # would silently drop queued OCR tasks; if the limit is ever hit the + # container restarts and recovers the queue from AOF instead. + mem_limit: 256m diff --git a/services/nextcloud/README.md b/services/nextcloud/README.md new file mode 100644 index 0000000..2687b88 --- /dev/null +++ b/services/nextcloud/README.md @@ -0,0 +1,101 @@ +# Nextcloud (drive / WebDAV) + +Drugi adapter dokumentów filaru KB #2 (moduł 4, `docs/kb/modules/04-nextcloud.md`): +zamiennik Google Drive — dowolne pliki + sync telefon/desktop, źródło dla +ingestu KB (moduł 5) przez WebDAV. + +**Nextcloud = archiwum KOPIA w hybrydzie kb-02** — ingest robi snapshot pliku +do archiwum KB; Nextcloud NIE jest źródłem prawdy (inaczej niż Paperless). +Backup „warto" (dane użytkownika), ale nie jest warunkiem brzegowym KB. + +## TODO DECYZJA OSKARA: host (PIHA vs SOLARIA) + +Otwarte w kb-02 i module 4. Compose jest przenośne (ścieżki po konwencji +`/opt/homelab/data`, bind IP i proxy w `.env`) — decyzja wybiera node +i dwie wartości w `.env`. + +| | PIHA | SOLARIA | +|---|---|---| +| Dostępność | 24/7 (sync zawsze działa) | sesyjna — sync dogania się po wybudzeniu | +| RAM/CPU | ciasno nawet po module 0 (Nextcloud+PHP ≈ 0.5–1 Gi+) | 62 Gi RAM, 24 rdzenie — bez znaczenia | +| Storage | NVMe 477 G (dzielone z resztą) | NVMe 2 T | +| Ingress | npm lokalnie | npm@PIHA proxuje po LAN do 192.168.31.70:8220 (npm proxuje na dowolny IP — bez przeszkód) | +| Wpływ na KB | żaden — KB czyta własną kopię z archiwum na PIHA w obu wariantach | jw. | + +Skłonność modułu 4: **SOLARIA** (dlatego `service.yaml` ma wstępnie +`owner_node: solaria`, a `env.example` IP SOLARII). Koszt: przerwy w sync, +gdy host śpi — do zaakceptowania, sync się dogoni. + +## Stack + +| Kontener | Obraz | Rola | +|------------------|-------------------------|---------------------------------------| +| `nextcloud` | `nextcloud:stable-apache` | app + WebDAV (port 80 → host 8220 na LAN_BIND_IP) | +| `nextcloud-cron` | `nextcloud:stable-apache` | joby w tle (`/cron.sh`, ten sam wolumen) | +| `nextcloud-db` | `postgres:16-alpine` | baza (bez portu na hoście) | +| `nextcloud-redis`| `redis:7-alpine` | cache + file locking (bez portu, bez persystencji) | + +TODO DECYZJA OSKARA: przy deployu przypiąć konkretną wersję major +(np. `nextcloud:31-apache`) zamiast ruchomego `stable` — Nextcloud nie +wspiera skoków o więcej niż jedną wersję major przy upgrade. + +## OIDC przez Forgejo (krok po-deployowy, occ) + +Mechanizm: oficjalna appka **`user_oidc`** (utrzymywana przez Nextcloud GmbH — +wybieramy ją zamiast community `sociallogin`). Konfiguruje się ją przez `occ` +po pierwszym starcie — NIE przez env, stąd kroki w checkliście: + +```bash +# w kontenerze nextcloud, jako www-data: +docker exec -u www-data nextcloud php occ app:install user_oidc +docker exec -u www-data nextcloud php occ user_oidc:provider forgejo \ + --clientid="" \ + --clientsecret="" \ + --discoveryuri="https://forgejo.kapala.org/.well-known/openid-configuration" \ + --scope="openid profile email" \ + --unique-uid=0 \ + --mapping-display-name=name --mapping-email=email --mapping-uid=preferred_username +``` + +Rejestracja w Forgejo (Settings → Applications, wzorzec jak Vikunja): + +- Redirect URI: `https://cloud.kapala.org/apps/user_oidc/code` +- Confidential client; scope `openid profile email` + +`forgejo.kapala.org` jest przypięte w compose przez `extra_hosts` do +`192.168.31.5` (npm@PIHA) — OIDC discovery po LAN, lekcja z Vikunji. +`--unique-uid=0` + mapping `preferred_username` daje czytelne loginy +(np. `oskar`) zamiast hashowanych ID — istotne dla WebDAV-owych URL-i. + +## WebDAV dla ingestu (moduł 5) + +Endpoint: `https://cloud.kapala.org/remote.php/dav/files//`. +Konta OIDC nie mają hasła — dla ingestu wygenerować **app password** +(Settings → Security → Devices & sessions) i trzymać je w sekretach +adaptera ingest, nie w tym repo. + +## Storage i backup + +- `/opt/homelab/data/nextcloud/html` — aplikacja + config + **pliki + użytkowników** (`html/data/`) +- `/opt/homelab/data/nextcloud/db` — Postgres + +TODO DECYZJA OSKARA: backup user-data (mniej krytyczny niż Paperless, bo +KB trzyma kopie zaingestowanych plików): propozycja — rsync/borg +`html/data` + `pg_dump` w tej samej nocnej pętli co backup Paperlessa, +retencja krótsza (np. 7 dziennych + 4 tygodniowe). + +## Cutover checklist (przy deployu — NIE teraz) + +1. Decyzja hosta ↑ podjęta; `LAN_BIND_IP`/`TRUSTED_PROXIES` w `.env` pod nią. +2. Port wolny na żywym hoście: `ss -tlnp | grep 8220`. +3. `mkdir -p /opt/homelab/data/nextcloud/{html,db}` na wybranym node. +4. `.env` z `env.example`. +5. DNS `cloud.kapala.org` → PIHA (DNS Only) + vhost w npm@PIHA (HTTPS → + `:8220`; jeśli host=SOLARIA, target = 192.168.31.70). +6. `docker compose up -d`; pierwszy start instaluje NC (2–3 min), + potem `./healthcheck.sh`. +7. Kroki `occ` dla `user_oidc` (wyżej) + rejestracja appki w Forgejo + + testowy login OIDC. +8. Test sync klientem (telefon) + test WebDAV (`curl -u user:app-password`). +9. Wpis w `hosts//services.yaml` + topology (dopiero przy deployu). diff --git a/services/nextcloud/docker-compose.yml b/services/nextcloud/docker-compose.yml new file mode 100644 index 0000000..da91669 --- /dev/null +++ b/services/nextcloud/docker-compose.yml @@ -0,0 +1,123 @@ +# Nextcloud (self-hosted Drive, WebDAV) — KB module 4. +# +# Second document source for KB pillar #2: arbitrary files + phone/desktop +# sync. KB ingest (module 5) SNAPSHOTS files into the KB archive (Nextcloud = +# KOPIA in the kb-02 hybrid) — Nextcloud is NOT a source of truth, which +# relaxes its backup and availability requirements vs paperless. +# +# TODO DECYZJA OSKARA: host — PIHA (always-on, ale ciasno) czy SOLARIA +# (mocna, sesyjna; sync dogania sie po wybudzeniu)? Modul 4 sklania sie ku +# SOLARIA. Compose jest przenosne: wszystkie sciezki po konwencji +# /opt/homelab/data, bind IP i proxy z .env — decyzja wybiera tylko node +# i wartosci w .env. +# +# EXPOSURE: LAN/Tailscale only przez npm@PIHA (cloud.kapala.org), zero public. +# Jesli host=SOLARIA, npm@PIHA proxuje po LAN do 192.168.31.70:8220. +services: + nextcloud: + # Multi-arch (arm64 + amd64) — runs on either candidate host. + # TODO DECYZJA OSKARA: przy deployu przypiac konkretna wersje major + # (np. nextcloud:31-apache) zamiast ruchomego "stable" — Nextcloud NIE + # wspiera przeskakiwania wersji major przy upgrade. + image: nextcloud:stable-apache + container_name: nextcloud + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + # OIDC discovery against Forgejo over the LAN (same lesson as vikunja). + extra_hosts: + - "forgejo.kapala.org:192.168.31.5" + env_file: + - .env + environment: + - POSTGRES_HOST=db + - POSTGRES_DB=nextcloud + - POSTGRES_USER=nextcloud + # Redis: PHP session locking + file locking cache. + - REDIS_HOST=redis + # TODO DECYZJA OSKARA: potwierdzic domene cloud.kapala.org + DNS + vhost npm. + - NEXTCLOUD_TRUSTED_DOMAINS=cloud.kapala.org + # Behind npm@PIHA (TLS terminated there); without these Nextcloud + # generates http:// links and login loops. + - OVERWRITEPROTOCOL=https + - OVERWRITEHOST=cloud.kapala.org + - OVERWRITECLIURL=https://cloud.kapala.org + # npm@PIHA as seen by this container: LAN IP of PIHA when Nextcloud + # runs on SOLARIA; docker bridge subnet when it runs on PIHA itself — + # hence configurable via .env. + - TRUSTED_PROXIES=${TRUSTED_PROXIES} + - PHP_MEMORY_LIMIT=512M + - PHP_UPLOAD_LIMIT=4G + - TZ=Europe/Warsaw + volumes: + # Whole app dir (code + config + user data in html/data). Runtime path + # convention: /opt/homelab/data// on the chosen node's NVMe. + - /opt/homelab/data/nextcloud/html:/var/www/html + ports: + # Bind to the node's LAN IP only, never 0.0.0.0 — npm@PIHA is the sole + # entry point (LAN/Tailscale). Port 8220 free on PIHA per the 2026-06-30 + # inventory; free on SOLARIA (nearly empty host). + # TODO DECYZJA OSKARA: potwierdzic port na zywym hoscie: ss -tlnp | grep 8220 + - "${LAN_BIND_IP}:8220:80" + healthcheck: + test: ["CMD", "curl", "-fs", "--max-time", "5", "http://localhost:80/status.php"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s + + # Background jobs (file scans, trash/versions cleanup, app jobs) — the + # official image's dedicated cron entrypoint on the same code/data volume. + cron: + image: nextcloud:stable-apache + container_name: nextcloud-cron + restart: unless-stopped + entrypoint: /cron.sh + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - .env + environment: + - POSTGRES_HOST=db + - POSTGRES_DB=nextcloud + - POSTGRES_USER=nextcloud + - REDIS_HOST=redis + - TZ=Europe/Warsaw + volumes: + - /opt/homelab/data/nextcloud/html:/var/www/html + + db: + image: postgres:16-alpine + container_name: nextcloud-db + restart: unless-stopped + env_file: + - .env + environment: + - POSTGRES_DB=nextcloud + - POSTGRES_USER=nextcloud + - TZ=Europe/Warsaw + volumes: + - /opt/homelab/data/nextcloud/db:/var/lib/postgresql/data + # No published port — only this stack talks to it. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: nextcloud-redis + restart: unless-stopped + # Pure cache/locking — no persistence needed, no published port. + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/services/nextcloud/env.example b/services/nextcloud/env.example new file mode 100644 index 0000000..a7152fc --- /dev/null +++ b/services/nextcloud/env.example @@ -0,0 +1,21 @@ +# Nextcloud secrets + host-local binds — copy to .env (gitignored) next to +# docker-compose.yml and fill in real values. Never commit .env. + +# LAN IP of the node Nextcloud lands on. The web port (8220) binds ONLY to +# this interface — never 0.0.0.0. +# TODO DECYZJA OSKARA: host! SOLARIA -> 192.168.31.70, PIHA -> 192.168.31.5. +LAN_BIND_IP=192.168.31.70 + +# Reverse proxy (npm@PIHA) as seen from THIS node's containers: +# - Nextcloud on SOLARIA: npm connects over the LAN -> PIHA's LAN IP. +# - Nextcloud on PIHA: npm is a local container -> use the docker bridge +# subnet instead (e.g. 172.16.0.0/12), verify with docker network inspect. +TRUSTED_PROXIES=192.168.31.5 + +# Postgres password for the nextcloud DB (app reads the same var). +POSTGRES_PASSWORD=change-me-very-strong + +# Bootstrap admin — auto-provisioned on FIRST start only. Used to install and +# configure the user_oidc app (see README), day-to-day login is Forgejo OIDC. +NEXTCLOUD_ADMIN_USER=oskar +NEXTCLOUD_ADMIN_PASSWORD=change-me-bootstrap-only diff --git a/services/nextcloud/healthcheck.sh b/services/nextcloud/healthcheck.sh new file mode 100755 index 0000000..511cb0c --- /dev/null +++ b/services/nextcloud/healthcheck.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Healthcheck for Nextcloud (app + cron + db + redis) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Port is bound to the LAN interface only, so localhost won't answer. +if [ -f "$SCRIPT_DIR/.env" ]; then + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/.env" +fi +BIND_IP="${LAN_BIND_IP:-127.0.0.1}" + +for c in nextcloud nextcloud-cron nextcloud-db nextcloud-redis; do + if ! docker ps --filter "name=$c" --filter "status=running" | grep -qw "$c"; then + echo "[FAIL] $c container is not running" + exit 1 + fi +done + +# status.php must report installed:true (also proves DB connectivity) +if ! curl -sf --max-time 10 "http://${BIND_IP}:8220/status.php" | grep -q '"installed":true'; then + echo "[FAIL] Nextcloud status.php not healthy on ${BIND_IP}:8220" + exit 1 +fi + +echo "[OK] nextcloud is healthy" +exit 0 diff --git a/services/nextcloud/service.yaml b/services/nextcloud/service.yaml new file mode 100644 index 0000000..7a6c544 --- /dev/null +++ b/services/nextcloud/service.yaml @@ -0,0 +1,38 @@ +service: + name: nextcloud + # TODO DECYZJA OSKARA: owner_node — piha (always-on, ciasno) vs solaria + # (mocna, sesyjna). Modul 4 / kb-02 sklaniaja sie ku SOLARIA: KB-kopia + # dokumentow i tak lezy w archiwum na PIHA, wiec zapytania KB dzialaja bez + # Nextclouda; sync z telefonu dogania sie po wybudzeniu hosta. + owner_node: solaria + role: file-sync-drive # Drive replacement + WebDAV source for KB ingest (archiwum = KOPIA) + exposure: private # LAN/Tailscale only via npm@PIHA (cloud.kapala.org); NO public ingress + dependencies: + - forgejo # OIDC identity provider (forgejo.kapala.org), app user_oidc + ports: + - container: 80 + host: 8220 # LAN_BIND_IP only, never 0.0.0.0 + protocol: tcp + healthcheck: + type: http + endpoint: http://localhost:8220/status.php # use the node's LAN bind IP — localhost does not answer + interval: 30s + timeout: 10s + retries: 5 + restart_policy: unless-stopped + persistence: + # Nextcloud = KOPIA in the kb-02 hybrid: KB snapshots files at ingest, so + # this data is NOT the KB source of truth. Backup is "warto" (user data), + # not a KB boundary condition like paperless. + paths: + - /opt/homelab/data/nextcloud/html # app + config + user files (html/data/) + - /opt/homelab/data/nextcloud/db # postgres data + runtime: + config_files: + - .env # secrets (gitignored, from env.example) + env_vars: + - LAN_BIND_IP # required — LAN IP of the CHOSEN host node + - TRUSTED_PROXIES # npm@PIHA as seen from this node + - POSTGRES_PASSWORD + - NEXTCLOUD_ADMIN_USER # bootstrap admin (before OIDC is wired) + - NEXTCLOUD_ADMIN_PASSWORD diff --git a/services/paperless-worker/README.md b/services/paperless-worker/README.md new file mode 100644 index 0000000..253c8b9 --- /dev/null +++ b/services/paperless-worker/README.md @@ -0,0 +1,100 @@ +# Paperless OCR worker (SOLARIA) + +Ciężki OCR filaru KB #2 (moduł 3, `docs/kb/modules/03-paperless-ocr-worker.md`). +Ten sam obraz co `services/paperless/`, ale z nadpisanym poleceniem — działa +**wyłącznie** jako celery worker podpięty do brokera/bazy paperless@PIHA, +ze storage widzianym przez NFS z PIHA. Realizuje wzorzec kb-02 +„serwis always-on + compute-worker on-demand z fallback". + +## Wynik badania: czy split-host worker jest wykonalny? + +**TAK — z zastrzeżeniami.** Ustalenia (GH discussion #3900 + docs): + +1. **Nie jest to oficjalnie wspierane** przez Paperless-ngx, ale maintainerzy + potwierdzają wykonalność i podają dokładnie ten wzorzec: drugi host, ten sam + obraz, `command: celery --app paperless worker --loglevel INFO`, dostęp + sieciowy do Redis + Postgres + współdzielonych plików. +2. Worker potrzebuje **pełnego kontekstu aplikacji**: broker (Redis), baza + (Postgres) i **te same pliki pod tymi samymi ścieżkami kontenerowymi** + (`/usr/src/paperless/{data,media,consume}`) — payloady zadań i DB niosą + ścieżki absolutne. +3. **Podział pracy**: consumer (inotify na `consume/`), beat (harmonogram) + i webserver zostają na PIHA; na SOLARIA idzie wyłącznie egzekucja zadań + celery (OCR/parsowanie/archiwizacja). Consumer na PIHA działa na lokalnym + dysku, więc inotify działa normalnie (ograniczenie „inotify nie działa po + NFS" nas nie dotyka — po NFS czyta tylko worker, nie consumer). +4. **Fallback**: obu-stronny worker na tej samej kolejce. Gdy SOLARIA śpi — + zadania czekają w Redis (AOF na PIHA) i wbudowany worker PIHA + (concurrency 1) mieli powoli. Gdy SOLARIA wstaje — worker z concurrency 4+ + rozładowuje kolejkę. Gdy worker zniknie w połowie zadania, celery na + transporcie redis odda nieodebrane zadanie po visibility timeout + (domyślnie 1 h) — zadanie nie ginie, najwyżej się opóźnia. +5. **GPU nie gra roli** — stockowy OCR (ocrmypdf/tesseract) jest CPU-only. + Zysk SOLARII to 24 rdzenie i 62 Gi RAM, nie CUDA. + +### Znane ryzyka (świadomie zaakceptowane / do obserwacji) + +- **Indeks Whoosh w `data/` przy dwóch piszących hostach.** Po konsumpcji + dokumentu worker dopisuje go do indeksu wyszukiwania (plikowy Whoosh + w `data/index`). Przy workerze na PIHA (lokalnie) i na SOLARIA (po NFS) + równolegle istnieje ryzyko wyścigu na lockach plikowych (znane objawy + w upstream: „This writer is closed", skorumpowany TOC indeksu). Mitygacja: + indeks to dane **pochodne** — `docker exec paperless document_index reindex` + odtwarza go w całości; oryginały i metadane nie są zagrożone. Jeśli + korupcje będą się powtarzać, opcje: przenieść cały batch-OCR w okna, gdy + działa tylko jeden worker, albo zrezygnować z fallbacku PIHA (kolejka + po prostu czeka na SOLARIĘ). +- **Postgres `max_connections`**: każdy proces workera trzyma połączenie. + Przy defaultowym 100 i concurrency 4+1 zapasu jest dużo; podbijając + `WORKER_CONCURRENCY` pilnować sumy połączeń (formuła w docs upstream). +- **Wersje obrazów muszą być identyczne** (PIHA i SOLARIA) — wspólny schemat + DB i sygnatury zadań. Podbijać oba compose naraz. + +## NFS: export na PIHA, mount na SOLARIA + +Transfer idzie po **LAN** (PIHA `192.168.31.5` ↔ SOLARIA `192.168.31.70`, +1 Gb/s, ten sam switch) — NIE po Tailscale. Przepustowość nie jest wąskim +gardłem OCR. + +### Host-side na PIHA (NIE w compose — krok przy deployu modułu 3) + +``` +# /etc/exports na PIHA — export TYLKO dla SOLARII: +/opt/homelab/data/paperless 192.168.31.70(rw,sync,no_subtree_check,no_root_squash) +``` + +```bash +sudo apt install nfs-kernel-server # jesli brak +sudo exportfs -ra +``` + +`no_root_squash` jest potrzebne, bo entrypoint obrazu (root) robi `chown` na +katalogach przy starcie kontenera na SOLARII; export jest ograniczony do +jednego IP w zaufanym LAN. + +### Strona SOLARII + +Mounty definiuje compose jako named volumes z driverem NFS — **zero wpisów +w /etc/fstab**; jedyny host-side wymóg to pakiet klienta: + +```bash +sudo apt install nfs-common +``` + +### UID mapping (krytyczne) + +Pliki na exporcie mają numerycznego właściciela — NFS nie tłumaczy nazw. +Dlatego `USERMAP_UID/GID=1000` jest ustawione **w obu** compose (PIHA +i SOLARIA); zmiana po jednej stronie = worker traci dostęp do plików. +Weryfikacja po deployu: `./healthcheck.sh` robi test zapisu na mount. + +## Cutover checklist (przy deployu modułu 3 — po działającym module 2) + +1. `services/paperless/` działa na PIHA (healthcheck zielony). +2. Export NFS na PIHA (wyżej) + `showmount -e 192.168.31.5` z SOLARII. +3. `nfs-common` na SOLARII. +4. `.env` z `env.example` — sekrety SKOPIOWANE z PIHA, nie nowe. +5. `docker compose up -d` + `./healthcheck.sh`. +6. Test: wrzucić PDF do consume na PIHA → obciążenie CPU na SOLARII, nie PIHA. +7. Test fallbacku: stop workera → zadanie czeka/mieli PIHA → start → drenaż. +8. Wpis w `hosts/solaria/services.yaml` + topology (dopiero przy deployu). diff --git a/services/paperless-worker/docker-compose.yml b/services/paperless-worker/docker-compose.yml new file mode 100644 index 0000000..0c73fb9 --- /dev/null +++ b/services/paperless-worker/docker-compose.yml @@ -0,0 +1,90 @@ +# Paperless OCR worker (SOLARIA) — KB module 3. +# +# Same image as services/paperless/ but the command is overridden to run ONLY +# a celery worker (no webserver, no consumer, no beat). It attaches to the +# broker/DB of paperless@PIHA and sees the document storage via NFS from PIHA. +# This split is NOT officially supported by paperless-ngx but is the +# maintainer-confirmed pattern (GH discussion #3900); constraints and risks +# are documented in README.md. +# +# When SOLARIA is powered down this worker simply disappears — queued tasks +# wait in Redis@PIHA (AOF-persisted) and PIHA's built-in concurrency-1 worker +# grinds slowly. When SOLARIA returns, this worker drains the queue. +services: + paperless-worker: + # MUST be the exact same tag as services/paperless/docker-compose.yml — + # shared DB schema + celery task signatures. Bump both together. + image: ghcr.io/paperless-ngx/paperless-ngx:2.14 + container_name: paperless-worker + restart: unless-stopped + # Worker-only mode: the image entrypoint handles USERMAP + language + # install, then execs this instead of the full s6 service tree. + # OCR (ocrmypdf/tesseract) is CPU-bound — the win here is SOLARIA's + # 24 cores, not the GPU (stock paperless OCR does not use CUDA). + command: celery --app paperless worker --loglevel INFO --concurrency ${WORKER_CONCURRENCY:-4} + env_file: + - .env + environment: + # Broker + DB on PIHA over the fast LAN (NOT Tailscale) — same L2 as + # the NFS mounts, transfer is not the bottleneck. + - PAPERLESS_REDIS=redis://192.168.31.5:6380 + - PAPERLESS_DBHOST=192.168.31.5 + - PAPERLESS_DBPORT=5434 + - PAPERLESS_DBNAME=paperless + - PAPERLESS_DBUSER=paperless + # Settings below MUST mirror services/paperless — drift between the two + # stacks changes how documents get named/OCR-ed depending on which host + # picked the task. + - PAPERLESS_URL=https://paper.kapala.org + - PAPERLESS_TIME_ZONE=Europe/Warsaw + - PAPERLESS_OCR_LANGUAGES=pol + - PAPERLESS_OCR_LANGUAGE=pol+eng + # Threads used INSIDE a single OCR task; total load ~ concurrency × + # threads. 4×4=16 threads leaves headroom on 24 cores/32 threads. + # TODO DECYZJA OSKARA: sizing pod batch 70k zalacznikow (moduł 5) — + # podbic po probce (np. concurrency 8) czy zostawic zapas na ollama/AI? + - PAPERLESS_THREADS_PER_WORKER=4 + # MUST match paperless@PIHA — files on the NFS export carry numeric + # UID/GID; a mismatch means this worker cannot read/write documents. + - USERMAP_UID=1000 + - USERMAP_GID=1000 + volumes: + # NFS volumes from PIHA (defined below). Container paths MUST be + # identical to paperless@PIHA — task payloads and the DB carry absolute + # /usr/src/paperless/... paths. + - paperless_data:/usr/src/paperless/data + - paperless_media:/usr/src/paperless/media + - paperless_consume:/usr/src/paperless/consume + healthcheck: + # Pings THIS worker through the broker — proves broker connectivity and + # a live celery process in one shot. + test: ["CMD", "celery", "--app", "paperless", "inspect", "ping", "--timeout", "10"] + interval: 60s + timeout: 15s + retries: 3 + start_period: 60s + +# Docker-managed NFS mounts (no /etc/fstab entry needed on SOLARIA). Docker +# mounts these lazily on container start; if PIHA is unreachable the container +# fails to start and docker retries via restart policy — acceptable, the queue +# waits on PIHA anyway. The HOST-side prerequisite is on PIHA: the /etc/exports +# entry (see README.md, "NFS export"). +volumes: + paperless_data: + driver: local + driver_opts: + type: nfs + o: addr=192.168.31.5,rw,nfsvers=4.1,hard,timeo=150 + device: ":/opt/homelab/data/paperless/data" + paperless_media: + driver: local + driver_opts: + type: nfs + o: addr=192.168.31.5,rw,nfsvers=4.1,hard,timeo=150 + device: ":/opt/homelab/data/paperless/media" + paperless_consume: + driver: local + driver_opts: + type: nfs + o: addr=192.168.31.5,rw,nfsvers=4.1,hard,timeo=150 + device: ":/opt/homelab/data/paperless/consume" diff --git a/services/paperless-worker/env.example b/services/paperless-worker/env.example new file mode 100644 index 0000000..2e51161 --- /dev/null +++ b/services/paperless-worker/env.example @@ -0,0 +1,16 @@ +# paperless-worker secrets — copy to .env (gitignored) next to +# docker-compose.yml on SOLARIA. Values are SHARED with paperless@PIHA: +# copy them from /opt/homelab/config or services/paperless/.env on PIHA, +# do NOT generate new ones. + +# MUST be identical to PAPERLESS_SECRET_KEY in services/paperless/.env @ PIHA. +PAPERLESS_SECRET_KEY=change-me-same-as-piha + +# MUST be identical to PAPERLESS_DBPASS in services/paperless/.env @ PIHA. +PAPERLESS_DBPASS=change-me-same-as-piha + +# Parallel OCR tasks on SOLARIA (celery --concurrency). Total CPU load is +# roughly WORKER_CONCURRENCY x PAPERLESS_THREADS_PER_WORKER (4x4=16 threads +# default, on 24 cores / 32 threads). +# TODO DECYZJA OSKARA: sizing pod batch 70k zalacznikow — zmierzyc na probce. +WORKER_CONCURRENCY=4 diff --git a/services/paperless-worker/healthcheck.sh b/services/paperless-worker/healthcheck.sh new file mode 100755 index 0000000..4fe08fa --- /dev/null +++ b/services/paperless-worker/healthcheck.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Healthcheck for paperless-worker (OCR worker on SOLARIA -> broker @ PIHA) +# +# NOTE: SOLARIA is session-availability. This script reports the worker's +# health while the host is up; the worker being absent because SOLARIA is +# off is a normal state handled by the queue on PIHA, not a failure here. + +# Container must be running +if ! docker ps --filter "name=paperless-worker" --filter "status=running" | grep -qw "paperless-worker"; then + echo "[FAIL] paperless-worker container is not running" + exit 1 +fi + +# Worker must answer a celery ping via the broker on PIHA — proves both +# broker connectivity (192.168.31.5:6380) and a live worker process. +if ! docker exec paperless-worker celery --app paperless inspect ping --timeout 10 2>/dev/null | grep -q "pong"; then + echo "[FAIL] celery worker does not answer ping (broker @ 192.168.31.5:6380 unreachable or worker dead)" + exit 1 +fi + +# NFS storage from PIHA must be readable AND writable (UID mapping check) +if ! docker exec paperless-worker sh -c 'touch /usr/src/paperless/media/.nfs-write-test && rm /usr/src/paperless/media/.nfs-write-test' 2>/dev/null; then + echo "[FAIL] NFS media mount from PIHA is not writable (mount down or UID mismatch)" + exit 1 +fi + +echo "[OK] paperless-worker is healthy" +exit 0 diff --git a/services/paperless-worker/service.yaml b/services/paperless-worker/service.yaml new file mode 100644 index 0000000..6adbdf0 --- /dev/null +++ b/services/paperless-worker/service.yaml @@ -0,0 +1,32 @@ +service: + name: paperless-worker + owner_node: solaria + role: ocr-compute-worker # heavy OCR for paperless@PIHA; "compute on-demand z fallback" pattern + exposure: local-only # no listening ports at all — outbound-only celery worker + dependencies: + - paperless # broker (redis 192.168.31.5:6380), DB (:5434) and NFS storage @ PIHA + ports: [] # none — connects out, serves nothing + healthcheck: + type: command + # celery ping through the broker: proves both broker reachability and a + # live worker process. Runs inside the container. + endpoint: docker exec paperless-worker celery --app paperless inspect ping --timeout 10 + interval: 60s + timeout: 15s + retries: 3 + restart_policy: unless-stopped + # SOLARIA is a session-availability node: this worker being down is a NORMAL + # state, not an incident. Queued OCR tasks wait in Redis@PIHA (AOF) and the + # built-in concurrency-1 worker on PIHA grinds slowly meanwhile. + persistence: + # Nothing to back up here — all document state lives on PIHA (owner: + # services/paperless). The NFS volumes below are mounts of PIHA data, + # backed up on the PIHA side. + paths: [] + runtime: + config_files: + - .env # secrets shared with paperless@PIHA (gitignored, from env.example) + env_vars: + - PAPERLESS_SECRET_KEY # MUST equal the value on PIHA + - PAPERLESS_DBPASS # MUST equal the value on PIHA + - WORKER_CONCURRENCY # optional, default 4 — parallel OCR tasks diff --git a/services/paperless/README.md b/services/paperless/README.md new file mode 100644 index 0000000..5216cc8 --- /dev/null +++ b/services/paperless/README.md @@ -0,0 +1,112 @@ +# Paperless-ngx (serwis, PIHA) + +Serwis dokumentów filaru KB #2 (moduł 2, `docs/kb/modules/02-paperless-service.md`): +UI + API + Postgres + Redis. Always-on na PIHA. Ciężki OCR wykonuje osobny worker +na SOLARIA (`services/paperless-worker/`, moduł 3) — tu zostaje tylko wolny +fallback (1 worker × 1 wątek). + +**Paperless = archiwum REFERENCJA w hybrydzie kb-02** — jego storage jest źródłem +prawdy dla KB. Backup jest OBOWIĄZKOWY (patrz niżej), nie opcjonalny. + +## Stack + +| Kontener | Obraz | Rola | +|--------------------|------------------------------------------|-----------------------------| +| `paperless` | `ghcr.io/paperless-ngx/paperless-ngx:2.14` | UI+API+consumer+fallback-OCR (port 8000) | +| `paperless-db` | `postgres:16-alpine` | metadane (alias `db`) | +| `paperless-broker` | `redis:7-alpine` (AOF on) | kolejka zadań OCR (alias `broker`) | + +Wszystkie porty bindowane na `LAN_BIND_IP` (192.168.31.5), nigdy 0.0.0.0: + +- `8210` → web UI (za npm@PIHA, vhost `paper.kapala.org`, LAN/Tailscale only) +- `5434` → Postgres dla workera@SOLARIA (5433 zajęte przez kb-postgres) +- `6380` → Redis dla workera@SOLARIA (6379 zajęte przez agent-system-redis) + +Obraz jest multi-arch (arm64 natywnie na Pi 5). Tag `2.14` MUSI być identyczny +z `services/paperless-worker/docker-compose.yml` — podbijać oba naraz. + +## Split OCR: serwis@PIHA + worker@SOLARIA (wynik badania) + +Rozproszony worker **nie jest oficjalnie wspierany** przez Paperless-ngx, ale +jest wykonalny i potwierdzony przez maintainerów (GH discussion #3900): drugi +host odpala ten sam obraz z `command: celery --app paperless worker` i musi +widzieć **ten sam Redis, tego samego Postgresa i te same pliki**. Stąd: + +- storage dokumentów leży na PIHA (bind mounty `/opt/homelab/data/paperless/*`) + i jest eksportowany przez **NFS po LAN** (nie Tailscale) do SOLARII; +- ścieżki w kontenerze (`/usr/src/paperless/{data,media,consume}`) muszą być + **identyczne** po obu stronach — payloady zadań i DB niosą ścieżki absolutne; +- pliki mają właściciela **numerycznego UID 1000** (`USERMAP_UID/GID=1000` + po obu stronach) — NFS przenosi numeryczne ID, nie nazwy; +- wbudowany worker na PIHA (nie da się go wyłączyć w stockowym obrazie) działa + jako wolny fallback, gdy SOLARIA śpi; zadania czekają w Redis (AOF włączone, + restart brokera nie gubi kolejki). + +Szczegóły NFS (export na PIHA, mount na SOLARIA, ryzyko indeksu Whoosh) — +`services/paperless-worker/README.md`. + +## OIDC przez Forgejo + +Mechanizm: `django-allauth` z providerem `openid_connect` +(`PAPERLESS_APPS=allauth.socialaccount.providers.openid_connect`), konfiguracja +providera w JSON-ie `PAPERLESS_SOCIALACCOUNT_PROVIDERS` (w `.env`, bo zawiera +client secret — patrz `env.example`). + +Rejestracja w Forgejo (Settings → Applications, wzorzec jak Vikunja): + +- Redirect URI: `https://paper.kapala.org/accounts/oidc/forgejo/login/callback/` +- Confidential client; scope `openid profile email` + +`forgejo.kapala.org` jest przypięte przez `extra_hosts` do `192.168.31.5` +(npm na PIHA), żeby OIDC discovery szło po LAN — ta sama lekcja co w Vikunji. + +Lokalny login zostaje na czas bootstrapu (konto `PAPERLESS_ADMIN_USER`); +po zweryfikowaniu OIDC rozważyć `PAPERLESS_DISABLE_REGULAR_LOGIN=true` +(TODO DECYZJA OSKARA w compose). + +## Storage i backup (WARUNEK BRZEGOWY hybrydy) + +Dane na PIHA NVMe: + +- `/opt/homelab/data/paperless/media` — **oryginały + zarchiwizowane PDF-y, + niezastępowalne** +- `/opt/homelab/data/paperless/data` — indeks wyszukiwania (Whoosh) + + klasyfikator — odtwarzalne (`document_index reindex`) +- `/opt/homelab/data/paperless/consume` — katalog zrzutu do ingestu +- `paperless_pgdata` (named volume) — metadane (tagi, korespondenci, daty) +- `paperless_redisdata` (named volume) — AOF kolejki zadań + +### Propozycja backupu (TODO DECYZJA OSKARA — zatwierdzić przed deployem) + +1. **Warstwa 1 — eksport spójny**: nocny cron na PIHA: + `docker exec paperless document_exporter ../export --delete -z` — + wbudowane narzędzie; zrzuca oryginały + archiwa + `manifest.json` + (pełne metadane, odtwarzalne przez `document_importer` bez dumpa SQL). + Ląduje w `/opt/homelab/data/paperless/export`. +2. **Warstwa 2 — kopia poza hosta**: rsync/borg eksportu na SOLARIA + (2 TB NVMe, ta sama LAN) — SOLARIA sesyjna, więc pull przy starcie + lub push z retry. Alternatywa/uzupełnienie: offsite (restic → chmura?). + VPS odpada (brak miejsca). +3. **Retencja (propozycja)**: 7 dziennych + 4 tygodniowe + 6 miesięcznych. +4. Dump Postgresa nie jest konieczny do odtworzenia (manifest wystarcza), + ale tani — można dołożyć `pg_dump` do tej samej nocnej pętli. + +## RAM na PIHA + +Oczekiwany spoczynek: paperless ~400–600 Mi (gunicorn + consumer + beat + +1 worker), postgres ~80 Mi, redis ~15 Mi. Limity twarde w +`hosts/piha/runtime/paperless/docker-compose.override.yml`. Warunek: moduł 0 +(odchudzenie PIHA) wykonany — po nim ~3.8 Gi available. + +## Cutover checklist (przy deployu — NIE teraz) + +1. Moduł 0 (piha-slim) domknięty; `free -g` na PIHA potwierdza zapas. +2. Porty wolne na żywym hoście: `ss -tlnp | grep -E '8210|5434|6380'`. +3. `mkdir -p /opt/homelab/data/paperless/{data,media,consume,export}` na PIHA. +4. `.env` z `env.example` (sekrety + rejestracja OAuth2 w Forgejo). +5. DNS `paper.kapala.org` → PIHA (DNS Only) + vhost w npm@PIHA (HTTPS → 192.168.31.5:8210). +6. `docker compose up -d` + `./healthcheck.sh` + testowy login OIDC. +7. Export NFS dla workera (patrz `services/paperless-worker/README.md`) — + dopiero przy module 3. +8. Wpis w `hosts/piha/services.yaml` (dopiero przy deployu — wcześniej + supervisor widziałby drift dla nieistniejącego serwisu). diff --git a/services/paperless/docker-compose.yml b/services/paperless/docker-compose.yml new file mode 100644 index 0000000..eb2cfc8 --- /dev/null +++ b/services/paperless/docker-compose.yml @@ -0,0 +1,139 @@ +# Paperless-ngx service stack (PIHA) — UI + API + Postgres + Redis broker. +# +# KB module 2 (docs/kb/modules/02-paperless-service.md). Heavy OCR runs on a +# SEPARATE worker on SOLARIA (services/paperless-worker/, module 3) that shares +# this stack's Redis broker, Postgres and document storage (NFS export from +# PIHA). The built-in celery worker here stays at concurrency 1 as the slow +# fallback when SOLARIA is asleep — the stock image cannot disable it anyway. +# +# EXPOSURE: LAN/Tailscale ONLY (documents are sensitive — kb-00 rule #4). +# Every published port binds to LAN_BIND_IP (192.168.31.5), never 0.0.0.0. +# Users enter through npm@PIHA (paper.kapala.org vhost); the worker on SOLARIA +# reaches Redis/Postgres over the same fast LAN, NOT Tailscale. +services: + paperless: + # Multi-arch manifest includes linux/arm64 — runs natively on the Pi 5. + # The paperless-worker on SOLARIA MUST run the exact same tag (shared DB + # schema + task signatures); bump both together. + image: ghcr.io/paperless-ngx/paperless-ngx:2.14 + container_name: paperless + restart: unless-stopped + depends_on: + db: + condition: service_healthy + broker: + condition: service_healthy + # Same trick as vikunja: pin the Forgejo OIDC issuer to npm on PIHA so + # discovery resolves over the LAN instead of flaky public DNS. + extra_hosts: + - "forgejo.kapala.org:192.168.31.5" + # Secrets via env_file only (vikunja pattern): PAPERLESS_SECRET_KEY, + # PAPERLESS_DBPASS, PAPERLESS_SOCIALACCOUNT_PROVIDERS (holds the OIDC + # client secret, hence the whole JSON lives in .env). + env_file: + - .env + environment: + # TODO DECYZJA OSKARA: potwierdzic domene paper.kapala.org (vs paper.okit.pl) + # + wpis DNS (A -> LAN/Tailscale, DNS Only) + vhost w npm@PIHA. + - PAPERLESS_URL=https://paper.kapala.org + - PAPERLESS_TIME_ZONE=Europe/Warsaw + - PAPERLESS_REDIS=redis://broker:6379 + - PAPERLESS_DBHOST=db + - PAPERLESS_DBNAME=paperless + - PAPERLESS_DBUSER=paperless + # OCR: Polish + English. `pol` is downloaded into the container at boot. + - PAPERLESS_OCR_LANGUAGES=pol + - PAPERLESS_OCR_LANGUAGE=pol+eng + # Fallback-only OCR on the Pi: single worker, single thread. The real + # OCR muscle is paperless-worker@SOLARIA pulling from the same queue. + - PAPERLESS_TASK_WORKERS=1 + - PAPERLESS_THREADS_PER_WORKER=1 + - PAPERLESS_WEBSERVER_WORKERS=1 + # Files on disk (media/, data/, consume/) are owned by this numeric UID. + # MUST equal USERMAP_UID/GID of paperless-worker@SOLARIA — the NFS export + # carries numeric IDs, not names. See services/paperless-worker/README.md. + - USERMAP_UID=1000 + - USERMAP_GID=1000 + # OIDC via Forgejo (django-allauth openid_connect). Provider JSON with + # client_id/secret comes from .env: PAPERLESS_SOCIALACCOUNT_PROVIDERS. + - PAPERLESS_APPS=allauth.socialaccount.providers.openid_connect + # First Forgejo login auto-creates the matching Paperless account. + - PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=true + # No self-service local signups; local admin login stays for bootstrap. + # TODO DECYZJA OSKARA: po zweryfikowaniu logowania OIDC ustawic + # PAPERLESS_DISABLE_REGULAR_LOGIN=true + PAPERLESS_REDIRECT_LOGIN_TO_SSO=true? + - PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=false + volumes: + # Bind mounts under /opt/homelab/data (runtime path convention). These + # exact directories are NFS-exported to SOLARIA for the OCR worker — + # container paths (/usr/src/paperless/...) MUST be identical on both + # hosts because the DB and task payloads carry absolute paths. + - /opt/homelab/data/paperless/data:/usr/src/paperless/data + - /opt/homelab/data/paperless/media:/usr/src/paperless/media + - /opt/homelab/data/paperless/consume:/usr/src/paperless/consume + - /opt/homelab/data/paperless/export:/usr/src/paperless/export + ports: + # LAN-only bind; npm@PIHA proxies paper.kapala.org -> 192.168.31.5:8210. + # TODO DECYZJA OSKARA: port 8210 wolny wg inwentaryzacji 2026-06-30 — + # potwierdzic na zywym hoscie przed deployem: ss -tlnp | grep 8210 + - "${LAN_BIND_IP}:8210:8000" + healthcheck: + test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + db: + image: postgres:16-alpine + container_name: paperless-db + restart: unless-stopped + env_file: + - .env + environment: + - POSTGRES_DB=paperless + - POSTGRES_USER=paperless + - TZ=Europe/Warsaw + volumes: + - paperless_pgdata:/var/lib/postgresql/data + ports: + # Published on the LAN for paperless-worker@SOLARIA (192.168.31.70). + # 5433 is taken by kb-postgres. Postgres itself still enforces + # scram-sha-256 auth — the bind is exposure control, not the only lock. + - "${LAN_BIND_IP}:5434:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"] + interval: 10s + timeout: 5s + retries: 5 + + broker: + image: redis:7-alpine + container_name: paperless-broker + restart: unless-stopped + # AOF persistence is REQUIRED: when SOLARIA is offline, queued OCR tasks + # (up to the 70k-attachment backlog) live only in this Redis. Without AOF a + # broker restart would silently drop the queue — violating the "zero lost + # tasks" criterion of module 3. Do NOT set maxmemory/eviction here. + command: redis-server --appendonly yes + volumes: + - paperless_redisdata:/data + ports: + # LAN-only bind for paperless-worker@SOLARIA. 6379 is taken by + # agent-system-redis. + # TODO DECYZJA OSKARA: Redis bez auth na bindzie LAN (zaufana siec + # domowa) — akceptowalne, czy dolozyc requirepass? (wtedy PAPERLESS_REDIS + # z haslem przenosi sie do .env po obu stronach) + - "${LAN_BIND_IP}:6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + # DB + broker state are host-local (NOT part of the NFS export). Document + # storage above is bind-mounted, not a named volume, because it doubles as + # the NFS export root for the SOLARIA worker. + paperless_pgdata: + paperless_redisdata: diff --git a/services/paperless/env.example b/services/paperless/env.example new file mode 100644 index 0000000..745b541 --- /dev/null +++ b/services/paperless/env.example @@ -0,0 +1,30 @@ +# Paperless secrets + host-local binds — copy to .env (gitignored) next to +# docker-compose.yml and fill in real values. Never commit .env. + +# LAN IP of PIHA. All published ports (web 8210, postgres 5434, redis 6380) +# bind ONLY to this interface — never 0.0.0.0. The OCR worker on SOLARIA +# connects over the same LAN. Verify after host rebuilds: ip -4 addr. +LAN_BIND_IP=192.168.31.5 + +# Django secret key. Generate once (e.g. openssl rand -base64 48), then never +# change — MUST be identical in services/paperless-worker/.env on SOLARIA. +PAPERLESS_SECRET_KEY=change-me-long-random-string + +# Postgres password for the paperless DB. Both values MUST be identical +# (POSTGRES_PASSWORD initializes the DB, PAPERLESS_DBPASS is how the app +# connects). The worker on SOLARIA uses the same value. +POSTGRES_PASSWORD=change-me-very-strong +PAPERLESS_DBPASS=change-me-very-strong + +# OIDC provider config (django-allauth openid_connect) — single-line JSON. +# Lives in .env because it embeds the Forgejo OAuth2 client secret. +# Register the app in Forgejo: Settings > Applications > +# Redirect URI: https://paper.kapala.org/accounts/oidc/forgejo/login/callback/ +# then paste client_id + secret below. +# TODO DECYZJA OSKARA: potwierdzic domene paper.kapala.org przed rejestracja w Forgejo. +PAPERLESS_SOCIALACCOUNT_PROVIDERS={"openid_connect":{"SCOPE":["openid","profile","email"],"OAUTH_PKCE_ENABLED":true,"APPS":[{"provider_id":"forgejo","name":"Forgejo","client_id":"CHANGE-ME-CLIENT-ID","secret":"CHANGE-ME-CLIENT-SECRET","settings":{"server_url":"https://forgejo.kapala.org/.well-known/openid-configuration"}}]}} + +# Bootstrap admin (local login) — used until OIDC is verified, then consider +# PAPERLESS_DISABLE_REGULAR_LOGIN=true in docker-compose.yml. +PAPERLESS_ADMIN_USER=oskar +PAPERLESS_ADMIN_PASSWORD=change-me-bootstrap-only diff --git a/services/paperless/healthcheck.sh b/services/paperless/healthcheck.sh new file mode 100755 index 0000000..909f465 --- /dev/null +++ b/services/paperless/healthcheck.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Healthcheck for Paperless-ngx (web + db + broker) on PIHA + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Ports are bound to the LAN interface only, so localhost won't answer. +# Read the bind IP from .env (same file compose uses for the port mapping). +if [ -f "$SCRIPT_DIR/.env" ]; then + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/.env" +fi +BIND_IP="${LAN_BIND_IP:-127.0.0.1}" + +for c in paperless paperless-db paperless-broker; do + if ! docker ps --filter "name=$c" --filter "status=running" | grep -qw "$c"; then + echo "[FAIL] $c container is not running" + exit 1 + fi +done + +# Web UI must respond (login page redirect counts — follow it) +if ! curl -sfL --max-time 10 "http://${BIND_IP}:8210/" > /dev/null; then + echo "[FAIL] paperless web is not responding on ${BIND_IP}:8210" + exit 1 +fi + +# Redis broker must answer on the LAN bind (the SOLARIA worker depends on it) +if ! docker exec paperless-broker redis-cli -h "${BIND_IP}" -p 6380 ping | grep -q PONG; then + echo "[FAIL] redis broker is not answering on ${BIND_IP}:6380 (worker@SOLARIA cannot connect)" + exit 1 +fi + +echo "[OK] paperless is healthy" +exit 0 diff --git a/services/paperless/service.yaml b/services/paperless/service.yaml new file mode 100644 index 0000000..738ce46 --- /dev/null +++ b/services/paperless/service.yaml @@ -0,0 +1,48 @@ +service: + name: paperless + owner_node: piha + role: document-management # KB pillar #2 source of truth for scans/invoices (archiwum = REFERENCJA) + exposure: private # LAN/Tailscale only via npm@PIHA (paper.kapala.org); NO public ingress + dependencies: + - forgejo # OIDC identity provider (forgejo.kapala.org) + # paperless-worker@SOLARIA is a consumer of this service (Redis broker, + # Postgres, NFS-exported storage) — not a dependency; the stack is fully + # functional (slow OCR fallback) with SOLARIA powered down. + ports: + - container: 8000 + host: 8210 # LAN_BIND_IP only, never 0.0.0.0 + protocol: tcp + - container: 5432 + host: 5434 # postgres for paperless-worker@SOLARIA (LAN bind) + protocol: tcp + - container: 6379 + host: 6380 # redis broker for paperless-worker@SOLARIA (LAN bind) + protocol: tcp + healthcheck: + type: http + endpoint: http://192.168.31.5:8210/ # LAN bind — localhost does not answer + interval: 30s + timeout: 10s + retries: 5 + restart_policy: unless-stopped + persistence: + # Paperless is the KB REFERENCE archive — this storage is the single source + # of truth for documents and its backup is MANDATORY (kb-02 hybrid boundary + # condition). data/media/consume are also the NFS export consumed by + # paperless-worker@SOLARIA. + paths: + - /opt/homelab/data/paperless/data # search index, classifier — rebuildable + - /opt/homelab/data/paperless/media # ORIGINALS + archived PDFs — irreplaceable + - /opt/homelab/data/paperless/consume # ingest drop dir + - /opt/homelab/data/paperless/export # document_exporter output (backup staging) + - paperless_pgdata # named volume: postgres (metadata, tags, correspondents) + - paperless_redisdata # named volume: redis AOF (queued OCR tasks) + runtime: + config_files: + - .env # secrets (gitignored, from env.example) + env_vars: + - LAN_BIND_IP # required — compose port-bind interpolation (192.168.31.5) + - PAPERLESS_SECRET_KEY + - PAPERLESS_DBPASS + - POSTGRES_PASSWORD # must equal PAPERLESS_DBPASS + - PAPERLESS_SOCIALACCOUNT_PROVIDERS # OIDC provider JSON incl. Forgejo client secret