diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5785ccb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.next +paket +proxdeploy-*.tar.gz +cli/bin +test-results +playwright-report +coverage +.env +.env.* +!.env.example +*.tsbuildinfo +src/generated diff --git a/.env.example b/.env.example index 5a9feb6..696f30a 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ # ── Datenbank ──────────────────────────────────────────────────────────────── DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy +# Nur Docker Compose: Passwort der mitgelieferten PostgreSQL (DATABASE_URL setzt Compose selbst). +POSTGRES_PASSWORT= # Nur für Tests; wird vor jedem Test geleert. -TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy_test +TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy +# Nur Docker Compose: Passwort der mitgelieferten PostgreSQL (DATABASE_URL setzt Compose selbst). +POSTGRES_PASSWORT=_test # ── Geheimnisse ────────────────────────────────────────────────────────────── # Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64. diff --git a/.gitignore b/.gitignore index 6bd802b..859bed2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ cli/bin/ playwright-report/ test-results/ .DS_Store +paket/ +proxdeploy-*.tar.gz diff --git a/.prettierignore b/.prettierignore index 99e9c2d..5f2f6d5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,4 @@ cli/ catalog/ test-results public/brand +paket diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bb2286..a68fcf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] ### Hinzugefügt +- Betrieb per Docker Compose (postgres, migrate, app, worker) mit einem Image für alles, non-root (#019) +- LXC-Installer `deploy/install-lxc.sh`: legt auf dem Proxmox-Host einen Debian-13-Container an, installiert Node.js und PostgreSQL, richtet systemd-Dienste ein; mit Update-Pfad (#019) +- Migrationen im Betrieb ohne Prisma-CLI, kompatibel zu Prisma (#019) +- Übersicht zeigt Nodes, CPUs und freien RAM je Server sowie die letzten Deployments (#018) - Marken-Dateien nach Vorgabe unter `public/brand/`: Symbol (dunkel/hell), kleines Icon, Avatar, Logo (dunkel/hell), Banner 1280×640, Favicons, Apple-Touch- und Manifest-Icons; erzeugt mit `pnpm marke` (#017) - Web-App-Manifest; Favicon passt sich hellem und dunklem Browser an (#017) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3c55d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# ProxDeploy – ein Image für app, worker und migrate. Gebaut wird mit scripts/paket-bauen.sh, +# derselben Struktur wie im LXC-Installer. +# +# docker build --build-arg VERSION=0.6.0 -t proxdeploy:0.6.0 . + +FROM node:24.21.0-trixie-slim AS bau +ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH CI=true +RUN corepack enable && corepack prepare pnpm@12.6.0 --activate +WORKDIR /quelle +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY prisma ./prisma +COPY prisma.config.ts ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile +COPY . . +ARG VERSION +# Ohne .git im Kontext gibt es keinen Commit; dann steht nur die Version da. +RUN scripts/paket-bauen.sh "${VERSION:-$(node -p 'require("./package.json").version')}" + +FROM node:24.21.0-trixie-slim +LABEL org.opencontainers.image.title="ProxDeploy" \ + org.opencontainers.image.description="VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI." \ + org.opencontainers.image.source="https://git.wilde-server.de/florian.wilde/proxdeploy" +ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 NEXT_TELEMETRY_DISABLED=1 +WORKDIR /opt/proxdeploy +COPY --from=bau --chown=root:root /quelle/paket ./ +# Läuft als „node“ (UID 1000), nie als root; der Code gehört root und ist nicht beschreibbar. +USER node +WORKDIR /opt/proxdeploy/app +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/v1/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +# app; worker und migrate setzen ihr Kommando in docker-compose.yml. +CMD ["node", "server.js"] diff --git a/deploy/im-container.sh b/deploy/im-container.sh new file mode 100755 index 0000000..3109e05 --- /dev/null +++ b/deploy/im-container.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# Läuft im Debian-13-Container (von install-lxc.sh per `pct exec` aufgerufen): +# +# im-container.sh installieren --url http://10.0.10.50:3000 +# im-container.sh aktualisieren +# +# Erwartet das Paket unter /root/proxdeploy-paket.tar.gz. +set -euo pipefail +# Über SSH/pct exec reisen die Locales des Aufrufers mit; im frischen Container gibt es sie nicht. +export LC_ALL=C.UTF-8 LANG=C.UTF-8 +unset LANGUAGE + +NODE_VERSION="24.21.0" +BASIS="/opt/proxdeploy" +ENV_DATEI="/etc/proxdeploy/proxdeploy.env" +PAKET="/root/proxdeploy-paket.tar.gz" + +fehler() { echo "✗ $*" >&2; exit 1; } +schritt() { echo "◐ $*"; } +fertig() { echo "✓ $*"; } + +node_installieren() { + case "$(uname -m)" in + x86_64) arch=x64 ;; + aarch64) arch=arm64 ;; + *) fehler "Architektur $(uname -m) wird nicht unterstützt." ;; + esac + if [ -x /opt/node/bin/node ] && [ "$(/opt/node/bin/node --version)" = "v$NODE_VERSION" ]; then return; fi + schritt "Node.js $NODE_VERSION" + local datei="node-v$NODE_VERSION-linux-$arch.tar.xz" tmp + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/$datei" "https://nodejs.org/dist/v$NODE_VERSION/$datei" + curl -fsSL -o "$tmp/SHASUMS256.txt" "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt" + (cd "$tmp" && grep " $datei\$" SHASUMS256.txt | sha256sum -c --quiet) || fehler "Prüfsumme von $datei stimmt nicht." + rm -rf /opt/node && mkdir -p /opt/node + tar -xJf "$tmp/$datei" -C /opt/node --strip-components=1 + rm -rf "$tmp" + fertig "Node.js $(/opt/node/bin/node --version)" +} + +paket_auspacken() { + [ -f "$PAKET" ] || fehler "$PAKET fehlt." + local version ziel + version="$(tar -xzOf "$PAKET" ./VERSION)" + ziel="$BASIS/versionen/$version" + rm -rf "$ziel" && mkdir -p "$ziel" + tar -xzf "$PAKET" -C "$ziel" + chown -R root:root "$ziel" + ln -sfn "$ziel" "$BASIS/aktuell" + rm -f "$PAKET" + # Die letzten drei Fassungen behalten – für ein Zurück per Symlink. + find "$BASIS/versionen" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -rn | tail -n +4 | + cut -d' ' -f2- | xargs -r rm -rf + echo "$version" +} + +dienste_schreiben() { + cat > /etc/systemd/system/proxdeploy-migrate.service < "/etc/systemd/system/proxdeploy-$dienst.service" </dev/null; then return 0; fi + sleep 2 + done + journalctl -u proxdeploy-app -u proxdeploy-migrate --no-pager -n 30 >&2 + fehler "Die app antwortet nicht." +} + +installieren() { + local url="" + while [ $# -gt 0 ]; do + case "$1" in + --url) url="$2"; shift 2 ;; + *) fehler "Unbekannte Option $1" ;; + esac + done + [ -n "$url" ] || fehler "--url fehlt." + [ -f "$ENV_DATEI" ] && fehler "ProxDeploy ist hier schon eingerichtet – zum Aktualisieren: install-lxc.sh --aktualisieren " + + schritt "Pakete (PostgreSQL, curl)" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends ca-certificates curl xz-utils openssl postgresql >/dev/null + fertig "PostgreSQL $(psql --version | awk '{print $3}')" + node_installieren + + schritt "Benutzer und Datenbank" + id proxdeploy >/dev/null 2>&1 || useradd --system --home-dir /var/lib/proxdeploy --create-home --shell /usr/sbin/nologin proxdeploy + local db_passwort admin_passwort + db_passwort="$(openssl rand -hex 24)" + admin_passwort="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)" + su postgres -c "psql -q -v ON_ERROR_STOP=1" < "$ENV_DATEI" </dev/null 2>&1 + auf_gesundheit_warten + # Der erste Admin ist angelegt – das Passwort hat in der Datei nichts mehr zu suchen. + sed -i '/^ADMIN_PASSWORT=/d' "$ENV_DATEI" + fertig "ProxDeploy $version läuft" + + cat </dev/null || echo –)" + systemctl stop proxdeploy-app proxdeploy-worker + version="$(paket_auspacken)" + dienste_schreiben + # migrate bleibt nach dem ersten Lauf „aktiv“ – für die neue Fassung ausdrücklich neu starten. + systemctl restart proxdeploy-migrate + systemctl start proxdeploy-app proxdeploy-worker + auf_gesundheit_warten + fertig "ProxDeploy $alt → $version" +} + +case "${1:-}" in + installieren) shift; installieren "$@" ;; + aktualisieren) shift; aktualisieren "$@" ;; + *) fehler "Aufruf: $0 installieren --url URL | aktualisieren" ;; +esac diff --git a/deploy/install-lxc.sh b/deploy/install-lxc.sh new file mode 100755 index 0000000..b8d24bd --- /dev/null +++ b/deploy/install-lxc.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# ProxDeploy in einem Debian-13-LXC einrichten – auf dem Proxmox-Host als root ausführen. +# +# ./install-lxc.sh --paket proxdeploy-0.6.0.tar.gz [Optionen] +# ./install-lxc.sh --aktualisieren --paket proxdeploy-0.6.1.tar.gz +# +# Optionen (alle optional außer --paket): +# --ctid N Container-ID (Standard: nächste freie) +# --hostname NAME Hostname (Standard: proxdeploy) +# --storage NAME Storage für die Disk (Standard: local-lvm) +# --disk GB Disk in GB (Standard: 8) +# --cores N CPU-Kerne (Standard: 2) +# --memory MB RAM (Standard: 2048) +# --bridge NAME Bridge (Standard: vmbr0) +# --vlan TAG VLAN-Tag +# --ip CIDR feste Adresse, z. B. 10.0.10.50/24 (Standard: DHCP); dazu --gw +# --gw ADRESSE Gateway bei fester Adresse +# --url URL öffentliche Adresse (APP_URL), z. B. https://proxdeploy.example +# (Standard: http://:3000) +# --tags LISTE zusätzliche Proxmox-Tags, z. B. proxdeploy-test +# +# Das Paket baut `scripts/paket-bauen.sh --tar` im Repository. +set -euo pipefail + +fehler() { echo "✗ $*" >&2; exit 1; } +schritt() { echo "◐ $*"; } +fertig() { echo "✓ $*"; } + +[ "$(id -u)" = 0 ] || fehler "Bitte als root auf dem Proxmox-Host ausführen." +command -v pct >/dev/null || fehler "pct fehlt – läuft das auf einem Proxmox-VE-Host?" + +ctid="" hostname="proxdeploy" storage="local-lvm" disk=8 cores=2 memory=2048 bridge="vmbr0" +vlan="" ip="dhcp" gw="" url="" paket="" aktualisieren="" tags="" +while [ $# -gt 0 ]; do + case "$1" in + --ctid) ctid="$2"; shift 2 ;; + --hostname) hostname="$2"; shift 2 ;; + --storage) storage="$2"; shift 2 ;; + --disk) disk="$2"; shift 2 ;; + --cores) cores="$2"; shift 2 ;; + --memory) memory="$2"; shift 2 ;; + --bridge) bridge="$2"; shift 2 ;; + --vlan) vlan="$2"; shift 2 ;; + --ip) ip="$2"; shift 2 ;; + --gw) gw="$2"; shift 2 ;; + --url) url="$2"; shift 2 ;; + --tags) tags="$2"; shift 2 ;; + --paket) paket="$2"; shift 2 ;; + --aktualisieren) aktualisieren="$2"; shift 2 ;; + -h|--help) sed -n '2,25p' "$0"; exit 0 ;; + *) fehler "Unbekannte Option $1 (siehe --help)" ;; + esac +done +[ -n "$paket" ] || fehler "--paket fehlt (proxdeploy-.tar.gz)." +[ -f "$paket" ] || fehler "$paket gibt es nicht." +tar -tzf "$paket" ./VERSION >/dev/null 2>&1 || fehler "$paket ist kein ProxDeploy-Paket (VERSION fehlt)." +version="$(tar -xzOf "$paket" ./VERSION)" +innen="$(dirname "$0")/im-container.sh" +[ -f "$innen" ] || fehler "im-container.sh fehlt neben diesem Skript." + +pushen() { + pct push "$1" "$paket" /root/proxdeploy-paket.tar.gz + pct push "$1" "$innen" /root/proxdeploy-im-container.sh --perms 0700 +} + +# ── Aktualisieren ─────────────────────────────────────────────────────────────────────────────── +if [ -n "$aktualisieren" ]; then + pct status "$aktualisieren" | grep -q running || fehler "Container $aktualisieren läuft nicht." + schritt "Aktualisiere Container $aktualisieren auf $version" + pushen "$aktualisieren" + pct exec "$aktualisieren" -- bash /root/proxdeploy-im-container.sh aktualisieren + exit 0 +fi + +# ── Neu einrichten ────────────────────────────────────────────────────────────────────────────── +[ -n "$ctid" ] || ctid="$(pvesh get /cluster/nextid)" +pct status "$ctid" >/dev/null 2>&1 && fehler "Container $ctid gibt es schon." +if [ "$ip" != dhcp ]; then + [[ "$ip" =~ ^[0-9.]+/[0-9]+$ ]] || fehler "--ip: Format 10.0.10.50/24" + [ -n "$gw" ] || fehler "Bei fester Adresse fehlt --gw." +fi + +schritt "LXC-Template Debian 13" +pveam update >/dev/null +vorlage="$(pveam available --section system | awk '/debian-13-standard_.*_amd64\.tar\.zst/ {print $2}' | sort -V | tail -1)" +[ -n "$vorlage" ] || fehler "Kein Debian-13-Template in pveam gefunden." +template_storage="$(pvesm status --content vztmpl | awk 'NR>1 {print $1; exit}')" +[ -n "$template_storage" ] || fehler "Kein Storage für Container-Templates (Inhalt vztmpl)." +if ! pveam list "$template_storage" | grep -q "$vorlage"; then + pveam download "$template_storage" "$vorlage" >/dev/null +fi +fertig "$vorlage auf $template_storage" + +netz="name=eth0,bridge=$bridge,firewall=1" +[ -n "$vlan" ] && netz="$netz,tag=$vlan" +if [ "$ip" = dhcp ]; then netz="$netz,ip=dhcp"; else netz="$netz,ip=$ip,gw=$gw"; fi + +schritt "Container $ctid anlegen" +pct create "$ctid" "$template_storage:vztmpl/$vorlage" \ + --hostname "$hostname" --cores "$cores" --memory "$memory" --swap 512 \ + --rootfs "$storage:$disk" --net0 "$netz" \ + --unprivileged 1 --features nesting=1 --onboot 1 \ + --tags "proxdeploy${tags:+;$tags}" \ + --description "ProxDeploy $version – eingerichtet mit install-lxc.sh" >/dev/null +pct start "$ctid" +fertig "Container $ctid ($hostname) läuft" + +schritt "Netzwerk" +adresse="" +for _ in $(seq 1 60); do + adresse="$(pct exec "$ctid" -- ip -4 -o addr show dev eth0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)" + [ -n "$adresse" ] && pct exec "$ctid" -- getent hosts deb.debian.org >/dev/null 2>&1 && break + adresse="" + sleep 2 +done +[ -n "$adresse" ] || fehler "Container $ctid bekommt keine Adresse oder kein DNS." +fertig "IP $adresse" + +[ -n "$url" ] || url="http://$adresse:3000" +pushen "$ctid" +pct exec "$ctid" -- bash /root/proxdeploy-im-container.sh installieren --url "$url" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..87af9e5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +# ProxDeploy im Betrieb: postgres, migrate (einmalig), app, worker. +# +# cp .env.example .env # Schlüssel erzeugen, ADMIN_PASSWORT und APP_URL setzen +# docker compose up -d --build +# +# Die app lauscht auf 127.0.0.1:3000 – davor gehört ein Reverse-Proxy mit TLS (TRUST_PROXY=true). + +name: proxdeploy + +x-proxdeploy: &proxdeploy + image: proxdeploy:${PROXDEPLOY_VERSION:-latest} + build: + context: . + args: + VERSION: ${PROXDEPLOY_VERSION:-} + env_file: ${PROXDEPLOY_ENV_DATEI:-.env} + environment: + DATABASE_URL: postgresql://proxdeploy:${POSTGRES_PASSWORT:?POSTGRES_PASSWORT in .env setzen}@postgres:5432/proxdeploy + restart: unless-stopped + +services: + postgres: + image: postgres:17.11-trixie + restart: unless-stopped + environment: + POSTGRES_USER: proxdeploy + POSTGRES_PASSWORD: ${POSTGRES_PASSWORT:?POSTGRES_PASSWORT in .env setzen} + POSTGRES_DB: proxdeploy + volumes: + - datenbank:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U proxdeploy"] + interval: 5s + timeout: 5s + retries: 20 + + migrate: + <<: *proxdeploy + command: ["node", "../worker/migrieren.mjs"] + restart: "no" + depends_on: + postgres: + condition: service_healthy + + app: + <<: *proxdeploy + ports: + - "${PROXDEPLOY_BIND:-127.0.0.1}:${PROXDEPLOY_PORT:-3000}:3000" + depends_on: + migrate: + condition: service_completed_successfully + + worker: + <<: *proxdeploy + command: ["node", "../worker/worker.mjs"] + healthcheck: + disable: true + # Lange Aufträge (Template-Bau) dürfen beim Stoppen zu Ende laufen. + stop_grace_period: 40s + depends_on: + migrate: + condition: service_completed_successfully + +volumes: + datenbank: diff --git a/docs/issues/019-installer.md b/docs/issues/019-installer.md index e61064d..b383ffb 100644 --- a/docs/issues/019-installer.md +++ b/docs/issues/019-installer.md @@ -1,5 +1,5 @@ # #019 Docker-Image und LXC-Installer -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.6.0 **Labels:** infrastruktur **Geschätzt:** 6 h **Tatsächlich:** – @@ -8,14 +8,22 @@ Produktionsbetrieb per Docker Compose oder in einem Debian-LXC. ## Aufgaben -- [ ] Dockerfile (standalone, non-root), Compose mit migrate -- [ ] `deploy/install-lxc.sh` (auf dem Proxmox-Host), Update-Pfad +- [x] Dockerfile (standalone, non-root), Compose mit migrate – lokal komplett hochgefahren (migrate → app healthy, worker bereit, Anmeldung) +- [x] `deploy/install-lxc.sh` (auf dem Proxmox-Host) + `deploy/im-container.sh`, Update-Pfad (`--aktualisieren`, drei Fassungen zum Zurückgehen) +- [x] Gemeinsames Laufzeit-Paket `scripts/paket-bauen.sh` für beide Wege +- [x] Migrationen ohne Prisma-CLI (`src/betrieb/migrieren.ts`), kompatibel zu `_prisma_migrations` ## Akzeptanzkriterien -- [ ] Installer in echtem LXC durchgelaufen +- [x] Installer in echtem LXC durchgelaufen (pve-demo, CT 109 `pd-installer`); darin Server angelegt, Verbindungstest, CLI-Login, Dry-Run und ein echter LXC-Auftrag ## Abhängigkeiten #001 ## Notizen / Entscheidungen -– +- **Eigener Migrationslauf statt `prisma migrate deploy`:** spart Prisma-CLI und Schema-Engine im + Betrieb; schreibt `_prisma_migrations` wie Prisma (Test prüft `prisma migrate status`). +- **worker als esbuild-Bundle** mit nur den Paketen, die er importiert (aus der Metadatei, exakte + Versionen aus der Entwicklung): 103 MB statt 741 MB. +- **Node im LXC von nodejs.org** mit SHA-256-Prüfung (Debian 13 hat nur Node 20). +- `ADMIN_PASSWORT` wird nach dem ersten Start aus der Env-Datei entfernt; das Passwort steht nur in der Ausgabe. +- Image 559 MB (node:24.21.0-trixie-slim), läuft als UID 1000; PostgreSQL 17.11. diff --git a/docs/issues/README.md b/docs/issues/README.md index 65ffa88..674a35f 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -22,7 +22,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#016](016-oidc.md) | Anmeldung per OIDC | erledigt | v0.5.0 | 4 h | 0 h 08 min | | [#017](017-branding.md) | Branding-Assets | erledigt | v0.6.0 | 4 h | – | | [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – | -| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – | +| [#019](019-installer.md) | Docker-Image und LXC-Installer | erledigt | v0.6.0 | 6 h | – | | [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – | | [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | umgesetzt, Abnahme offen | v0.3.0 | 3 h | 0 h 19 min | | [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min | diff --git a/eslint.config.mjs b/eslint.config.mjs index 292ac71..eac2650 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,5 +43,7 @@ export default defineConfig([ "playwright-report/**", "dist/**", "cli/**", + // Laufzeit-Paket aus scripts/paket-bauen.sh. + "paket/**", ]), ]); diff --git a/scripts/paket-bauen.sh b/scripts/paket-bauen.sh new file mode 100755 index 0000000..a9a5fa4 --- /dev/null +++ b/scripts/paket-bauen.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Baut das Laufzeit-Paket von ProxDeploy – dieselbe Struktur für Docker-Image und LXC-Installer: +# +# paket/ +# app/ Next standalone (server.js, .next, node_modules), dazu public/, catalog/, +# prisma/migrations/ – Arbeitsverzeichnis für app und worker +# worker/worker.mjs worker als ein Bundle (esbuild); Pakete aus worker/node_modules +# worker/migrieren.mjs Migrationen ohne Prisma-CLI +# worker/node_modules/ Produktions-Abhängigkeiten, flach (ohne Symlinks) +# VERSION +# +# scripts/paket-bauen.sh [version] → paket/ +# scripts/paket-bauen.sh 0.6.0 --tar → zusätzlich proxdeploy-0.6.0.tar.gz +set -euo pipefail +cd "$(dirname "$0")/.." +version="${1:-$(node -p 'require("./package.json").version')}" +commit="$(git rev-parse --short HEAD 2>/dev/null || echo unbekannt)" + +rm -rf paket +mkdir -p paket/worker + +# 1. Web-App (standalone). Version und Commit landen im Build (Seitenleiste, /api/v1/ich). +NEXT_PUBLIC_APP_VERSION="$version" NEXT_PUBLIC_GIT_SHA="$commit" pnpm build >/dev/null +cp -a .next/standalone paket/app +cp -a .next/static paket/app/.next/static +cp -a public paket/app/public +cp -a catalog paket/app/catalog +mkdir -p paket/app/prisma && cp -a prisma/migrations paket/app/prisma/migrations + +# 2. worker und Migrationslauf als Bundles; eigener Code (inkl. Prisma-Client aus src/generated) +# wird gebündelt, Pakete bleiben extern. +for eintrag in "src/worker/main.ts:worker.mjs" "src/betrieb/migrieren-start.ts:migrieren.mjs"; do + pnpm exec esbuild "${eintrag%%:*}" --bundle --platform=node --format=esm --target=node24 \ + --packages=external --log-level=warning \ + --define:process.env.NEXT_PUBLIC_APP_VERSION="\"$version\"" \ + --define:process.env.NEXT_PUBLIC_GIT_SHA="\"$commit\"" \ + --metafile="paket/worker/${eintrag##*:}.meta.json" \ + --outfile="paket/worker/${eintrag##*:}" +done + +# 3. Nur die Pakete, die die Bundles wirklich importieren (aus den esbuild-Metadateien), in den +# Versionen aus package.json – flach, ohne Build-Skripte. Next, React & Co. braucht der worker nicht. +tmp="$(mktemp -d)" +node - "$tmp" paket/worker/*.meta.json <<'JS' +const fs = require("node:fs"); +const [ziel, ...metas] = process.argv.slice(2); +const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); +const benoetigt = new Set(); +for (const datei of metas) { + const meta = JSON.parse(fs.readFileSync(datei, "utf8")); + for (const eingabe of Object.values(meta.inputs)) + for (const imp of eingabe.imports ?? []) + if (imp.external && !imp.path.startsWith("node:")) { + const teile = imp.path.split("/"); + benoetigt.add(imp.path.startsWith("@") ? teile.slice(0, 2).join("/") : teile[0]); + } +} +const abhaengigkeiten = {}; +for (const name of [...benoetigt].sort()) { + if (!pkg.dependencies[name]) throw new Error(`${name} wird vom worker importiert, steht aber nicht in dependencies`); + // Exakt die Version, die in der Entwicklung installiert ist (aus dem Lockfile aufgelöst). + abhaengigkeiten[name] = JSON.parse(fs.readFileSync(`node_modules/${name}/package.json`, "utf8")).version; +} +fs.writeFileSync(`${ziel}/package.json`, JSON.stringify({ name: "proxdeploy-worker", private: true, type: "module", dependencies: abhaengigkeiten }, null, 2)); +console.log(`worker braucht: ${Object.entries(abhaengigkeiten).map(([n, v]) => `${n}@${v}`).join(", ")}`); +JS +rm -f paket/worker/*.meta.json +cp pnpm-workspace.yaml "$tmp/" +(cd "$tmp" && pnpm install --prod --ignore-scripts --config.node-linker=hoisted >/dev/null) +mv "$tmp/node_modules" paket/worker/node_modules +rm -rf "$tmp" + +echo "$version+$commit" > paket/VERSION +echo "paket/ gebaut ($version+$commit, $(du -sh paket | cut -f1))" + +if [ "${2:-}" = "--tar" ]; then + tar -czf "proxdeploy-$version.tar.gz" -C paket . + sha256sum "proxdeploy-$version.tar.gz" +fi diff --git a/src/betrieb/migrieren-start.ts b/src/betrieb/migrieren-start.ts new file mode 100644 index 0000000..84d3794 --- /dev/null +++ b/src/betrieb/migrieren-start.ts @@ -0,0 +1,23 @@ +/** + * Einstieg für den Betrieb: `node migrieren.mjs` (Docker: Dienst „migrate“, LXC: vor app und worker). + * Liest DATABASE_URL und den Ordner der Migrationen (Standard: prisma/migrations neben dem Aufruf). + */ +import { migrieren } from "./migrieren"; + +const url = process.env["DATABASE_URL"]; +if (!url) { + console.error("DATABASE_URL fehlt."); + process.exit(2); +} +const ordner = process.env["MIGRATIONEN"] ?? "prisma/migrations"; +try { + const { eingespielt, vorhanden } = await migrieren(url, ordner, (text) => console.log(text)); + console.log( + eingespielt.length > 0 + ? `${eingespielt.length} Migration(en) eingespielt, ${vorhanden} waren schon da.` + : `Datenbank aktuell (${vorhanden} Migrationen).`, + ); +} catch (fehler) { + console.error((fehler as Error).message); + process.exit(1); +} diff --git a/src/betrieb/migrieren.ts b/src/betrieb/migrieren.ts new file mode 100644 index 0000000..c362bbf --- /dev/null +++ b/src/betrieb/migrieren.ts @@ -0,0 +1,94 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import pg from "pg"; + +/** + * Spielt die Migrationen aus `prisma/migrations/` ein – im Betrieb statt `prisma migrate deploy`, + * damit weder Docker-Image noch LXC das Prisma-CLI samt Schema-Engine brauchen. + * + * Kompatibel zu Prisma: dieselbe Tabelle `_prisma_migrations`, Prüfsumme = SHA-256 der + * migration.sql. In der Entwicklung bleibt `prisma migrate dev` also benutzbar, und beide + * erkennen, was schon eingespielt ist. + * + * Jede Migration läuft in einer eigenen Transaktion; eine Sperre (`pg_advisory_lock`) verhindert, + * dass app und worker gleichzeitig migrieren. Eine eingespielte Migration, deren Datei sich + * geändert hat, ist ein Fehler – dann stimmt der Codestand nicht zur Datenbank. + */ +const SPERRE = 724_196_331; // beliebig, aber fest: „proxdeploy-migration“ + +export type MigrationsErgebnis = { eingespielt: string[]; vorhanden: number }; + +export async function migrieren( + datenbankUrl: string, + ordner: string, + log: (text: string) => void = () => {}, +): Promise { + const migrationen = readdirSync(ordner, { withFileTypes: true }) + .filter((e) => e.isDirectory() && /^\d{14}_/.test(e.name)) + .map((e) => e.name) + .sort(); + const client = new pg.Client({ connectionString: datenbankUrl }); + await client.connect(); + try { + await client.query("SELECT pg_advisory_lock($1)", [SPERRE]); + await client.query(`CREATE TABLE IF NOT EXISTS "_prisma_migrations" ( + "id" VARCHAR(36) PRIMARY KEY NOT NULL, + "checksum" VARCHAR(64) NOT NULL, + "finished_at" TIMESTAMPTZ, + "migration_name" VARCHAR(255) NOT NULL, + "logs" TEXT, + "rolled_back_at" TIMESTAMPTZ, + "started_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + "applied_steps_count" INTEGER NOT NULL DEFAULT 0 + )`); + const { rows } = await client.query<{ + migration_name: string; + checksum: string; + finished_at: Date | null; + }>(`SELECT migration_name, checksum, finished_at FROM "_prisma_migrations" WHERE rolled_back_at IS NULL`); + const bekannt = new Map(rows.map((r) => [r.migration_name, r])); + const unfertig = rows.filter((r) => !r.finished_at); + if (unfertig.length > 0) { + throw new Error( + `Migration ${unfertig.map((r) => r.migration_name).join(", ")} ist unvollständig eingespielt – bitte prüfen.`, + ); + } + + const eingespielt: string[] = []; + for (const name of migrationen) { + const sql = readFileSync(join(ordner, name, "migration.sql")); + const pruefsumme = createHash("sha256").update(sql).digest("hex"); + const vorhanden = bekannt.get(name); + if (vorhanden) { + if (vorhanden.checksum !== pruefsumme) { + throw new Error(`Migration ${name} wurde nach dem Einspielen verändert (Prüfsumme weicht ab).`); + } + continue; + } + log(`Migration ${name} …`); + await client.query("BEGIN"); + try { + const id = randomUUID(); + await client.query( + `INSERT INTO "_prisma_migrations" (id, checksum, migration_name, started_at) VALUES ($1, $2, $3, now())`, + [id, pruefsumme, name], + ); + await client.query(sql.toString("utf8")); + await client.query( + `UPDATE "_prisma_migrations" SET finished_at = now(), applied_steps_count = 1 WHERE id = $1`, + [id], + ); + await client.query("COMMIT"); + } catch (fehler) { + await client.query("ROLLBACK"); + throw new Error(`Migration ${name} fehlgeschlagen: ${(fehler as Error).message}`); + } + eingespielt.push(name); + } + return { eingespielt, vorhanden: migrationen.length - eingespielt.length }; + } finally { + await client.query("SELECT pg_advisory_unlock($1)", [SPERRE]).catch(() => {}); + await client.end(); + } +} diff --git a/tests/engine/shellcheck.test.ts b/tests/engine/shellcheck.test.ts index ffe62c4..141502b 100644 --- a/tests/engine/shellcheck.test.ts +++ b/tests/engine/shellcheck.test.ts @@ -52,4 +52,36 @@ describe.runIf(shellcheckVerfuegbar())("shellcheck", () => { })(); expect(ausgabe).toBe(""); }, 60_000); + + it("findet in den Betriebsskripten nichts (Installer, Paket, CLI-Build)", () => { + const dateien = [ + "deploy/install-lxc.sh", + "deploy/im-container.sh", + "scripts/paket-bauen.sh", + "scripts/cli-pruefen.sh", + "cli/bauen.sh", + ]; + const ausgabe = (() => { + try { + execFileSync( + "docker", + [ + "run", + "--rm", + "-v", + `${process.cwd()}:/mnt:ro`, + "-w", + "/mnt", + "koalaman/shellcheck:stable", + ...dateien, + ], + { stdio: "pipe" }, + ); + return ""; + } catch (fehler) { + return String((fehler as { stdout?: Buffer }).stdout ?? fehler); + } + })(); + expect(ausgabe).toBe(""); + }, 60_000); }); diff --git a/tests/migrieren.test.ts b/tests/migrieren.test.ts new file mode 100644 index 0000000..81b5716 --- /dev/null +++ b/tests/migrieren.test.ts @@ -0,0 +1,64 @@ +import { execFileSync } from "node:child_process"; +import pg from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { migrieren } from "@/betrieb/migrieren"; +import { datenbankVerfuegbar } from "./helpers/db"; + +const NAME = "proxdeploy_migtest"; + +describe.runIf(datenbankVerfuegbar)("Migrationen im Betrieb (ohne Prisma-CLI)", () => { + const basis = new URL(process.env["TEST_DATABASE_URL"]!); + const ziel = new URL(basis); + ziel.pathname = `/${NAME}`; + const verwaltung = async (sql: string) => { + const c = new pg.Client({ connectionString: basis.href }); + await c.connect(); + try { + await c.query(sql); + } finally { + await c.end(); + } + }; + + beforeAll(async () => { + await verwaltung(`DROP DATABASE IF EXISTS ${NAME} WITH (FORCE)`); + await verwaltung(`CREATE DATABASE ${NAME}`); + }); + afterAll(async () => { + await verwaltung(`DROP DATABASE IF EXISTS ${NAME} WITH (FORCE)`); + }); + + it("spielt alle ein, beim zweiten Mal nichts – und Prisma hält den Stand für aktuell", async () => { + const erstes = await migrieren(ziel.href, "prisma/migrations"); + expect(erstes.eingespielt.length).toBeGreaterThan(5); + expect(erstes.eingespielt[0]).toMatch(/_grundgeruest$/); + expect(await migrieren(ziel.href, "prisma/migrations")).toEqual({ + eingespielt: [], + vorhanden: erstes.eingespielt.length, + }); + + const status = execFileSync("pnpm", ["exec", "prisma", "migrate", "status"], { + env: { ...process.env, DATABASE_URL: ziel.href }, + encoding: "utf8", + }); + expect(status).toMatch(/Database schema is up to date/); + + // Auch die Regeln aus den Migrationen (Trigger) sind da: das Audit-Log ist nur anhängbar. + const c = new pg.Client({ connectionString: ziel.href }); + await c.connect(); + try { + await c.query(`INSERT INTO "Protokoll" (quelle, aktion) VALUES ('SYSTEM', 'Test')`); + await expect(c.query(`DELETE FROM "Protokoll"`)).rejects.toThrow(); + } finally { + await c.end(); + } + }); + + it("verweigert, wenn eine eingespielte Migration verändert wurde", async () => { + const c = new pg.Client({ connectionString: ziel.href }); + await c.connect(); + await c.query(`UPDATE "_prisma_migrations" SET checksum = 'x' WHERE migration_name LIKE '%_server'`); + await c.end(); + await expect(migrieren(ziel.href, "prisma/migrations")).rejects.toThrow(/verändert/); + }); +});