fix(backend): Neustart gegen die eigene Datenbank, /info als Lebendprobe

Der Dienst lief einmal und stuerzte danach in einer Schleife: MODE=PostgreSQL
laesst H2 unquotierte Bezeichner klein anlegen, Liquibase sucht seine
Verwaltungstabellen gross, findet nichts, legt sie an — "Table
databasechangelog already exists". Der erste Start ging, jeder weitere nicht.
Gemessen mit dem echten Jar: ohne den Modus laufen beide. Liquibase auf
Kleinschreibung zu konfigurieren half nicht, es korrigiert den Namen selbst
zurueck — deshalb weicht der Modus ganz.

Die Testsuite konnte das nicht finden (jeder Test bekommt eine frische
In-Memory-DB), und der Regressionstest dafuer hat zweimal gelogen: erst reichte
er die URL als Default-Property herein, die die application.yaml ueberstimmt;
dann als Argument, aber damit pruefte er eine URL, die er sich selbst
ausgedacht hatte. Jetzt hat die URL einen Regler (werkbaum.data-dir), der Test
ueberschreibt nur den, und die Gegenprobe faellt.

Dieselbe Sorte Fehler eine Ebene hoeher: Die Testkonfiguration hiess
application.yaml und verdeckte damit die Hauptkonfiguration vollstaendig. Sie
ist jetzt eine Profil-Ueberlagerung.

Dazu GET /api/v1/info mit Name, Version und Bauzeitpunkt. Die Lebendprobe
erwartete bisher eine 404 von einem Dokument, das es nicht gibt — ein
erwarteter Fehler ist eine schlechte Zusicherung, dieselbe 404 liefert auch ein
falsch konfigurierter Proxy.

138 Backend-Tests. Gegenprobe: MODE=PostgreSQL zurueck -> genau der
Neustart-Test faellt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 19:02:51 +02:00
co-authored by Claude Opus 5
parent 78901793f0
commit dea904814b
17 changed files with 436 additions and 52 deletions
+10 -2
View File
@@ -270,9 +270,15 @@ The backend is a separate deploy, because it is a service rather than files:
```bash ```bash
scripts/install-jdk.sh # once: a JDK 21 into the server's home scripts/install-jdk.sh # once: a JDK 21 into the server's home
scripts/deploy-backend.sh # build, upload, systemd user unit, restart 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 scripts/deploy-prod.sh # the editor — also writes the /api/ proxy rule
``` ```
`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
returns one too.
Configuration lives in the git-ignored `.env` (template `.env.example`): Configuration lives in the git-ignored `.env` (template `.env.example`):
`BACKEND_SSH`, and optionally `BACKEND_DIR`, `BACKEND_JDK_DIR`, `BACKEND_PORT`, `BACKEND_SSH`, and optionally `BACKEND_DIR`, `BACKEND_JDK_DIR`, `BACKEND_PORT`,
`BACKEND_XMX`. `BACKEND_XMX`.
@@ -292,8 +298,10 @@ Configuration lives in the git-ignored `.env` (template `.env.example`):
- **The master password never leaves the server.** It goes into - **The master password never leaves the server.** It goes into
`<BACKEND_DIR>/env` (mode 600), which the deploy creates empty on the first `<BACKEND_DIR>/env` (mode 600), which the deploy creates empty on the first
run. Until a hash is in it, the document list stays locked — deliberately. run. Until a hash is in it, the document list stays locked — deliberately.
Generate it **interactively** (`htpasswd -nBC 12 ''`, no `-b`): a password on `scripts/reset-password.sh` asks for it, sends it to the server over
a command line lands in the shell history, and the shell mangles it on the **stdin** (never as an argument — those show up in the process list) and
verifies afterwards that hash and password match. Never put a password on a
command line: it lands in the shell history, and the shell mangles it on the
way — `ge$heim` becomes `ge`. What gets hashed is then not what you type way — `ge$heim` becomes `ge`. What gets hashed is then not what you type
later, and the server answers 401 while everything looks right. later, and the server answers 401 while everything looks right.
- **Memory is the scarce resource on that host**, not CPU. The JVM flags are - **Memory is the scarce resource on that host**, not CPU. The JVM flags are
+36 -8
View File
@@ -218,21 +218,47 @@ alles richtig aussieht.
## Persistenz ## Persistenz
- **H2 im File-Modus** (`./data/editor.mv.db`) mit `MODE=PostgreSQL` - **H2 im File-Modus** (`${werkbaum.data-dir:./data}/editor.mv.db`) läuft im
läuft im Server-Prozess, keine Datenbank-Installation nötig. Dokumente und Server-Prozess, keine Datenbank-Installation nötig. Dokumente und Historie
Historie überleben einen Neustart. überleben einen Neustart.
- **Bewusst ohne `MODE=PostgreSQL`**, obwohl es naheliegt: In dem Modus legt H2
unquotierte Bezeichner klein an, Liquibase sucht seine Verwaltungstabellen
aber groß — findet nichts, legt sie an, und H2 antwortet „Table
databasechangelog already exists". Der erste Start ging, **jeder weitere
stürzte ab**. Gemessen und in D77 begründet.
- Die JDBC-URL hat **einen** Regler: `werkbaum.data-dir`. Der Regressionstest
überschreibt nur den, damit alles Übrige an der ausgelieferten URL unter
Test steht.
- **Schema per Liquibase im formatierten SQL-Format** - **Schema per Liquibase im formatierten SQL-Format**
(`src/main/resources/db/changelog/db.changelog-master.sql`, kein XML). (`src/main/resources/db/changelog/db.changelog-master.sql`, kein XML).
Neue Änderungen werden als weitere `--changeset`-Blöcke angehängt; Neue Änderungen werden als weitere `--changeset`-Blöcke angehängt;
Hibernate validiert nur (`ddl-auto: validate`). Hibernate validiert nur (`ddl-auto: validate`).
- **Umstieg auf echtes PostgreSQL:** im Wesentlichen JDBC-URL/Credentials in - **Umstieg auf echtes PostgreSQL:** im Wesentlichen JDBC-URL/Credentials in
der `application.yaml` tauschen und den Postgres-Treiber als Dependency der `application.yaml` tauschen und den Postgres-Treiber als Dependency
ergänzen Schema-Migrationen und Code bleiben unverändert. ergänzen Schema-Migrationen und Code bleiben unverändert. (Der
- Tests laufen gegen H2 in-memory (`src/test/resources/application.yaml`), H2-PostgreSQL-Modus war ein Nachbau davon und ist es nicht wert, siehe oben.)
mit demselben Liquibase-Schema. - Tests laufen gegen H2 in-memory. Die Testkonfiguration heißt
`application-test.yaml` und ist eine **Profil-Überlagerung**: Gleichnamig
(`application.yaml`) verdeckte sie die Hauptkonfiguration vollständig, und
die Tests prüften eine Konfiguration, die in Produktion nie läuft.
- Service-Methoden sind `@Transactional`: Dokument-Änderung und - Service-Methoden sind `@Transactional`: Dokument-Änderung und
Historieneintrag werden atomar geschrieben. Historieneintrag werden atomar geschrieben.
## Läuft er? `GET /api/v1/info`
```json
{"name":"editor-backend","version":"0.1.0-SNAPSHOT","builtAt":"2026-08-26T16:59:40.341Z"}
```
Offen, ohne Nebenwirkung, und sagt zugleich, **welcher Stand** läuft. Vorher
war die Lebendprobe eine Anfrage nach einem nicht existierenden Dokument mit
der Erwartung **404** — ein erwarteter *Fehler* ist eine schlechte
Zusicherung, weil ihn auch ein falsch konfigurierter Proxy liefert.
Die Angaben kommen aus `META-INF/build-info.properties`
(`springBoot { buildInfo() }`, Teil des Boot-Plugins). Fehlt die Datei — etwa
beim Start aus der IDE —, steht dort `unbekannt` statt eines Fehlers.
## Betrieb auf der stabilen Instanz ## Betrieb auf der stabilen Instanz
`scripts/deploy-backend.sh` (im Repo-Wurzelordner) baut das Fat-Jar, legt es `scripts/deploy-backend.sh` (im Repo-Wurzelordner) baut das Fat-Jar, legt es
@@ -244,8 +270,10 @@ nur auf `127.0.0.1` — von außen kommt man über die Proxy-Regel in
- **Speicher:** `-Xmx192m -Xms48m` plus Freiraum-Verhältnisse; gemessen rund - **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 174 MB RSS gegen 291 MB ohne Angaben. Nach einem GC leben ~45 MB. Zu wenig
Luft? `BACKEND_XMX` in `.env`. Luft? `BACKEND_XMX` in `.env`.
- **Master-Passwort:** `<BACKEND_DIR>/env` auf dem Server, Modus 600. Ohne - **Master-Passwort:** `scripts/reset-password.sh` fragt es verdeckt ab, hasht
Hash bleibt `GET /documents` gesperrt. 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.
- **Datenbank:** H2 im Dateimodus unter `<BACKEND_DIR>/data/`. Ein Deploy - **Datenbank:** H2 im Dateimodus unter `<BACKEND_DIR>/data/`. Ein Deploy
fasst das Verzeichnis nicht an (kein `--delete`). Umstieg auf das dort fasst das Verzeichnis nicht an (kein `--delete`). Umstieg auf das dort
laufende PostgreSQL: JDBC-URL in der `application.yaml` tauschen und den laufende PostgreSQL: JDBC-URL in der `application.yaml` tauschen und den
+6
View File
@@ -11,6 +11,12 @@ plugins {
group = "de.werkbaum" group = "de.werkbaum"
version = "0.1.0-SNAPSHOT" version = "0.1.0-SNAPSHOT"
// Erzeugt META-INF/build-info.properties (Teil des Boot-Plugins, keine neue
// Abhaengigkeit) - daraus speist sich GET /api/v1/info.
springBoot {
buildInfo()
}
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(21) languageVersion = JavaLanguageVersion.of(21)
@@ -10,6 +10,7 @@ import de.werkbaum.generated.model.DocumentCreateRequest
import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry
import de.werkbaum.generated.model.DocumentUpdateRequest import de.werkbaum.generated.model.DocumentUpdateRequest
import de.werkbaum.generated.model.RestoreRequest import de.werkbaum.generated.model.RestoreRequest
import de.werkbaum.generated.model.ServiceInfo
import de.werkbaum.domain.ChangeAuthor import de.werkbaum.domain.ChangeAuthor
import de.werkbaum.domain.ChangeEvent import de.werkbaum.domain.ChangeEvent
import de.werkbaum.domain.ChangeFeed import de.werkbaum.domain.ChangeFeed
@@ -18,6 +19,7 @@ import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.service.DocumentService import de.werkbaum.service.DocumentService
import de.werkbaum.service.LiveEditingService import de.werkbaum.service.LiveEditingService
import org.springframework.boot.info.BuildProperties
import org.springframework.http.CacheControl import org.springframework.http.CacheControl
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@@ -36,8 +38,30 @@ import java.util.UUID
class DocumentsController( class DocumentsController(
private val service: DocumentService, private val service: DocumentService,
private val liveEditing: LiveEditingService, private val liveEditing: LiveEditingService,
/**
* Aus `META-INF/build-info.properties` (Gradle: `springBoot { buildInfo() }`).
* Bewusst optional: Wer die Anwendung aus der IDE startet, hat die Datei
* nicht — dann fehlt die Zusatzangabe, statt dass der Start scheitert.
*/
private val buildProperties: BuildProperties? = null,
) : DocumentsApi { ) : DocumentsApi {
/**
* Lebendprobe: läuft der Dienst, und welcher Stand ist es?
*
* Offen und ohne Nebenwirkung. Vorher musste die Prüfung eine Anfrage nach
* einem nicht existierenden Dokument stellen und auf **404** hoffen — ein
* erwarteter Fehler ist eine schlechte Zusicherung, weil ihn auch ein
* falsch konfigurierter Proxy liefert.
*/
override fun getInfo(): ResponseEntity<ServiceInfo> = ResponseEntity.ok(
ServiceInfo(
name = buildProperties?.name ?: "werkbaum-backend",
version = buildProperties?.version ?: "unbekannt",
builtAt = buildProperties?.time?.atOffset(java.time.ZoneOffset.UTC),
)
)
override fun listDocuments(): ResponseEntity<List<ApiDocument>> = override fun listDocuments(): ResponseEntity<List<ApiDocument>> =
ResponseEntity.ok(service.findAll().map { it.toApi() }) ResponseEntity.ok(service.findAll().map { it.toApi() })
+7 -3
View File
@@ -3,9 +3,13 @@ spring:
name: editor-backend name: editor-backend
datasource: datasource:
# H2 im File-Modus mit PostgreSQL-Kompatibilitaet. # H2 im File-Modus. Bewusst OHNE MODE=PostgreSQL, obwohl das naheliegt:
# Spaeterer Umstieg auf echtes PostgreSQL = im Wesentlichen nur diese URL aendern. # In dem Modus legt H2 unquotierte Bezeichner klein an, Liquibase sucht
url: jdbc:h2:file:./data/editor;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH # seine Verwaltungstabellen aber gross - findet nichts, legt sie an, und H2
# antwortet "Table databasechangelog already exists". Folge: Der erste
# Start geht, JEDER WEITERE stuerzt ab. Gemessen und in D77 begruendet;
# der Umstieg auf echtes PostgreSQL bleibt eine Frage von URL und Treiber.
url: jdbc:h2:file:${werkbaum.data-dir:./data}/editor;DEFAULT_NULL_ORDERING=HIGH
username: sa username: sa
password: "" password: ""
driver-class-name: org.h2.Driver driver-class-name: org.h2.Driver
@@ -313,6 +313,25 @@ paths:
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
/info:
get:
tags: [Documents]
operationId: getInfo
summary: Name und Version des Dienstes
description: >
Offen und ohne Nebenwirkung - gedacht als Lebendprobe fuer Deploy und
Ueberwachung. Ohne diesen Endpunkt bliebe dafuer nur eine Anfrage nach
einem Dokument, das es nicht gibt, und man muesste auf eine 404 hoffen:
Ein erwarteter Fehler ist eine schlechte Zusicherung, weil ihn auch ein
falsch konfigurierter Proxy liefert.
responses:
"200":
description: Der Dienst laeuft
content:
application/json:
schema:
$ref: "#/components/schemas/ServiceInfo"
components: components:
responses: responses:
NotFound: NotFound:
@@ -565,6 +584,19 @@ components:
items: items:
$ref: "#/components/schemas/ChangeEvent" $ref: "#/components/schemas/ChangeEvent"
ServiceInfo:
type: object
required: [name, version]
properties:
name:
type: string
version:
type: string
builtAt:
type: string
format: date-time
description: Fehlt, wenn ohne Build-Informationen gestartet (z. B. aus der IDE).
ProblemDetail: ProblemDetail:
type: object type: object
description: Fehlerformat nach RFC 9457 (Problem Details) description: Fehlerformat nach RFC 9457 (Problem Details)
@@ -0,0 +1,53 @@
package de.werkbaum
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import org.springframework.boot.builder.SpringApplicationBuilder
import java.nio.file.Path
/**
* Die Anwendung muss **gegen ihre eigene Datenbank neu starten** können.
*
* Klingt selbstverständlich und war es nicht: `DATABASE_TO_LOWER=TRUE` lässt
* H2 unquotierte Bezeichner klein anlegen (wie PostgreSQL, dafür steht es in
* der URL), Liquibase sucht seine Verwaltungstabellen aber unter
* `DATABASECHANGELOG`, findet nichts und legt sie an — woraufhin H2
* „Table databasechangelog already exists" sagt. Der **erste** Start ging,
* jeder weitere stürzte ab.
*
* Gefunden hat das erst der Server, nicht die Testsuite: Jeder Test bekommt
* eine frische In-Memory-Datenbank, und auch von Hand gestartet wurde immer
* gegen ein leeres Verzeichnis. Der Fall „starte noch einmal" kam schlicht nie
* vor — bis der erste Deploy ihn zum Regelfall machte (D77-Nachtrag).
*
* Deshalb hier eine **Datei**-Datenbank und zwei Starts nacheinander. Der Test
* kostet zwei Kontext-Starts; das ist er wert.
*/
class RestartTest {
@Test
fun `startet auch gegen eine bestehende Datenbank`(@TempDir dir: Path) {
repeat(2) { lauf ->
val context = SpringApplicationBuilder(EditorBackendApplication::class.java)
.run(
// Überschrieben wird **nur das Verzeichnis**, nicht die
// ganze JDBC-URL: Sonst prüfte der Test eine URL, die er
// sich selbst ausgedacht hat, und die ausgelieferte bliebe
// ungeprüft — genau daran ist der erste Anlauf gescheitert.
//
// Und als **Argument**, nicht als `properties(...)`:
// Letztere sind Default-Properties mit der NIEDRIGSTEN
// Priorität, die `application.yaml` überstimmt sie.
"--werkbaum.data-dir=$dir",
"--server.port=0",
"--werkbaum.master-password.hash={noop}egal",
)
try {
context.isRunning shouldBe true
} finally {
context.close()
}
}
}
}
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestPropertySource import org.springframework.test.context.TestPropertySource
import org.springframework.test.web.servlet.client.RestTestClient import org.springframework.test.web.servlet.client.RestTestClient
import java.util.Base64 import java.util.Base64
@@ -18,6 +19,7 @@ import java.util.Base64
* UUID offen da, und das Zugriffsmodell (D76) wäre hinfällig — ohne dass es * UUID offen da, und das Zugriffsmodell (D76) wäre hinfällig — ohne dass es
* jemandem auffiele. * jemandem auffiele.
*/ */
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient @AutoConfigureRestTestClient
@TestPropertySource( @TestPropertySource(
@@ -26,8 +28,8 @@ import java.util.Base64
// Eigene Datenbank: Dieser Test braucht einen zweiten Spring-Kontext, // Eigene Datenbank: Dieser Test braucht einen zweiten Spring-Kontext,
// und zwei Kontexte auf derselben In-Memory-H2 stolpern uebereinander // und zwei Kontexte auf derselben In-Memory-H2 stolpern uebereinander
// (Liquibase legt DATABASECHANGELOG ein zweites Mal an). // (Liquibase legt DATABASECHANGELOG ein zweites Mal an).
"spring.datasource.url=jdbc:h2:mem:editor-master-pw;MODE=PostgreSQL;" + "spring.datasource.url=jdbc:h2:mem:editor-master-pw;" +
"DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1", "DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1",
] ]
) )
class MasterPasswordDefaultTest { class MasterPasswordDefaultTest {
@@ -8,7 +8,9 @@ import io.cucumber.spring.CucumberContextConfiguration
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
@ActiveProfiles("test")
@CucumberContextConfiguration @CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Seit Boot 4 stellt @SpringBootTest die Test-Client-Bean nicht mehr von selbst bereit // Seit Boot 4 stellt @SpringBootTest die Test-Client-Bean nicht mehr von selbst bereit
@@ -71,6 +71,22 @@ class DocumentStepDefinitions {
lastResponse = listDocuments("test-geheim") lastResponse = listDocuments("test-geheim")
} }
@Wenn("ich die Info des Dienstes abrufe")
fun `ich rufe die Info ab`() {
lastResponse = client.get()
.uri("/api/v1/info")
.exchange()
.returnResult(String::class.java)
}
@Und("die Antwort nennt einen Namen und eine Version")
fun `die Antwort nennt Name und Version`() {
withClue("Antwort: ${body()}") {
body() shouldContain "\"name\""
body() shouldContain "\"version\""
}
}
@Wenn("ich alle Dokumente ohne Master-Passwort abrufe") @Wenn("ich alle Dokumente ohne Master-Passwort abrufe")
fun `ich rufe alle Dokumente ohne Passwort ab`() { fun `ich rufe alle Dokumente ohne Passwort ab`() {
lastResponse = client.get() lastResponse = client.get()
@@ -0,0 +1,20 @@
# Nur die Abweichungen fuer Tests. Bewusst `application-test.yaml` und nicht
# `application.yaml`: Gleichnamig VERDECKT die Testdatei die Hauptkonfiguration
# vollstaendig, und dann pruefen die Tests eine Konfiguration, die in
# Produktion nie laeuft. Als Profil-Ueberlagerung laedt Spring erst die
# Hauptdatei und legt diese darueber - Fehler wie die Liquibase-Schreibweise
# (D77-Nachtrag) fallen so im Test auf statt beim zweiten Start am Server.
spring:
datasource:
url: jdbc:h2:mem:editor-test;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1
werkbaum:
live-editing:
# Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten.
max-wait: 5s
master-password:
# {noop} = Klartext. Nur im Test; in Produktion steht hier {bcrypt}$2a$...
hash: "{noop}test-geheim"
max-attempts: 3
lockout: 2s
@@ -1,27 +0,0 @@
spring:
datasource:
url: jdbc:h2:mem:editor-test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1
username: sa
password: ""
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
threads:
virtual:
enabled: true
werkbaum:
live-editing:
# Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten.
max-wait: 5s
master-password:
# {noop} = Klartext. Nur im Test; in Produktion steht hier {bcrypt}$2a$...
hash: "{noop}test-geheim"
max-attempts: 3
lockout: 2s
@@ -4,6 +4,15 @@ Funktionalität: Dokumente verwalten
möchte ich Dokumente anlegen, abrufen, ändern und löschen können, möchte ich Dokumente anlegen, abrufen, ändern und löschen können,
damit das Backend die Grundlage für den Editor bildet. damit das Backend die Grundlage für den Editor bildet.
Szenario: Der Dienst sagt, wer er ist
Wenn ich die Info des Dienstes abrufe
Dann erhalte ich den Status 200
Und die Antwort nennt einen Namen und eine Version
Szenario: Die Info ist ohne Master-Passwort zu haben
Wenn ich die Info des Dienstes abrufe
Dann erhalte ich den Status 200
Szenario: Ein neues Dokument anlegen Szenario: Ein neues Dokument anlegen
Wenn ich ein Dokument mit dem Titel "Notizen" und dem Inhalt "Hallo Welt" anlege Wenn ich ein Dokument mit dem Titel "Notizen" und dem Inhalt "Hallo Welt" anlege
Dann erhalte ich den Status 201 Dann erhalte ich den Status 201
+62
View File
@@ -6386,6 +6386,68 @@ nichts. Der Test hat bewiesen, dass die richtigen Pfade *übergeben* werden,
nicht dass die Gegenseite sie versteht. Dieselbe Grenze wie in D25 und D72, nicht dass die Gegenseite sie versteht. Dieselbe Grenze wie in D25 und D72,
nur eine Ebene tiefer: Ein Stub prüft die eigene Seite der Naht. nur eine Ebene tiefer: Ein Stub prüft die eigene Seite der Naht.
**Nachtrag beim ersten Betrieb: `MODE=PostgreSQL` verhindert den Neustart.**
Der Dienst lief einmal und stürzte danach in einer Schleife: Liquibase legt
seine Verwaltungstabelle an, H2 antwortet „Table databasechangelog already
exists". In dem Modus schreibt H2 unquotierte Bezeichner **klein** (wie
PostgreSQL, dafür stand er in der URL); Liquibase sucht sie **groß**, findet
nichts und legt sie neu an. Der erste Start ging, jeder weitere nicht.
Gemessen, mit dem echten Jar und je frischem Verzeichnis:
| URL | zweiter Start |
|---|---|
| `MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE` (bisher) | stürzt ab |
| dito, Liquibase-Tabellen kleingeschrieben konfiguriert | stürzt ab |
| **ohne `MODE=PostgreSQL`** | **läuft** |
Die zweite Zeile ist der Grund, warum der Modus ganz weicht statt Liquibase
umkonfiguriert zu werden: Liquibase korrigiert den Namen selbst wieder auf
Großschreibung. Verloren geht wenig — der Modus ließ H2 wie PostgreSQL
*aussehen*, das Schema kommt aber ohnehin aus einem Liquibase-Changelog, und
der Umstieg auf echtes PostgreSQL bleibt eine Frage von URL und Treiber.
**Warum die Testsuite das nicht gefunden hat, und was daraus folgt.** Jeder
Test bekam eine frische In-Memory-Datenbank; „starte noch einmal" kam nie vor.
Der Regressionstest dafür hat mich dann **zweimal hintereinander belogen**, und
beide Male auf dieselbe Art — er prüfte etwas anderes, als er behauptete:
1. Er reichte die JDBC-URL über `SpringApplicationBuilder.properties(…)`
herein. Das sind **Default**-Properties mit der *niedrigsten* Priorität; die
`application.yaml` überstimmt sie. Der Test lief die ganze Zeit gegen eine
andere Datenbank und meldete für jede Variante dasselbe Ergebnis.
2. Als Kommandozeilen-Argument gereicht wirkte die URL — aber jetzt prüfte der
Test eine URL, die er sich **selbst ausgedacht** hatte, während die
ausgelieferte ungeprüft blieb. Die Gegenprobe (Modus zurückbauen, muss
fallen) blieb prompt stumm.
Beides fiel nur auf, weil die Gegenprobe zum Verfahren gehört. Jetzt hat die
URL **einen** Regler — `werkbaum.data-dir` —, der Test überschreibt nur den,
und alles Übrige an der ausgelieferten URL steht unter Test. Mit
`MODE=PostgreSQL` zurück fällt genau die eine danach benannte Zusicherung.
Dieselbe Lehre eine Ebene höher: Die Testkonfiguration hieß
`application.yaml` und **verdeckte** damit die Hauptkonfiguration vollständig —
die Tests prüften eine Konfiguration, die in Produktion nie läuft. Sie heißt
jetzt `application-test.yaml` und ist eine Profil-Überlagerung.
**Eine Lebendprobe braucht einen eigenen Endpunkt.** Bis hierher fragte das
Deploy nach einem Dokument, das es nicht gibt, und hoffte auf **404**. Ein
erwarteter *Fehler* ist eine schlechte Zusicherung: Dieselbe 404 liefert auch
ein falsch konfigurierter Proxy. `GET /api/v1/info` antwortet stattdessen mit
Name, Version und Bauzeitpunkt — offen, ohne Nebenwirkung, und es sagt
zugleich, **welcher Stand** läuft. Die Daten kommen aus
`META-INF/build-info.properties` (Gradle: `springBoot { buildInfo() }`, Teil
des Boot-Plugins — keine neue Abhängigkeit) und sind optional: Wer aus der IDE
startet, hat die Datei nicht und bekommt „unbekannt" statt eines Fehlers.
**Das Passwort setzt jetzt ein eigenes Skript** (`scripts/reset-password.sh`).
Es fragt verdeckt nach, schickt das Passwort über **stdin** zum Server (nicht
als Argument — Argumente stehen in der Prozessliste, die auf einem geteilten
Host jeder lesen kann), hasht dort mit `htpasswd -i` und prüft anschließend
selbst mit `htpasswd -vi`, ob Hash und Passwort zueinander passen. Genau diese
Gegenprobe fehlte, als das erste 401 wie ein Konfigurationsfehler aussah.
**Nicht getestet, weil es nicht zu testen war:** Alles bis zur SSH-Grenze ist **Nicht getestet, weil es nicht zu testen war:** Alles bis zur SSH-Grenze ist
gemessen — die erzeugte Unit ist mit `systemd-analyze verify` gültig, das Jar gemessen — die erzeugte Unit ist mit `systemd-analyze verify` gültig, das Jar
startet mit **genau** den Flags der Unit in einer Sekunde, antwortet auf die startet mit **genau** den Flags der Unit in einer Sekunde, antwortet auf die
+9 -3
View File
@@ -116,7 +116,8 @@
- [ ] #be: Backend (XL) - [ ] #be: Backend (XL)
- [/] #be.scaffold: Kotlin and Spring Boot scaffold (M) - [/] #be.scaffold: Kotlin and Spring Boot scaffold (M)
- [^] #be.scaffold.gradle: Gradle Kotlin DSL, JDK 21 (S) - [^] #be.scaffold.gradle: Gradle Kotlin DSL, JDK 21 (S)
- [ ] #be.scaffold.ci: Health endpoint and CI (S) - [x] #be.scaffold.info: An endpoint that says name and version (XS)
- [ ] #be.scaffold.ci: Continuous integration for the backend (S)
- [^] #be.docs: Documents on the server (L) :#be.scaffold - [^] #be.docs: Documents on the server (L) :#be.scaffold
- [^] #be.docs.model: Text plus metadata, no structure format (M) - [^] #be.docs.model: Text plus metadata, no structure format (M)
- [^] #be.docs.rest: REST endpoints (S) - [^] #be.docs.rest: REST endpoints (S)
@@ -768,9 +769,14 @@
The build itself: Kotlin DSL, one module, the current long-term-support The build itself: Kotlin DSL, one module, the current long-term-support
Java. Java.
#be.scaffold.info
Something to ask "are you there, and which build is it?" — open and without
side effects. Expecting a 404 from a document that does not exist would be a
poor assurance: a misconfigured proxy returns one too.
#be.scaffold.ci #be.scaffold.ci
Enough to see that it lives: a health endpoint, and a pipeline that builds A pipeline that builds and tests the backend on every push, like the editor
and tests it on every push. has. The "is it alive" half moved out into its own node and is done.
#be.docs #be.docs
Documents on a server instead of in one browser. Text plus metadata — Documents on a server instead of in one browser. Text plus metadata —
+9 -7
View File
@@ -190,14 +190,16 @@ REMOTE
# ---- 4) Nachsehen, ob er wirklich antwortet ---- # ---- 4) Nachsehen, ob er wirklich antwortet ----
if [ "$RESTART" -eq 1 ]; then if [ "$RESTART" -eq 1 ]; then
echo "==> Warten, bis der Dienst antwortet" echo "==> Warten, bis der Dienst antwortet"
# Es gibt (noch) keinen Health-Endpunkt — der steht als eigener Knoten im # `GET /api/v1/info` — offen, ohne Nebenwirkung, und sagt zugleich, welcher
# Plan (`#be.scaffold.ci`). Bis dahin ist die ehrlichste Lebendprobe eine # Stand läuft. Vorher stand hier eine Anfrage nach einem nicht existierenden
# Anfrage nach einem Dokument, das es nicht gibt: 404 heißt, die Anwendung # Dokument mit der Erwartung 404; ein erwarteter **Fehler** ist eine schlechte
# ist oben und beantwortet Anfragen. # Zusicherung, weil ihn auch ein falsch konfigurierter Proxy liefert.
PROBE="http://127.0.0.1:${PORT}/api/v1/documents/00000000-0000-0000-0000-000000000000" PROBE="http://127.0.0.1:${PORT}/api/v1/info"
if ssh "$SSH_TARGET" "for i in \$(seq 1 45); do if ssh "$SSH_TARGET" "for i in \$(seq 1 45); do
code=\$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 '$PROBE' || true) body=\$(curl -s --max-time 3 '$PROBE' || true)
if [ \"\$code\" = '404' ]; then echo \" oben nach \${i}s (HTTP 404 wie erwartet)\"; exit 0; fi case \"\$body\" in
*'\"version\"'*) echo \" oben nach \${i}s: \$body\"; exit 0 ;;
esac
sleep 1 sleep 1
done done
echo ' ! antwortet nicht.'; exit 1"; then echo ' ! antwortet nicht.'; exit 1"; then
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
#
# Werkbaum — Master-Passwort der Dokumentenliste setzen (D77).
#
# Fragt das Passwort **verdeckt ab** und hasht es auf dem Server. Bewusst so
# und nicht `htpasswd -b … PASSWORT`: Ein Passwort auf der Kommandozeile landet
# in der Shell-History, und schlimmer — die Shell fasst es vorher an. `ge$heim`
# wird zu `ge`, `ge heim` zu `geheim`. Gehasht wird dann etwas anderes als das,
# was man später eintippt, und der Server antwortet mit 401, obwohl alles
# richtig aussieht (genau so passiert, D76-Nachtrag 6).
#
# Verwendung:
# scripts/reset-password.sh [ssh-ziel] [--no-restart]
#
# ssh-ziel ohne Angabe BACKEND_SSH aus .env (Vorlage: .env.example)
# --no-restart Hash schreiben, Dienst nicht neu starten
#
# Das Passwort verlässt diesen Rechner nur über die SSH-Verbindung und wird
# **nur als Hash** gespeichert (`<BACKEND_DIR>/env`, Modus 600). Danach prüft
# das Skript selbst, ob Hash und Passwort zueinander passen — der Fehler oben
# sieht sonst aus wie ein Konfigurationsfehler.
#
# Siehe docs/DECISIONS.md D77 und backend/README.md.
set -euo pipefail
SSH_TARGET=""
RESTART=1
for arg in "$@"; do
case "$arg" in
--no-restart) RESTART=0 ;;
-h|--help)
awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0"
exit 0 ;;
-*) echo "Unbekannte Option: $arg" >&2; exit 2 ;;
*)
if [ -n "$SSH_TARGET" ]; then echo "Zu viele Argumente." >&2; exit 2; fi
SSH_TARGET="$arg" ;;
esac
done
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=/dev/null
. "$ROOT/scripts/lib-env.sh"
[ -n "$SSH_TARGET" ] || SSH_TARGET="$(env_value BACKEND_SSH)"
if [ -z "$SSH_TARGET" ]; then
echo "Usage: $0 <ssh-ziel> (oder BACKEND_SSH in .env)" >&2
exit 2
fi
DIR="$(env_value BACKEND_DIR)"; DIR="${DIR:-opt/werkbaum}"
case "$DIR" in /*) DIR_SH="$DIR" ;; *) DIR_SH="\$HOME/$DIR" ;; esac
echo "==> Master-Passwort für ${SSH_TARGET}"
echo " (die Dokumentenliste; alles andere ist über die Dokument-UUID erreichbar)"
# Verdeckt einlesen, zweimal. `read -s` gibt es in bash überall; auf ein
# Terminal angewiesen ist es nicht, deshalb der Hinweis bei Rohr-Eingabe.
if [ ! -t 0 ]; then
echo "Kein Terminal — dieses Skript fragt nach und braucht eines." >&2
exit 2
fi
printf ' Neues Passwort: '
read -rs PASSWORT
printf '\n Wiederholen: '
read -rs PASSWORT2
printf '\n'
if [ "$PASSWORT" != "$PASSWORT2" ]; then
echo "Die beiden Eingaben sind verschieden — nichts geändert." >&2
exit 1
fi
if [ -z "$PASSWORT" ]; then
echo "Leeres Passwort — nichts geändert." >&2
exit 1
fi
if [ "${#PASSWORT}" -lt 8 ]; then
# Keine Regeln über Sonderzeichen; die Länge ist das, was zählt, und dieses
# eine Passwort schützt eine Liste, die alle Dokument-Adressen preisgäbe.
echo "Kürzer als 8 Zeichen — bitte länger wählen." >&2
exit 1
fi
# Das Passwort geht über **stdin** an den Server, nie als Argument: Argumente
# stehen in der Prozessliste, die auf einem geteilten Host jeder lesen kann.
echo "==> hashen und speichern"
printf '%s' "$PASSWORT" | ssh "$SSH_TARGET" DIR="$DIR_SH" RESTART="$RESTART" 'bash -s' <<'REMOTE'
set -euo pipefail
DIR="$(eval echo "$DIR")"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
PASSWORT="$(cat)"
[ -n "$PASSWORT" ] || { echo "Kein Passwort angekommen." >&2; exit 1; }
command -v htpasswd >/dev/null || {
echo "htpasswd fehlt auf dem Server (Paket apache2-utils)." >&2; exit 1; }
# htpasswd -i liest das Passwort von stdin - es steht also weder in der
# Prozessliste noch in einer History.
HASH="$(printf '%s' "$PASSWORT" | htpasswd -niBC 12 '' | tr -d ':\n')"
case "$HASH" in
\$2*) : ;;
*) echo "htpasswd hat keinen bcrypt-Hash geliefert." >&2; exit 1 ;;
esac
mkdir -p "$DIR"
umask 077
printf 'WERKBAUM_MASTER_PASSWORD_HASH={bcrypt}%s\n' "$HASH" > "$DIR/env.neu"
mv "$DIR/env.neu" "$DIR/env"
chmod 600 "$DIR/env"
echo " gespeichert in $DIR/env (Modus 600)"
# Gegenprobe, bevor irgendjemand sich wundert: Passen Hash und Passwort?
CHK="$(mktemp)"
trap 'rm -f "$CHK"' EXIT
printf 'werkbaum:%s\n' "$HASH" > "$CHK"
if printf '%s' "$PASSWORT" | htpasswd -vi "$CHK" werkbaum >/dev/null 2>&1; then
echo " Gegenprobe: Hash und Passwort passen zueinander."
else
echo " ! Gegenprobe FEHLGESCHLAGEN - der Hash passt nicht zum Passwort." >&2
exit 1
fi
if [ "$RESTART" = "1" ]; then
if systemctl --user is-enabled werkbaum-backend.service >/dev/null 2>&1; then
systemctl --user restart werkbaum-backend.service
echo " Dienst neu gestartet."
else
echo " ! Dienst noch nicht eingerichtet - scripts/deploy-backend.sh ausführen."
fi
fi
REMOTE
unset PASSWORT PASSWORT2
echo "==> Fertig. Probe:"
echo " curl -su werkbaum:<passwort> https://werkbaum.javagil.de/api/v1/documents"