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 }