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>
214 lines
7.6 KiB
Go
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
|
|
}
|
|
}
|