#!/usr/bin/env bash # # Werkbaum — ein Befehl für alles, was auf dem Server passiert. # # Verwendung: # remote [ARGS] # # Backend (systemd-User-Unit werkbaum-backend, Vorgabe ~/opt/werkbaum): # remote backend deploy [-y] [--no-build] [--no-restart] # bauen, hochladen, Unit schreiben, neu starten # und nachsehen, ob /api/v1/info antwortet # remote backend upload [-y] [--no-build] # wie deploy, aber ohne Neustart — das Jar wird # erst beim nächsten Start aktiv # remote backend start|stop|restart|status|enable|disable # den Dienst schalten (systemctl --user) # remote backend log [ARGS] backend.log folgen; weitere ARGS gehen an tail # remote backend info welcher Stand läuft — GET /api/v1/info, wenn # möglich über die öffentliche Adresse, also # samt Proxy-Regel # remote backend setup nur die systemd-Unit neu schreiben und laden # remote backend install-jdk [--force] # JDK 21 ins Home legen oder erneuern # remote backend reset-password [--from-env] [--no-restart] # Master-Passwort der Dokumentenliste setzen # remote backend documents [--from-env] # die Dokumentenliste abrufen — die eine Sache, # für die es das Master-Passwort gibt # remote backend backup [datei] Datenbank sichern. H2 hält die Datei offen, # also: Dienst anhalten, data/ holen, wieder # starten. Vorgabe backups/werkbaum-data-.tgz # # Frontend (statische Site, Vorgabe ~/doms//htdocs-ssl): # remote frontend deploy [-y] [--no-promote] # befördern (D30), bauen, zusammenstellen, # spiegeln # remote frontend preview nur zeigen, was sich am Ziel änderte — # ohne Beförderung und ohne zu schreiben # remote frontend info welche Version dort liegt (Footer der # ausgelieferten index.html) # # Host: # remote ssh [CMD] Shell auf dem Server, oder CMD dort ausführen # # Konfiguration steht in der git-ignorierten `.env` im Repo-Wurzelordner # (Vorlage: .env.example) — BACKEND_SSH, BACKEND_DIR, BACKEND_PORT und # DEPLOY_TARGET. Anders als im Vorbild gibt es hier nur EIN Konto: derselbe # Login trägt die Site und den Dienst. # # Die eigentliche Arbeit machen weiterhin die Skripte unter `scripts/`; die sind # einzeln aufrufbar und in beiden READMEs beschrieben. Dieses Werkzeug ist die # Vordertür und bringt nur mit, wofür es bisher kein Skript gab: die # systemd-Verben, das Log, die Zustandsabfragen und die Sicherung. # # Siehe README (Abschnitt Deployment) und docs/DECISIONS.md D77. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" # shellcheck source=../scripts/lib-env.sh . "$ROOT/scripts/lib-env.sh" UNIT=werkbaum-backend.service usage() { awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0" } die() { echo "$*" >&2; exit 1; } # Das SSH-Ziel: BACKEND_SSH, sonst der user@host-Teil von DEPLOY_TARGET. Beides # ist derselbe Login, aber DEPLOY_TARGET trägt zusätzlich einen Pfad — den # schneidet das `%%:*` weg. sshTarget() { local t; t="$(env_value BACKEND_SSH)" if [ -z "$t" ]; then t="$(env_value DEPLOY_TARGET)"; t="${t%%:*}" fi [ -n "$t" ] || die "Kein SSH-Ziel: BACKEND_SSH (oder DEPLOY_TARGET) in .env setzen." printf '%s' "$t" } backendDir() { local d; d="$(env_value BACKEND_DIR)"; printf '%s' "${d:-opt/werkbaum}"; } backendPort() { local p; p="$(env_value BACKEND_PORT)"; printf '%s' "${p:-9080}"; } # Derselbe Pfad in der Schreibweise, die eine Shell auf dem Server versteht. # rsync und systemd brauchen andere — die kennt scripts/deploy-backend.sh. backendDirSh() { local d; d="$(backendDir)" case "$d" in /*) printf '%s' "$d" ;; *) printf '$HOME/%s' "$d" ;; esac } # Die öffentliche Adresse steht nirgends ausdrücklich, wohl aber im rsync-Ziel # des Frontends (`.../doms//htdocs-ssl`). Lässt sie sich nicht ablesen, # geben die Aufrufer eine ehrliche Antwort statt zu raten. publicBase() { env_value DEPLOY_TARGET | sed -n 's|.*/doms/\([^/]*\)/.*|https://\1|p' } # `systemctl --user` findet seinen Manager ohne XDG_RUNTIME_DIR nicht — über # eine nicht-interaktive SSH-Sitzung ist die Variable oft nicht gesetzt. Die # klassische Falle bei User-Units aus einem Skript heraus (D77). sshSystemctl() { ssh "$(sshTarget)" \ "export XDG_RUNTIME_DIR=\${XDG_RUNTIME_DIR:-/run/user/\$(id -u)}; systemctl --user $*" } # Ein tty nur, wo es eins gibt — sonst warnt ssh einen skriptenden Aufrufer an. sshHost() { local ttyOpt=-t; [ -t 0 ] || ttyOpt= # shellcheck disable=SC2086 exec ssh $ttyOpt "$(sshTarget)" "$@" } badAction() { if [ -z "$2" ]; then echo "Ziel '$1' braucht eine Aktion." >&2 else echo "Unbekannte Aktion '$2' für Ziel '$1'." >&2; fi usage >&2 exit 2 } # ---------------------------------------------------------------- backend ---- cmdBackendLog() { # tty nur, wo es eins gibt — sonst warnt ssh und die Warnung landet mitten im # Log. Gemessen: „Pseudo-terminal will not be allocated ...“ als erste Zeile. local ttyOpt=-t; [ -t 0 ] || ttyOpt= # shellcheck disable=SC2086 exec ssh $ttyOpt "$(sshTarget)" "tail -f $* \"$(backendDirSh)/backend.log\"" } # Wartet, bis der Dienst wieder antwortet. Direkt an ihm gefragt (127.0.0.1), # nicht über den Proxy: Hier geht es darum, ob er oben ist — am Proxy hat sich # nichts geändert. Ohne dieses Warten sagt ein Befehl „Dienst wieder gestartet", # während die nächste Anfrage noch 503 bekommt (beim Bauen gemessen: ~14 s). waitForBackend() { ssh "$(sshTarget)" " export XDG_RUNTIME_DIR=\${XDG_RUNTIME_DIR:-/run/user/\$(id -u)} systemctl --user is-active --quiet $UNIT || { echo ' Dienst läuft nicht.'; exit 0; } for i in \$(seq 1 60); do case \"\$(curl -s --max-time 3 http://127.0.0.1:$(backendPort)/api/v1/info || true)\" in *'\"version\"'*) echo \" Antwortet wieder (nach \${i}s).\"; exit 0 ;; esac sleep 1 done echo ' ! Antwortet nicht — nachsehen mit: remote backend log' >&2; exit 1" } cmdBackendInfo() { local base; base="$(publicBase)" if [ -n "$base" ]; then echo "==> ${base}/api/v1/info" # --fail, damit ein 502 des Proxys nicht als Erfolg mit leerem Rumpf endet. curl -sS --fail --max-time 10 "${base}/api/v1/info" || { echo >&2 echo " Antwortet nicht über die öffentliche Adresse. Läuft der Dienst?" >&2 echo " Nachsehen: remote backend status · remote backend log" >&2 exit 1 } echo else # Ohne ablesbare Domain wenigstens direkt am Dienst fragen. Das prüft dann # den Proxy NICHT mit — deshalb wird es auch so gesagt. echo "==> keine Domain aus DEPLOY_TARGET ablesbar — frage direkt am Dienst" ssh "$(sshTarget)" "curl -sS --fail --max-time 10 http://127.0.0.1:$(backendPort)/api/v1/info" echo fi } # Die Dokumentenliste ist der einzige geschützte Endpunkt (D76-Nachtrag 6). # Ohne --from-env fragt curl selbst nach dem Passwort; mit --from-env kommt es # aus der .env — in beiden Fällen NIE über die Kommandozeile: `curl -u user:pw` # stünde in der Prozessliste, die auf einem geteilten Host jeder lesen kann, # und die Shell fasst das Passwort vorher an (aus `ge$heim` wird `ge`). cmdBackendDocuments() { local fromEnv=0 for a in "$@"; do case "$a" in --from-env) fromEnv=1 ;; *) die "Unbekannte Option: $a" ;; esac done local base; base="$(publicBase)" [ -n "$base" ] || die "Keine Domain aus DEPLOY_TARGET ablesbar — Adresse unbekannt." local url="${base}/api/v1/documents" if [ "$fromEnv" -eq 1 ]; then local pw; pw="$(env_value WERKBAUM_MASTER_PASSWORT)" [ -n "$pw" ] || die "WERKBAUM_MASTER_PASSWORT steht nicht in ${ENV_FILE}." # curl liest die Konfiguration von stdin; in doppelten Anführungszeichen # sind dort `\` und `"` zu escapen, sonst nichts. local esc=${pw//\\/\\\\}; esc=${esc//\"/\\\"} echo "==> ${url} (Passwort aus ${ENV_FILE})" printf 'user = "werkbaum:%s"\n' "$esc" | curl -sS --fail -K - "$url" || { echo >&2; echo " 401? Dann passt der Hash auf dem Server nicht zum" >&2 echo " Passwort in der .env: remote backend reset-password --from-env" >&2 exit 1 } else echo "==> ${url}" curl -sS --fail -u werkbaum "$url" || { echo >&2; exit 1; } fi echo } # Sichern heißt hier: anhalten, holen, wieder starten. H2 hält die Datei offen, # solange der Dienst läuft — eine Kopie im Betrieb kann zerrissen sein, und eine # Sicherung, der man nicht trauen kann, ist keine. Die Auszeit dauert Sekunden. cmdBackendBackup() { local out="${1:-}" if [ -z "$out" ]; then mkdir -p "$ROOT/backups" out="$ROOT/backups/werkbaum-data-$(date +%Y%m%d-%H%M%S).tgz" fi [ ! -e "$out" ] || die "Gibt es schon: $out" echo "==> Sichern von $(sshTarget):$(backendDir)/data nach ${out}" echo " (der Dienst hält dafür kurz an)" # Der Tar-Strom geht über stdout in die Datei — ALLE Meldungen müssen deshalb # nach stderr, sonst landen sie im Archiv. Und der Trap muss auch dann noch # starten, wenn die lokale Seite wegbricht: Ohne PIPE bliebe der Dienst # ausgerechnet dann unten, wenn beim Herunterladen etwas schiefgeht. local tmp="${out}.teil" if ! ssh "$(sshTarget)" DIR="$(backendDirSh)" UNIT="$UNIT" 'bash -s' > "$tmp" <<'REMOTE' set -euo pipefail DIR="$(eval echo "$DIR")" export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" [ -d "$DIR/data" ] || { echo "Kein $DIR/data auf dem Server." >&2; exit 1; } LIEF=0 systemctl --user is-active --quiet "$UNIT" && LIEF=1 wiederAn() { if [ "$LIEF" = 1 ]; then # NICHTS nach stdout: dort liegt der Tar-Strom. Ein einziges Wort darin # macht das Archiv unlesbar, und der Trap kann an jeder Stelle zuschlagen. systemctl --user start "$UNIT" >/dev/null 2>&1 || true echo " Dienst wieder gestartet." >&2 fi } trap wiederAn EXIT HUP INT TERM PIPE if [ "$LIEF" = 1 ]; then echo " Dienst anhalten ..." >&2 systemctl --user stop "$UNIT" >&2 else echo " Dienst lief nicht — nichts anzuhalten." >&2 fi tar czf - -C "$DIR" data REMOTE then rm -f "$tmp" die "Sicherung fehlgeschlagen — nichts geschrieben." fi # Gegenprobe, bevor jemand der Datei vertraut: Lässt sie sich lesen, und ist # die Datenbank drin? Ein Archiv, das man nie geöffnet hat, ist eine Hoffnung. if ! tar tzf "$tmp" >/dev/null 2>&1; then rm -f "$tmp" die "Das Archiv ist nicht lesbar — nichts geschrieben." fi if ! tar tzf "$tmp" | grep -q 'data/editor.*\.db'; then rm -f "$tmp" die "Im Archiv liegt keine H2-Datenbank (data/editor*.db) — nichts geschrieben." fi mv "$tmp" "$out" echo "==> $(du -h "$out" | cut -f1) in ${out}" echo " Inhalt: $(tar tzf "$out" | tr '\n' ' ')" waitForBackend echo echo " Zurückspielen — der Dienst muss dafür stehen:" echo " remote backend stop" echo " ssh $(sshTarget) 'rm -rf $(backendDirSh)/data'" echo " ssh $(sshTarget) 'tar xzf - -C $(backendDirSh)' < ${out}" echo " remote backend start" } cmdBackend() { local action="${1:-}"; shift || true case "$action" in deploy) scripts/deploy-backend.sh "$@" ;; upload) scripts/deploy-backend.sh --no-restart "$@" ;; setup) scripts/deploy-backend.sh --unit-only "$@" ;; install-jdk) scripts/install-jdk.sh "$@" ;; reset-password) scripts/reset-password.sh "$@" ;; log) cmdBackendLog "$@" ;; info) cmdBackendInfo "$@" ;; documents) cmdBackendDocuments "$@" ;; backup) cmdBackendBackup "$@" ;; status) sshSystemctl "status $UNIT --no-pager -l" ;; enable|disable) sshSystemctl "$action --now $UNIT" ;; start|stop|restart) sshSystemctl "$action $UNIT" ;; *) badAction backend "$action" ;; esac } # --------------------------------------------------------------- frontend ---- # Was liegt am Ziel? Der Footer trägt Version und den Commit, aus dem gebaut # wurde (D16) — die einzige Stelle, an der die ausgelieferte Datei sagt, was # sie ist. Deshalb wird sie gelesen und nicht der lokale Stand behauptet. cmdFrontendInfo() { local base; base="$(publicBase)" [ -n "$base" ] || die "Keine Domain aus DEPLOY_TARGET ablesbar." echo "==> ${base}/" local html html="$(curl -sS --fail --max-time 15 "${base}/")" \ || die " Die Seite antwortet nicht." local ver commit ver="$(printf '%s' "$html" | grep -o ']*>[^<]*' | head -1)" commit="$(printf '%s' "$ver" | sed -n 's/.*href="\([^"]*\)".*/\1/p')" if [ -z "$ver" ]; then echo " Kein Versions-Link im Footer — ist das wirklich Werkbaum?" exit 1 fi echo " Version: $(printf '%s' "$ver" | sed -n 's/.*>\([^<]*\)<.*/\1/p')" echo " Commit: ${commit}" # Ob der ausgelieferte Stand der eigene ist, sagt nur der Vergleich. local head; head="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" if [ -n "$head" ]; then case "$commit" in *"$head") echo " Das ist genau dein HEAD." ;; *) echo " Dein HEAD ist ein anderer: ${head}" ;; esac fi } cmdFrontend() { local action="${1:-}"; shift || true case "$action" in deploy) scripts/deploy-prod.sh "$@" ;; preview) scripts/deploy-prod.sh --dry-run "$@" ;; info) cmdFrontendInfo "$@" ;; *) badAction frontend "$action" ;; esac } # ------------------------------------------------------------------ Start ---- target="${1:-}"; shift || true case "$target" in backend) cmdBackend "$@" ;; frontend) cmdFrontend "$@" ;; ssh) sshHost "$@" ;; ''|-h|--help) usage ;; *) echo "Unbekanntes Ziel '$target'." >&2; usage >&2; exit 2 ;; esac