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:
mhoennig
2026-08-26 19:59:24 +02:00
parent 9443790382
commit ac6df1bb6b
11 changed files with 566 additions and 31 deletions
+16
View File
@@ -0,0 +1,16 @@
# direnv-Konfiguration.
#
# Legt `tools/` auf den PATH, solange man in diesem Verzeichnisbaum steht — dann
# genügt `remote backend deploy` statt `tools/remote backend deploy`. Wirkt nur
# hier und darunter, die globale Shell-Konfiguration bleibt unangetastet.
#
# Einmal pro Arbeitskopie freizugeben (direnv lädt eine Datei erst, wenn man ihr
# traut) — und erneut, sobald sich diese Datei ändert:
#
# direnv allow
#
# Bewusst `tools` und nicht `scripts`: Auf dem PATH hieße der Befehl sonst
# `deploy-prod.sh`. Die Skripte bleiben, wo sie sind, und einzeln aufrufbar;
# `tools/remote` ist die Vordertür davor.
PATH_add tools
+4
View File
@@ -69,6 +69,10 @@ node_modules/
.env.local
*.local
# Datenbank-Sicherungen vom Server (`remote backend backup`). Sie enthalten
# fremde Pläne — sie gehören nie ins Repository, auch nicht versehentlich.
backups/
# === Temporäres ===
tmp/
*.tmp
+5
View File
@@ -9,6 +9,11 @@ Integrations-Backend.
- `backend/` — Kotlin/Spring-Boot-Anwendung (Taiga-Integration, Persistenz).
Eigene Regeln: backend/CLAUDE.md
- `docs/` — Projektdokumente · `brand/` — Marke (brand/BRAND.md)
- `tools/remote` — alles, was auf dem Server passiert, als Ziel und Aktion
(`remote backend deploy`, `remote frontend preview`, …; `remote --help`).
Die Arbeit machen weiterhin die Skripte unter `scripts/``remote` ist die
Vordertür davor. Braucht ein Skript einen neuen Schalter, kommt der **ins
Skript**, nicht ins Werkzeug (D77-Nachtrag).
## Verbindliche Referenzen
- Sprachdefinition: @docs/SPEC.md — die Notation ist der gemeinsame Vertrag
+33 -4
View File
@@ -246,6 +246,31 @@ repository, while the **version number** links to that exact commit
"GitHub Actions". The repo must be **public** for this (GitHub Pages via Actions
is only available for private repos on a paid plan).
### One command for the server: `remote`
Everything that happens on the server is reachable as **target and action**:
```bash
remote backend deploy # build, upload, unit, restart, then probe
remote backend log # follow backend.log
remote backend status # systemctl --user status
remote backend info # which build is running, through the proxy
remote backend backup # stop, fetch the database, start again
remote backend reset-password # asks, hashes it on the server, verifies
remote frontend deploy # promote, build, assemble, mirror
remote frontend preview # what would change — writes nothing
remote frontend info # which version is out there
remote ssh # a shell on the server
```
`remote --help` lists them all. With [direnv](https://direnv.net) the repo's
`.envrc` puts `tools/` on `PATH` (once per checkout: `direnv allow`), so the
command needs no path; otherwise call `tools/remote`.
The scripts under `scripts/` remain the implementation and stay usable on their
own — `remote` is the front door and brings along only what had no script
before: the systemd verbs, the log, the state queries and the backup.
### Stable instance
`scripts/deploy-prod.sh` mirrors a badge-free production build to a server over
@@ -268,12 +293,16 @@ footer version link points at a commit GitHub does not know yet.
The backend is a separate deploy, because it is a service rather than files:
```bash
scripts/install-jdk.sh # once: a JDK 21 into the server's home
scripts/deploy-backend.sh # build, upload, systemd user unit, restart
scripts/reset-password.sh # asks for the master password, hashes it there
scripts/deploy-prod.sh # the editor — also writes the /api/ proxy rule
remote backend install-jdk # once: a JDK 21 into the server's home
remote backend deploy # build, upload, systemd user unit, restart
remote backend reset-password # asks for the master password, hashes it there
remote frontend deploy # the editor — also writes the /api/ proxy rule
```
(The same four as `scripts/install-jdk.sh`, `scripts/deploy-backend.sh`,
`scripts/reset-password.sh` and `scripts/deploy-prod.sh`, if you'd rather call
them directly.)
`GET /api/v1/info` answers with name, version and build time — that is the
liveness check, for the deploy and for monitoring. Expecting a **404** from a
document that does not exist would be a poor assurance: a misconfigured proxy
+19 -11
View File
@@ -261,25 +261,33 @@ beim Start aus der IDE —, steht dort `unbekannt` statt eines Fehlers.
## Betrieb auf der stabilen Instanz
`scripts/deploy-backend.sh` (im Repo-Wurzelordner) baut das Fat-Jar, legt es
samt systemd-User-Unit ins Home des Servers und startet den Dienst neu;
`scripts/install-jdk.sh` bringt einmalig ein JDK 21 dorthin. Der Dienst lauscht
nur auf `127.0.0.1` — von außen kommt man über die Proxy-Regel in
`scripts/prod.htaccess`, die der Frontend-Deploy mitspiegelt.
`remote backend deploy` (aus dem Repo-Wurzelordner; ohne direnv
`tools/remote …`) baut das Fat-Jar, legt es samt systemd-User-Unit ins Home des
Servers und startet den Dienst neu; `remote backend install-jdk` bringt einmalig
ein JDK 21 dorthin. Der Dienst lauscht nur auf `127.0.0.1` — von außen kommt man
über die Proxy-Regel in `scripts/prod.htaccess`, die der Frontend-Deploy
mitspiegelt.
Dahinter stehen unverändert `scripts/deploy-backend.sh` und
`scripts/install-jdk.sh`; `remote --help` zeigt alle Ziele und Aktionen.
- **Speicher:** `-Xmx192m -Xms48m` plus Freiraum-Verhältnisse; gemessen rund
174 MB RSS gegen 291 MB ohne Angaben. Nach einem GC leben ~45 MB. Zu wenig
Luft? `BACKEND_XMX` in `.env`.
- **Master-Passwort:** `scripts/reset-password.sh` fragt es verdeckt ab, hasht
es auf dem Server und prüft selbst nach, ob Hash und Passwort zueinander
passen. Gespeichert wird nur der Hash (`<BACKEND_DIR>/env`, Modus 600); ohne
ihn bleibt `GET /documents` gesperrt.
- **Master-Passwort:** `remote backend reset-password` fragt es verdeckt ab,
hasht es auf dem Server und prüft selbst nach, ob Hash und Passwort
zueinander passen. Gespeichert wird nur der Hash (`<BACKEND_DIR>/env`, Modus
600); ohne ihn bleibt `GET /documents` gesperrt. Abrufen lässt sich die Liste
mit `remote backend documents`.
- **Datenbank:** H2 im Dateimodus unter `<BACKEND_DIR>/data/`. Ein Deploy
fasst das Verzeichnis nicht an (kein `--delete`). Umstieg auf das dort
laufende PostgreSQL: JDBC-URL in der `application.yaml` tauschen und den
Treiber ergänzen — Schema und Code bleiben.
- **Nachsehen:** `systemctl --user status werkbaum-backend`,
`tail -f <BACKEND_DIR>/backend.log`.
- **Sichern:** `remote backend backup`. H2 hält die Datei offen, solange der
Dienst läuft — gesichert wird deshalb mit kurzem Anhalten, und das Archiv
wird nach dem Herunterladen gelesen, bevor der Befehl es behält.
- **Nachsehen:** `remote backend status`, `remote backend log`,
`remote backend info`.
Begründungen: docs/DECISIONS.md D77 (und D76-Nachträge 13 zur Vermessung der
Zielumgebung).
+1
View File
@@ -25,6 +25,7 @@ reverse.
- The backend can be deployed to a server: its own JDK, a systemd service, and Apache passing `/api/` on
- "Put on the server" in the document menu turns a local plan into a shared one and puts the link in the address bar
- A service endpoint `/api/v1/info` says which build is running — for deploys and monitoring
- One `remote` command drives the server: deploy, log, service state, and a database backup that is read back before it is kept
## 2026-08-25
+82
View File
@@ -6516,3 +6516,85 @@ Probe mit 404 und ist von außen nicht erreichbar (`server.address=127.0.0.1`,
gegengeprüft über die LAN-Adresse). Der Deploy selbst — SSH, `systemctl`, die
Proxy-Regel im Betrieb — läuft erst, wenn jemand ihn startet. Das ist dieselbe
Grenze wie in D25 und D72: Was die Umgebung stellt, stellt der Emulator nicht.
**Nachtrag — `tools/remote`: eine Vordertür, Ziel und Aktion (2026-08-26).**
Die Skripte deckten den Deploy ab und sonst nichts. Alles Übrige — Log
ansehen, Dienst schalten, fragen was läuft — war ein von Hand getipptes
`ssh … systemctl --user …`, jedes Mal samt der `XDG_RUNTIME_DIR`-Falle. Nach
dem Muster eines anderen Projekts des Nutzers gibt es dafür jetzt einen
Befehl: `remote <ziel> <aktion>`, mit `backend`, `frontend` und `ssh` als
Zielen. Eine `.envrc` legt `tools/` auf den PATH (direnv), sodass `remote`
ohne Pfad genügt.
**Die Skripte bleiben die Implementierung, `remote` ist die Vordertür.** Sie
sind in beiden READMEs und in diesem Eintrag beschrieben, einzeln aufrufbar
und in Vorbereitung eines CI-Laufs nützlich; sie in das Werkzeug zu ziehen
hätte einen großen Diff für keinen Gewinn gebracht. `remote` bringt nur mit,
wofür es bisher gar nichts gab: die systemd-Verben, `log`, `info`,
`documents`, `backup`. Der Preis ist benannt — zwei Namen für dieselbe Sache,
deshalb nennen die READMEs jetzt `remote …` zuerst.
**Wo es einen Schalter brauchte, kam der ins Skript, nicht ins Werkzeug.**
`remote backend setup` schreibt nur die Unit neu; dafür hat
`deploy-backend.sh` ein `--unit-only` bekommen, statt dass `tools/remote`
die Platzhalter der Vorlage ein zweites Mal ersetzt. Genau diese Verdopplung
ist in D16 schon einmal teuer geworden (dieselben `sed`-Regeln in Workflow
und Skript). Ebenso `remote frontend preview``deploy-prod.sh --dry-run`.
**`--dry-run` schaltet die Beförderung ausdrücklich mit ab.** Sie läuft als
Schritt 0, also **vor** der rsync-Vorschau, und macht einen Commit (D30) —
ein Probelauf, der etwas schreibt, ist keiner. Gebaut und zusammengestellt
wird trotzdem, sonst wüsste der Vergleich nicht, wogegen er läuft.
Nachgemessen: HEAD und der Plan bleiben unangetastet.
**Sichern heißt anhalten.** 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. `remote backend backup` hält den Dienst an,
holt `data/` als Tar-Strom und startet ihn wieder — gemessen rund 8 s Auszeit.
Zwei Feinheiten, die beide lautlos zuschlagen würden: Der Strom geht über
stdout in die Datei, **alle** Meldungen müssen deshalb nach stderr (sonst
landen sie im Archiv), und der Trap fängt `PIPE` mit ab — bricht die lokale
Seite weg, bliebe der Dienst sonst ausgerechnet dann unten.
**Und der Befehl liest das Archiv, bevor er es behält.** Erst nach
`tar tzf` und dem Nachweis, dass eine H2-Datei darin liegt, wird aus
`.teil` die endgültige Datei. Die eigentliche Gegenprobe lief einmal von
Hand und gehört hierher, weil sie die Zusage prüft und nicht die Mechanik:
Das Archiv lokal ausgepackt, das Backend mit `--werkbaum.data-dir` dagegen
gestartet — es kommt hoch und liefert genau die Dokumente, die auch auf dem
Server stehen. Ein Archiv, das nie jemand geöffnet hat, ist eine Hoffnung.
**`backend info` fragt über die öffentliche Adresse**, nicht am Dienst
vorbei: So ist die Proxy-Regel mitgeprüft. Die Domain wird aus dem
rsync-Ziel des Frontends abgelesen (`…/doms/<domain>/…`); ohne sie fragt der
Befehl direkt an `127.0.0.1` **und sagt, dass er es tut**. `frontend info`
liest den Versions-Link aus dem ausgelieferten Footer (D16) und vergleicht
den Commit mit dem eigenen HEAD — die einzige Stelle, an der die Datei
selbst sagt, was sie ist.
**`documents` bekommt das Passwort nie über die Kommandozeile.** Ohne
Angabe fragt curl selbst danach; mit `--from-env` kommt es als
`curl -K -` über stdin. Die Begründung steht schon im
`reset-password`-Nachtrag oben und gilt hier wörtlich: Argumente stehen in
der Prozessliste, und die Shell verändert das Passwort vorher.
**Gemessen statt geraten, zwei Kleinigkeiten am Rand:** Ein `frontend log`
gibt es **nicht**`~/doms/werkbaum.javagil.de/var/` ist leer, der Managed
Webspace reicht die Apache-Logs nicht ins Home (nur monatliche
`~/var/domaintraffic-*.log`); an seine Stelle tritt `frontend info`. Und
`ssh -t` gehört nur dorthin, wo es ein Terminal gibt: Sonst steht
„Pseudo-terminal will not be allocated" als erste Zeile mitten im Log
(beim Bauen gemessen).
**Der Befehl wartet, bis der Dienst wieder antwortet.** Die erste Fassung von
`backup` meldete „Dienst wieder gestartet" und das nächste `info` bekam ein
503 — der Start dauert rund 8 s. Ein Werkzeug, das eine Sache meldet, die
gleich darauf nicht stimmt, ist schlechter als eines, das schweigt.
**Nachgemessen** gegen die produktive Instanz: `backend info` (200 samt
Version), `frontend info` (1.1.162, Commit = HEAD), `backend status`,
`backend log`, `backend documents --from-env` (liefert die Dokumente),
`backend setup -y` (Unit neu, Jar unangetastet — mtime unverändert),
`backend restart`, `backend backup` samt lokalem Wiederanlauf,
`frontend preview` (nichts geschrieben, HEAD unverändert) und die
Fehlerpfade (Exit-Code 2 bei unbekanntem Ziel und fehlender Aktion).
+13 -1
View File
@@ -107,10 +107,12 @@
- [^] #bld.pwa.sw: A service worker that serves the app offline (S) %% network-first, cache as fallback
- [^] #bld.pwa.update: The reload notice stays truthful under the worker (S) %% see D45/D73
- [^] #bld.pwa.files: Double-click a .werkbaum file, save without any dialog (S) :#ed.files.inplace %% file_handlers + launchQueue
- [^] #bld.backend: Ship the backend as a service of its own (M)
- [^] #bld.backend: Ship the backend as a service of its own (L)
- [^] #bld.backend.jdk: Its own JDK 21 in the server's home (S) %% only 17 is installed there
- [^] #bld.backend.unit: A systemd user unit that survives the session (S)
- [^] #bld.backend.proxy: Apache passes /api/ on, in .htaccess (XS) %% measured: the P flag is allowed
- [x] #bld.backend.remote: One command for everything on the server (S) %% remote <target> <action>
- [x] #bld.backend.backup: Save the database, and prove it can be read back (XS)
- [-] #bld.ghpages: Deploy through a gh-pages branch (S) %% GitHub Actions instead
- [-] #bld.gfonts: Fonts from Google (XS) %% self-hosted, no third-party request
- [ ] #be: Backend (XL)
@@ -749,6 +751,16 @@
.htaccess and the vhost directory is out of reach. Measured on the target: a
request is held open for 30 seconds, which is what the change feed needs.
#bld.backend.remote
Deploying, reading the log, asking what is running and switching the service
were four different incantations, three of them typed by hand as ssh plus
systemctl. One front door names them all as target and action.
#bld.backend.backup
The database is a file in a home directory, and H2 holds it open, so a copy
taken while the service runs can be torn. Stop, fetch, start again — and read
the archive back before trusting it.
#bld.ghpages
Rejected: the official Actions route needs no extra branch and no deploy
key.
+25 -14
View File
@@ -8,11 +8,15 @@
# `scripts/deploy-prod.sh` mitspiegelt (D76-Nachtrag 2).
#
# Verwendung:
# scripts/deploy-backend.sh [-y] [--no-build] [--no-restart] [ssh-ziel]
# scripts/deploy-backend.sh [-y] [--no-build] [--no-restart] [--unit-only] [ssh-ziel]
#
# -y ohne Rückfrage spiegeln und neu starten
# --no-build vorhandenes Jar nehmen (z. B. Wiederholung eines Deploys)
# --no-restart nur hochladen, Dienst nicht anfassen
# --unit-only nur die systemd-Unit aus der Vorlage neu schreiben und laden;
# kein Bauen, kein Jar. Für den Fall, dass sich Port, Speicher
# oder die Vorlage selbst geändert haben. Die Platzhalter kennt
# damit weiterhin genau eine Stelle — dieses Skript.
#
# Konfiguration aus der git-ignorierten `.env` (Vorlage: .env.example):
# BACKEND_SSH user@host — Pflicht (oder als Argument)
@@ -37,12 +41,14 @@ set -euo pipefail
YES=0
BUILD=1
RESTART=1
UNIT_ONLY=0
SSH_TARGET=""
for arg in "$@"; do
case "$arg" in
-y|--yes) YES=1 ;;
--no-build) BUILD=0 ;;
--no-restart) RESTART=0 ;;
--unit-only) UNIT_ONLY=1; BUILD=0 ;;
-h|--help)
awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0"
exit 0 ;;
@@ -90,18 +96,22 @@ echo "==> Ziel ${SSH_TARGET}:~/${DIR#/}, Port ${PORT}, -Xmx${XMX}"
# ---- 1) Bauen ----
JAR=""
if [ "$BUILD" -eq 1 ]; then
echo "==> ./gradlew bootJar (mit Tests)"
(cd backend && ./gradlew --quiet check bootJar)
if [ "$UNIT_ONLY" -eq 1 ]; then
echo "==> Nur die Unit (--unit-only)"
else
echo "==> Build übersprungen (--no-build)"
if [ "$BUILD" -eq 1 ]; then
echo "==> ./gradlew bootJar (mit Tests)"
(cd backend && ./gradlew --quiet check bootJar)
else
echo "==> Build übersprungen (--no-build)"
fi
JAR="$(ls -1t backend/build/libs/*.jar 2>/dev/null | grep -v -- '-plain\.jar$' | head -1 || true)"
if [ -z "$JAR" ]; then
echo "Kein Fat-Jar in backend/build/libs — ohne --no-build versuchen." >&2
exit 1
fi
echo " ${JAR} ($(du -h "$JAR" | cut -f1))"
fi
JAR="$(ls -1t backend/build/libs/*.jar 2>/dev/null | grep -v -- '-plain\.jar$' | head -1 || true)"
if [ -z "$JAR" ]; then
echo "Kein Fat-Jar in backend/build/libs — ohne --no-build versuchen." >&2
exit 1
fi
echo " ${JAR} ($(du -h "$JAR" | cut -f1))"
# ---- 2) Unit-Datei aus der Vorlage ----
# Die Platzhalter werden hier ersetzt, nicht auf dem Server: So steht im
@@ -114,11 +124,11 @@ sed -e "s#__JAVA__#${JAVA_UNIT}#g" \
-e "s#__XMX__#${XMX}#g" \
-e "s#__PORT__#${PORT}#g" \
scripts/werkbaum-backend.service > "$STAGE/werkbaum-backend.service"
cp "$JAR" "$STAGE/werkbaum-backend.jar"
[ "$UNIT_ONLY" -eq 1 ] || cp "$JAR" "$STAGE/werkbaum-backend.jar"
if [ "$YES" -ne 1 ]; then
echo "==> Es werden übertragen:"
echo " werkbaum-backend.jar -> ${DIR}/"
[ "$UNIT_ONLY" -eq 1 ] || echo " werkbaum-backend.jar -> ${DIR}/"
echo " werkbaum-backend.service -> ~/.config/systemd/user/"
[ "$RESTART" -eq 1 ] && echo " und der Dienst neu gestartet."
printf '==> Weiter? [y/N] '
@@ -132,7 +142,8 @@ fi
# räumt nicht auf.
echo "==> Übertragen"
ssh "$SSH_TARGET" "mkdir -p \"$DIR_SH\" \"\$HOME/.config/systemd/user\""
rsync -az --chmod=F644 "$STAGE/werkbaum-backend.jar" "$SSH_TARGET:$DIR_RSYNC/werkbaum-backend.jar"
[ "$UNIT_ONLY" -eq 1 ] || \
rsync -az --chmod=F644 "$STAGE/werkbaum-backend.jar" "$SSH_TARGET:$DIR_RSYNC/werkbaum-backend.jar"
rsync -az --chmod=F644 "$STAGE/werkbaum-backend.service" \
"$SSH_TARGET:~/.config/systemd/user/werkbaum-backend.service"
+14 -1
View File
@@ -11,12 +11,17 @@
# Zielverzeichnis. `--delete`: am Ziel bleibt nichts Altes stehen.
#
# Verwendung:
# scripts/deploy-prod.sh [-y] [--no-promote] [rsync-ziel]
# scripts/deploy-prod.sh [-y] [--no-promote] [--dry-run] [rsync-ziel]
#
# -y ohne Rückfrage befördern und spiegeln (sonst erst Vorschau
# via --dry-run + Nachfrage)
# --no-promote Beförderungsschritt überspringen (z. B. Wiederholung eines
# Deploys, der schon befördert hat)
# --dry-run nur zeigen, was sich am Ziel änderte, und dann aufhören.
# Schaltet die Beförderung ausdrücklich mit ab: die macht einen
# Commit, und ein Probelauf, der etwas schreibt, ist keiner.
# Gebaut und zusammengestellt wird trotzdem — sonst wüsste der
# Vergleich nicht, wogegen er läuft.
#
# Das Ziel ist entweder das Argument ODER — wenn keins angegeben ist — die
# Variable DEPLOY_TARGET aus der git-ignorierten Datei .env im Repo-Wurzelordner
@@ -38,11 +43,13 @@ set -euo pipefail
# ---- Argumente ----
YES=0
PROMOTE=1
DRY=0
TARGET=""
for arg in "$@"; do
case "$arg" in
-y|--yes) YES=1 ;;
--no-promote) PROMOTE=0 ;;
--dry-run) DRY=1; PROMOTE=0 ;;
-h|--help)
awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0"
exit 0 ;;
@@ -158,6 +165,12 @@ sed -e "s/__BACKEND_PORT__/${BACKEND_PORT}/g" scripts/prod.htaccess > "$STAGE/.h
# abräumen. Bedient Hostsharing die Challenge außerhalb des Docroots, ist es ein
# No-op.
RSYNC_OPTS=(-avz --delete --chmod=D755,F644 --filter='protect /.well-known/***')
if [ "$DRY" -eq 1 ]; then
echo "==> Probelauf (rsync --dry-run --delete) nach ${TARGET}:"
rsync "${RSYNC_OPTS[@]}" --dry-run "$STAGE"/ "$TARGET"
echo "==> Nichts geschrieben (--dry-run)."
exit 0
fi
if [ "$YES" -ne 1 ]; then
echo "==> Vorschau (rsync --dry-run --delete) nach ${TARGET}:"
rsync "${RSYNC_OPTS[@]}" --dry-run "$STAGE"/ "$TARGET"
Executable
+354
View File
@@ -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