syncova-backup/packages/retention/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

452 lines
16 KiB
Go

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
}