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>
228 lines
8.2 KiB
Go
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()
|
|
}
|