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>
696 lines
24 KiB
Go
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
|
|
}
|