feat(tools): remote <ziel> <aktion> als Vordertuer zum Server (D77-Nachtrag)
Deployen, Log ansehen, Dienst schalten und fragen was laeuft waren vier verschiedene Beschwoerungen, drei davon von Hand als ssh + systemctl. Jetzt: remote backend deploy|upload|setup|install-jdk|reset-password remote backend start|stop|restart|status|enable|disable remote backend log|info|documents|backup remote frontend deploy|preview|info remote ssh Die Skripte unter scripts/ bleiben die Implementierung und einzeln aufrufbar; tools/remote bringt nur mit, wofuer es bisher nichts gab. Wo ein Schalter noetig war, kam er ins Skript statt ins Werkzeug: --unit-only in deploy-backend.sh (sonst kennte eine zweite Stelle die Unit-Platzhalter) und --dry-run in deploy-prod.sh, das die Befoerderung ausdruecklich mit abschaltet. Neu ist die Sicherung: H2 haelt die Datei offen, also anhalten, holen, wieder starten (gemessen ~8 s Auszeit) - und das Archiv lesen, bevor der Befehl es behaelt. Gegenprobe von Hand: lokal ausgepackt, Backend mit --werkbaum.data-dir dagegen gestartet, es liefert genau die Dokumente des Servers. .envrc legt tools/ auf den PATH (direnv), 217 Plan-Knoten, 0 Warnungen.
This commit is contained in:
Executable
+354
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Werkbaum — ein Befehl für alles, was auf dem Server passiert.
|
||||
#
|
||||
# Verwendung:
|
||||
# remote <ziel> <aktion> [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-<zeit>.tgz
|
||||
#
|
||||
# Frontend (statische Site, Vorgabe ~/doms/<domain>/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/<domain>/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 '<a class="ver"[^>]*>[^<]*</a>' | 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
|
||||
Reference in New Issue
Block a user