Speicherfehler erklaeren und die Ablage von selbst pruefen
Beim Ablegen eines Schreibens stand in der Oberflaeche "Das Schreiben konnte nicht abgelegt werden." Bei einer Fehlersuche am 31.08.2026 lagen hinter genau diesem Satz nacheinander DREI verschiedene Ursachen: ein fehlender Ablagebereich, ein Endpunkt, der auf einen fremden Anbieter zeigte, und eine Verschluesselung, die der Speicher nicht anbietet. Ohne Zugang zum Serverlog waere die Suche aussichtslos gewesen. Jetzt uebersetzt src/lib/speicher/fehler.ts die bekannten Faelle in Saetze, die sagen, was zu tun ist - fehlender Ablagebereich mit Verweis auf Schritt 6 der Installation, abgelehnte Zugangsdaten mit den Namen der beiden Variablen, nicht unterstuetzte Verschluesselung mit dem Hinweis auf S3_SSE, und nicht erreichbarer Speicher mit der Zusicherung, dass die Eingaben bleiben. Durchgereicht wird dabei nichts. Fremde Fehlertexte tragen Hostnamen, Eimernamen und Anfragekennungen - das gehoert ins Log, nicht auf den Bildschirm eines Betreuers. Uebersetzt werden nur die bekannten Faelle; alles andere bleibt bei der allgemeinen Meldung. Dazu eine Bereitschaftspruefung: Sie schreibt eine winzige Datei, liest sie zurueck und raeumt sie weg - und beantwortet damit erreichbar, Ablagebereich vorhanden und Schreibrecht auf einmal. Das Ergebnis steht in /api/gesundheit und als Zeile im Log. ## Warum nicht instrumentation.ts Der naheliegende Ort waere der Starthaken von Next. Der wird aber auch fuer die Edge-Laufzeit gebuendelt, und ueber die Treiber kaeme node:path in ein Buendel, in dem es das nicht gibt - der Build schlaegt fehl. Das war ausprobiert, nicht vermutet. Die Pruefung haengt deshalb an der Gesundheitsabfrage. Der Healthcheck ruft sie ab 40 Sekunden nach dem Start alle 30 Sekunden auf; die erste Pruefung passiert also von selbst kurz nach dem Hochfahren. Eine Schonfrist von fuenf Minuten verhindert, dass dabei alle 30 Sekunden eine Probedatei in den Objektspeicher geht, und gemeldet wird nur bei einer Aenderung. Ein gestoerter Speicher fuehrt ausdruecklich NICHT zu 503. Sonst startete der Healthcheck den Behaelter neu und machte die ganze Akte unerreichbar - wegen eines Teils, den ein Neustart nicht repariert. Gegen drei Mutationen geprueft: NotImplemented ohne Textpruefung, alte Reihenfolge bei der Codeermittlung, Erklaerung nicht benutzt - jede wird rot. Refs #44
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Die Bereitschaftsprüfung der Dateiablage.
|
||||
*
|
||||
* Bis v1.11.1 fiel eine kaputte Speicherkonfiguration erst auf, wenn
|
||||
* jemand ein Dokument ablegen wollte. Auf dem Livesystem waren das
|
||||
* mehrere Wochen nach der Installation — und dann mitten in der Arbeit,
|
||||
* mit einer Meldung, die nichts sagte.
|
||||
*
|
||||
* Die Prüfung schreibt eine winzige Datei, liest sie zurück und räumt sie
|
||||
* weg. Sie beantwortet damit drei Fragen auf einmal: erreichbar,
|
||||
* Ablagebereich vorhanden, Schreibrecht da.
|
||||
*/
|
||||
|
||||
let verzeichnis = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
verzeichnis = await mkdtemp(join(tmpdir(), 'bc-pruefung-'))
|
||||
process.env.SPEICHER_TREIBER = 'datei'
|
||||
process.env.DATEI_SPEICHER_PFAD = verzeichnis
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(verzeichnis, { recursive: true, force: true })
|
||||
delete process.env.SPEICHER_TREIBER
|
||||
delete process.env.DATEI_SPEICHER_PFAD
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Bereitschaftsprüfung', () => {
|
||||
it('meldet eine benutzbare Ablage', async () => {
|
||||
const { speicherPruefen } = await import('@/lib/speicher/pruefung')
|
||||
|
||||
const zustand = await speicherPruefen()
|
||||
|
||||
expect(zustand.ok).toBe(true)
|
||||
expect(zustand.treiber).toBe('datei')
|
||||
})
|
||||
|
||||
it('lässt nichts liegen', async () => {
|
||||
// Eine Pruefung, die bei jedem Aufruf eine Datei zuruecklaesst, waere
|
||||
// nach einem Jahr Betrieb ein Verzeichnis voller Muell.
|
||||
const { speicherPruefen } = await import('@/lib/speicher/pruefung')
|
||||
|
||||
await speicherPruefen()
|
||||
await speicherPruefen()
|
||||
await speicherPruefen()
|
||||
|
||||
const inhalt = await readdir(join(verzeichnis, 'pruefung')).catch(() => [])
|
||||
expect(inhalt).toEqual([])
|
||||
})
|
||||
|
||||
it('meldet einen Fehler, statt zu werfen', async () => {
|
||||
// Der Aufrufer ist die Gesundheitsauskunft. Wirft die Pruefung, ist
|
||||
// die Auskunft weg - und mit ihr die Erklaerung.
|
||||
// Eine Datei als Ablageverzeichnis: Das Anlegen des Unterordners
|
||||
// scheitert dann zuverlaessig und auf jedem System.
|
||||
const alsDatei = join(verzeichnis, 'keine-ablage')
|
||||
await writeFile(alsDatei, 'ich bin eine Datei')
|
||||
process.env.DATEI_SPEICHER_PFAD = alsDatei
|
||||
const { speicherPruefen } = await import('@/lib/speicher/pruefung')
|
||||
|
||||
const zustand = await speicherPruefen()
|
||||
|
||||
expect(zustand.ok).toBe(false)
|
||||
if (!zustand.ok) expect(zustand.meldung.length).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Gemerkter Zustand', () => {
|
||||
it('prüft nicht bei jedem Aufruf neu', async () => {
|
||||
// Der Healthcheck fragt alle 30 Sekunden. Ohne Schonfrist schriebe er
|
||||
// ebenso oft eine Probedatei in den Objektspeicher.
|
||||
const modul = await import('@/lib/speicher/pruefung')
|
||||
modul.zustandVergessen()
|
||||
|
||||
const erste = await modul.speicherZustand()
|
||||
const zweite = await modul.speicherZustand()
|
||||
|
||||
expect(zweite).toBe(erste)
|
||||
})
|
||||
|
||||
it('meldet denselben Zustand nicht wieder und wieder', async () => {
|
||||
const protokoll = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const modul = await import('@/lib/speicher/pruefung')
|
||||
modul.zustandVergessen()
|
||||
|
||||
await modul.speicherZustand()
|
||||
// Wie nach fuenf Minuten: neu pruefen, aber das Meldegedaechtnis bleibt.
|
||||
modul.schonfristUeberspringen()
|
||||
await modul.speicherZustand()
|
||||
|
||||
// Zweimal geprueft, aber nur die erste Meldung - sonst stuende alle
|
||||
// fuenf Minuten dieselbe Zeile im Log und niemand liest sie mehr.
|
||||
const speicherzeilen = protokoll.mock.calls.filter((aufruf) =>
|
||||
String(aufruf[0]).startsWith('[speicher]'),
|
||||
)
|
||||
expect(speicherzeilen).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { speicherfehlerErkennen, speicherfehlerMeldung } from '@/lib/speicher/fehler'
|
||||
|
||||
/**
|
||||
* Speicherfehler müssen sagen, was zu tun ist.
|
||||
*
|
||||
* Anlass ist eine Fehlersuche vom 31.08.2026. In der Oberfläche stand
|
||||
* dreimal hintereinander derselbe Satz — „Das Schreiben konnte nicht
|
||||
* abgelegt werden." — und dahinter lagen drei völlig verschiedene
|
||||
* Ursachen: ein fehlender Ablagebereich, ein Endpunkt, der auf einen
|
||||
* fremden Anbieter zeigte, und eine Verschlüsselung, die der Speicher
|
||||
* nicht anbietet.
|
||||
*
|
||||
* Ohne Zugang zum Serverlog wäre die Suche aussichtslos gewesen.
|
||||
*/
|
||||
|
||||
/** Baut einen Fehler, wie ihn das AWS-SDK wirft. */
|
||||
function sdkFehler(code: string, nachricht = ''): Error {
|
||||
const fehler = new Error(nachricht || code) as Error & { Code: string }
|
||||
fehler.Code = code
|
||||
return fehler
|
||||
}
|
||||
|
||||
describe('Speicherfehler erklären', () => {
|
||||
it('erkennt den fehlenden Ablagebereich und nennt den Installationsschritt', () => {
|
||||
const erkannt = speicherfehlerErkennen(sdkFehler('NoSuchBucket'))
|
||||
|
||||
expect(erkannt?.art).toBe('kein-ablagebereich')
|
||||
expect(erkannt?.meldung).toMatch(/Schritt 6/)
|
||||
})
|
||||
|
||||
it('erkennt die nicht unterstützte Verschlüsselung und nennt die Stellschraube', () => {
|
||||
const erkannt = speicherfehlerErkennen(
|
||||
sdkFehler('NotImplemented', 'Server side encryption specified but KMS is not configured'),
|
||||
)
|
||||
|
||||
expect(erkannt?.art).toBe('verschluesselung')
|
||||
expect(erkannt?.meldung).toMatch(/S3_SSE/)
|
||||
})
|
||||
|
||||
it('verwechselt ein anderes NotImplemented nicht damit', () => {
|
||||
// "NotImplemented" schicken S3-Nachbauten fuer allerlei; nur der Fall
|
||||
// mit der Verschluesselung hat eine passende Erklaerung.
|
||||
expect(speicherfehlerErkennen(sdkFehler('NotImplemented', 'Versioning not supported'))).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['AccessDenied', 'SignatureDoesNotMatch', 'InvalidAccessKeyId'])(
|
||||
'erkennt %s als Zugangsproblem',
|
||||
(code) => {
|
||||
const erkannt = speicherfehlerErkennen(sdkFehler(code))
|
||||
|
||||
expect(erkannt?.art).toBe('zugang')
|
||||
expect(erkannt?.meldung).toMatch(/S3_ACCESS_KEY_ID/)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT'])(
|
||||
'erkennt %s als nicht erreichbar',
|
||||
(code) => {
|
||||
const fehler = new Error('connect failed') as Error & { code: string }
|
||||
fehler.code = code
|
||||
|
||||
expect(speicherfehlerErkennen(fehler)?.art).toBe('nicht-erreichbar')
|
||||
},
|
||||
)
|
||||
|
||||
it('verspricht bei einem Netzfehler, dass die Eingaben bleiben', () => {
|
||||
const fehler = new Error('connect ECONNREFUSED 172.18.0.3:9000')
|
||||
|
||||
expect(speicherfehlerErkennen(fehler)?.meldung).toMatch(/gehen nicht verloren/)
|
||||
})
|
||||
|
||||
it('gibt bei einem unbekannten Fehler null zurück', () => {
|
||||
// Dann bleibt es bei der allgemeinen Meldung des Aufrufers. Fremde
|
||||
// Fehlertexte durchzureichen waere die schlechtere Wahl.
|
||||
expect(speicherfehlerErkennen(new Error('irgendwas ging schief'))).toBeNull()
|
||||
expect(speicherfehlerErkennen(null)).toBeNull()
|
||||
expect(speicherfehlerErkennen('kaputt')).toBeNull()
|
||||
})
|
||||
|
||||
it('verrät nichts über Hostnamen, Eimer oder Anfragekennungen', () => {
|
||||
// Solche Angaben stehen in den Fehlern des SDK und gehoeren ins Log,
|
||||
// nicht auf den Bildschirm eines Betreuers.
|
||||
const fehler = sdkFehler('NoSuchBucket', 'The specified bucket does not exist') as Error & {
|
||||
BucketName: string
|
||||
RequestId: string
|
||||
}
|
||||
fehler.BucketName = 'betreuungs-cockpit'
|
||||
fehler.RequestId = '18D0FEDD87C08296'
|
||||
|
||||
const meldung = speicherfehlerErkennen(fehler)!.meldung
|
||||
|
||||
expect(meldung).not.toContain('betreuungs-cockpit')
|
||||
expect(meldung).not.toContain('18D0FEDD87C08296')
|
||||
expect(meldung).not.toContain('bucket')
|
||||
})
|
||||
})
|
||||
|
||||
describe('speicherfehlerMeldung', () => {
|
||||
it('nimmt die erkannte Erklärung, wenn es eine gibt', () => {
|
||||
const meldung = speicherfehlerMeldung(sdkFehler('NoSuchBucket'), 'Allgemeine Meldung.')
|
||||
|
||||
expect(meldung).not.toBe('Allgemeine Meldung.')
|
||||
expect(meldung).toMatch(/Ablagebereich/)
|
||||
})
|
||||
|
||||
it('fällt sonst auf die Meldung des Aufrufers zurück', () => {
|
||||
const meldung = speicherfehlerMeldung(new Error('unbekannt'), 'Allgemeine Meldung.')
|
||||
|
||||
expect(meldung).toBe('Allgemeine Meldung.')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user