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>
343 lines
12 KiB
Go
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
|
|
}
|