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>
474 lines
16 KiB
Go
474 lines
16 KiB
Go
package reports
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// buildBackupReport erzeugt Tages-, Wochen- und Monatsbericht.
|
|
//
|
|
// Die drei unterscheiden sich nur in der Aufschluesselung, nicht in den
|
|
// Kennzahlen: Der Tagesbericht listet die Laeufe einzeln, der Wochenbericht
|
|
// zusaetzlich je Tag, der Monatsbericht je Auftrag. Sie als drei getrennte
|
|
// Berichte zu schreiben hiesse, dieselbe Erfolgsquote dreimal zu berechnen —
|
|
// und beim naechsten Fund an zwei Stellen zu vergessen.
|
|
func (generator *Generator) buildBackupReport(buildContext context.Context, report *Report,
|
|
reportType ReportType) error {
|
|
statistics, statisticsError := generator.loadRunStatistics(buildContext,
|
|
report.PeriodFrom, report.PeriodTo)
|
|
if statisticsError != nil {
|
|
return statisticsError
|
|
}
|
|
|
|
overviewSection := Section{
|
|
Title: "Überblick",
|
|
Description: "Alle Läufe, die im gewählten Zeitraum begonnen wurden.",
|
|
Metrics: []Metric{
|
|
KnownMetric("Läufe insgesamt", float64(statistics.TotalRuns), UnitCount),
|
|
KnownMetric("Erfolgreich", float64(statistics.SucceededRuns), UnitCount),
|
|
KnownMetric("Teilweise fehlgeschlagen", float64(statistics.PartialRuns), UnitCount),
|
|
KnownMetric("Gescheitert", float64(statistics.FailedRuns), UnitCount),
|
|
successRateMetric(statistics),
|
|
KnownMetric("Gelesene Datenmenge", float64(statistics.BytesProcessed), UnitBytes),
|
|
KnownMetric("Abgelegte Datenmenge", float64(statistics.BytesWritten), UnitBytes),
|
|
KnownMetric("Erfasste Objekte", float64(statistics.FilesProcessed), UnitCount),
|
|
KnownMetric("Übergangene Objekte", float64(statistics.FilesSkipped), UnitCount),
|
|
averageDurationMetric(statistics),
|
|
longestDurationMetric(statistics),
|
|
},
|
|
}
|
|
|
|
if statistics.RunningRuns > 0 {
|
|
// Laufende Vorgaenge stehen ausserhalb der Quote und werden trotzdem
|
|
// genannt: Sonst wundert sich der Leser ueber die Differenz zwischen
|
|
// „Laeufe insgesamt" und der Summe der Ergebnisse.
|
|
overviewSection.Metrics = append(overviewSection.Metrics,
|
|
KnownMetric("Noch laufend", float64(statistics.RunningRuns), UnitCount))
|
|
}
|
|
|
|
report.Sections = append(report.Sections, overviewSection)
|
|
|
|
if statistics.PartialRuns > 0 {
|
|
report.AddNote("%d Lauf/Läufe endeten als Teilfehler. Sie zählen nicht als Erfolg und "+
|
|
"werden nicht selbsttätig wiederholt — die übergangenen Objekte wären beim nächsten "+
|
|
"Versuch dieselben.", statistics.PartialRuns)
|
|
}
|
|
|
|
if statistics.FinishedRuns() == 0 {
|
|
report.AddNote("Im gewählten Zeitraum wurde kein Lauf abgeschlossen. Die Kennzahlen, die " +
|
|
"einen Lauf voraussetzen, sind deshalb nicht bestimmbar und werden nicht mit null " +
|
|
"ausgewiesen.")
|
|
}
|
|
|
|
if breakdownError := generator.appendBackupBreakdown(buildContext, report, reportType); breakdownError != nil {
|
|
return breakdownError
|
|
}
|
|
|
|
return generator.appendRunTable(buildContext, report)
|
|
}
|
|
|
|
// appendBackupBreakdown ergaenzt die Aufschluesselung je Reportart.
|
|
func (generator *Generator) appendBackupBreakdown(buildContext context.Context, report *Report,
|
|
reportType ReportType) error {
|
|
switch reportType {
|
|
case TypeWeeklyBackup:
|
|
return generator.appendDailyBreakdown(buildContext, report)
|
|
case TypeMonthlyBackup:
|
|
return generator.appendJobBreakdown(buildContext, report)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// appendDailyBreakdown schluesselt die Laeufe nach Tagen auf.
|
|
//
|
|
// Sie zeigt Muster, die in der Summe untergehen — etwa einen Auftrag, der
|
|
// immer freitags scheitert.
|
|
func (generator *Generator) appendDailyBreakdown(buildContext context.Context, report *Report) error {
|
|
const selectStatement = `
|
|
SELECT date_trunc('day', created_at) AS tag,
|
|
count(*),
|
|
count(*) FILTER (WHERE status = 'succeeded'),
|
|
count(*) FILTER (WHERE status = 'partial_failure'),
|
|
count(*) FILTER (WHERE status = 'failed'),
|
|
COALESCE(sum(bytes_processed), 0)
|
|
FROM backup_job_runs
|
|
WHERE created_at >= $1 AND created_at < $2
|
|
GROUP BY tag
|
|
ORDER BY tag`
|
|
|
|
dayRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
|
|
report.PeriodFrom, report.PeriodTo)
|
|
if queryError != nil {
|
|
return fmt.Errorf("die tagesaufschluesselung konnte nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer dayRows.Close()
|
|
|
|
breakdownTable := Table{
|
|
Title: "Läufe je Tag",
|
|
Columns: []string{"Tag", "Läufe", "Erfolgreich", "Teilfehler", "Gescheitert",
|
|
"Gelesene Datenmenge"},
|
|
Rows: make([][]string, 0, 32),
|
|
EmptyNotice: "Im gewählten Zeitraum gab es an keinem Tag einen Lauf.",
|
|
}
|
|
|
|
for dayRows.Next() {
|
|
var (
|
|
dayStart time.Time
|
|
totalCount, succeededCount int64
|
|
partialCount, failedCount, bytesProcessedSum int64
|
|
)
|
|
|
|
if scanError := dayRows.Scan(&dayStart, &totalCount, &succeededCount,
|
|
&partialCount, &failedCount, &bytesProcessedSum); scanError != nil {
|
|
return fmt.Errorf("ein tag konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
breakdownTable.Rows = append(breakdownTable.Rows, []string{
|
|
dayStart.UTC().Format("2006-01-02"),
|
|
fmt.Sprintf("%d", totalCount),
|
|
fmt.Sprintf("%d", succeededCount),
|
|
fmt.Sprintf("%d", partialCount),
|
|
fmt.Sprintf("%d", failedCount),
|
|
FormatBytes(float64(bytesProcessedSum)),
|
|
})
|
|
}
|
|
|
|
if rowsError := dayRows.Err(); rowsError != nil {
|
|
return rowsError
|
|
}
|
|
|
|
report.Sections = append(report.Sections, Section{
|
|
Title: "Aufschlüsselung nach Tagen",
|
|
Tables: []Table{breakdownTable},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// appendJobBreakdown schluesselt die Laeufe nach Auftraegen auf.
|
|
func (generator *Generator) appendJobBreakdown(buildContext context.Context, report *Report) error {
|
|
const selectStatement = `
|
|
SELECT j.name,
|
|
count(*),
|
|
count(*) FILTER (WHERE r.status = 'succeeded'),
|
|
count(*) FILTER (WHERE r.status = 'partial_failure'),
|
|
count(*) FILTER (WHERE r.status = 'failed'),
|
|
COALESCE(sum(r.bytes_processed), 0),
|
|
COALESCE(avg(EXTRACT(EPOCH FROM (r.completed_at - r.started_at))), 0)
|
|
FROM backup_job_runs r
|
|
JOIN backup_jobs j ON j.id = r.job_id
|
|
WHERE r.created_at >= $1 AND r.created_at < $2
|
|
GROUP BY j.name
|
|
ORDER BY count(*) FILTER (WHERE r.status = 'failed') DESC, j.name`
|
|
|
|
jobRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
|
|
report.PeriodFrom, report.PeriodTo)
|
|
if queryError != nil {
|
|
return fmt.Errorf("die auftragsaufschluesselung konnte nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer jobRows.Close()
|
|
|
|
breakdownTable := Table{
|
|
Title: "Läufe je Auftrag",
|
|
Columns: []string{"Auftrag", "Läufe", "Erfolgreich", "Teilfehler", "Gescheitert",
|
|
"Datenmenge", "Mittlere Laufzeit"},
|
|
Rows: make([][]string, 0, 32),
|
|
EmptyNotice: "Im gewählten Zeitraum lief kein Auftrag.",
|
|
}
|
|
|
|
for jobRows.Next() {
|
|
var (
|
|
jobName string
|
|
totalCount, succeededCount int64
|
|
partialCount, failedCount, bytesProcessedSum int64
|
|
averageSeconds float64
|
|
)
|
|
|
|
if scanError := jobRows.Scan(&jobName, &totalCount, &succeededCount, &partialCount,
|
|
&failedCount, &bytesProcessedSum, &averageSeconds); scanError != nil {
|
|
return fmt.Errorf("ein auftrag konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
breakdownTable.Rows = append(breakdownTable.Rows, []string{
|
|
jobName,
|
|
fmt.Sprintf("%d", totalCount),
|
|
fmt.Sprintf("%d", succeededCount),
|
|
fmt.Sprintf("%d", partialCount),
|
|
fmt.Sprintf("%d", failedCount),
|
|
FormatBytes(float64(bytesProcessedSum)),
|
|
FormatDuration(averageSeconds),
|
|
})
|
|
}
|
|
|
|
if rowsError := jobRows.Err(); rowsError != nil {
|
|
return rowsError
|
|
}
|
|
|
|
report.Sections = append(report.Sections, Section{
|
|
Title: "Aufschlüsselung nach Aufträgen",
|
|
Tables: []Table{breakdownTable},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// maximumListedRuns begrenzt die Zahl einzeln aufgefuehrter Laeufe.
|
|
//
|
|
// Ein Monatsbericht ueber eine grosse Anlage haette sonst zehntausend Zeilen.
|
|
// Die Begrenzung wird im Bericht **ausgesprochen** — eine stillschweigend
|
|
// gekuerzte Liste liest sich wie eine vollstaendige.
|
|
const maximumListedRuns = 500
|
|
|
|
// appendRunTable listet die einzelnen Laeufe.
|
|
func (generator *Generator) appendRunTable(buildContext context.Context, report *Report) error {
|
|
const selectStatement = `
|
|
SELECT j.name, r.status, r.trigger, r.started_at, r.completed_at,
|
|
COALESCE(r.bytes_processed, 0), COALESCE(r.files_processed, 0),
|
|
COALESCE(r.files_skipped, 0), COALESCE(r.error_code, '')
|
|
FROM backup_job_runs r
|
|
JOIN backup_jobs j ON j.id = r.job_id
|
|
WHERE r.created_at >= $1 AND r.created_at < $2
|
|
ORDER BY r.created_at DESC
|
|
LIMIT $3`
|
|
|
|
runRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
|
|
report.PeriodFrom, report.PeriodTo, maximumListedRuns)
|
|
if queryError != nil {
|
|
return fmt.Errorf("die laeufe konnten nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer runRows.Close()
|
|
|
|
runTable := Table{
|
|
Title: "Einzelne Läufe",
|
|
Columns: []string{"Auftrag", "Ergebnis", "Auslöser", "Beginn", "Dauer",
|
|
"Datenmenge", "Objekte", "Übergangen", "Fehlercode"},
|
|
Rows: make([][]string, 0, 64),
|
|
EmptyNotice: "Im gewählten Zeitraum wurde kein Lauf begonnen.",
|
|
}
|
|
|
|
for runRows.Next() {
|
|
var (
|
|
jobName, runStatus, runTrigger, errorCode string
|
|
startedAt, completedAt *time.Time
|
|
bytesProcessed, filesProcessed int64
|
|
filesSkipped int64
|
|
)
|
|
|
|
if scanError := runRows.Scan(&jobName, &runStatus, &runTrigger, &startedAt, &completedAt,
|
|
&bytesProcessed, &filesProcessed, &filesSkipped, &errorCode); scanError != nil {
|
|
return fmt.Errorf("ein lauf konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
runTable.Rows = append(runTable.Rows, []string{
|
|
jobName,
|
|
runStatusLabel(runStatus),
|
|
runTrigger,
|
|
formatOptionalTime(startedAt),
|
|
formatOptionalDuration(startedAt, completedAt),
|
|
FormatBytes(float64(bytesProcessed)),
|
|
fmt.Sprintf("%d", filesProcessed),
|
|
fmt.Sprintf("%d", filesSkipped),
|
|
errorCode,
|
|
})
|
|
}
|
|
|
|
if rowsError := runRows.Err(); rowsError != nil {
|
|
return rowsError
|
|
}
|
|
|
|
if len(runTable.Rows) == maximumListedRuns {
|
|
report.AddNote("Die Liste der einzelnen Läufe ist auf %d Einträge begrenzt. Es gibt "+
|
|
"möglicherweise weitere; die Kennzahlen im Überblick zählen alle.", maximumListedRuns)
|
|
}
|
|
|
|
report.Sections = append(report.Sections, Section{
|
|
Title: "Läufe im Einzelnen",
|
|
Tables: []Table{runTable},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildFailedBackupReport listet die gescheiterten Laeufe.
|
|
//
|
|
// Teilfehler stehen ausdruecklich mit darin: Sie werden nicht selbsttaetig
|
|
// wiederholt und verschwinden sonst aus dem Blick — genau der Zustand, den
|
|
// dieser Bericht sichtbar machen soll.
|
|
func (generator *Generator) buildFailedBackupReport(buildContext context.Context, report *Report) error {
|
|
statistics, statisticsError := generator.loadRunStatistics(buildContext,
|
|
report.PeriodFrom, report.PeriodTo)
|
|
if statisticsError != nil {
|
|
return statisticsError
|
|
}
|
|
|
|
report.Sections = append(report.Sections, Section{
|
|
Title: "Überblick",
|
|
Description: "Gescheiterte Läufe und Teilfehler im gewählten Zeitraum.",
|
|
Metrics: []Metric{
|
|
KnownMetric("Gescheiterte Läufe", float64(statistics.FailedRuns), UnitCount),
|
|
KnownMetric("Teilfehler", float64(statistics.PartialRuns), UnitCount),
|
|
KnownMetric("Übergangene Objekte", float64(statistics.FilesSkipped), UnitCount),
|
|
KnownMetric("Läufe insgesamt", float64(statistics.TotalRuns), UnitCount),
|
|
successRateMetric(statistics),
|
|
},
|
|
})
|
|
|
|
if classError := generator.appendFailureClasses(buildContext, report); classError != nil {
|
|
return classError
|
|
}
|
|
|
|
return generator.appendFailureTable(buildContext, report)
|
|
}
|
|
|
|
// appendFailureClasses fasst die Fehler nach Klassen zusammen.
|
|
//
|
|
// Die Klasse entscheidet ueber die Behandlung: transient und network werden
|
|
// wiederholt, auth und integrity nicht. Eine Haeufung in einer Klasse sagt mehr
|
|
// als zehn Einzelmeldungen.
|
|
func (generator *Generator) appendFailureClasses(buildContext context.Context, report *Report) error {
|
|
const selectStatement = `
|
|
SELECT COALESCE(failure_class, 'ohne Klassifizierung'), COALESCE(error_code, ''), count(*)
|
|
FROM backup_job_runs
|
|
WHERE created_at >= $1 AND created_at < $2
|
|
AND status IN ('failed', 'partial_failure')
|
|
GROUP BY 1, 2
|
|
ORDER BY count(*) DESC`
|
|
|
|
classRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
|
|
report.PeriodFrom, report.PeriodTo)
|
|
if queryError != nil {
|
|
return fmt.Errorf("die fehlerklassen konnten nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer classRows.Close()
|
|
|
|
classTable := Table{
|
|
Title: "Fehler nach Klasse",
|
|
Columns: []string{"Fehlerklasse", "Fehlercode", "Anzahl"},
|
|
Rows: make([][]string, 0, 16),
|
|
EmptyNotice: "Im gewählten Zeitraum ist kein Lauf gescheitert.",
|
|
}
|
|
|
|
for classRows.Next() {
|
|
var (
|
|
failureClass, errorCode string
|
|
occurrenceCount int64
|
|
)
|
|
|
|
if scanError := classRows.Scan(&failureClass, &errorCode, &occurrenceCount); scanError != nil {
|
|
return fmt.Errorf("eine fehlerklasse konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
classTable.Rows = append(classTable.Rows, []string{
|
|
failureClass, errorCode, fmt.Sprintf("%d", occurrenceCount),
|
|
})
|
|
}
|
|
|
|
if rowsError := classRows.Err(); rowsError != nil {
|
|
return rowsError
|
|
}
|
|
|
|
report.Sections = append(report.Sections, Section{
|
|
Title: "Fehlerbild",
|
|
Description: "Die Klasse entscheidet über die Behandlung: transiente und Netzwerkfehler werden wiederholt, Anmelde- und Integritätsfehler nicht.",
|
|
Tables: []Table{classTable},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// appendFailureTable listet die gescheiterten Laeufe einzeln.
|
|
func (generator *Generator) appendFailureTable(buildContext context.Context, report *Report) error {
|
|
const selectStatement = `
|
|
SELECT j.name, r.status, r.started_at, r.completed_at,
|
|
COALESCE(r.error_code, ''), COALESCE(r.failure_class, ''),
|
|
COALESCE(r.error_message, ''), COALESCE(r.files_skipped, 0),
|
|
r.attempt_number
|
|
FROM backup_job_runs r
|
|
JOIN backup_jobs j ON j.id = r.job_id
|
|
WHERE r.created_at >= $1 AND r.created_at < $2
|
|
AND r.status IN ('failed', 'partial_failure')
|
|
ORDER BY r.created_at DESC
|
|
LIMIT $3`
|
|
|
|
failureRows, queryError := generator.connectionPool.Query(buildContext, selectStatement,
|
|
report.PeriodFrom, report.PeriodTo, maximumListedRuns)
|
|
if queryError != nil {
|
|
return fmt.Errorf("die gescheiterten laeufe konnten nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer failureRows.Close()
|
|
|
|
failureTable := Table{
|
|
Title: "Gescheiterte Läufe und Teilfehler",
|
|
Columns: []string{"Auftrag", "Ergebnis", "Beginn", "Versuch", "Fehlercode",
|
|
"Klasse", "Übergangen", "Meldung"},
|
|
Rows: make([][]string, 0, 32),
|
|
EmptyNotice: "Im gewählten Zeitraum ist kein Lauf gescheitert und keiner endete als " +
|
|
"Teilfehler.",
|
|
}
|
|
|
|
for failureRows.Next() {
|
|
var (
|
|
jobName, runStatus, errorCode string
|
|
failureClass, errorMessage string
|
|
startedAt, completedAt *time.Time
|
|
filesSkipped int64
|
|
attemptNumber int32
|
|
)
|
|
|
|
if scanError := failureRows.Scan(&jobName, &runStatus, &startedAt, &completedAt,
|
|
&errorCode, &failureClass, &errorMessage, &filesSkipped, &attemptNumber); scanError != nil {
|
|
return fmt.Errorf("ein gescheiterter lauf konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
failureTable.Rows = append(failureTable.Rows, []string{
|
|
jobName,
|
|
runStatusLabel(runStatus),
|
|
formatOptionalTime(startedAt),
|
|
fmt.Sprintf("%d", attemptNumber),
|
|
errorCode,
|
|
failureClass,
|
|
fmt.Sprintf("%d", filesSkipped),
|
|
errorMessage,
|
|
})
|
|
}
|
|
|
|
if rowsError := failureRows.Err(); rowsError != nil {
|
|
return rowsError
|
|
}
|
|
|
|
report.Sections = append(report.Sections, Section{
|
|
Title: "Im Einzelnen",
|
|
Tables: []Table{failureTable},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatOptionalTime stellt einen Zeitpunkt dar, der fehlen kann.
|
|
func formatOptionalTime(pointInTime *time.Time) string {
|
|
if pointInTime == nil {
|
|
return "—"
|
|
}
|
|
|
|
return FormatTimestamp(*pointInTime)
|
|
}
|
|
|
|
// formatOptionalDuration stellt eine Dauer aus zwei Zeitpunkten dar.
|
|
//
|
|
// Fehlt einer von beiden, steht dort ein Gedankenstrich und keine Null: Ein
|
|
// abgebrochener Lauf hat keine Dauer von null Sekunden.
|
|
func formatOptionalDuration(startedAt, completedAt *time.Time) string {
|
|
if startedAt == nil || completedAt == nil {
|
|
return "—"
|
|
}
|
|
|
|
return FormatDuration(completedAt.Sub(*startedAt).Seconds())
|
|
}
|