deploy/Dockerfile
Rama main · réplica de lectura del repositorio original.
# syntax=docker/dockerfile:1
#
# Ein Image, ein Container, ein Volume (/app/data), siehe docs/00-DECISIONS.md D-10.
# Build-Kontext ist das Repository-Root:
#
# docker buildx build -f deploy/Dockerfile --platform linux/amd64,linux/arm64 -t nodvard-deck:latest .
#
# Nur einige Module (z. B. für ein Einzelprodukt), durch Kommas getrennt; ohne Angabe kommen alle mit:
#
# docker buildx build -f deploy/Dockerfile --build-arg NODVARD_DECK_EXTENSIONS=docker -t nodvard-docker:test .
#
# Multi-Arch ohne Sonderfälle im Code: `python:3.12-slim` und `node:22-slim` liefern
# beide Ziel-Architekturen (linux/arm64 für den Pi, linux/amd64 für x86-Rechner, D-10), und
# keine Backend-Abhängigkeit (siehe backend/pyproject.toml) braucht eine Kompilierung
# ohne vorgefertigtes Wheel für eine der beiden.
# ---------------------------------------------------------------------------
# Stufe 1: Frontend + Extension-Bundles bauen
# ---------------------------------------------------------------------------
# --platform=$BUILDPLATFORM: das Ergebnis ist reines JS/statische Dateien,
# architekturunabhängig -- diese Stufe auf der BUILD-Maschine nativ laufen zu lassen
# (statt sie für arm64 zu emulieren) beschleunigt einen Cross-Build für den Pi
# erheblich. Nur die Python-Laufzeitstufe unten braucht die Ziel-Architektur wirklich.
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json* frontend/
RUN cd frontend && npm ci
COPY frontend/ frontend/
# Extensions VOR dem Kern-Build: Tailwind erzeugt
# nur Klassen, die es in den gescannten Quellen findet -- die Extension-Seiten stehen
# in tailwind.config.js `content`, lagen hier aber erst NACH `npm run build` vor. Ihre
# eigenen Klassen (farbige Badges, Abstände ...) fehlten deshalb im ausgelieferten CSS.
COPY extensions/ extensions/
ARG NODVARD_DECK_EXTENSIONS=""
# --chmod: Die Rechte im Build-Kontext schwanken (Windows, Kopien), der Standard-Build darf daran nicht scheitern.
COPY --chmod=755 deploy/select_extensions.sh /usr/local/bin/select_extensions.sh
RUN select_extensions.sh "$NODVARD_DECK_EXTENSIONS" extensions
# `tsc -b` prüft auch die Tests mit; frontend/src/lib/targetAddress.test.ts liest dieselben
# Beispiele wie das SDK (sdk/python/tests/vectors/same_target.json). Ohne sie scheitert der Build.
COPY sdk/python/tests/vectors/ sdk/python/tests/vectors/
# Ebenso liest frontend/src/routes/settings/updaterTexts.test.ts die Protokoll- und Status-Vektoren des Update-Helfers.
COPY deploy/updater/tests/vectors/ deploy/updater/tests/vectors/
RUN cd frontend && npm run build
# Extension-Frontend-Bundles sind laut .gitignore committete Fixtures (keine
# eigene CI) -- hier trotzdem frisch gebaut, weil das Docker-Image bewusst nicht von
# "hat jemand vor dem Commit an node build.mjs gedacht" abhängen soll. Kein Fehler,
# wenn eine Extension kein frontend/ hat (nicht jede Extension hat eine eigene Seite).
RUN for build_script in extensions/*/frontend/build.mjs; do \
[ -f "$build_script" ] && node "$build_script"; \
done; true
# ---------------------------------------------------------------------------
# Stufe 2: Laufzeit-Image
# ---------------------------------------------------------------------------
FROM python:3.12-slim AS runtime
# Kein root-Prozess für den Anwendungscode. Feste UID/GID (nicht `useradd` ohne -u/-g)
# für reproduzierbare Ownership -- relevant für den Volume-Trick unten.
RUN groupadd --gid 1000 lattice && useradd --uid 1000 --gid lattice --no-create-home --shell /usr/sbin/nologin lattice
WORKDIR /app
# Bewusst NUR die neuen Namen (die alten LATTICE_* lesen Code und Compose weiter als
# Rückfall). Alte Namen zusätzlich im Image würden den Rückweg aufs alte Image nicht
# absichern -- das bringt seine eigenen ENV-Werte mit -- und bei jedem Start die Warnung
# "alte Namen" auslösen.
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
NODVARD_DECK_ENV=prod \
NODVARD_DECK_LOG_JSON=true \
NODVARD_DECK_DATA_DIR=/app/data
# Für die documents-Extension (nodvard_deck_ext_documents/ocr.py): `pytesseract` ist nur
# der Python-Wrapper, das eigentliche OCR-Programm ist dieses System-Paket -- ohne
# `tesseract-ocr` hier scheitert jeder Upload mit TesseractNotFoundError (Extension
# bleibt trotzdem nutzbar, siehe ocr.py/__init__.py::_run_ocr()s Fehler-Isolation,
# nur eben ohne erkannten Text). `-deu` zusätzlich zu `-eng`, Homelab-Kontext ist
# deutschsprachig (siehe ocr.py::_OCR_LANGS_PRIMARY).
# Nur, wenn das Modul documents mitkommt (ohne Angabe kommen alle Module mit). Vor den
# pip-Schritten, damit eine Änderung am Backend apt nicht jedes Mal neu laufen lässt.
ARG NODVARD_DECK_EXTENSIONS=""
# Ein neuer Wert holt die Pakete frisch: Sonst bliebe diese Schicht samt ihrem Stand der Debian-Sicherheitsupdates im
# Build-Cache, bis sich das Basis-Image ändert. Der Release-Job setzt die Nummer seines Laufs.
ARG APT_REFRESH=""
RUN case ",${NODVARD_DECK_EXTENSIONS:-documents}," in \
*,documents,*) apt-get update && apt-get install --no-install-recommends -y \
tesseract-ocr tesseract-ocr-deu \
&& rm -rf /var/lib/apt/lists/* ;; \
esac
# nodvard-sdk zuerst installieren (backend hängt via pyproject.toml `dependencies =
# ["nodvard-sdk", ...]` davon ab, siehe dortige Datei) -- pip auflöst das zwar auch
# in einem Schritt richtig, getrennte Layer sind aber der bessere Build-Cache: sdk/
# ändert sich seltener als backend/.
# Reproduzierbar: alle Abhängigkeiten auf die zuletzt getesteten Versionen festgelegt --
# ein neues Release eines Pakets bricht den Build nicht mehr unbemerkt (Beispiel:
# SQLAlchemy 2.1.0 kam ohne greenlet).
COPY deploy/constraints.txt constraints.txt
COPY sdk/python sdk/python
RUN pip install --no-cache-dir -c constraints.txt ./sdk/python
COPY backend backend
RUN pip install --no-cache-dir -c constraints.txt ./backend
# Extensions sind Verzeichnis-gescannt, kein separater `pip install`-Schritt
# (backend/src/nodvard_deck/ext/discovery.py::scan_directory() hängt sie per `sys.path`
# ein) -- die hier kopierte Version enthält die in Stufe 1 frisch gebauten Bundles.
COPY --from=frontend-build /src/extensions ./extensions
COPY --from=frontend-build /src/frontend/dist ./frontend/dist
# Lizenzen der mitgelieferten Fremdkomponenten (deren Lizenzen verlangen, die Texte mitzuliefern).
# Die Datei ist eingecheckt und wird mit `python scripts/third_party_licenses.py` erzeugt;
# backend/tests/test_licenses.py schlägt an, wenn sie nicht zu constraints.txt und
# package-lock.json passt. Bewusst NICHT im Build erzeugt: dort fehlen je nach Stufe
# entweder die Python- oder die npm-Pakete, und ein Build soll nicht an Papierkram scheitern.
# Daneben die eigene Lizenz des Projekts (PolyForm Noncommercial 1.0.0), damit das Image ihren Text mitbringt.
COPY THIRD_PARTY_LICENSES /app/THIRD_PARTY_LICENSES
COPY LICENSE /app/LICENSE
# Nur der Datenordner gehört `lattice`. Code, Erweiterungen und Oberfläche bleiben root-eigen und für
# andere nur lesbar: Wer als `lattice` Code ausführt (Fehler in einer Erweiterung oder Abhängigkeit), kann
# unter /app nichts ablegen -- und so nichts einschleusen, das später als root startet (Healthcheck,
# `docker compose exec`). `/app` selbst ist ebenfalls root-eigen: sonst könnte `lattice` dort z. B. ein Paket
# `http/` anlegen, das ein als root gestartetes Python statt des echten lädt. Vorher werden die
# Erweiterungen und die Migrationen (lädt Alembic bei jedem Start) einmal übersetzt, weil `lattice` zur
# Laufzeit keine `__pycache__`-Ordner mehr anlegen darf; sonst übersetzt Python sie bei jedem Start neu.
RUN python -m compileall -q /app/extensions /app/backend/migrations || true
RUN mkdir -p /app/data \
&& chown -R root:root /app \
&& chmod -R go+rX,go-w /app \
&& chown lattice:lattice /app/data \
&& chmod 700 /app/data
# Docker initialisiert ein frisches BENANNTES Volume aus dem Inhalt (inkl. Ownership)
# des Image-Pfads, an dem es zum ersten Mal gemountet wird -- deshalb reicht dieses
# `chown` VOR `VOLUME` für den Normalfall. Ein Bind-Mount von einem Host-Verzeichnis
# bringt seine eigene Ownership mit; die setzt deploy/entrypoint.sh beim Start selbst
# (siehe dort).
VOLUME /app/data
# Ohne diesen Entrypoint startet uvicorn gegen ein unmigriertes Volume und stirbt sofort mit
# "no such table: roles" -- siehe entrypoint.sh für die volle Begründung. Root-eigen;
# world-readable/-executable reicht, der `lattice`-Nutzer braucht keinen Schreibzugriff darauf.
#
# Warum `chmod 755` statt `chmod +x`: `chmod +x`
# (relativ, ohne u/g/o) ADDIERT nur das Execute-Bit auf die vorhandenen Rechte --
# welche Rechte die kopierte Datei tatsächlich mitbringt, hängt vom Build-Kontext
# ab (z. B. per `git archive | tar -x` auf einen Pi übertragen: andere Bit-Ausgangslage
# als beim direkten Docker-Desktop-Build auf Windows). Mögliches Ergebnis: `rwxrwx--x`
# -- für den `lattice`-Prozess (weder Owner noch Gruppe) bleibt nicht einmal Lesen
# erlaubt, `/bin/sh` kann das Skript nicht öffnen. `chmod 755` (absolut) setzt
# die Rechte unabhängig vom Ausgangszustand fest, statt sie relativ draufzusatteln.
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod 755 /entrypoint.sh
# Bewusst KEIN `USER lattice` mehr: Der Container startet als root, damit der Entrypoint dem
# Datenordner /app/data die richtigen Besitzrechte geben kann (eingebundene NAS-/Host-Ordner
# gehören oft root oder einem anderen Benutzer). Er wechselt mit `setpriv` zu `lattice`, BEVOR
# Migration und Anwendung starten -- beide laufen nie als root (Einzelheiten in entrypoint.sh).
# Läuft der Container mit `user:` (Compose) oder unter Kubernetes schon als Nicht-root,
# bleibt alles wie früher. Bauzeit-Prüfung: Fehlt setpriv im Basis-Image, scheitert schon der
# Build und nicht erst der Start beim Nutzer.
RUN setpriv --reuid=1000 --regid=1000 --clear-groups --no-new-privs true
# Herkunft des Images: `.github/workflows/release.yml` setzt `VERSION` (z. B. 0.6.0 oder 0.6.0-rc1) und
# `IMAGE` (ghcr.io/nodvard/deck, ohne Tag). Daran erkennt "Nach Updates suchen", ob das offizielle Image
# läuft (dann passen die Anleitungen für Compose, Portainer usw.) und welche Version es genau ist. Ein
# eigener Build lässt beides leer, dann entsteht keine Datei. `EDITION=dock` macht daraus Nodvard Dock (Name,
# Dienstname, eigenes Image für die Update-Suche; nodvard_deck/edition.py), passend zu NODVARD_DECK_EXTENSIONS=docker.
# Bewusst eine DATEI (/app/image-info.json, sicher als JSON geschrieben von nodvard_deck.image_info) und
# KEINE ENV: Die ENV eines Images landet in den Einstellungen jedes Containers; wer den Container mit diesen
# Einstellungen und einem neuen Image neu anlegt, nimmt die alte Version mit. Die Datei kommt immer aus dem
# Image, das gerade läuft. Die Build-Argumente sieht nur dieser RUN-Schritt (als Umgebung), nicht der Container.
# Bewusst erst hier unten: ein neuer Wert baut nur diese eine Schicht neu, nicht die Pakete darüber.
ARG VERSION=""
ARG IMAGE=""
ARG EDITION=""
RUN python -m nodvard_deck.image_info /app/image-info.json
EXPOSE 8080
# `--start-period=300s`: vor dem Start der Anwendung läuft die Migration samt Kopie der Datenbank (`nodvard_deck.boot`);
# bei einer großen Datenbank auf einem Pi dauert das Minuten. Während der Karenzzeit zählen Fehlschläge nicht,
# der Container gilt nicht als "unhealthy". Die Notseite antwortet mit 503, der Check schlägt dann an.
# Der Check läuft als root (das Image hat bewusst kein `USER`, siehe oben). `python -I` (isoliert) nimmt den
# aktuellen Ordner nicht in den Suchpfad auf und ignoriert PYTHON*-Umgebungsvariablen und das Benutzerverzeichnis
# für Pakete: so kann nichts, was jemand neben das Programm legt, mit root-Rechten geladen werden.
HEALTHCHECK --interval=30s --timeout=3s --start-period=300s --retries=3 \
CMD python -I -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/api/v1/health', timeout=2)" || exit 1
ENTRYPOINT ["/entrypoint.sh"]
# `--ws-max-size`: größte WebSocket-Nachricht (uvicorn-Standard wären 16 MiB, auch von Clients ohne Konto im
# 10-Sekunden-Anmeldefenster von /ws). 1 MiB reicht für Anmeldung, Terminal-Eingaben und Ereignisse bei Weitem.
# `--no-proxy-headers`: uvicorn glaubt sonst vorab still jeder Anfrage von 127.0.0.1 ihr `X-Forwarded-For`. Wem
# geglaubt wird, entscheidet allein die Einstellung "Vertrauenswürdiger Proxy" (api/proxy_headers.py).
# Der Entrypoint liest aus dem Startbefehl nur --host und --port.
CMD ["uvicorn", "nodvard_deck.main:app", "--host", "0.0.0.0", "--port", "8080", "--ws-max-size", "1048576", "--no-proxy-headers"]