diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..852e4fa --- /dev/null +++ b/.envrc @@ -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 diff --git a/.gitignore b/.gitignore index 1787ca4..ba7a350 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 19bf0db..79228b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 1bd2e4d..dfa4792 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/README.md b/backend/README.md index 8c784cf..8055c20 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 (`/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 (`/env`, Modus + 600); ohne ihn bleibt `GET /documents` gesperrt. Abrufen lässt sich die Liste + mit `remote backend documents`. - **Datenbank:** H2 im Dateimodus unter `/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.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 1–3 zur Vermessung der Zielumgebung). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f612c98..94b02ad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d242711..bef8bcc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 `, 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//…`); 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). diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 06b235b..d717db8 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -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 + - [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. diff --git a/scripts/deploy-backend.sh b/scripts/deploy-backend.sh index 7904151..ebe1a11 100755 --- a/scripts/deploy-backend.sh +++ b/scripts/deploy-backend.sh @@ -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" diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 7228f5c..f707f35 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -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" diff --git a/tools/remote b/tools/remote new file mode 100755 index 0000000..cd9fe29 --- /dev/null +++ b/tools/remote @@ -0,0 +1,354 @@ +#!/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