Dateiablage im Image anlegen, sonst schlaegt jeder Upload fehl
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Das Volume `files` wird auf /data/files gelegt. Diesen Pfad gab es im Image nicht, also legte Docker ihn beim Einhaengen selbst an - als root mit 755. Der Dienst laeuft aber als afis (1001) und bekam beim ersten Schreibversuch EACCES. Betroffen war jeder Upload: Anhaenge, Lichtbilder, und auch die fertigen PDF-Abzuege, die in derselben Ablage landen. Nachgemessen statt vermutet: ein leeres Volume uebernimmt Verzeichnis und Eigentuemer aus dem Image, ein bereits befuelltes behaelt seine alten Rechte. Da bisher nie ein Upload durchkam, ist die Ablage leer - ein Neubau genuegt, das Volume muss nicht weg. Dazu die Ablage in die Statusanzeige. Sie war der zweite stille Ausfall dieser Art nach clamd: ein Zustand, den man nur durch Ausprobieren erfaehrt, gehoert dorthin, wo man ihn sieht. Geprueft wird das Schreibrecht, nicht durch eine Probedatei - ein Healthcheck, der im Sekundentakt schreibt und aufraeumt, waere selbst ein Risiko. Die Pruefung dazu stellt den echten Fall nach, ein leeres gesperrtes Verzeichnis. Mein erster Versuch sperrte nur das Elternverzeichnis, waehrend quarantine/ und store/ darin schon standen und beschreibbar blieben - der Dienst haette weitergearbeitet, und die Anzeige haette zu Recht nichts gemeldet.
This commit is contained in:
@@ -82,6 +82,23 @@ COPY --from=build --chown=afis:afis /repo/web/public ./web/public
|
|||||||
# Migrations-CLI samt Schema, Migrationsordner und Engine.
|
# Migrations-CLI samt Schema, Migrationsordner und Engine.
|
||||||
COPY --from=build --chown=afis:afis /migrator ./migrator
|
COPY --from=build --chown=afis:afis /migrator ./migrator
|
||||||
|
|
||||||
|
# Die Dateiablage **muss im Image liegen**, sonst kann niemand hochladen.
|
||||||
|
#
|
||||||
|
# `files` ist ein benanntes Volume und wird auf /data/files gelegt. Existiert
|
||||||
|
# dieser Pfad im Image nicht, legt Docker ihn beim Einhaengen selbst an - als
|
||||||
|
# root mit 755. Der Dienst laeuft aber als afis (1001) und bekommt beim ersten
|
||||||
|
# Schreibversuch EACCES: jeder Upload scheiterte mit einer nichtssagenden
|
||||||
|
# Meldung, vom Lichtbild bis zum Anhang.
|
||||||
|
#
|
||||||
|
# Liegt das Verzeichnis dagegen im Image und ist leer, uebernimmt Docker es
|
||||||
|
# samt Eigentuemer in ein leeres Volume. Nachgemessen: ein bereits befuelltes
|
||||||
|
# Volume behaelt seine alten Rechte, ein leeres nicht.
|
||||||
|
#
|
||||||
|
# quarantine/ und store/ gleich mit anlegen - dieselben zwei Verzeichnisse, die
|
||||||
|
# lib/files/paths.ts erwartet.
|
||||||
|
RUN mkdir -p /data/files/quarantine /data/files/store && \
|
||||||
|
chown -R afis:afis /data
|
||||||
|
|
||||||
USER afis
|
USER afis
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
+45
-5
@@ -1,13 +1,17 @@
|
|||||||
|
import { constants } from 'node:fs'
|
||||||
|
import { access, mkdir } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { getEnv } from '@/lib/env'
|
import { getEnv } from '@/lib/env'
|
||||||
import { pingClamav } from '@/lib/files/clamav'
|
import { pingClamav } from '@/lib/files/clamav'
|
||||||
|
import { QUARANTINE, STORE } from '@/lib/files/paths'
|
||||||
|
|
||||||
export type ComponentState = 'up' | 'degraded' | 'down'
|
export type ComponentState = 'up' | 'degraded' | 'down'
|
||||||
|
|
||||||
export type HealthReport = {
|
export type HealthReport = {
|
||||||
status: ComponentState
|
status: ComponentState
|
||||||
checkedAt: string
|
checkedAt: string
|
||||||
components: Record<'database' | 'discordGateway' | 'clamav', {
|
components: Record<'database' | 'discordGateway' | 'clamav' | 'fileStore', {
|
||||||
status: ComponentState
|
status: ComponentState
|
||||||
detail?: string
|
detail?: string
|
||||||
}>
|
}>
|
||||||
@@ -49,29 +53,65 @@ async function checkClamav(): Promise<{ status: ComponentState; detail?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sammelt den Zustand der drei Abhaengigkeiten. Die Gesamtbewertung faellt
|
* Ist die Dateiablage beschreibbar?
|
||||||
|
*
|
||||||
|
* Steht hier, weil genau das einmal unbemerkt kaputt war: das Volume wurde auf
|
||||||
|
* einen Pfad gelegt, den es im Image nicht gab, Docker legte ihn als root an,
|
||||||
|
* und der Dienst laeuft als afis. Jeder Upload scheiterte, und zu sehen war
|
||||||
|
* davon nichts - bis jemand eine Datei anhaengen wollte. Ein Zustand, den man
|
||||||
|
* nur durch Ausprobieren erfaehrt, gehoert in die Statusanzeige.
|
||||||
|
*
|
||||||
|
* Geprueft wird das Recht, nicht durch eine Probedatei: ein Healthcheck, der
|
||||||
|
* im Sekundentakt schreibt und aufraeumt, ist selbst ein Risiko.
|
||||||
|
*/
|
||||||
|
async function checkFileStore(): Promise<{ status: ComponentState; detail?: string }> {
|
||||||
|
const wurzel = path.resolve(getEnv().FILES_DIR)
|
||||||
|
try {
|
||||||
|
for (const bereich of [QUARANTINE, STORE]) {
|
||||||
|
const verzeichnis = path.join(wurzel, bereich)
|
||||||
|
// Anlegen, falls es fehlt - das ist der Normalfall bei einer frischen
|
||||||
|
// Ablage und kein Fehler.
|
||||||
|
await mkdir(verzeichnis, { recursive: true })
|
||||||
|
await access(verzeichnis, constants.W_OK | constants.X_OK)
|
||||||
|
}
|
||||||
|
return { status: 'up' }
|
||||||
|
} catch (error) {
|
||||||
|
const grund = error instanceof Error && 'code' in error ? String(error.code) : 'unbekannt'
|
||||||
|
return {
|
||||||
|
status: 'down',
|
||||||
|
detail:
|
||||||
|
grund === 'EACCES'
|
||||||
|
? `${wurzel} ist fuer den Dienst nicht beschreibbar`
|
||||||
|
: `${wurzel}: ${grund}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammelt den Zustand der Abhaengigkeiten. Die Gesamtbewertung faellt
|
||||||
* bewusst nur auf `down`, wenn die Datenbank weg ist: Worker, clamav und pdf
|
* bewusst nur auf `down`, wenn die Datenbank weg ist: Worker, clamav und pdf
|
||||||
* haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll
|
* haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll
|
||||||
* nicht den ganzen Web-Container als tot markieren. Sichtbar bleibt der Ausfall
|
* nicht den ganzen Web-Container als tot markieren. Sichtbar bleibt der Ausfall
|
||||||
* trotzdem, in dieser Antwort und in Dockge am jeweiligen Dienst.
|
* trotzdem, in dieser Antwort und in Dockge am jeweiligen Dienst.
|
||||||
*/
|
*/
|
||||||
export async function collectHealth(): Promise<HealthReport> {
|
export async function collectHealth(): Promise<HealthReport> {
|
||||||
const [database, discordGateway, clamav] = await Promise.all([
|
const [database, discordGateway, clamav, fileStore] = await Promise.all([
|
||||||
checkDatabase(),
|
checkDatabase(),
|
||||||
checkDiscordGateway(),
|
checkDiscordGateway(),
|
||||||
checkClamav(),
|
checkClamav(),
|
||||||
|
checkFileStore(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const status: ComponentState =
|
const status: ComponentState =
|
||||||
database.status === 'down'
|
database.status === 'down'
|
||||||
? 'down'
|
? 'down'
|
||||||
: discordGateway.status === 'up' && clamav.status === 'up'
|
: discordGateway.status === 'up' && clamav.status === 'up' && fileStore.status === 'up'
|
||||||
? 'up'
|
? 'up'
|
||||||
: 'degraded'
|
: 'degraded'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
components: { database, discordGateway, clamav },
|
components: { database, discordGateway, clamav, fileStore },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export const healthText = {
|
|||||||
database: 'Datenbank',
|
database: 'Datenbank',
|
||||||
discordGateway: 'Discord-Gateway',
|
discordGateway: 'Discord-Gateway',
|
||||||
clamav: 'Virenprüfung',
|
clamav: 'Virenprüfung',
|
||||||
|
fileStore: 'Dateiablage',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { chmod, mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Statusanzeige ist die einzige Stelle, an der ein stiller Ausfall
|
||||||
|
* auffaellt, bevor jemand darueber stolpert. Beide Faelle, die es hier je
|
||||||
|
* wirklich gab, stehen deshalb als Pruefung da: ein Virenscanner, der nicht
|
||||||
|
* antwortet, und eine Dateiablage, in die der Dienst nicht schreiben darf.
|
||||||
|
*/
|
||||||
|
let ablage = ''
|
||||||
|
|
||||||
vi.mock('@/lib/db', () => ({
|
vi.mock('@/lib/db', () => ({
|
||||||
db: {
|
db: {
|
||||||
@@ -8,19 +19,65 @@ vi.mock('@/lib/db', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/lib/env', () => ({
|
vi.mock('@/lib/env', () => ({
|
||||||
getEnv: () => ({ CLAMAV_ADDRESS: 'clamav:3310' }),
|
getEnv: () => ({ CLAMAV_ADDRESS: 'clamav:3310', FILES_DIR: ablage }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/lib/files/clamav', () => ({ pingClamav: vi.fn(async () => true) }))
|
vi.mock('@/lib/files/clamav', () => ({ pingClamav: vi.fn(async () => true) }))
|
||||||
|
|
||||||
describe('collectHealth', () => {
|
describe('collectHealth', () => {
|
||||||
it('meldet betriebsbereit, wenn alle drei Abhaengigkeiten antworten', async () => {
|
beforeAll(async () => {
|
||||||
|
ablage = await mkdtemp(path.join(tmpdir(), 'afis-ablage-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await chmod(ablage, 0o755).catch(() => undefined)
|
||||||
|
await rm(ablage, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet betriebsbereit, wenn alle Abhaengigkeiten antworten', async () => {
|
||||||
const { collectHealth } = await import('@/lib/health/check')
|
const { collectHealth } = await import('@/lib/health/check')
|
||||||
const report = await collectHealth()
|
const report = await collectHealth()
|
||||||
expect(report.status).toBe('up')
|
expect(report.status).toBe('up')
|
||||||
expect(report.components.database.status).toBe('up')
|
expect(report.components.database.status).toBe('up')
|
||||||
expect(report.components.discordGateway.status).toBe('up')
|
expect(report.components.discordGateway.status).toBe('up')
|
||||||
expect(report.components.clamav.status).toBe('up')
|
expect(report.components.clamav.status).toBe('up')
|
||||||
|
expect(report.components.fileStore.status).toBe('up')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('legt die beiden Verzeichnisse an, wenn sie fehlen', async () => {
|
||||||
|
// Eine frische Ablage ist leer. Das ist kein Fehler, sondern der
|
||||||
|
// Normalfall beim ersten Start.
|
||||||
|
const { collectHealth } = await import('@/lib/health/check')
|
||||||
|
const report = await collectHealth()
|
||||||
|
expect(report.components.fileStore.status).toBe('up')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet eine Ablage, in die der Dienst nicht schreiben darf', async () => {
|
||||||
|
// Genau der Fehler, der jeden Upload scheitern liess: das Volume gehoerte
|
||||||
|
// root, der Dienst laeuft als afis. Zu sehen war davon nichts.
|
||||||
|
//
|
||||||
|
// Nachgestellt wird ein **leeres**, gesperrtes Verzeichnis - so lag es
|
||||||
|
// auch im Container. Ein Verzeichnis, in dem quarantine/ und store/ schon
|
||||||
|
// stehen und beschreibbar sind, ist der Fall nicht: dann arbeitet der
|
||||||
|
// Dienst weiter, und die Anzeige soll ihn auch nicht anschwaerzen.
|
||||||
|
const vorher = ablage
|
||||||
|
ablage = await mkdtemp(path.join(tmpdir(), 'afis-gesperrt-'))
|
||||||
|
await chmod(ablage, 0o555)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { collectHealth } = await import('@/lib/health/check')
|
||||||
|
const report = await collectHealth()
|
||||||
|
|
||||||
|
expect(report.components.fileStore.status).toBe('down')
|
||||||
|
expect(report.components.fileStore.detail).toContain('nicht beschreibbar')
|
||||||
|
// Die Datenbank steht, also faellt nicht alles aus - aber betriebsbereit
|
||||||
|
// ist es auch nicht.
|
||||||
|
expect(report.status).toBe('degraded')
|
||||||
|
} finally {
|
||||||
|
await chmod(ablage, 0o755)
|
||||||
|
await rm(ablage, { recursive: true, force: true })
|
||||||
|
ablage = vorher
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('bleibt nur eingeschraenkt, wenn allein die Virenpruefung ausfaellt', async () => {
|
it('bleibt nur eingeschraenkt, wenn allein die Virenpruefung ausfaellt', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user