syncova-backup/packages/reports/infra_reports.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

343 lines
12 KiB
Go

package reports
import (
"context"
"fmt"
"time"
)
// buildRepositoryReport beschreibt Belegung und Zustand der Repositories.
//
// Ein Zustandsbericht, kein Verlauf: Die Belegung wird nicht historisiert, ein
// Bericht „vom letzten Dienstag" waere nicht zu erfuellen. Fuer den Verlauf gibt
// es die Diagramme aus Phase 13.
func (generator *Generator) buildRepositoryReport(buildContext context.Context, report *Report) error {
const selectStatement = `
SELECT r.name, r.repository_type, r.status, r.hardened,
r.capacity_bytes, r.used_bytes, r.enforcement_level, r.last_checked_at,
(SELECT count(*) FROM backups b
WHERE b.repository_id = r.id AND b.status = 'complete' AND b.deleted_at IS NULL)
FROM repositories r
ORDER BY r.name`
repositoryRows, queryError := generator.connectionPool.Query(buildContext, selectStatement)
if queryError != nil {
return fmt.Errorf("die repositories konnten nicht gelesen werden: %w", queryError)
}
defer repositoryRows.Close()
repositoryTable := Table{
Title: "Repositories",
Columns: []string{"Name", "Art", "Zustand", "Belegt", "Kapazität", "Auslastung",
"Löschschutz", "Backups", "Zuletzt geprüft"},
Rows: make([][]string, 0, 16),
EmptyNotice: "Es ist kein Repository eingerichtet.",
}
var (
totalRepositories int
unavailableCount int
hardenedCount int
withoutCapacityCount int
totalUsedBytes int64
totalCapacityBytes int64
)
for repositoryRows.Next() {
var (
repositoryName, repositoryType, repositoryStatus string
isHardened bool
capacityBytes, usedBytes *int64
enforcementLevel *string
lastCheckedAt *time.Time
backupCount int64
)
if scanError := repositoryRows.Scan(&repositoryName, &repositoryType, &repositoryStatus,
&isHardened, &capacityBytes, &usedBytes, &enforcementLevel, &lastCheckedAt,
&backupCount); scanError != nil {
return fmt.Errorf("ein repository konnte nicht gelesen werden: %w", scanError)
}
totalRepositories++
if repositoryStatus == "unavailable" {
unavailableCount++
}
if isHardened {
hardenedCount++
}
if usedBytes != nil {
totalUsedBytes += *usedBytes
}
if capacityBytes != nil && *capacityBytes > 0 {
totalCapacityBytes += *capacityBytes
} else {
withoutCapacityCount++
}
repositoryTable.Rows = append(repositoryTable.Rows, []string{
repositoryName,
repositoryType,
repositoryStatusLabel(repositoryStatus),
formatOptionalBytes(usedBytes),
formatOptionalBytes(capacityBytes),
utilizationCell(usedBytes, capacityBytes),
enforcementCell(isHardened, enforcementLevel),
fmt.Sprintf("%d", backupCount),
formatOptionalTime(lastCheckedAt),
})
}
if rowsError := repositoryRows.Err(); rowsError != nil {
return rowsError
}
overviewMetrics := []Metric{
KnownMetric("Repositories", float64(totalRepositories), UnitCount),
KnownMetric("Nicht erreichbar", float64(unavailableCount), UnitCount),
KnownMetric("Mit Löschschutz", float64(hardenedCount), UnitCount),
KnownMetric("Belegt insgesamt", float64(totalUsedBytes), UnitBytes),
}
// Ohne hinterlegte Kapazitaet gibt es keine Auslastung. Sie aus der
// belegten Menge zu schaetzen ergaebe immer hundert Prozent — die
// bequemste Art, eine Warnung zu erzeugen, die nichts bedeutet.
if totalCapacityBytes > 0 {
overviewMetrics = append(overviewMetrics,
KnownMetric("Kapazität insgesamt", float64(totalCapacityBytes), UnitBytes),
KnownMetric("Auslastung insgesamt",
float64(totalUsedBytes)*100/float64(totalCapacityBytes), UnitPercent))
} else {
overviewMetrics = append(overviewMetrics,
UnknownMetric("Kapazität insgesamt", UnitBytes,
"Für kein Repository ist eine Kapazität hinterlegt."),
UnknownMetric("Auslastung insgesamt", UnitPercent,
"Ohne hinterlegte Kapazität lässt sich keine Auslastung bilden."))
}
report.Sections = append(report.Sections, Section{
Title: "Überblick",
Metrics: overviewMetrics,
Tables: []Table{repositoryTable},
})
if withoutCapacityCount > 0 {
report.AddNote("Für %d von %d Repositories ist keine Kapazität hinterlegt. Deren "+
"Auslastung lässt sich nicht bestimmen; sie fehlt in der Gesamtsumme, statt "+
"geschätzt zu werden.", withoutCapacityCount, totalRepositories)
}
if hardenedCount < totalRepositories {
report.AddNote("%d von %d Repositories haben keinen gemessenen Löschschutz. Wer Zugriff "+
"auf die Dateien hat, kann dort Wiederherstellungspunkte entfernen.",
totalRepositories-hardenedCount, totalRepositories)
}
return nil
}
// repositoryStatusLabel benennt den Zustand eines Repositories.
//
// Die vier Werte stammen aus dem Schema. Ein unbekannter wird durchgereicht
// statt auf „unbekannt" abgebildet — sonst verschwaende ein Zustand, den eine
// spaetere Migration ergaenzt hat, hinter einem freundlichen Wort. Genau dieser
// Fehler steckte im Repository-Widget der Phase 12.
func repositoryStatusLabel(rawStatus string) string {
switch rawStatus {
case "active":
return "aktiv"
case "read_only":
return "nur lesbar"
case "unavailable":
return "NICHT ERREICHBAR"
case "maintenance":
return "Wartung"
default:
return rawStatus
}
}
// formatOptionalBytes stellt eine Datenmenge dar, die fehlen kann.
func formatOptionalBytes(byteCount *int64) string {
if byteCount == nil {
return "nicht hinterlegt"
}
return FormatBytes(float64(*byteCount))
}
// utilizationCell bildet die Auslastung eines Repositories.
func utilizationCell(usedBytes, capacityBytes *int64) string {
if usedBytes == nil || capacityBytes == nil || *capacityBytes <= 0 {
return "nicht bestimmbar"
}
return FormatPercent(float64(*usedBytes) * 100 / float64(*capacityBytes))
}
// enforcementCell beschreibt den Loeschschutz eines Repositories.
//
// „Gehaertet" allein ist keine Aussage: Der Schutz wird gemessen, nicht
// behauptet (Phase 11). Ohne Messung steht das hier auch so.
func enforcementCell(isHardened bool, enforcementLevel *string) string {
if !isHardened {
return "keiner"
}
if enforcementLevel == nil || *enforcementLevel == "" {
return "gehärtet, ungemessen"
}
return *enforcementLevel
}
// buildRecoveryReport beschreibt die Wiederherstellungen eines Zeitraums.
//
// Er ist die einzige Quelle **tatsaechlich gemessener** Wiederherstellungszeiten
// der Anlage — und damit die Grundlage des RTO-Teils im Bericht ueber die
// Wiederherstellungsziele.
func (generator *Generator) buildRecoveryReport(buildContext context.Context, report *Report) error {
const statisticsStatement = `
SELECT count(*),
count(*) FILTER (WHERE status = 'succeeded'),
count(*) FILTER (WHERE status = 'partial_failure'),
count(*) FILTER (WHERE status = 'failed'),
COALESCE(sum(bytes_restored), 0),
COALESCE(sum(files_restored), 0),
COALESCE(avg(EXTRACT(EPOCH FROM (completed_at - started_at)))
FILTER (WHERE completed_at IS NOT NULL AND started_at IS NOT NULL), 0),
COALESCE(max(EXTRACT(EPOCH FROM (completed_at - started_at))), 0),
count(*) FILTER (WHERE completed_at IS NOT NULL AND started_at IS NOT NULL)
FROM restore_jobs
WHERE created_at >= $1 AND created_at < $2`
var (
totalRestores, succeededRestores int64
partialRestores, failedRestores int64
bytesRestored, filesRestored int64
averageSeconds, longestSeconds float64
measuredRestores int64
)
scanError := generator.connectionPool.QueryRow(buildContext, statisticsStatement,
report.PeriodFrom, report.PeriodTo).Scan(
&totalRestores, &succeededRestores, &partialRestores, &failedRestores,
&bytesRestored, &filesRestored, &averageSeconds, &longestSeconds, &measuredRestores)
if scanError != nil {
return fmt.Errorf("die kennzahlen der wiederherstellungen konnten nicht gelesen werden: %w", scanError)
}
overviewMetrics := []Metric{
KnownMetric("Wiederherstellungen", float64(totalRestores), UnitCount),
KnownMetric("Erfolgreich", float64(succeededRestores), UnitCount),
KnownMetric("Teilweise fehlgeschlagen", float64(partialRestores), UnitCount),
KnownMetric("Gescheitert", float64(failedRestores), UnitCount),
KnownMetric("Wiederhergestellte Datenmenge", float64(bytesRestored), UnitBytes),
KnownMetric("Wiederhergestellte Objekte", float64(filesRestored), UnitCount),
}
if measuredRestores > 0 {
overviewMetrics = append(overviewMetrics,
KnownMetric("Mittlere Dauer", averageSeconds, UnitSeconds),
KnownMetric("Längste Dauer", longestSeconds, UnitSeconds))
} else {
overviewMetrics = append(overviewMetrics,
UnknownMetric("Mittlere Dauer", UnitSeconds,
"Im gewählten Zeitraum wurde keine Wiederherstellung abgeschlossen."),
UnknownMetric("Längste Dauer", UnitSeconds,
"Im gewählten Zeitraum wurde keine Wiederherstellung abgeschlossen."))
}
report.Sections = append(report.Sections, Section{
Title: "Überblick",
Description: "Alle Wiederherstellungen, die im gewählten Zeitraum angelegt wurden.",
Metrics: overviewMetrics,
})
if totalRestores == 0 {
report.AddNote("Im gewählten Zeitraum fand keine Wiederherstellung statt. Damit gibt es " +
"für diesen Zeitraum keine gemessene Wiederherstellungszeit — und ohne Messung " +
"keine belastbare Aussage über die Wiederherstellungsdauer der Anlage.")
}
return generator.appendRestoreTable(buildContext, report)
}
// appendRestoreTable listet die Wiederherstellungen einzeln.
func (generator *Generator) appendRestoreTable(buildContext context.Context, report *Report) error {
const selectStatement = `
SELECT COALESCE(b.backup_id_in_repository, '—'), rj.status, rj.target_type,
COALESCE(rj.target_ref, ''), rj.started_at, rj.completed_at,
COALESCE(rj.bytes_restored, 0), COALESCE(rj.files_restored, 0),
COALESCE(rj.files_skipped, 0), COALESCE(rj.error_code, ''),
COALESCE(u.username, '')
FROM restore_jobs rj
LEFT JOIN backups b ON b.id = rj.backup_id
LEFT JOIN users u ON u.id = rj.created_by
WHERE rj.created_at >= $1 AND rj.created_at < $2
ORDER BY rj.created_at DESC
LIMIT $3`
restoreRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
report.PeriodFrom, report.PeriodTo, maximumListedRuns)
if queryError != nil {
return fmt.Errorf("die wiederherstellungen konnten nicht gelesen werden: %w", queryError)
}
defer restoreRows.Close()
restoreTable := Table{
Title: "Wiederherstellungen",
Columns: []string{"Backup", "Ergebnis", "Ziel", "Pfad", "Beginn", "Dauer",
"Datenmenge", "Objekte", "Übergangen", "Fehlercode", "Ausgelöst von"},
Rows: make([][]string, 0, 32),
EmptyNotice: "Im gewählten Zeitraum wurde keine Wiederherstellung angelegt.",
}
for restoreRows.Next() {
var (
backupName, restoreStatus, targetType string
targetReference, errorCode, userName string
startedAt, completedAt *time.Time
bytesRestored, filesRestored int64
filesSkipped int64
)
if scanError := restoreRows.Scan(&backupName, &restoreStatus, &targetType, &targetReference,
&startedAt, &completedAt, &bytesRestored, &filesRestored, &filesSkipped,
&errorCode, &userName); scanError != nil {
return fmt.Errorf("eine wiederherstellung konnte nicht gelesen werden: %w", scanError)
}
restoreTable.Rows = append(restoreTable.Rows, []string{
backupName,
runStatusLabel(restoreStatus),
targetType,
targetReference,
formatOptionalTime(startedAt),
formatOptionalDuration(startedAt, completedAt),
FormatBytes(float64(bytesRestored)),
fmt.Sprintf("%d", filesRestored),
fmt.Sprintf("%d", filesSkipped),
errorCode,
userName,
})
}
if rowsError := restoreRows.Err(); rowsError != nil {
return rowsError
}
report.Sections = append(report.Sections, Section{
Title: "Im Einzelnen",
Tables: []Table{restoreTable},
})
return nil
}