package retention import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/syncova/syncova/packages/repository" ) // StoredPolicy ist eine abgelegte Aufbewahrungsregel. type StoredPolicy struct { // ID ist der oeffentliche Bezeichner. ID uuid.UUID `json:"id"` // Policy ist die Regel selbst. Policy Policy `json:"policy"` // Description erklaert die Regel in einem Satz. Description string `json:"description"` // CreatedBy benennt den Anlegenden. CreatedBy *uuid.UUID `json:"created_by,omitempty"` // CreatedAt ist der Anlagezeitpunkt in UTC. CreatedAt time.Time `json:"created_at"` // UpdatedAt ist der Zeitpunkt der letzten Aenderung in UTC. UpdatedAt time.Time `json:"updated_at"` // UsedByJobCount ist die Zahl der Auftraege, die diese Regel verwenden. // // Sie steht in der Antwort, damit niemand eine Regel aendert, ohne zu // wissen, wie viele Sicherungen davon abhaengen. UsedByJobCount int `json:"used_by_job_count"` } // Fehler der Datenzugriffsschicht. var ( // ErrPolicyNotFound meldet eine nicht vorhandene Regel. ErrPolicyNotFound = errors.New("die aufbewahrungsregel wurde nicht gefunden") // ErrPolicyInUse meldet eine noch verwendete Regel. ErrPolicyInUse = errors.New("die aufbewahrungsregel wird noch von sicherungsauftraegen verwendet") // ErrRepositoryNotFound meldet ein nicht vorhandenes Repository. ErrRepositoryNotFound = errors.New("das repository wurde nicht gefunden") ) // Store legt Aufbewahrungsregeln und ihre Laeufe 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} } // policyColumnList sind die Spalten einer Regel in fester Reihenfolge. const policyColumnList = ` id, name, keep_within_seconds, keep_last, keep_daily, keep_weekly, keep_monthly, keep_yearly, time_zone, created_by, created_at, updated_at` // scanPolicy liest eine Regelzeile. func scanPolicy(scanner interface{ Scan(...any) error }) (*StoredPolicy, error) { var ( storedPolicy StoredPolicy keepWithinSeconds *int64 keepDaily *int keepWeekly *int keepMonthly *int keepYearly *int timeZone *string ) scanError := scanner.Scan( &storedPolicy.ID, &storedPolicy.Policy.Name, &keepWithinSeconds, &storedPolicy.Policy.KeepLast, &keepDaily, &keepWeekly, &keepMonthly, &keepYearly, &timeZone, &storedPolicy.CreatedBy, &storedPolicy.CreatedAt, &storedPolicy.UpdatedAt) if scanError != nil { return nil, scanError } if keepWithinSeconds != nil { storedPolicy.Policy.KeepWithin = time.Duration(*keepWithinSeconds) * time.Second } for target, source := range map[*int]*int{ &storedPolicy.Policy.KeepDaily: keepDaily, &storedPolicy.Policy.KeepWeekly: keepWeekly, &storedPolicy.Policy.KeepMonthly: keepMonthly, &storedPolicy.Policy.KeepYearly: keepYearly, } { if source != nil { *target = *source } } if timeZone != nil { storedPolicy.Policy.TimeZone = *timeZone } storedPolicy.Description = storedPolicy.Policy.Describe() return &storedPolicy, nil } // nullableSeconds wandelt eine Zeitspanne in einen Datenbankwert. func nullableSeconds(duration time.Duration) *int64 { if duration <= 0 { return nil } seconds := int64(duration.Seconds()) return &seconds } // nullableCount wandelt eine Anzahl in einen Datenbankwert. func nullableCount(count int) *int { if count <= 0 { return nil } return &count } // CreatePolicy legt eine Aufbewahrungsregel an. func (store *Store) CreatePolicy(createContext context.Context, policy Policy, createdBy *uuid.UUID) (*StoredPolicy, error) { if validationError := policy.Validate(); validationError != nil { return nil, validationError } const insertStatement = ` INSERT INTO retention_policies (name, rules, keep_within_seconds, keep_last, keep_daily, keep_weekly, keep_monthly, keep_yearly, time_zone, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING ` + policyColumnList // Die alte rules-Spalte bleibt gefuellt: Sie ist NOT NULL und haelt die // Regel in der Form, die ein spaeteres Format lesen kann. encodedRules, encodeError := json.Marshal(policy) if encodeError != nil { return nil, fmt.Errorf("die regel konnte nicht kodiert werden: %w", encodeError) } createdPolicy, scanError := scanPolicy(store.connectionPool.QueryRow(createContext, insertStatement, policy.Name, encodedRules, nullableSeconds(policy.KeepWithin), policy.KeepLast, nullableCount(policy.KeepDaily), nullableCount(policy.KeepWeekly), nullableCount(policy.KeepMonthly), nullableCount(policy.KeepYearly), nullableString(policy.TimeZone), createdBy)) if scanError != nil { return nil, fmt.Errorf("die aufbewahrungsregel konnte nicht angelegt werden: %w", scanError) } return createdPolicy, nil } // nullableString wandelt eine leere Zeichenkette in NULL. func nullableString(value string) *string { if value == "" { return nil } return &value } // GetPolicy liest eine Aufbewahrungsregel. func (store *Store) GetPolicy(readContext context.Context, policyIdentifier uuid.UUID) (*StoredPolicy, error) { const selectStatement = `SELECT ` + policyColumnList + ` FROM retention_policies WHERE id = $1` loadedPolicy, scanError := scanPolicy(store.connectionPool.QueryRow(readContext, selectStatement, policyIdentifier)) if errors.Is(scanError, pgx.ErrNoRows) { return nil, fmt.Errorf("%w: %s", ErrPolicyNotFound, policyIdentifier) } if scanError != nil { return nil, fmt.Errorf("die aufbewahrungsregel konnte nicht gelesen werden: %w", scanError) } loadedPolicy.UsedByJobCount, _ = store.countJobsUsingPolicy(readContext, policyIdentifier) return loadedPolicy, nil } // countJobsUsingPolicy zaehlt die Auftraege, die eine Regel verwenden. func (store *Store) countJobsUsingPolicy(countContext context.Context, policyIdentifier uuid.UUID) (int, error) { const countStatement = ` SELECT count(*) FROM backup_jobs WHERE retention_policy_id = $1 AND deleted_at IS NULL` var jobCount int if scanError := store.connectionPool.QueryRow(countContext, countStatement, policyIdentifier).Scan(&jobCount); scanError != nil { return 0, scanError } return jobCount, nil } // ListPolicies liefert alle Aufbewahrungsregeln. func (store *Store) ListPolicies(listContext context.Context) ([]StoredPolicy, error) { const selectStatement = `SELECT ` + policyColumnList + ` FROM retention_policies ORDER BY name` policyRows, queryError := store.connectionPool.Query(listContext, selectStatement) if queryError != nil { return nil, fmt.Errorf("die aufbewahrungsregeln konnten nicht gelesen werden: %w", queryError) } defer policyRows.Close() loadedPolicies := make([]StoredPolicy, 0) for policyRows.Next() { loadedPolicy, scanError := scanPolicy(policyRows) if scanError != nil { return nil, fmt.Errorf("eine aufbewahrungsregel konnte nicht gelesen werden: %w", scanError) } loadedPolicies = append(loadedPolicies, *loadedPolicy) } if rowsError := policyRows.Err(); rowsError != nil { return nil, rowsError } for policyIndex := range loadedPolicies { loadedPolicies[policyIndex].UsedByJobCount, _ = store.countJobsUsingPolicy(listContext, loadedPolicies[policyIndex].ID) } return loadedPolicies, nil } // UpdatePolicy aendert eine Aufbewahrungsregel. // // Der Aufrufer muss vorher wissen, was die Aenderung bewirkt: Eine verschaerfte // Regel loescht beim naechsten Lauf Backups, die es heute noch gibt. Die // Bestaetigung dafuer verlangt die API, nicht diese Schicht — hier waere sie zu // spaet. func (store *Store) UpdatePolicy(updateContext context.Context, policyIdentifier uuid.UUID, policy Policy) (*StoredPolicy, error) { if validationError := policy.Validate(); validationError != nil { return nil, validationError } encodedRules, encodeError := json.Marshal(policy) if encodeError != nil { return nil, fmt.Errorf("die regel konnte nicht kodiert werden: %w", encodeError) } const updateStatement = ` UPDATE retention_policies SET name = $2, rules = $3, keep_within_seconds = $4, keep_last = $5, keep_daily = $6, keep_weekly = $7, keep_monthly = $8, keep_yearly = $9, time_zone = $10, updated_at = now() WHERE id = $1 RETURNING ` + policyColumnList updatedPolicy, scanError := scanPolicy(store.connectionPool.QueryRow(updateContext, updateStatement, policyIdentifier, policy.Name, encodedRules, nullableSeconds(policy.KeepWithin), policy.KeepLast, nullableCount(policy.KeepDaily), nullableCount(policy.KeepWeekly), nullableCount(policy.KeepMonthly), nullableCount(policy.KeepYearly), nullableString(policy.TimeZone))) if errors.Is(scanError, pgx.ErrNoRows) { return nil, fmt.Errorf("%w: %s", ErrPolicyNotFound, policyIdentifier) } if scanError != nil { return nil, fmt.Errorf("die aufbewahrungsregel konnte nicht geaendert werden: %w", scanError) } return updatedPolicy, nil } // DeletePolicy entfernt eine Aufbewahrungsregel. // // Eine noch verwendete Regel wird nicht geloescht. Der Fremdschluessel setzte // sie sonst auf NULL, und die betroffenen Auftraege stuenden ohne Aufbewahrung // da — ohne dass jemand einen Fehler saehe. func (store *Store) DeletePolicy(deleteContext context.Context, policyIdentifier uuid.UUID) error { usingJobCount, countError := store.countJobsUsingPolicy(deleteContext, policyIdentifier) if countError != nil { return countError } if usingJobCount > 0 { return fmt.Errorf("%w (%d auftraege)", ErrPolicyInUse, usingJobCount) } commandTag, execError := store.connectionPool.Exec(deleteContext, `DELETE FROM retention_policies WHERE id = $1`, policyIdentifier) if execError != nil { return fmt.Errorf("die aufbewahrungsregel konnte nicht geloescht werden: %w", execError) } if commandTag.RowsAffected() == 0 { return fmt.Errorf("%w: %s", ErrPolicyNotFound, policyIdentifier) } return nil } // RecordRun haelt einen Lauf der Aufbewahrung fest. // // Auch die Vorschau wird festgehalten. Sie loescht nichts, beantwortet aber die // Frage „wer hat wann nachgesehen, was verschwinden wuerde?" — und ein Lauf, der // erst geplant und dann ausgefuehrt wurde, ist damit nachvollziehbar. func (store *Store) RecordRun(recordContext context.Context, repositoryIdentifier uuid.UUID, policyIdentifier *uuid.UUID, plan *Plan, executionResult *ExecutionResult, correlationIdentifier uuid.UUID, triggeredBy *uuid.UUID) (uuid.UUID, error) { encodedPlan, encodeError := json.Marshal(plan) if encodeError != nil { return uuid.Nil, fmt.Errorf("der plan konnte nicht abgelegt werden: %w", encodeError) } isDryRun := executionResult == nil var ( deletedCount int failedCount int chunksRemoved int64 bytesFreed int64 errorMessage *string ) if executionResult != nil { deletedCount = len(executionResult.DeletedBackupIDs) failedCount = len(executionResult.FailedBackupIDs) chunksRemoved = executionResult.ChunksRemoved bytesFreed = executionResult.BytesFreed if executionResult.IsPartialFailure() { joinedFailures := "" for failureIndex, failureText := range executionResult.Failures { if failureIndex > 0 { joinedFailures += "; " } joinedFailures += failureText } errorMessage = &joinedFailures } } const insertStatement = ` INSERT INTO retention_runs (repository_id, retention_policy_id, policy_name, dry_run, evaluated_count, kept_count, deleted_count, protected_count, failed_count, chunks_removed, bytes_freed, plan, error_message, correlation_id, triggered_by, completed_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,now()) RETURNING id` var runIdentifier uuid.UUID scanError := store.connectionPool.QueryRow(recordContext, insertStatement, repositoryIdentifier, policyIdentifier, plan.PolicyName, isDryRun, len(plan.Decisions), plan.KeptCount, deletedCount, plan.ProtectedCount, failedCount, chunksRemoved, bytesFreed, encodedPlan, errorMessage, correlationIdentifier, triggeredBy, ).Scan(&runIdentifier) if scanError != nil { return uuid.Nil, fmt.Errorf("der aufbewahrungslauf konnte nicht festgehalten werden: %w", scanError) } return runIdentifier, nil } // MarkBackupsDeleted vermerkt geloeschte Backups in der Control Plane. // // Die Zeilen bleiben stehen und werden nur gekennzeichnet: Die Frage „warum ist // das Backup von vorletzter Woche weg?" muss beantwortbar bleiben, gerade wenn // niemand mehr weiss, wer geloescht hat. func (store *Store) MarkBackupsDeleted(markContext context.Context, repositoryIdentifier uuid.UUID, backupIdentifiersInRepository []string, deletedBy *uuid.UUID, deletionReason string) error { if len(backupIdentifiersInRepository) == 0 { return nil } const updateStatement = ` UPDATE backups SET deleted_at = now(), deleted_by = $3, deletion_reason = $4 WHERE repository_id = $1 AND backup_id_in_repository = ANY($2) AND deleted_at IS NULL` if _, execError := store.connectionPool.Exec(markContext, updateStatement, repositoryIdentifier, backupIdentifiersInRepository, deletedBy, deletionReason); execError != nil { return fmt.Errorf("die loeschung konnte nicht vermerkt werden: %w", execError) } return nil } // SaveEnforcementLevel schreibt eine gemessene Durchsetzungsstufe fort. func (store *Store) SaveEnforcementLevel(saveContext context.Context, repositoryIdentifier uuid.UUID, enforcementReport *repository.EnforcementReport) error { encodedReport, encodeError := json.Marshal(enforcementReport) if encodeError != nil { return fmt.Errorf("der messbericht konnte nicht abgelegt werden: %w", encodeError) } const updateStatement = ` UPDATE repositories SET enforcement_level = $2, enforcement_measured_at = $3, enforcement_report = $4 WHERE id = $1` commandTag, execError := store.connectionPool.Exec(saveContext, updateStatement, repositoryIdentifier, string(enforcementReport.Level), enforcementReport.MeasuredAt, encodedReport) if execError != nil { return fmt.Errorf("die durchsetzungsstufe konnte nicht gespeichert werden: %w", execError) } if commandTag.RowsAffected() == 0 { return fmt.Errorf("%w: %s", ErrRepositoryNotFound, repositoryIdentifier) } return nil } // SaveLegalHold vermerkt einen Legal Hold in der Control Plane. func (store *Store) SaveLegalHold(saveContext context.Context, backupIdentifier uuid.UUID, isHeld bool, reason string, placedBy *uuid.UUID) error { const updateStatement = ` UPDATE backups SET legal_hold = $2, legal_hold_reason = CASE WHEN $2 THEN $3 ELSE NULL END, legal_hold_placed_at = CASE WHEN $2 THEN now() ELSE NULL END, legal_hold_placed_by = CASE WHEN $2 THEN $4::uuid ELSE NULL END WHERE id = $1` if _, execError := store.connectionPool.Exec(saveContext, updateStatement, backupIdentifier, isHeld, nullableString(reason), placedBy); execError != nil { return fmt.Errorf("der legal hold konnte nicht vermerkt werden: %w", execError) } return nil } // SaveImmutableUntil schreibt eine verlaengerte Aufbewahrungsfrist fort. // // Die Bedingung im WHERE ist die Sperre gegen Verkuerzung: Ein aelterer Wert // wird nicht uebernommen, auch wenn der Aufrufer ihn schickt. Dieselbe Regel // gilt im Repository — hier steht sie ein zweites Mal, weil die Datenbank die // Quelle der Uebersichten ist. func (store *Store) SaveImmutableUntil(saveContext context.Context, backupIdentifier uuid.UUID, immutableUntil time.Time) error { const updateStatement = ` UPDATE backups SET immutable_until = $2 WHERE id = $1 AND (immutable_until IS NULL OR immutable_until < $2)` if _, execError := store.connectionPool.Exec(saveContext, updateStatement, backupIdentifier, immutableUntil); execError != nil { return fmt.Errorf("die aufbewahrungsfrist konnte nicht gespeichert werden: %w", execError) } return nil }