syncova-backup/packages/jobs/backup_list.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

228 lines
8.2 KiB
Go

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()
}