package jobs import ( "context" "fmt" "strings" "time" "github.com/google/uuid" ) // BackupListItem ist ein Wiederherstellungspunkt in der Uebersicht. // // Die Struktur traegt bewusst mehr als StoredBackup: Eine Liste von // Wiederherstellungspunkten, die nur Kennungen zeigt, zwingt die Oberflaeche zu // einer Abfrage je Zeile — und beantwortet die einzige wirklich wichtige Frage // nicht: Kann ich mich auf diesen Punkt verlassen? type BackupListItem struct { // ID ist der oeffentliche Bezeichner. ID uuid.UUID `json:"id"` // RepositoryID ist das Repository. RepositoryID uuid.UUID `json:"repository_id"` // RepositoryName ist der sprechende Name des Repositorys. RepositoryName string `json:"repository_name"` // JobID ist der erzeugende Sicherungsauftrag; nil bei fremdem Ursprung. JobID *uuid.UUID `json:"job_id,omitempty"` // JobName ist der Name des Auftrags. JobName string `json:"job_name,omitempty"` // BackupIDInRepository ist die Kennung innerhalb des Repositorys. BackupIDInRepository string `json:"backup_id_in_repository"` // BackupType ist die Art des Backups. BackupType string `json:"backup_type"` // Status ist der Zustand. Status string `json:"status"` // ConsistencyLevel beschreibt die erreichte Konsistenz. ConsistencyLevel string `json:"consistency_level,omitempty"` // LogicalBytes ist die Menge der Ursprungsdaten. LogicalBytes int64 `json:"logical_bytes"` // EncryptedBytes ist die abgelegte Datenmenge. EncryptedBytes int64 `json:"encrypted_bytes"` // 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"` // Classification ist die objektive Einstufung (Phase 10). Classification string `json:"classification,omitempty"` // LastVerifiedAt ist die letzte Integritaetspruefung in UTC. LastVerifiedAt *time.Time `json:"last_verified_at,omitempty"` // LastRestoreTestAt ist der letzte Wiederherstellungstest in UTC. LastRestoreTestAt *time.Time `json:"last_restore_test_at,omitempty"` // AssuranceScore ist die zuletzt berechnete Bewertung in Prozent. // // Ein Zeiger, kein Nullwert: „nie berechnet" und „null Prozent" sind zwei // verschiedene Aussagen, und die zweite ist ein Befund. AssuranceScore *int `json:"assurance_score,omitempty"` // ImmutableUntil ist das Ende der Aufbewahrungspflicht in UTC. ImmutableUntil *time.Time `json:"immutable_until,omitempty"` // LegalHold meldet einen unbefristeten Schutz. LegalHold bool `json:"legal_hold"` // IsProtected meldet, ob eine Loeschung derzeit unzulaessig ist. IsProtected bool `json:"is_protected"` // DeletedAt ist der Zeitpunkt der Loeschung in UTC. // // Ein geloeschter Wiederherstellungspunkt bleibt sichtbar, wenn ausdruecklich // danach gefragt wird: Die Frage „warum ist das Backup von vorletzter Woche // weg?" ist die erste, die im Ernstfall gestellt wird. DeletedAt *time.Time `json:"deleted_at,omitempty"` // DeletionReason begruendet die Loeschung. DeletionReason string `json:"deletion_reason,omitempty"` // CreatedAt ist der Anlagezeitpunkt in UTC. CreatedAt time.Time `json:"created_at"` } // BackupListFilter schraenkt die Liste der Wiederherstellungspunkte ein. type BackupListFilter struct { // RepositoryID beschraenkt auf ein Repository. RepositoryID *uuid.UUID // JobID beschraenkt auf einen Sicherungsauftrag. JobID *uuid.UUID // Status beschraenkt auf einen Zustand. Status string // Classification beschraenkt auf eine Einstufung. Classification string // IncludeDeleted nimmt geloeschte Punkte auf. // // Standard ist false: Wer nach Wiederherstellungspunkten fragt, meint die // vorhandenen. Geloeschte erscheinen nur auf ausdrueckliche Nachfrage. IncludeDeleted bool // OnlyProtected beschraenkt auf geschuetzte Punkte. OnlyProtected bool // Page ist die angeforderte Seite. Page int // PageSize ist die Seitengroesse. PageSize int } // protectionExpression entscheidet, ob ein Wiederherstellungspunkt geschuetzt ist. // // Die Regel steht an genau einer Stelle, damit die Oberflaeche sie nicht // nachbauen muss — und sie ist ausdruecklich gegen NULL abgesichert: // `legal_hold = false OR immutable_until > now()` liefert in der SQL-Dreiwert- // logik **NULL**, sobald keine Frist gesetzt ist. Ein NULL laesst sich nicht in // ein bool lesen, und die ganze Liste schluege fehl. const protectionExpression = "(b.legal_hold = true OR " + "(b.immutable_until IS NOT NULL AND b.immutable_until > now()))" // maximumBackupPageSize begrenzt die Seitengroesse. // // Ohne Grenze koennte eine einzige Anfrage Millionen Zeilen ziehen und den // Dienst fuer alle anderen lahmlegen. const maximumBackupPageSize = 200 // normalize fuellt fehlende Werte und begrenzt die Seitengroesse. func (filter *BackupListFilter) normalize() { if filter.Page < 1 { filter.Page = 1 } if filter.PageSize < 1 || filter.PageSize > maximumBackupPageSize { filter.PageSize = 50 } } // ListBackups liefert eine Seite von Wiederherstellungspunkten. func (store *PostgresStore) ListBackups(listContext context.Context, filter BackupListFilter) ([]BackupListItem, int, error) { filter.normalize() conditions := make([]string, 0, 6) arguments := make([]any, 0, 6) appendCondition := func(condition string, argument any) { arguments = append(arguments, argument) conditions = append(conditions, fmt.Sprintf(condition, len(arguments))) } if filter.RepositoryID != nil { appendCondition("b.repository_id = $%d", *filter.RepositoryID) } if filter.JobID != nil { appendCondition("j.id = $%d", *filter.JobID) } if filter.Status != "" { appendCondition("b.status = $%d", filter.Status) } if filter.Classification != "" { appendCondition("b.classification = $%d", filter.Classification) } if !filter.IncludeDeleted { conditions = append(conditions, "b.deleted_at IS NULL") } if filter.OnlyProtected { conditions = append(conditions, protectionExpression) } whereClause := "" if len(conditions) > 0 { whereClause = "WHERE " + strings.Join(conditions, " AND ") } arguments = append(arguments, filter.PageSize, (filter.Page-1)*filter.PageSize) selectStatement := fmt.Sprintf(` SELECT b.id, b.repository_id, r.name, j.id, j.name, b.backup_id_in_repository, b.backup_type, b.status, COALESCE(b.consistency_level, ''), COALESCE(b.logical_bytes, 0), COALESCE(b.encrypted_bytes, 0), b.started_at, b.completed_at, COALESCE(b.classification, ''), b.last_verified_at, b.last_restore_test_at, b.assurance_score, b.immutable_until, b.legal_hold, %s AS is_protected, b.deleted_at, COALESCE(b.deletion_reason, ''), b.created_at, COUNT(*) OVER () AS total_count 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 %s ORDER BY b.created_at DESC LIMIT $%d OFFSET $%d`, protectionExpression, whereClause, len(arguments)-1, len(arguments)) backupRows, queryError := store.connectionPool.Query(listContext, selectStatement, arguments...) if queryError != nil { return nil, 0, fmt.Errorf("die wiederherstellungspunkte konnten nicht gelesen werden: %w", queryError) } defer backupRows.Close() loadedBackups := make([]BackupListItem, 0, filter.PageSize) var totalCount int for backupRows.Next() { var ( listItem BackupListItem jobName *string isDeleted *time.Time ) if scanError := backupRows.Scan( &listItem.ID, &listItem.RepositoryID, &listItem.RepositoryName, &listItem.JobID, &jobName, &listItem.BackupIDInRepository, &listItem.BackupType, &listItem.Status, &listItem.ConsistencyLevel, &listItem.LogicalBytes, &listItem.EncryptedBytes, &listItem.StartedAt, &listItem.CompletedAt, &listItem.Classification, &listItem.LastVerifiedAt, &listItem.LastRestoreTestAt, &listItem.AssuranceScore, &listItem.ImmutableUntil, &listItem.LegalHold, &listItem.IsProtected, &isDeleted, &listItem.DeletionReason, &listItem.CreatedAt, &totalCount, ); scanError != nil { return nil, 0, fmt.Errorf("ein wiederherstellungspunkt konnte nicht gelesen werden: %w", scanError) } if jobName != nil { listItem.JobName = *jobName } listItem.DeletedAt = isDeleted loadedBackups = append(loadedBackups, listItem) } return loadedBackups, totalCount, backupRows.Err() }