feat: /status fuer die Statusseite -- und der Fehler, den es dabei gefunden hat
/health gab es und bleibt, wie es ist: app.get('/health', c => c.json({ok:true})).
Diese Zeile beweist genau eines -- der Prozess nimmt Anfragen an. Daran haengt
der HEALTHCHECK des Containers, und DORT ist billig richtig: eine schwere
Pruefung, die bei einer langsamen Platte einmal ausfaellt, liesse Docker den
Container neu starten, also genau dann, wenn er unter Last steht.
Fuer eine Statusseite ist das zu duenn -- sie stuende auf Gruen, waehrend die
Platte voll ist und kein Upload mehr angenommen wird.
/status sieht deshalb wirklich nach: Datenbank (eine echte Abfrage, nicht "die
Datei ist da"), Platte (schreiben UND wieder loeschen, der einzige Beweis),
Ausliefern (eine zufaellige Datei aus der Datenbank auf der Platte nachmessen),
Bestand, Platz und Sicherung. 200 wenn der Dienst sein Geschaeft tut, 503 wenn
nicht; ?streng=1 laesst auch eine Beeintraechtigung rot werden -- WELCHES von
beiden richtig ist, weiss nur, wer die Statusseite betreibt.
Oeffentlich, aber wortkarg: keine Dateizahlen, Groessen, Pfade, Tokennamen,
Benutzer. Und mit einer Zehn-Sekunden-Bremse -- ein oeffentlicher Endpunkt, der
auf die Platte schreibt, waere sonst ein Verstaerker.
UND DABEI FIEL EIN FEHLER IN MEINEM EIGENEN ZURUECKSPIEL-SKRIPT AUF.
Der harte Weg sollte an einem Wegwerf-Container geprueft werden. Der traf
zufaellig auf ein GEBRAUCHTES Volume, und die Zahlen waren eindeutig:
media.db aus dem Archiv, mit altem WAL daneben : 0 Zeilen
dieselbe Datei ohne die beiden Begleiter : 4452 Zeilen
Dateien auf der Platte : 4452
zurueckspielen.ps1 entfernte media.db, aber NICHT media.db-wal und
media.db-shm. Die liegen bei einem echten Zurueckspielen immer da -- der
laufende Dienst arbeitet im WAL-Modus. SQLite spielt das WAL der ALTEN
Datenbank ueber die NEUE, und heraus kommt der schlimmste denkbare Zustand: der
Dienst kommt hoch, /health ist gruen, die Mediathek ist leer, waehrend alle
Dateien danebenliegen.
Drei Konsequenzen:
1. Die Aufraeumzeile steht jetzt an EINER Stelle und nimmt media.db-wal,
media.db-shm und *.tmp mit. Uebung und Ernstfall fahren denselben Befehl --
zwei Fassungen waeren zwei, von denen die geuebte die harmlosere ist.
2. Die Uebung TAEUSCHT JETZT EINE BESTEHENDE INSTALLATION VOR, bevor sie
zurueckspielt: Container starten, warten bis media.db-wal daliegt, stoppen,
und erst dann einspielen. In ein leeres Volume zu spielen probt den Fall,
der nie eintritt.
3. /status erkennt den Zustand selbst -- "kein Eintrag in der Datenbank, aber
Dateien auf der Platte". Am kaputten Container gemessen:
/health sagt: HTTP 200
/status sagt: HTTP 503
Der geuebte Lauf danach, ueber eine vorgetaeuschte Installation:
im Volume liegt jetzt: files media.db media.db-shm media.db-wal
4452 Medieneintraege, 4452 Dateien -- gleich viele
ok items/shushi.png · items/weedbud_1.png · items/cc-castella.png
Die Uebung ist bestanden.
Gefunden beim Ueben und nicht im Ernstfall. Genau dafuer gibt es sie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -117,7 +117,14 @@ sind ohnehin öffentlich, ein zurückgezogener Link nimmt nur die Übersicht weg
|
|||||||
Passwort. Das Passwort bleibt als Notausgang.
|
Passwort. Das Passwort bleibt als Notausgang.
|
||||||
|
|
||||||
**Sichern** — täglich, geprüft, in die Nextcloud. Und der Dienst meldet nach
|
**Sichern** — täglich, geprüft, in die Nextcloud. Und der Dienst meldet nach
|
||||||
Discord, wenn die Sicherung ausbleibt.
|
Discord, wenn die Sicherung ausbleibt. Zurückspielen lässt sich das Ganze mit
|
||||||
|
`tools\zurueckspielen.ps1` — und **üben** lässt es sich auch, in einem
|
||||||
|
Wegwerf-Volume neben dem laufenden Dienst.
|
||||||
|
|
||||||
|
**Beobachten** — `/health` für den Container-Healthcheck (billig, beweist nur,
|
||||||
|
dass der Prozess antwortet) und `/status` für eine Statusseite: das prüft
|
||||||
|
Datenbank, Platte, Ausliefern, Bestand, Platz und Sicherung und antwortet mit
|
||||||
|
**503**, wenn der Dienst sein Geschäft nicht mehr tun kann.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+76
@@ -838,6 +838,82 @@ Container an, ersetzt `media.db`, `files/` und `papierkorb/`, **löscht
|
|||||||
Galerie zeigte die Vorschau einer Datei, die dort nicht mehr liegt), startet
|
Galerie zeigte die Vorschau einer Datei, die dort nicht mehr liegt), startet
|
||||||
wieder und zählt selbst nach.
|
wieder und zählt selbst nach.
|
||||||
|
|
||||||
|
### ✅ Fertig — /status für die Statusseite, und ein Fehler, den es dabei fand
|
||||||
|
|
||||||
|
**Es gab schon `/health`**, und es bleibt, wie es ist:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.get('/health', (c) => c.json({ ok: true }))
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Zeile beweist genau eines: der Prozess nimmt Anfragen an. Daran hängt der
|
||||||
|
`HEALTHCHECK` des Containers, und **dort** ist billig richtig — eine schwere
|
||||||
|
Prüfung, die bei einer langsamen Platte einmal ausfällt, ließe Docker den
|
||||||
|
Container neu starten, also genau dann, wenn er unter Last steht.
|
||||||
|
|
||||||
|
Für eine Statusseite ist das zu dünn: sie stünde auf Grün, während die Platte
|
||||||
|
voll ist. `/status` sieht deshalb wirklich nach — Datenbank (echte Abfrage),
|
||||||
|
Platte (schreiben *und wieder löschen*), Ausliefern (zufällige Datei nachmessen),
|
||||||
|
Bestand, Platz, Sicherung. **200** wenn der Dienst sein Geschäft tut, **503**
|
||||||
|
wenn nicht; `?streng=1` lässt auch eine Beeinträchtigung rot werden.
|
||||||
|
|
||||||
|
Öffentlich, aber wortkarg: keine Dateizahlen, Größen, Pfade, Tokennamen oder
|
||||||
|
Benutzer. Und mit einer 10-Sekunden-Bremse — ein öffentlicher Endpunkt, der auf
|
||||||
|
die Platte schreibt, wäre sonst ein Verstärker.
|
||||||
|
|
||||||
|
**Beide Wege gemessen**, der weiche am laufenden Dienst (Sicherungszeitpunkt auf
|
||||||
|
40 Stunden gesetzt und wieder zurück):
|
||||||
|
|
||||||
|
```
|
||||||
|
stand: beeintraechtigt · /status -> 200 · /status?streng=1 -> 503
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Und dabei fiel ein Fehler in meinem eigenen Zurückspiel-Skript auf
|
||||||
|
|
||||||
|
Der harte Weg sollte an einem Wegwerf-Container geprüft werden. Der traf
|
||||||
|
zufällig auf ein **gebrauchtes** Volume — und zeigte:
|
||||||
|
|
||||||
|
| | Zeilen in `media.db` |
|
||||||
|
|---|---|
|
||||||
|
| mit den alten `media.db-wal` / `-shm` daneben | **0** |
|
||||||
|
| dieselbe Datei ohne sie | **4452** |
|
||||||
|
| Dateien auf der Platte | **4452** |
|
||||||
|
|
||||||
|
`zurueckspielen.ps1` entfernte `media.db`, aber **nicht die beiden
|
||||||
|
WAL-Begleiter**. Die liegen bei einem echten Zurückspielen immer da — der
|
||||||
|
laufende Dienst arbeitet im WAL-Modus. SQLite spielt das WAL der *alten*
|
||||||
|
Datenbank über die *neue*, und heraus kommt der schlimmste denkbare Zustand:
|
||||||
|
**der Dienst kommt hoch, `/health` ist grün, die Mediathek ist leer** — während
|
||||||
|
alle Dateien danebenliegen.
|
||||||
|
|
||||||
|
Zwei Konsequenzen:
|
||||||
|
|
||||||
|
1. Die Aufräumzeile steht jetzt an **einer** Stelle und nimmt `media.db-wal`,
|
||||||
|
`media.db-shm` und `*.tmp` mit. Übung und Ernstfall fahren denselben Befehl —
|
||||||
|
zwei Fassungen wären zwei, von denen die geübte die harmlosere ist.
|
||||||
|
2. **Die Übung täuscht jetzt eine bestehende Installation vor**, bevor sie
|
||||||
|
zurückspielt. In ein leeres Volume zu spielen probt den Fall, der nie
|
||||||
|
eintritt.
|
||||||
|
|
||||||
|
```
|
||||||
|
==> Eine bestehende Installation vortaeuschen
|
||||||
|
im Volume liegt jetzt: files media.db media.db-shm media.db-wal
|
||||||
|
==> Zurueckspielen -- mit DEMSELBEN Befehl wie der Ernstfall
|
||||||
|
4452 Medieneintraege · 4452 Dateien -- gleich viele
|
||||||
|
ok items/shushi.png · items/weedbud_1.png · items/cc-castella.png
|
||||||
|
Die Uebung ist bestanden.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Und `/status` erkennt diesen Zustand jetzt selbst. Am kaputten Container
|
||||||
|
gemessen:
|
||||||
|
|
||||||
|
```
|
||||||
|
/health sagt: HTTP 200
|
||||||
|
/status sagt: HTTP 503 kein Eintrag in der Datenbank, aber Dateien auf der Platte
|
||||||
|
```
|
||||||
|
|
||||||
|
Genau dafür gibt es die Übung: gefunden beim Üben, nicht im Ernstfall.
|
||||||
|
|
||||||
### ⬜ Als Nächstes
|
### ⬜ Als Nächstes
|
||||||
|
|
||||||
**Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container
|
**Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container
|
||||||
|
|||||||
+63
@@ -137,6 +137,69 @@ direkt hierher.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Für eine Statusseite
|
||||||
|
|
||||||
|
Zwei Adressen, beide öffentlich, beide ohne Anmeldung — und sie beantworten
|
||||||
|
**verschiedene** Fragen.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health -> 200 {"ok":true}
|
||||||
|
GET /status -> 200 | 503 mit Begründung
|
||||||
|
```
|
||||||
|
|
||||||
|
`/health` beweist genau eines: **der Prozess nimmt Anfragen an.** Daran hängt
|
||||||
|
auch der `HEALTHCHECK` des Containers, und dort ist eine billige Prüfung
|
||||||
|
richtig — eine schwere, die bei einer langsamen Platte einmal ausfällt, ließe
|
||||||
|
Docker den Container neu starten, also genau dann, wenn er unter Last steht.
|
||||||
|
|
||||||
|
`/status` sieht wirklich nach:
|
||||||
|
|
||||||
|
| Prüfung | wie |
|
||||||
|
|---|---|
|
||||||
|
| `datenbank` | eine echte Abfrage gegen eine echte Tabelle |
|
||||||
|
| `platte` | schreiben **und wieder löschen** — der einzige Beweis |
|
||||||
|
| `ausliefern` | eine zufällige Datei aus der Datenbank auf der Platte nachmessen |
|
||||||
|
| `bestand` | kein Eintrag, aber Dateien da? Dann stimmt etwas nicht |
|
||||||
|
| `platz` | ab 95 % belegt |
|
||||||
|
| `sicherung` | älter als 26 Stunden oder fehlgeschlagen |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "dienst": "d4rk_media", "ok": true, "stand": "gesund", "seit": 4271,
|
||||||
|
"pruefungen": [ { "was": "datenbank", "ok": true }, … ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zwei Stufen, und der Unterschied ist der zwischen „tut es nicht" und „braucht
|
||||||
|
Aufmerksamkeit".** Datenbank, Platte, Ausliefern und Bestand sind das Geschäft
|
||||||
|
dieses Dienstes — fällt eines aus, ist er `gestoert` und die Antwort ist
|
||||||
|
**503**. Eine alte Sicherung oder eine volle Platte machen ihn
|
||||||
|
`beeintraechtigt`: er liefert weiter aus, die Antwort bleibt **200**.
|
||||||
|
|
||||||
|
Wer auch das rot haben will, hängt `?streng=1` an — dann gibt alles außer
|
||||||
|
`gesund` eine 503.
|
||||||
|
|
||||||
|
### In Uptime Kuma
|
||||||
|
|
||||||
|
```
|
||||||
|
Dienst läuft https://fivemanage.d4rkst3r.de/status
|
||||||
|
Braucht Pflege https://fivemanage.d4rkst3r.de/status?streng=1
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warum `bestand` dabei ist.** Nachgemessen an genau diesem Dienst: wird eine
|
||||||
|
> Sicherung zurückgespielt, ohne `media.db-wal` daneben zu entfernen, spielt
|
||||||
|
> SQLite das WAL der **alten** Datenbank über die neue. Ergebnis: 0 Einträge,
|
||||||
|
> während 4452 Dateien danebenliegen. Der Dienst kommt hoch, `/health` ist
|
||||||
|
> grün, die Mediathek ist leer.
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> /health sagt: HTTP 200
|
||||||
|
> /status sagt: HTTP 503 kein Eintrag in der Datenbank, aber Dateien auf der Platte
|
||||||
|
> ```
|
||||||
|
|
||||||
|
Was **nicht** hinausgeht: Dateizahlen, Größen, Pfade, Tokennamen, Benutzer. Die
|
||||||
|
einzige Zahl ist die Laufzeit in Sekunden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Anmeldung: Token
|
## Anmeldung: Token
|
||||||
|
|
||||||
Alle Skript-Wege brauchen einen Token im Kopf:
|
Alle Skript-Wege brauchen einen Token im Kopf:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from
|
|||||||
import { uploadRoutes } from './routes/upload.js'
|
import { uploadRoutes } from './routes/upload.js'
|
||||||
import { fivemanageRoutes } from './routes/fivemanage.js'
|
import { fivemanageRoutes } from './routes/fivemanage.js'
|
||||||
import { freigabeRoutes } from './routes/freigabe.js'
|
import { freigabeRoutes } from './routes/freigabe.js'
|
||||||
|
import { statusRoutes } from './routes/status.js'
|
||||||
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
|
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
@@ -208,8 +209,21 @@ app.use('*', async (c, next) => {
|
|||||||
|
|
||||||
// ------------------------------------------------------- Dashboard und API
|
// ------------------------------------------------------- Dashboard und API
|
||||||
|
|
||||||
|
// Der HEALTHCHECK des Containers haengt hier dran, und deshalb bleibt es
|
||||||
|
// billig: eine schwere Pruefung, die bei einer langsamen Platte einmal
|
||||||
|
// ausfaellt, liesse Docker den Container neu starten -- also genau dann, wenn
|
||||||
|
// er unter Last steht.
|
||||||
|
//
|
||||||
|
// Was diese Zeile beweist, ist genau eines: der Prozess nimmt Anfragen an.
|
||||||
|
// Fuer eine STATUSSEITE ist das zu duenn -- dafuer gibt es /status daneben,
|
||||||
|
// das wirklich nachsieht.
|
||||||
app.get('/health', (c) => c.json({ ok: true }))
|
app.get('/health', (c) => c.json({ ok: true }))
|
||||||
|
|
||||||
|
// Fuer die Statusseite. Oeffentlich wie /health, aber es prueft Datenbank,
|
||||||
|
// Platte, Ausliefern und die Sicherung -- und antwortet mit 503, wenn der
|
||||||
|
// Dienst sein Geschaeft nicht mehr tun kann.
|
||||||
|
app.route('/status', statusRoutes)
|
||||||
|
|
||||||
app.route('/api', uploadRoutes)
|
app.route('/api', uploadRoutes)
|
||||||
// NACH uploadRoutes: unsere eigenen Wege haben Vorrang, falls je ein Name
|
// NACH uploadRoutes: unsere eigenen Wege haben Vorrang, falls je ein Name
|
||||||
// zusammenfaellt. Derzeit tut er das nicht (/upload, /exists, /media gegen
|
// zusammenfaellt. Derzeit tut er das nicht (/upload, /exists, /media gegen
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// Der Endpunkt fuer die Statusseite.
|
||||||
|
//
|
||||||
|
// WARUM NICHT /health. Das gibt es und bleibt, wie es ist:
|
||||||
|
//
|
||||||
|
// app.get('/health', (c) => c.json({ ok: true }))
|
||||||
|
//
|
||||||
|
// Diese Zeile beweist genau eines -- der Prozess nimmt HTTP-Anfragen an. Sie
|
||||||
|
// haengt am HEALTHCHECK im Dockerfile, und DORT ist eine billige Pruefung
|
||||||
|
// richtig: eine schwere, die bei einer langsamen Platte einmal ausfaellt,
|
||||||
|
// liesse Docker den Container neu starten. Der Dienst kippte dann genau dann,
|
||||||
|
// wenn er unter Last steht.
|
||||||
|
//
|
||||||
|
// Fuer eine Statusseite ist sie aber zu duenn. Sie stuende auf Gruen, waehrend
|
||||||
|
// die Platte voll ist und kein Upload mehr angenommen wird.
|
||||||
|
//
|
||||||
|
// HIER WIRD DESHALB WIRKLICH NACHGESEHEN, und zwar an den vier Stellen, an
|
||||||
|
// denen dieser Dienst scheitern kann:
|
||||||
|
//
|
||||||
|
// Datenbank eine echte Abfrage, nicht "die Datei ist da"
|
||||||
|
// Platte schreiben UND wieder loeschen -- der einzige Beweis
|
||||||
|
// Ausliefern eine Datei aus der Datenbank suchen und nachmessen
|
||||||
|
// Sicherung wie alt ist der letzte Lauf
|
||||||
|
//
|
||||||
|
// WAS NICHT HINAUSGEHT: Dateizahlen, Groessen, Pfade, Tokennamen, Benutzer.
|
||||||
|
// Ein oeffentlicher Endpunkt sagt "gesund" oder "krank" -- nicht, wie gross
|
||||||
|
// der Bestand ist. Die einzige Zahl, die herausgeht, ist die Laufzeit, und die
|
||||||
|
// steht in jedem Serverkopf ohnehin naeher dran, als es hier stuende.
|
||||||
|
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { readdir, rm, stat, statfs, writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { db, now, type Media } from '../db.js'
|
||||||
|
import { getSetting } from '../settings.js'
|
||||||
|
import { absolutePath } from '../storage.js'
|
||||||
|
|
||||||
|
export const statusRoutes = new Hono()
|
||||||
|
|
||||||
|
type Pruefung = { was: string; ok: boolean; hinweis?: string }
|
||||||
|
|
||||||
|
/** Wie alt eine Sicherung sein darf, bevor es auffaellt. 26 Stunden -- die
|
||||||
|
* gleiche Schwelle wie beim Wachhund in meldung.ts, damit nicht zwei Stellen
|
||||||
|
* verschiedener Meinung darueber sind, was "aktuell" heisst. */
|
||||||
|
const SICHERUNG_STUNDEN = 26
|
||||||
|
|
||||||
|
// -- Nicht bei jeder Anfrage rechnen -----------------------------------------
|
||||||
|
//
|
||||||
|
// Der Endpunkt ist OEFFENTLICH und schreibt auf die Platte. Ohne Bremse waere
|
||||||
|
// er ein Verstaerker: wer ihn im Sekundentakt abruft, erzeugt im Sekundentakt
|
||||||
|
// Schreibvorgaenge. Ein Ergebnis von vor zehn Sekunden ist fuer eine
|
||||||
|
// Statusseite so gut wie ein frisches -- Monitore fragen ohnehin im
|
||||||
|
// Minutentakt.
|
||||||
|
const FRISCHE_MS = 10_000
|
||||||
|
let letzte: { zeit: number; antwort: Antwort } | null = null
|
||||||
|
|
||||||
|
type Antwort = {
|
||||||
|
dienst: string
|
||||||
|
ok: boolean
|
||||||
|
stand: 'gesund' | 'beeintraechtigt' | 'gestoert'
|
||||||
|
seit: number
|
||||||
|
pruefungen: Pruefung[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruefen(): Promise<Antwort> {
|
||||||
|
const pruefungen: Pruefung[] = []
|
||||||
|
|
||||||
|
// -- Datenbank ------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Eine ECHTE Abfrage gegen eine echte Tabelle. `SELECT 1` bewiese nur, dass
|
||||||
|
// SQLite antwortet; eine beschaedigte Datei faellt erst auf, wenn jemand
|
||||||
|
// eine Seite davon liest.
|
||||||
|
let medien = 0
|
||||||
|
try {
|
||||||
|
medien = (db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }).n
|
||||||
|
pruefungen.push({ was: 'datenbank', ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'datenbank',
|
||||||
|
ok: false,
|
||||||
|
hinweis: err instanceof Error ? err.message : 'nicht lesbar',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Platte ---------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// SCHREIBEN und wieder loeschen. Ein `stat` auf den Ordner bewiese nur, dass
|
||||||
|
// es ihn gibt -- eine volle oder schreibgeschuetzte Platte sieht dabei
|
||||||
|
// genauso aus wie eine gesunde. Die Probe liegt im Datenordner und NICHT
|
||||||
|
// unter files/: dort wuerde sie der Verwaisten-Sucher finden.
|
||||||
|
const probe = join(config.dataDir, `.status-${process.pid}-${randomBytes(4).toString('hex')}`)
|
||||||
|
try {
|
||||||
|
await writeFile(probe, 'probe')
|
||||||
|
await rm(probe, { force: true })
|
||||||
|
pruefungen.push({ was: 'platte', ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
await rm(probe, { force: true }).catch(() => {})
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'platte',
|
||||||
|
ok: false,
|
||||||
|
hinweis: err instanceof Error ? err.message : 'nicht beschreibbar',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Ausliefern -----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Die Probe aufs Exempel: eine Datei aus der Datenbank holen und nachsehen,
|
||||||
|
// ob sie mit der eingetragenen Groesse dort liegt. Damit haengen Datenbank
|
||||||
|
// UND Platte UND die Pfadaufloesung gemeinsam mit im Test -- das ist der
|
||||||
|
// Unterschied zwischen "beide Teile leben" und "sie passen zueinander".
|
||||||
|
//
|
||||||
|
// Ein leerer Bestand ist kein Fehler: ein frisch aufgesetzter Dienst ist
|
||||||
|
// gesund und hat nichts auszuliefern.
|
||||||
|
if (medien > 0) {
|
||||||
|
try {
|
||||||
|
const zeile = db
|
||||||
|
.prepare('SELECT path, size FROM media ORDER BY RANDOM() LIMIT 1')
|
||||||
|
.get() as Pick<Media, 'path' | 'size'>
|
||||||
|
const info = await stat(absolutePath(zeile.path))
|
||||||
|
if (info.size === zeile.size) {
|
||||||
|
pruefungen.push({ was: 'ausliefern', ok: true })
|
||||||
|
} else {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'ausliefern',
|
||||||
|
ok: false,
|
||||||
|
hinweis: 'Groesse auf der Platte weicht vom Eintrag ab',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Der Pfad steht ABSICHTLICH nicht in der Meldung: das hier ist
|
||||||
|
// oeffentlich.
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'ausliefern',
|
||||||
|
ok: false,
|
||||||
|
hinweis: 'eine eingetragene Datei liegt nicht auf der Platte',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// KEIN EINTRAG, ABER DATEIEN AUF DER PLATTE -- der eine Zustand, in dem
|
||||||
|
// alles gesund AUSSIEHT und trotzdem alles weg ist.
|
||||||
|
//
|
||||||
|
// Nachgemessen, und zwar an genau diesem Dienst: wird eine Sicherung
|
||||||
|
// zurueckgespielt, ohne media.db-wal daneben zu entfernen, spielt SQLite
|
||||||
|
// das WAL der ALTEN Datenbank ueber die neue. Ergebnis: 0 Zeilen,
|
||||||
|
// waehrend 4452 Dateien danebenliegen. Der Dienst kommt hoch, /health
|
||||||
|
// ist gruen, die Mediathek ist leer.
|
||||||
|
//
|
||||||
|
// Ein frisch aufgesetzter Dienst hat weder Eintraege noch Dateien und
|
||||||
|
// ist voellig gesund. Erst der WIDERSPRUCH aus beidem ist das Zeichen.
|
||||||
|
try {
|
||||||
|
const drin = await readdir(config.filesDir)
|
||||||
|
if (drin.length > 0) {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'bestand',
|
||||||
|
ok: false,
|
||||||
|
hinweis: 'kein Eintrag in der Datenbank, aber Dateien auf der Platte',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* kein Dateiordner -- bei einem frischen Dienst normal */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Platz ----------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Nur als Beeintraechtigung, nicht als Stoerung: bei 95 % laeuft alles
|
||||||
|
// noch, aber nicht mehr lange. Prozent statt Gigabyte -- die absolute Zahl
|
||||||
|
// waere eine Auskunft ueber die Maschine.
|
||||||
|
try {
|
||||||
|
const s = await statfs(config.dataDir)
|
||||||
|
const belegt = 1 - (s.bavail * s.bsize) / (s.blocks * s.bsize)
|
||||||
|
if (belegt >= 0.95) {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'platz',
|
||||||
|
ok: false,
|
||||||
|
hinweis: `${Math.round(belegt * 100)} % belegt`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
pruefungen.push({ was: 'platz', ok: true })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Auf manchen Dateisystemen gibt es statfs nicht. Kein Grund, den
|
||||||
|
// Dienst deswegen krank zu nennen.
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Sicherung ------------------------------------------------------------
|
||||||
|
const roh = getSetting('sicherung.letzte')
|
||||||
|
if (!roh) {
|
||||||
|
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'noch keine gemeldet' })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const l = JSON.parse(roh) as { at: number; ok: boolean }
|
||||||
|
const stunden = (now() - l.at) / 3_600_000
|
||||||
|
if (!l.ok) {
|
||||||
|
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'letzte fehlgeschlagen' })
|
||||||
|
} else if (stunden > SICHERUNG_STUNDEN) {
|
||||||
|
pruefungen.push({
|
||||||
|
was: 'sicherung',
|
||||||
|
ok: false,
|
||||||
|
hinweis: `letzte vor ${Math.round(stunden)} Stunden`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
pruefungen.push({ was: 'sicherung', ok: true })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pruefungen.push({ was: 'sicherung', ok: false, hinweis: 'Eintrag unlesbar' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Das Urteil -----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// ZWEI STUFEN, und der Unterschied ist der zwischen "tut es nicht" und
|
||||||
|
// "braucht Aufmerksamkeit". Datenbank, Platte und Ausliefern sind das
|
||||||
|
// Geschaeft dieses Dienstes -- faellt eines aus, ist er gestoert. Eine alte
|
||||||
|
// Sicherung oder eine volle Platte tun heute noch nicht weh und morgen
|
||||||
|
// sehr.
|
||||||
|
const HART = ['datenbank', 'platte', 'ausliefern', 'bestand']
|
||||||
|
const gestoert = pruefungen.some((p) => HART.includes(p.was) && !p.ok)
|
||||||
|
const schwach = pruefungen.some((p) => !p.ok)
|
||||||
|
|
||||||
|
return {
|
||||||
|
dienst: 'd4rk_media',
|
||||||
|
ok: !gestoert,
|
||||||
|
stand: gestoert ? 'gestoert' : schwach ? 'beeintraechtigt' : 'gesund',
|
||||||
|
// Auf Sekunden gerundet -- eine Statusseite zeigt "seit 3 Tagen" an,
|
||||||
|
// keine Millisekunden.
|
||||||
|
seit: Math.round(process.uptime()),
|
||||||
|
pruefungen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Endpunkt.
|
||||||
|
*
|
||||||
|
* `?streng=1` laesst auch eine Beeintraechtigung mit 503 antworten. Ohne den
|
||||||
|
* Schalter ist eine alte Sicherung kein "down" -- der Dienst liefert ja aus.
|
||||||
|
* WELCHES von beiden richtig ist, weiss nur, wer die Statusseite betreibt:
|
||||||
|
* deshalb ein Schalter und keine Entscheidung meinerseits. */
|
||||||
|
statusRoutes.get('/', async (c) => {
|
||||||
|
if (!letzte || now() - letzte.zeit > FRISCHE_MS) {
|
||||||
|
letzte = { zeit: now(), antwort: await pruefen() }
|
||||||
|
}
|
||||||
|
const a = letzte.antwort
|
||||||
|
const streng = c.req.query('streng') === '1'
|
||||||
|
const schlecht = streng ? a.stand !== 'gesund' : !a.ok
|
||||||
|
|
||||||
|
return c.json(a, schlecht ? 503 : 200, {
|
||||||
|
// Eine zwischengespeicherte Statusmeldung ist keine. Der Proxy greift
|
||||||
|
// hier zwar nicht zu (die Adresse endet auf keine Bildendung), aber das
|
||||||
|
// ist eine Einstellung und keine Zusicherung.
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -35,6 +35,32 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# WAS BEIM ZURUECKSPIELEN WEG MUSS -- an EINER Stelle, weil die Uebung genau
|
||||||
|
# denselben Befehl fahren soll wie der Ernstfall. Zwei Fassungen waeren zwei,
|
||||||
|
# von denen die geuebte die harmlosere ist.
|
||||||
|
#
|
||||||
|
# media.db-wal UND media.db-shm STEHEN HIER MIT ABSICHT, und das ist teuer
|
||||||
|
# gelernt: SQLite laeuft in diesem Dienst im WAL-Modus, neben der Datenbank
|
||||||
|
# liegen also immer zwei Begleitdateien. Die Sicherung enthaelt sie NICHT --
|
||||||
|
# sie braucht sie auch nicht, `db.backup()` schreibt eine fertig
|
||||||
|
# zusammengefuehrte Datei.
|
||||||
|
#
|
||||||
|
# Wer beim Zurueckspielen nur media.db ersetzt, laesst damit das WAL der ALTEN
|
||||||
|
# Datenbank neben der NEUEN liegen. SQLite spielt es beim Oeffnen ein, und das
|
||||||
|
# Ergebnis ist das Schlimmste, was hier passieren kann:
|
||||||
|
#
|
||||||
|
# media.db aus dem Archiv, mit altem WAL daneben : 0 Zeilen
|
||||||
|
# dieselbe Datei ohne die beiden Begleiter : 4452 Zeilen
|
||||||
|
# Dateien auf der Platte : 4452
|
||||||
|
#
|
||||||
|
# Der Dienst kaeme HOCH, /health waere gruen, und die Mediathek waere leer --
|
||||||
|
# waehrend alle Dateien danebenliegen. Nachgemessen am 12.08.2026, gefunden
|
||||||
|
# beim Ueben und nicht im Ernstfall. Genau dafuer gibt es die Uebung.
|
||||||
|
#
|
||||||
|
# .tmp ebenfalls: writeFileAtomic legt Zwischendateien an, und ein Absturz
|
||||||
|
# mitten im Schreiben laesst eine liegen.
|
||||||
|
$script:AUFRAEUMEN = 'rm -rf files media.db media.db-wal media.db-shm papierkorb thumbs webp *.tmp'
|
||||||
|
|
||||||
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
|
function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan }
|
||||||
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
|
function Gut($t) { Write-Host " $t" -ForegroundColor Green }
|
||||||
function Schlecht($t) { Write-Host " $t" -ForegroundColor Red }
|
function Schlecht($t) { Write-Host " $t" -ForegroundColor Red }
|
||||||
@@ -103,12 +129,37 @@ if (-not $Echt) {
|
|||||||
docker volume rm $pVolume 2>&1 | Out-Null
|
docker volume rm $pVolume 2>&1 | Out-Null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Schritt 'Wegwerf-Volume anlegen und Archiv hineinspielen'
|
Schritt 'Wegwerf-Volume anlegen'
|
||||||
docker volume create $pVolume | Out-Null
|
docker volume create $pVolume | Out-Null
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ERST EINE BESTEHENDE INSTALLATION VORTAEUSCHEN, und das ist der
|
||||||
|
# Kern der Uebung: im Ernstfall wird ueber etwas zurueckgespielt, das
|
||||||
|
# schon da ist -- mit einer laufenden Datenbank, mit media.db-wal
|
||||||
|
# daneben, mit Vorschauen. In ein LEERES Volume zu spielen probt genau
|
||||||
|
# den Fall, der nie eintritt.
|
||||||
|
#
|
||||||
|
# Dass das ein Unterschied ist, ist nicht theoretisch: die erste
|
||||||
|
# Fassung dieses Skripts liess media.db-wal stehen, und der Dienst kam
|
||||||
|
# danach mit einer LEEREN Mediathek hoch, waehrend alle Dateien
|
||||||
|
# danebenlagen. Aufgefallen ist es nur, weil ein Uebungslauf zufaellig
|
||||||
|
# auf ein gebrauchtes Volume traf.
|
||||||
|
Schritt 'Eine bestehende Installation vortaeuschen (damit die Uebung eine ist)'
|
||||||
|
docker run -d --name "$pContainer-alt" `
|
||||||
|
-e PUBLIC_URL=http://probe.invalid -e ADMIN_PASSWORD=nur-fuer-die-uebung `
|
||||||
|
-v "${pVolume}:/data" $Abbild | Out-Null
|
||||||
|
Start-Sleep -Seconds 4
|
||||||
|
docker stop "$pContainer-alt" 2>&1 | Out-Null
|
||||||
|
docker rm "$pContainer-alt" 2>&1 | Out-Null
|
||||||
|
$begleiter = docker run --rm -v "${pVolume}:/data" --entrypoint sh $Abbild `
|
||||||
|
-c "ls /data | tr '\n' ' '"
|
||||||
|
Gut "im Volume liegt jetzt: $($begleiter.Trim())"
|
||||||
|
|
||||||
|
Schritt 'Zurueckspielen -- mit DEMSELBEN Befehl wie der Ernstfall'
|
||||||
docker run --rm -v "${pVolume}:/data" -v "${archivOrdner}:/in:ro" `
|
docker run --rm -v "${pVolume}:/data" -v "${archivOrdner}:/in:ro" `
|
||||||
--entrypoint sh $Abbild -c "cd /data && tar xzf '/in/$archivDatei' && chown -R node:node /data"
|
--entrypoint sh $Abbild -c "cd /data && $script:AUFRAEUMEN && tar xzf '/in/$archivDatei' && chown -R node:node /data"
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Auspacken fehlgeschlagen' }
|
if ($LASTEXITCODE -ne 0) { throw 'Auspacken fehlgeschlagen' }
|
||||||
Gut 'ausgepackt'
|
Gut 'eingespielt'
|
||||||
|
|
||||||
Schritt 'Einen zweiten Dienst davorhaengen (ohne Port nach aussen)'
|
Schritt 'Einen zweiten Dienst davorhaengen (ohne Port nach aussen)'
|
||||||
# KEIN veroeffentlichter Port: der Uebungslauf soll sich mit nichts
|
# KEIN veroeffentlichter Port: der Uebungslauf soll sich mit nichts
|
||||||
@@ -201,6 +252,7 @@ const zeilen = db.prepare('SELECT path, sha256, size FROM media ORDER BY RANDOM(
|
|||||||
# Aufraeumen IMMER -- auch nach einem Abbruch. Ein liegengebliebenes
|
# Aufraeumen IMMER -- auch nach einem Abbruch. Ein liegengebliebenes
|
||||||
# Uebungs-Volume mit 300 MB findet sonst niemand wieder.
|
# Uebungs-Volume mit 300 MB findet sonst niemand wieder.
|
||||||
Schritt 'Aufraeumen'
|
Schritt 'Aufraeumen'
|
||||||
|
docker rm -f "$pContainer-alt" 2>&1 | Out-Null
|
||||||
docker rm -f $pContainer 2>&1 | Out-Null
|
docker rm -f $pContainer 2>&1 | Out-Null
|
||||||
docker volume rm $pVolume 2>&1 | Out-Null
|
docker volume rm $pVolume 2>&1 | Out-Null
|
||||||
Gut 'Wegwerf-Volume und Probe-Container entfernt'
|
Gut 'Wegwerf-Volume und Probe-Container entfernt'
|
||||||
@@ -241,7 +293,7 @@ try {
|
|||||||
# In EINEM Container-Aufruf: zwischen Loeschen und Einspielen darf nichts
|
# In EINEM Container-Aufruf: zwischen Loeschen und Einspielen darf nichts
|
||||||
# liegen, in dem jemand den Dienst wieder startet.
|
# liegen, in dem jemand den Dienst wieder startet.
|
||||||
docker run --rm -v "${Volume}:/data" -v "${archivOrdner}:/in:ro" --entrypoint sh $Abbild `
|
docker run --rm -v "${Volume}:/data" -v "${archivOrdner}:/in:ro" --entrypoint sh $Abbild `
|
||||||
-c "cd /data && rm -rf files media.db papierkorb thumbs webp && tar xzf '/in/$archivDatei' && chown -R node:node /data"
|
-c "cd /data && $script:AUFRAEUMEN && tar xzf '/in/$archivDatei' && chown -R node:node /data"
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Einspielen fehlgeschlagen' }
|
if ($LASTEXITCODE -ne 0) { throw 'Einspielen fehlgeschlagen' }
|
||||||
Gut 'eingespielt'
|
Gut 'eingespielt'
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user