feat(betrieb): Docker Compose und LXC-Installer

Ein Laufzeit-Paket (scripts/paket-bauen.sh) für beide Wege: Next standalone, worker als Bundle mit nur den nötigen Paketen, Migrationen ohne Prisma-CLI. Compose lokal komplett hochgefahren; der Installer auf pve-demo in einem echten Debian-13-LXC durchgelaufen.

Closes #019

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:35:41 +02:00
co-authored by Claude Opus 5.5
parent 7f07efe05d
commit 533a821087
17 changed files with 767 additions and 7 deletions
+13
View File
@@ -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
+5 -1
View File
@@ -1,7 +1,11 @@
# ── Datenbank ──────────────────────────────────────────────────────────────── # ── Datenbank ────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy 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. # 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 ────────────────────────────────────────────────────────────── # ── Geheimnisse ──────────────────────────────────────────────────────────────
# Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64. # Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64.
+2
View File
@@ -13,3 +13,5 @@ cli/bin/
playwright-report/ playwright-report/
test-results/ test-results/
.DS_Store .DS_Store
paket/
proxdeploy-*.tar.gz
+1
View File
@@ -10,3 +10,4 @@ cli/
catalog/ catalog/
test-results test-results
public/brand public/brand
paket
+4
View File
@@ -8,6 +8,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt ### 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) - 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) - Web-App-Manifest; Favicon passt sich hellem und dunklem Browser an (#017)
+34
View File
@@ -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"]
+214
View File
@@ -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 <<EOF
[Unit]
Description=ProxDeploy – Datenbank-Migrationen
After=postgresql.service network-online.target
Requires=postgresql.service
[Service]
Type=oneshot
RemainAfterExit=yes
User=proxdeploy
EnvironmentFile=$ENV_DATEI
WorkingDirectory=$BASIS/aktuell/app
ExecStart=/opt/node/bin/node ../worker/migrieren.mjs
EOF
for dienst in app worker; do
if [ "$dienst" = app ]; then start="server.js"; beschreibung="Web-GUI und REST-API"; else start="../worker/worker.mjs"; beschreibung="worker (Aufträge)"; fi
cat > "/etc/systemd/system/proxdeploy-$dienst.service" <<EOF
[Unit]
Description=ProxDeploy – $beschreibung
After=proxdeploy-migrate.service
Requires=proxdeploy-migrate.service
[Service]
User=proxdeploy
EnvironmentFile=$ENV_DATEI
Environment=NODE_ENV=production
WorkingDirectory=$BASIS/aktuell/app
ExecStart=/opt/node/bin/node $start
Restart=on-failure
RestartSec=5
TimeoutStopSec=40
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
[Install]
WantedBy=multi-user.target
EOF
done
systemctl daemon-reload
}
auf_gesundheit_warten() {
for _ in $(seq 1 60); do
if curl -fsS -o /dev/null "http://127.0.0.1:3000/api/v1/gesundheit" 2>/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 <ctid>"
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" <<SQL
DO \$\$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'proxdeploy') THEN
CREATE ROLE proxdeploy LOGIN PASSWORD '$db_passwort';
ELSE
ALTER ROLE proxdeploy PASSWORD '$db_passwort';
END IF;
END \$\$;
SQL
su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname = 'proxdeploy'\"" | grep -q 1 ||
su postgres -c "createdb -O proxdeploy proxdeploy"
fertig "Datenbank proxdeploy"
schritt "Konfiguration $ENV_DATEI"
mkdir -p /etc/proxdeploy
umask 027
cat > "$ENV_DATEI" <<EOF
# ProxDeploy – erzeugt von im-container.sh am $(date -Iseconds)
DATABASE_URL=postgresql://proxdeploy:$db_passwort@127.0.0.1:5432/proxdeploy
PROXDEPLOY_SCHLUESSEL=$(openssl rand -base64 32)
SITZUNG_GEHEIMNIS=$(openssl rand -base64 48)
APP_URL=$url
# Hinter einem Reverse-Proxy auf true setzen (wertet X-Forwarded-* aus).
TRUST_PROXY=false
PORT=3000
HOSTNAME=0.0.0.0
LOG_LEVEL=info
WORKER_PARALLEL=4
ADMIN_BENUTZER=admin
ADMIN_PASSWORT=$admin_passwort
EOF
chown root:proxdeploy "$ENV_DATEI"
chmod 0640 "$ENV_DATEI"
umask 022
schritt "ProxDeploy"
local version
version="$(paket_auspacken)"
dienste_schreiben
systemctl enable --now proxdeploy-app proxdeploy-worker >/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 <<EOF
┌──────────────────────────────────────────────────────────────┐
ProxDeploy ist bereit: $url
Benutzer: admin
Passwort: $admin_passwort
(wird nur jetzt angezeigt – nach der Anmeldung unter
Einstellungen → Passwort ändern)
Konfiguration: $ENV_DATEI
Logs: journalctl -u proxdeploy-app -u proxdeploy-worker
└──────────────────────────────────────────────────────────────┘
EOF
}
aktualisieren() {
[ -f "$ENV_DATEI" ] || fehler "Hier ist ProxDeploy noch nicht eingerichtet."
node_installieren
local alt version
alt="$(cat "$BASIS/aktuell/VERSION" 2>/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
+121
View File
@@ -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 <ctid> --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://<IP des Containers>:3000)
# --tags LISTE zusätzliche Proxmox-Tags, z. B. proxdeploy-test
#
# Das Paket baut `scripts/paket-bauen.sh <version> --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-<version>.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"
+65
View File
@@ -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:
+13 -5
View File
@@ -1,5 +1,5 @@
# #019 Docker-Image und LXC-Installer # #019 Docker-Image und LXC-Installer
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.6.0 **Meilenstein:** v0.6.0
**Labels:** infrastruktur **Labels:** infrastruktur
**Geschätzt:** 6 h **Tatsächlich:** – **Geschätzt:** 6 h **Tatsächlich:** –
@@ -8,14 +8,22 @@
Produktionsbetrieb per Docker Compose oder in einem Debian-LXC. Produktionsbetrieb per Docker Compose oder in einem Debian-LXC.
## Aufgaben ## Aufgaben
- [ ] Dockerfile (standalone, non-root), Compose mit migrate - [x] Dockerfile (standalone, non-root), Compose mit migrate – lokal komplett hochgefahren (migrate → app healthy, worker bereit, Anmeldung)
- [ ] `deploy/install-lxc.sh` (auf dem Proxmox-Host), Update-Pfad - [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 ## 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 ## Abhängigkeiten
#001 #001
## Notizen / Entscheidungen ## 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.
+1 -1
View File
@@ -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 | | [#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 | – | | [#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 | – | | [#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 | – | | [#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 | | [#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 | | [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
+2
View File
@@ -43,5 +43,7 @@ export default defineConfig([
"playwright-report/**", "playwright-report/**",
"dist/**", "dist/**",
"cli/**", "cli/**",
// Laufzeit-Paket aus scripts/paket-bauen.sh.
"paket/**",
]), ]),
]); ]);
+79
View File
@@ -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
+23
View File
@@ -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);
}
+94
View File
@@ -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<MigrationsErgebnis> {
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();
}
}
+32
View File
@@ -52,4 +52,36 @@ describe.runIf(shellcheckVerfuegbar())("shellcheck", () => {
})(); })();
expect(ausgabe).toBe(""); expect(ausgabe).toBe("");
}, 60_000); }, 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);
}); });
+64
View File
@@ -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/);
});
});