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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ cli/bin/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
paket/
|
||||||
|
proxdeploy-*.tar.gz
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ cli/
|
|||||||
catalog/
|
catalog/
|
||||||
test-results
|
test-results
|
||||||
public/brand
|
public/brand
|
||||||
|
paket
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
Executable
+214
@@ -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
|
||||||
Executable
+121
@@ -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"
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -43,5 +43,7 @@ export default defineConfig([
|
|||||||
"playwright-report/**",
|
"playwright-report/**",
|
||||||
"dist/**",
|
"dist/**",
|
||||||
"cli/**",
|
"cli/**",
|
||||||
|
// Laufzeit-Paket aus scripts/paket-bauen.sh.
|
||||||
|
"paket/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
Executable
+79
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user