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>
452 lines
16 KiB
Go
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
|
|
}
|