syncova-backup/packages/verification/store.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

696 lines
24 KiB
Go

package verification
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// JobStatus ist der Zustand eines Pruefauftrags.
type JobStatus string
const (
// JobStatusQueued wartet auf Ausfuehrung.
JobStatusQueued JobStatus = "queued"
// JobStatusRunning laeuft.
JobStatusRunning JobStatus = "running"
// JobStatusCompleted ist abgeschlossen.
JobStatusCompleted JobStatus = "completed"
// JobStatusFailed ist gescheitert.
//
// Zu unterscheiden von einem Ergebnis „beschaedigt": Hier konnte die
// Pruefung selbst nicht stattfinden. Sie sagt dann nichts ueber das Backup.
JobStatusFailed JobStatus = "failed"
// JobStatusCancelled wurde abgebrochen.
JobStatusCancelled JobStatus = "cancelled"
)
// IsFinished meldet einen abgeschlossenen Auftrag.
func (jobStatus JobStatus) IsFinished() bool {
return jobStatus != JobStatusQueued && jobStatus != JobStatusRunning
}
// JobResult ist das Ergebnis einer Pruefung.
type JobResult string
const (
// ResultClean meldet eine Pruefung ohne Beanstandung.
ResultClean JobResult = "clean"
// ResultCorrupted meldet beschaedigte Daten.
ResultCorrupted JobResult = "corrupted"
// ResultIncomplete meldet fehlende Daten.
ResultIncomplete JobResult = "incomplete"
// ResultFailed meldet eine nicht durchfuehrbare Pruefung.
ResultFailed JobResult = "failed"
)
// Job ist ein Pruefauftrag.
type Job struct {
// ID ist der oeffentliche Bezeichner.
ID uuid.UUID `json:"id"`
// BackupID ist das geprueft Backup in der Control Plane.
BackupID uuid.UUID `json:"backup_id"`
// VerificationType ist die Art der Pruefung.
VerificationType VerificationType `json:"verification_type"`
// Status ist der Zustand.
Status JobStatus `json:"status"`
// Result ist das Ergebnis; leer solange nicht abgeschlossen.
Result JobResult `json:"result,omitempty"`
// Report ist der vollstaendige Pruefbericht.
Report *Report `json:"report,omitempty"`
// RestoreTestReport ist der Bericht eines Wiederherstellungstests.
RestoreTestReport *RestoreTestReport `json:"restore_test_report,omitempty"`
// ChunksChecked ist die Zahl gepruefter Bloecke.
ChunksChecked int64 `json:"chunks_checked"`
// ChunksMissing ist die Zahl fehlender Bloecke.
ChunksMissing int64 `json:"chunks_missing"`
// ChunksCorrupted ist die Zahl beschaedigter Bloecke.
ChunksCorrupted int64 `json:"chunks_corrupted"`
// BytesRead ist die gelesene Datenmenge.
BytesRead int64 `json:"bytes_read"`
// StartedAt ist der Beginn in UTC.
StartedAt *time.Time `json:"started_at,omitempty"`
// CompletedAt ist das Ende in UTC.
CompletedAt *time.Time `json:"completed_at,omitempty"`
// ErrorMessage ist die verstaendliche Fehlermeldung.
ErrorMessage string `json:"error_message,omitempty"`
// CorrelationID verbindet den Auftrag mit seinen Protokollzeilen.
CorrelationID uuid.UUID `json:"correlation_id"`
// CreatedBy benennt den Anfordernden; nil bei automatischer Pruefung.
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
// CreatedAt ist der Anlagezeitpunkt in UTC.
CreatedAt time.Time `json:"created_at"`
}
// ErrJobNotFound meldet einen nicht vorhandenen Pruefauftrag.
var ErrJobNotFound = errors.New("der pruefauftrag wurde nicht gefunden")
// ErrBackupBusy meldet ein bereits in Pruefung befindliches Backup.
var ErrBackupBusy = errors.New("dieses backup wird bereits geprueft")
// uniqueViolationCode ist der PostgreSQL-Fehlercode fuer Eindeutigkeitsverstoesse.
const uniqueViolationCode = "23505"
// Store legt Pruefauftraege in PostgreSQL ab.
type Store struct {
// connectionPool ist der Datenbankpool der Control Plane.
connectionPool *pgxpool.Pool
}
// NewStore erzeugt die Datenzugriffsschicht.
func NewStore(connectionPool *pgxpool.Pool) *Store {
return &Store{connectionPool: connectionPool}
}
// jobColumnList sind die Spalten eines Auftrags in fester Reihenfolge.
const jobColumnList = `
id, backup_id, verification_type, status, result, report,
chunks_checked, chunks_missing, chunks_corrupted, bytes_read,
started_at, completed_at, error_message, correlation_id, created_by, created_at`
// rowScanner deckt QueryRow und Rows gemeinsam ab.
type rowScanner interface {
// Scan liest die Spalten einer Zeile.
Scan(destinations ...any) error
}
// scanJob liest eine Auftragszeile.
func scanJob(scanner rowScanner) (*Job, error) {
var (
loadedJob Job
typeText string
statusText string
resultText *string
reportJSON []byte
errorMessage *string
)
scanError := scanner.Scan(
&loadedJob.ID, &loadedJob.BackupID, &typeText, &statusText, &resultText, &reportJSON,
&loadedJob.ChunksChecked, &loadedJob.ChunksMissing, &loadedJob.ChunksCorrupted, &loadedJob.BytesRead,
&loadedJob.StartedAt, &loadedJob.CompletedAt, &errorMessage,
&loadedJob.CorrelationID, &loadedJob.CreatedBy, &loadedJob.CreatedAt,
)
if scanError != nil {
return nil, scanError
}
loadedJob.VerificationType = VerificationType(typeText)
loadedJob.Status = JobStatus(statusText)
if resultText != nil {
loadedJob.Result = JobResult(*resultText)
}
if errorMessage != nil {
loadedJob.ErrorMessage = *errorMessage
}
if len(reportJSON) > 0 {
// Ein Wiederherstellungstest traegt einen erweiterten Bericht. Er wird
// in beide Formen gelesen, damit die Zusatzangaben nicht verloren gehen.
if loadedJob.VerificationType == TypeRestoreTest {
var restoreReport RestoreTestReport
if unmarshalError := json.Unmarshal(reportJSON, &restoreReport); unmarshalError == nil {
loadedJob.RestoreTestReport = &restoreReport
loadedJob.Report = &restoreReport.Report
}
} else {
var generalReport Report
if unmarshalError := json.Unmarshal(reportJSON, &generalReport); unmarshalError == nil {
loadedJob.Report = &generalReport
}
}
}
return &loadedJob, nil
}
// CreateJob legt einen Pruefauftrag an.
func (store *Store) CreateJob(createContext context.Context, backupIdentifier uuid.UUID, verificationType VerificationType, createdBy *uuid.UUID) (*Job, error) {
const insertStatement = `
INSERT INTO verification_jobs (backup_id, verification_type, correlation_id, created_by)
VALUES ($1, $2, gen_random_uuid(), $3)
RETURNING ` + jobColumnList
createdJob, scanError := scanJob(store.connectionPool.QueryRow(createContext, insertStatement,
backupIdentifier, string(verificationType), createdBy))
if scanError != nil {
if isUniqueViolation(scanError) {
// Zwei gleichzeitige Pruefungen desselben Backups lesen dieselben
// Daten doppelt, ohne mehr festzustellen.
return nil, ErrBackupBusy
}
return nil, fmt.Errorf("der pruefauftrag konnte nicht angelegt werden: %w", scanError)
}
return createdJob, nil
}
// GetJob liest einen Pruefauftrag.
func (store *Store) GetJob(readContext context.Context, jobIdentifier uuid.UUID) (*Job, error) {
const selectStatement = `SELECT ` + jobColumnList + ` FROM verification_jobs WHERE id = $1`
loadedJob, scanError := scanJob(store.connectionPool.QueryRow(readContext, selectStatement, jobIdentifier))
if errors.Is(scanError, pgx.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", ErrJobNotFound, jobIdentifier)
}
if scanError != nil {
return nil, fmt.Errorf("der pruefauftrag konnte nicht gelesen werden: %w", scanError)
}
return loadedJob, nil
}
// ListJobs liefert eine Seite von Pruefauftraegen.
func (store *Store) ListJobs(listContext context.Context, page int, pageSize int) ([]Job, int, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 50
}
const selectStatement = `
SELECT ` + jobColumnList + `, COUNT(*) OVER () AS total_count
FROM verification_jobs
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`
jobRows, queryError := store.connectionPool.Query(listContext, selectStatement,
pageSize, (page-1)*pageSize)
if queryError != nil {
return nil, 0, fmt.Errorf("die auftragsliste konnte nicht gelesen werden: %w", queryError)
}
defer jobRows.Close()
loadedJobs := make([]Job, 0, pageSize)
var totalCount int
for jobRows.Next() {
var (
loadedJob Job
typeText string
statusText string
resultText *string
reportJSON []byte
errorMessage *string
)
if scanError := jobRows.Scan(
&loadedJob.ID, &loadedJob.BackupID, &typeText, &statusText, &resultText, &reportJSON,
&loadedJob.ChunksChecked, &loadedJob.ChunksMissing, &loadedJob.ChunksCorrupted, &loadedJob.BytesRead,
&loadedJob.StartedAt, &loadedJob.CompletedAt, &errorMessage,
&loadedJob.CorrelationID, &loadedJob.CreatedBy, &loadedJob.CreatedAt, &totalCount,
); scanError != nil {
return nil, 0, fmt.Errorf("ein pruefauftrag konnte nicht gelesen werden: %w", scanError)
}
loadedJob.VerificationType = VerificationType(typeText)
loadedJob.Status = JobStatus(statusText)
if resultText != nil {
loadedJob.Result = JobResult(*resultText)
}
if errorMessage != nil {
loadedJob.ErrorMessage = *errorMessage
}
loadedJobs = append(loadedJobs, loadedJob)
}
return loadedJobs, totalCount, jobRows.Err()
}
// ClaimQueuedJobs uebernimmt anstehende Pruefauftraege.
func (store *Store) ClaimQueuedJobs(claimContext context.Context, maximumJobs int, schedulerInstance string) ([]Job, error) {
transaction, transactionError := store.connectionPool.Begin(claimContext)
if transactionError != nil {
return nil, fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
}
defer func() { _ = transaction.Rollback(claimContext) }()
const selectStatement = `
SELECT ` + jobColumnList + `
FROM verification_jobs
WHERE status = 'queued'
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED`
jobRows, queryError := transaction.Query(claimContext, selectStatement, maximumJobs)
if queryError != nil {
return nil, fmt.Errorf("die anstehenden pruefungen konnten nicht ermittelt werden: %w", queryError)
}
claimedJobs := make([]Job, 0, maximumJobs)
for jobRows.Next() {
claimedJob, scanError := scanJob(jobRows)
if scanError != nil {
jobRows.Close()
return nil, fmt.Errorf("eine anstehende pruefung konnte nicht gelesen werden: %w", scanError)
}
claimedJobs = append(claimedJobs, *claimedJob)
}
jobRows.Close()
if rowsError := jobRows.Err(); rowsError != nil {
return nil, rowsError
}
const markStatement = `UPDATE verification_jobs SET scheduler_instance = $2, heartbeat_at = now() WHERE id = $1`
for _, claimedJob := range claimedJobs {
if _, execError := transaction.Exec(claimContext, markStatement, claimedJob.ID, schedulerInstance); execError != nil {
return nil, fmt.Errorf("die uebernahme schlug fehl: %w", execError)
}
}
if commitError := transaction.Commit(claimContext); commitError != nil {
return nil, fmt.Errorf("die uebernahme konnte nicht festgeschrieben werden: %w", commitError)
}
return claimedJobs, nil
}
// StartJob setzt einen uebernommenen Auftrag auf „laeuft".
func (store *Store) StartJob(startContext context.Context, jobIdentifier uuid.UUID, schedulerInstance string) error {
const updateStatement = `
UPDATE verification_jobs
SET status = 'running', started_at = now(), heartbeat_at = now(), scheduler_instance = $2
WHERE id = $1 AND status = 'queued'`
commandTag, execError := store.connectionPool.Exec(startContext, updateStatement,
jobIdentifier, schedulerInstance)
if execError != nil {
return fmt.Errorf("die pruefung konnte nicht gestartet werden: %w", execError)
}
if commandTag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s steht nicht mehr auf 'queued'", ErrJobNotFound, jobIdentifier)
}
return nil
}
// RecordHeartbeat meldet eine laufende Pruefung als lebendig.
func (store *Store) RecordHeartbeat(heartbeatContext context.Context, jobIdentifier uuid.UUID) error {
const updateStatement = `
UPDATE verification_jobs SET heartbeat_at = now() WHERE id = $1 AND status = 'running'`
if _, execError := store.connectionPool.Exec(heartbeatContext, updateStatement, jobIdentifier); execError != nil {
return fmt.Errorf("die lebendmeldung schlug fehl: %w", execError)
}
return nil
}
// FinishJob schreibt das Ergebnis einer Pruefung fest und stuft das Backup ein.
//
// Beides geschieht in einer Transaktion: Eine Einstufung ohne den zugehoerigen
// Pruefbericht waere eine Behauptung ohne Beleg.
func (store *Store) FinishJob(finishContext context.Context, jobIdentifier uuid.UUID, report *Report, restoreReport *RestoreTestReport) error {
transaction, transactionError := store.connectionPool.Begin(finishContext)
if transactionError != nil {
return fmt.Errorf("die transaktion konnte nicht begonnen werden: %w", transactionError)
}
defer func() { _ = transaction.Rollback(finishContext) }()
jobResult := determineResult(report, restoreReport)
reportPayload := any(report)
if restoreReport != nil {
reportPayload = restoreReport
}
reportJSON, marshalError := json.Marshal(reportPayload)
if marshalError != nil {
return fmt.Errorf("der pruefbericht konnte nicht abgelegt werden: %w", marshalError)
}
const updateJobStatement = `
UPDATE verification_jobs
SET status = 'completed', completed_at = now(), result = $2, report = $3,
chunks_checked = $4, chunks_missing = $5, chunks_corrupted = $6, bytes_read = $7
WHERE id = $1
RETURNING backup_id, verification_type`
var (
backupIdentifier uuid.UUID
typeText string
)
scanError := transaction.QueryRow(finishContext, updateJobStatement,
jobIdentifier, string(jobResult), reportJSON,
int64(report.ChunksChecked), int64(report.ChunksMissing), int64(report.ChunksCorrupted),
report.BytesRead,
).Scan(&backupIdentifier, &typeText)
if errors.Is(scanError, pgx.ErrNoRows) {
return fmt.Errorf("%w: %s", ErrJobNotFound, jobIdentifier)
}
if scanError != nil {
return fmt.Errorf("das ergebnis konnte nicht festgeschrieben werden: %w", scanError)
}
if updateError := updateBackupFromVerification(finishContext, transaction, backupIdentifier,
VerificationType(typeText), jobResult, restoreReport); updateError != nil {
return updateError
}
if commitError := transaction.Commit(finishContext); commitError != nil {
return fmt.Errorf("das ergebnis konnte nicht festgeschrieben werden: %w", commitError)
}
return nil
}
// determineResult leitet das Ergebnis aus dem Bericht ab.
func determineResult(report *Report, restoreReport *RestoreTestReport) JobResult {
if restoreReport != nil {
if restoreReport.HasCorruption() || restoreReport.FilesMismatched > 0 {
return ResultCorrupted
}
if !restoreReport.IsClean() {
return ResultIncomplete
}
return ResultClean
}
if report.HasCorruption() {
return ResultCorrupted
}
if !report.IsClean() {
return ResultIncomplete
}
return ResultClean
}
// updateBackupFromVerification schreibt die Einstufung am Backup fort.
//
// Der Pruefzeitpunkt wird **nur bei einem sauberen Ergebnis** gesetzt: Eine
// Pruefung, die Beschaedigungen fand, ist kein Beleg fuer Unversehrtheit. Ihn
// trotzdem zu setzen liesse das Backup bei der naechsten Bewertung als
// „kuerzlich geprueft" erscheinen.
func updateBackupFromVerification(updateContext context.Context, transaction pgx.Tx, backupIdentifier uuid.UUID, verificationType VerificationType, jobResult JobResult, restoreReport *RestoreTestReport) error {
if jobResult != ResultClean {
// Ein Befund stuft das Backup herab **und loescht die bisherigen
// Nachweise**.
//
// Das Loeschen ist der wesentliche Teil: Eine bestandene Pruefung und ein
// bestandener Wiederherstellungstest beziehen sich auf einen Zustand des
// Repositorys, den es nachweislich nicht mehr gibt. Blieben ihre
// Zeitstempel stehen, stuende das Backup nach der naechsten sauberen
// Pruefung sofort wieder als „wiederherstellbar" da — auf Grundlage eines
// Tests, der vor dem Schaden lief. Wer den Nachweis will, muss ihn
// wiederholen.
const downgradeStatement = `
UPDATE backups
SET classification = 'corrupted',
last_verified_at = NULL,
last_restore_test_at = NULL,
last_restore_test_duration_seconds = NULL
WHERE id = $1`
if _, execError := transaction.Exec(updateContext, downgradeStatement, backupIdentifier); execError != nil {
return fmt.Errorf("die einstufung konnte nicht gesetzt werden: %w", execError)
}
return nil
}
switch verificationType {
case TypeRestoreTest:
var measuredSeconds *float64
if restoreReport != nil && restoreReport.DurationSeconds > 0 {
measuredSeconds = &restoreReport.DurationSeconds
}
// Ein Teiltest belegt nicht die Wiederherstellbarkeit des gesamten
// Backups und darf es deshalb nicht als „wiederherstellbar" einstufen.
if restoreReport != nil && restoreReport.WasPartialTest {
const partialStatement = `
UPDATE backups SET last_verified_at = now(),
classification = CASE
WHEN classification = 'recoverable' THEN 'recoverable'
ELSE 'verified'
END
WHERE id = $1`
_, execError := transaction.Exec(updateContext, partialStatement, backupIdentifier)
return execError
}
const restoreStatement = `
UPDATE backups
SET last_restore_test_at = now(),
last_restore_test_duration_seconds = $2,
last_verified_at = COALESCE(last_verified_at, now()),
classification = 'recoverable'
WHERE id = $1`
if _, execError := transaction.Exec(updateContext, restoreStatement,
backupIdentifier, measuredSeconds); execError != nil {
return fmt.Errorf("die einstufung konnte nicht gesetzt werden: %w", execError)
}
case TypeChunkPresence, TypeChunkIntegrity:
// Eine bestandene Blockpruefung hebt auf „geprueft" — aber niemals von
// „wiederherstellbar" herab: Ein bestandener Wiederherstellungstest
// bleibt die staerkere Aussage.
const verifyStatement = `
UPDATE backups
SET last_verified_at = now(),
classification = CASE
WHEN classification = 'recoverable' THEN 'recoverable'
ELSE 'verified'
END
WHERE id = $1`
if _, execError := transaction.Exec(updateContext, verifyStatement, backupIdentifier); execError != nil {
return fmt.Errorf("die einstufung konnte nicht gesetzt werden: %w", execError)
}
default:
// Manifest- und Kettenpruefung sagen nichts ueber die Daten und heben
// die Einstufung deshalb nicht.
}
return nil
}
// FailJob vermerkt eine nicht durchfuehrbare Pruefung.
//
// Sie stuft das Backup **nicht** herab: Dass die Pruefung nicht laufen konnte,
// sagt nichts ueber die Daten. Alles andere waere ein Fehlalarm.
func (store *Store) FailJob(failContext context.Context, jobIdentifier uuid.UUID, errorMessage string) error {
const updateStatement = `
UPDATE verification_jobs
SET status = 'failed', completed_at = now(), result = 'failed', error_message = $2
WHERE id = $1`
if _, execError := store.connectionPool.Exec(failContext, updateStatement,
jobIdentifier, errorMessage); execError != nil {
return fmt.Errorf("der fehlschlag konnte nicht vermerkt werden: %w", execError)
}
return nil
}
// CancelJob bricht einen Pruefauftrag ab.
func (store *Store) CancelJob(cancelContext context.Context, jobIdentifier uuid.UUID) error {
const updateStatement = `
UPDATE verification_jobs
SET status = 'cancelled', completed_at = now(), result = 'failed',
error_message = 'Die Pruefung wurde abgebrochen.'
WHERE id = $1 AND status IN ('queued', 'running')`
commandTag, execError := store.connectionPool.Exec(cancelContext, updateStatement, jobIdentifier)
if execError != nil {
return fmt.Errorf("die pruefung konnte nicht abgebrochen werden: %w", execError)
}
if commandTag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s laeuft nicht mehr", ErrJobNotFound, jobIdentifier)
}
return nil
}
// ReclaimStaleJobs gibt Pruefungen abgestuerzter Control-Server frei.
func (store *Store) ReclaimStaleJobs(reclaimContext context.Context, staleAfter time.Duration) (int, error) {
const updateStatement = `
UPDATE verification_jobs
SET status = 'failed', completed_at = now(), result = 'failed',
error_message = 'Der ausfuehrende Control-Server meldete sich nicht mehr. Die Pruefung sagt nichts ueber das Backup.'
WHERE status IN ('queued', 'running')
AND heartbeat_at IS NOT NULL
AND heartbeat_at < now() - $1::interval`
commandTag, execError := store.connectionPool.Exec(reclaimContext, updateStatement, staleAfter.String())
if execError != nil {
return 0, fmt.Errorf("verwaiste pruefungen konnten nicht freigegeben werden: %w", execError)
}
return int(commandTag.RowsAffected()), nil
}
// SaveAssuranceScore schreibt eine berechnete Bewertung fort.
func (store *Store) SaveAssuranceScore(saveContext context.Context, backupIdentifier uuid.UUID, score *AssuranceScore) error {
const updateStatement = `
UPDATE backups
SET assurance_score = $2, assurance_unknown_inputs = $3, assurance_calculated_at = now()
WHERE id = $1`
if _, execError := store.connectionPool.Exec(saveContext, updateStatement,
backupIdentifier, score.Percentage, score.UnknownInputCount); execError != nil {
return fmt.Errorf("die bewertung konnte nicht gespeichert werden: %w", execError)
}
return nil
}
// BackupAssuranceFacts liest die fuer eine Bewertung noetigen Tatsachen.
func (store *Store) BackupAssuranceFacts(readContext context.Context, backupIdentifier uuid.UUID) (BackupFacts, error) {
const selectStatement = `
SELECT b.status, b.completed_at, b.last_verified_at, b.last_restore_test_at,
b.last_restore_test_duration_seconds, b.classification,
b.immutable_until, COALESCE(b.encrypted_bytes, 0) > 0,
r.status = 'active',
COALESCE(j.rpo_seconds, 0), COALESCE(j.rto_seconds, 0),
COALESCE(run.files_skipped, 0)
FROM backups b
JOIN repositories r ON r.id = b.repository_id
LEFT JOIN backup_job_runs run ON run.id = b.job_run_id
LEFT JOIN backup_jobs j ON j.id = run.job_id
WHERE b.id = $1`
var (
backupStatus string
completedAt *time.Time
lastVerifiedAt *time.Time
lastRestoreTestAt *time.Time
restoreDuration *float64
classification *string
immutableUntil *time.Time
isEncrypted bool
repositoryHealthy bool
recoveryPointSecs int64
recoveryTimeSecs int64
filesSkippedInRun int64
)
scanError := store.connectionPool.QueryRow(readContext, selectStatement, backupIdentifier).Scan(
&backupStatus, &completedAt, &lastVerifiedAt, &lastRestoreTestAt,
&restoreDuration, &classification, &immutableUntil, &isEncrypted,
&repositoryHealthy, &recoveryPointSecs, &recoveryTimeSecs, &filesSkippedInRun)
if errors.Is(scanError, pgx.ErrNoRows) {
return BackupFacts{}, fmt.Errorf("das backup %s wurde nicht gefunden", backupIdentifier)
}
if scanError != nil {
return BackupFacts{}, fmt.Errorf("die tatsachen konnten nicht gelesen werden: %w", scanError)
}
facts := BackupFacts{
RunSucceeded: backupStatus == "complete" && filesSkippedInRun == 0,
RunPartial: filesSkippedInRun > 0,
CompletedAt: completedAt,
LastVerifiedAt: lastVerifiedAt,
LastRestoreTestAt: lastRestoreTestAt,
IsEncrypted: isEncrypted,
IsImmutable: immutableUntil != nil,
RepositoryHealthy: repositoryHealthy,
RecoveryPointObjective: time.Duration(recoveryPointSecs) * time.Second,
RecoveryTimeObjective: time.Duration(recoveryTimeSecs) * time.Second,
}
if restoreDuration != nil {
facts.MeasuredRestoreDuration = time.Duration(*restoreDuration * float64(time.Second))
}
if classification != nil && Classification(*classification) == ClassificationCorrupted {
facts.VerificationFoundCorruption = true
}
// Eine Kopie an einem zweiten Ort ist noch nicht umgesetzt. Sie hier als
// vorhanden anzunehmen waere die bequeme und falsche Entscheidung.
facts.HasOffsiteCopy = false
return facts, nil
}
// isUniqueViolation erkennt einen Eindeutigkeitsverstoss.
func isUniqueViolation(occurredError error) bool {
var pgError interface{ SQLState() string }
return errors.As(occurredError, &pgError) && pgError.SQLState() == uniqueViolationCode
}