syncova-backup/packages/reports/generator.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

214 lines
7.6 KiB
Go

package reports
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/syncova/syncova/packages/security"
)
// Generator erzeugt Berichte aus dem Bestand der Control Plane.
//
// Berichte werden **nicht gespeichert**. Ein abgelegter Bericht veraltet mit
// jedem Tag, ohne dass sich etwas an ihm aendert — dieselbe Ueberlegung wie bei
// der Recovery Assurance (Phase 10) und der Sicherheitsbewertung (Phase 15).
// Wer einen Bericht aufbewahren will, laedt ihn herunter; dann traegt die Datei
// ihren Erzeugungszeitpunkt bei sich.
type Generator struct {
// connectionPool ist der Datenbankpool der Control Plane.
connectionPool *pgxpool.Pool
// securityInspector beurteilt die Sicherheitslage.
//
// Er darf nil sein; dann melden die beiden Berichte, die ihn brauchen,
// einen Fehler, statt eine erfundene Lage zu beschreiben.
securityInspector *security.Inspector
}
// NewGenerator erzeugt den Berichtsersteller.
func NewGenerator(connectionPool *pgxpool.Pool, securityInspector *security.Inspector) *Generator {
return &Generator{connectionPool: connectionPool, securityInspector: securityInspector}
}
// Generate erzeugt einen Bericht.
func (generator *Generator) Generate(generateContext context.Context, reportType ReportType,
periodFrom, periodTo time.Time, generatedBy string) (*Report, error) {
definition, found := FindDefinition(reportType)
if !found {
return nil, fmt.Errorf("die reportart %q ist unbekannt", reportType)
}
report := newReport(definition, periodFrom, periodTo, generatedBy)
var buildError error
switch reportType {
case TypeDailyBackup, TypeWeeklyBackup, TypeMonthlyBackup:
buildError = generator.buildBackupReport(generateContext, report, reportType)
case TypeFailedBackup:
buildError = generator.buildFailedBackupReport(generateContext, report)
case TypeRepositoryCapacity:
buildError = generator.buildRepositoryReport(generateContext, report)
case TypeRecovery:
buildError = generator.buildRecoveryReport(generateContext, report)
case TypeSecurity:
buildError = generator.buildSecurityReport(generateContext, report)
case TypeCompliance:
buildError = generator.buildComplianceReport(generateContext, report)
case TypeRecoveryObjectives:
buildError = generator.buildObjectivesReport(generateContext, report)
default:
buildError = fmt.Errorf("fuer die reportart %q gibt es keine erzeugung", reportType)
}
if buildError != nil {
return nil, buildError
}
return report, nil
}
// runStatistics fasst die Laeufe eines Zeitraums zusammen.
type runStatistics struct {
// TotalRuns ist die Zahl abgeschlossener Laeufe.
TotalRuns int64
// SucceededRuns ist die Zahl erfolgreicher Laeufe.
SucceededRuns int64
// PartialRuns ist die Zahl der Teilfehler.
//
// Getrennt gefuehrt, nicht zu den Erfolgen geschlagen: Ein Lauf mit
// uebergangenen Objekten ist keine Sicherung, auf die man sich verlassen
// kann (verbindliche Regel 1).
PartialRuns int64
// FailedRuns ist die Zahl gescheiterter Laeufe.
FailedRuns int64
// RunningRuns ist die Zahl noch laufender Vorgaenge.
RunningRuns int64
// BytesProcessed ist die gelesene Datenmenge.
BytesProcessed int64
// BytesWritten ist die abgelegte Datenmenge.
BytesWritten int64
// FilesProcessed ist die Zahl erfasster Objekte.
FilesProcessed int64
// FilesSkipped ist die Zahl uebergangener Objekte.
FilesSkipped int64
// TotalDurationSeconds ist die Summe der Laufzeiten.
TotalDurationSeconds float64
// LongestDurationSeconds ist die laengste Laufzeit.
LongestDurationSeconds float64
}
// FinishedRuns zaehlt die abgeschlossenen Laeufe.
//
// Laufende Vorgaenge zaehlen nicht mit: Sie haben noch kein Ergebnis, und sie in
// den Nenner einer Erfolgsquote zu nehmen zoege diese ohne Grund nach unten.
func (statistics runStatistics) FinishedRuns() int64 {
return statistics.SucceededRuns + statistics.PartialRuns + statistics.FailedRuns
}
// loadRunStatistics liest die Kennzahlen der Laeufe eines Zeitraums.
func (generator *Generator) loadRunStatistics(loadContext context.Context,
periodFrom, periodTo time.Time) (runStatistics, error) {
const selectStatement = `
SELECT
count(*),
count(*) FILTER (WHERE status = 'succeeded'),
count(*) FILTER (WHERE status = 'partial_failure'),
count(*) FILTER (WHERE status = 'failed'),
count(*) FILTER (WHERE status = 'running'),
COALESCE(sum(bytes_processed), 0),
COALESCE(sum(bytes_written), 0),
COALESCE(sum(files_processed), 0),
COALESCE(sum(files_skipped), 0),
COALESCE(sum(EXTRACT(EPOCH FROM (completed_at - started_at))), 0),
COALESCE(max(EXTRACT(EPOCH FROM (completed_at - started_at))), 0)
FROM backup_job_runs
WHERE created_at >= $1 AND created_at < $2`
var statistics runStatistics
scanError := generator.connectionPool.QueryRow(loadContext, selectStatement, periodFrom, periodTo).Scan(
&statistics.TotalRuns, &statistics.SucceededRuns, &statistics.PartialRuns,
&statistics.FailedRuns, &statistics.RunningRuns,
&statistics.BytesProcessed, &statistics.BytesWritten,
&statistics.FilesProcessed, &statistics.FilesSkipped,
&statistics.TotalDurationSeconds, &statistics.LongestDurationSeconds)
if scanError != nil {
return runStatistics{}, fmt.Errorf("die kennzahlen der laeufe konnten nicht gelesen werden: %w", scanError)
}
return statistics, nil
}
// successRateMetric bildet die Erfolgsquote eines Zeitraums.
//
// **Keine Laeufe sind nicht hundert Prozent.** Ohne einen einzigen
// abgeschlossenen Lauf gibt es keine Quote — und ein Bericht, der in diesem Fall
// „100 %" ausweist, meldet ausgerechnet bei ausgefallener Sicherung den besten
// aller Zustaende. Dieselbe Regel wie in der Uebersicht (Phase 12).
func successRateMetric(statistics runStatistics) Metric {
finishedRuns := statistics.FinishedRuns()
if finishedRuns == 0 {
return UnknownMetric("Erfolgsquote", UnitPercent,
"Im gewählten Zeitraum wurde kein Lauf abgeschlossen. Ohne Lauf gibt es keine Quote.")
}
return KnownMetric("Erfolgsquote",
float64(statistics.SucceededRuns)*100/float64(finishedRuns), UnitPercent)
}
// averageDurationMetric bildet die mittlere Laufzeit.
func averageDurationMetric(statistics runStatistics) Metric {
finishedRuns := statistics.FinishedRuns()
if finishedRuns == 0 {
return UnknownMetric("Mittlere Laufzeit", UnitSeconds,
"Im gewählten Zeitraum wurde kein Lauf abgeschlossen.")
}
return KnownMetric("Mittlere Laufzeit",
statistics.TotalDurationSeconds/float64(finishedRuns), UnitSeconds)
}
// longestDurationMetric bildet die laengste Laufzeit.
//
// Ohne abgeschlossenen Lauf gibt es keine laengste Laufzeit. Die Datenbank
// liefert dort dank COALESCE eine Null — und eine Null neben einer als
// „nicht bestimmbar" ausgewiesenen Durchschnittszeit liest sich wie eine
// **Messung**: als sei ein Lauf in null Sekunden durchgelaufen. Im Nachweis
// aufgefallen.
func longestDurationMetric(statistics runStatistics) Metric {
if statistics.FinishedRuns() == 0 {
return UnknownMetric("Längste Laufzeit", UnitSeconds,
"Im gewählten Zeitraum wurde kein Lauf abgeschlossen.")
}
return KnownMetric("Längste Laufzeit", statistics.LongestDurationSeconds, UnitSeconds)
}
// runStatusLabel benennt den Zustand eines Laufs.
//
// Ein Teilfehler heisst „TEILWEISE FEHLGESCHLAGEN", nicht „erfolgreich mit
// Anmerkungen" (verbindliche Regel 1). Der Bericht ist genau der Ort, an dem
// diese Unterscheidung gelesen wird.
func runStatusLabel(rawStatus string) string {
switch rawStatus {
case "succeeded":
return "erfolgreich"
case "partial_failure":
return "TEILWEISE FEHLGESCHLAGEN"
case "failed":
return "GESCHEITERT"
case "running":
return "läuft"
case "queued":
return "eingereiht"
case "cancelled":
return "abgebrochen"
default:
return rawStatus
}
}