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>
203 lines
7.8 KiB
Go
203 lines
7.8 KiB
Go
package retention
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// connectTestDatabase oeffnet die Testdatenbank.
|
|
func connectTestDatabase(testInstance *testing.T) *pgxpool.Pool {
|
|
testInstance.Helper()
|
|
|
|
connectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL")
|
|
if connectionString == "" {
|
|
testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; die Datenbanktests werden uebersprungen")
|
|
}
|
|
|
|
connectContext, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancelConnect()
|
|
|
|
connectionPool, poolError := pgxpool.New(connectContext, connectionString)
|
|
if poolError != nil {
|
|
testInstance.Skipf("die Testdatenbank war nicht erreichbar: %v", poolError)
|
|
}
|
|
|
|
if pingError := connectionPool.Ping(connectContext); pingError != nil {
|
|
connectionPool.Close()
|
|
testInstance.Skipf("die Testdatenbank antwortete nicht: %v", pingError)
|
|
}
|
|
|
|
testInstance.Cleanup(connectionPool.Close)
|
|
|
|
return connectionPool
|
|
}
|
|
|
|
// seedRepositoryWithBackups legt ein Repository mit Backups in der Datenbank an.
|
|
func seedRepositoryWithBackups(testInstance *testing.T, connectionPool *pgxpool.Pool, backupIdentifiers []string) uuid.UUID {
|
|
testInstance.Helper()
|
|
|
|
backgroundContext := context.Background()
|
|
uniqueSuffix := uuid.NewString()[:8]
|
|
|
|
var repositoryID uuid.UUID
|
|
if scanError := connectionPool.QueryRow(backgroundContext,
|
|
`INSERT INTO repositories (name, location) VALUES ($1, $2) RETURNING id`,
|
|
"ret-"+uniqueSuffix, "/tmp/ret-"+uniqueSuffix).Scan(&repositoryID); scanError != nil {
|
|
testInstance.Fatalf("das Repository liess sich nicht eintragen: %v", scanError)
|
|
}
|
|
|
|
for _, backupIdentifier := range backupIdentifiers {
|
|
if _, execError := connectionPool.Exec(backgroundContext,
|
|
`INSERT INTO backups (repository_id, backup_id_in_repository, backup_type, status,
|
|
manifest_ref, completed_at)
|
|
VALUES ($1, $2, 'full', 'complete', 'manifests/x.manifest.json', now())`,
|
|
repositoryID, backupIdentifier); execError != nil {
|
|
testInstance.Fatalf("das Backup liess sich nicht eintragen: %v", execError)
|
|
}
|
|
}
|
|
|
|
testInstance.Cleanup(func() {
|
|
cleanupContext := context.Background()
|
|
|
|
_, _ = connectionPool.Exec(cleanupContext, `DELETE FROM retention_runs WHERE repository_id = $1`, repositoryID)
|
|
_, _ = connectionPool.Exec(cleanupContext, `DELETE FROM backups WHERE repository_id = $1`, repositoryID)
|
|
_, _ = connectionPool.Exec(cleanupContext, `DELETE FROM repositories WHERE id = $1`, repositoryID)
|
|
})
|
|
|
|
return repositoryID
|
|
}
|
|
|
|
// TestMarkBackupsDeletedRecordsEveryIdentifier prueft den Vermerk geloeschter Backups.
|
|
//
|
|
// Der Test stammt aus einem Fund: Nach einem Aufbewahrungslauf waren im
|
|
// Repository zwei Backups geloescht, in der Control Plane aber **keines**
|
|
// vermerkt. Die Liste der Wiederherstellungspunkte zeigte damit Punkte, die es
|
|
// nicht mehr gab — und der Fehler war unsichtbar, weil er nur protokolliert und
|
|
// nicht geworfen wird.
|
|
func TestMarkBackupsDeletedRecordsEveryIdentifier(testInstance *testing.T) {
|
|
connectionPool := connectTestDatabase(testInstance)
|
|
store := NewStore(connectionPool)
|
|
|
|
backupIdentifiers := []string{"backup-alpha", "backup-beta", "backup-gamma"}
|
|
repositoryIdentifier := seedRepositoryWithBackups(testInstance, connectionPool, backupIdentifiers)
|
|
|
|
deletedIdentifiers := []string{"backup-alpha", "backup-beta"}
|
|
|
|
if markError := store.MarkBackupsDeleted(context.Background(), repositoryIdentifier,
|
|
deletedIdentifiers, nil, "Aufbewahrungsregel 7 Tage"); markError != nil {
|
|
testInstance.Fatalf("der Vermerk schlug fehl: %v", markError)
|
|
}
|
|
|
|
var deletedCount int
|
|
if scanError := connectionPool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM backups WHERE repository_id = $1 AND deleted_at IS NOT NULL`,
|
|
repositoryIdentifier).Scan(&deletedCount); scanError != nil {
|
|
testInstance.Fatalf("die Zaehlung schlug fehl: %v", scanError)
|
|
}
|
|
|
|
if deletedCount != len(deletedIdentifiers) {
|
|
testInstance.Fatalf("%d von %d Backups wurden als geloescht vermerkt",
|
|
deletedCount, len(deletedIdentifiers))
|
|
}
|
|
|
|
// Der Grund muss mitkommen: Ohne ihn bleibt die Frage „warum ist das Backup
|
|
// von vorletzter Woche weg?" unbeantwortet.
|
|
var deletionReason string
|
|
if scanError := connectionPool.QueryRow(context.Background(),
|
|
`SELECT COALESCE(deletion_reason, '') FROM backups
|
|
WHERE repository_id = $1 AND backup_id_in_repository = 'backup-alpha'`,
|
|
repositoryIdentifier).Scan(&deletionReason); scanError != nil {
|
|
testInstance.Fatalf("der Grund liess sich nicht lesen: %v", scanError)
|
|
}
|
|
|
|
if deletionReason != "Aufbewahrungsregel 7 Tage" {
|
|
testInstance.Errorf("der Grund war %q, erwartet wurde die Aufbewahrungsregel", deletionReason)
|
|
}
|
|
|
|
// Das dritte Backup bleibt unberuehrt.
|
|
var remainingCount int
|
|
if scanError := connectionPool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM backups WHERE repository_id = $1 AND deleted_at IS NULL`,
|
|
repositoryIdentifier).Scan(&remainingCount); scanError != nil {
|
|
testInstance.Fatalf("die Zaehlung schlug fehl: %v", scanError)
|
|
}
|
|
|
|
if remainingCount != 1 {
|
|
testInstance.Errorf("es blieben %d Backups unberuehrt, erwartet wurde 1", remainingCount)
|
|
}
|
|
}
|
|
|
|
// TestMarkBackupsDeletedIsIdempotent prueft den zweiten Aufruf.
|
|
//
|
|
// Ein bereits vermerktes Backup darf seinen Loeschzeitpunkt nicht verlieren:
|
|
// Der erste Vermerk ist der richtige.
|
|
func TestMarkBackupsDeletedIsIdempotent(testInstance *testing.T) {
|
|
connectionPool := connectTestDatabase(testInstance)
|
|
store := NewStore(connectionPool)
|
|
|
|
repositoryIdentifier := seedRepositoryWithBackups(testInstance, connectionPool, []string{"backup-solo"})
|
|
|
|
if markError := store.MarkBackupsDeleted(context.Background(), repositoryIdentifier,
|
|
[]string{"backup-solo"}, nil, "erster Lauf"); markError != nil {
|
|
testInstance.Fatalf("der erste Vermerk schlug fehl: %v", markError)
|
|
}
|
|
|
|
var firstDeletionTime time.Time
|
|
if scanError := connectionPool.QueryRow(context.Background(),
|
|
`SELECT deleted_at FROM backups WHERE repository_id = $1`,
|
|
repositoryIdentifier).Scan(&firstDeletionTime); scanError != nil {
|
|
testInstance.Fatalf("der Zeitpunkt liess sich nicht lesen: %v", scanError)
|
|
}
|
|
|
|
if markError := store.MarkBackupsDeleted(context.Background(), repositoryIdentifier,
|
|
[]string{"backup-solo"}, nil, "zweiter Lauf"); markError != nil {
|
|
testInstance.Fatalf("der zweite Vermerk schlug fehl: %v", markError)
|
|
}
|
|
|
|
var secondDeletionTime time.Time
|
|
var deletionReason string
|
|
|
|
if scanError := connectionPool.QueryRow(context.Background(),
|
|
`SELECT deleted_at, COALESCE(deletion_reason, '') FROM backups WHERE repository_id = $1`,
|
|
repositoryIdentifier).Scan(&secondDeletionTime, &deletionReason); scanError != nil {
|
|
testInstance.Fatalf("der Zeitpunkt liess sich nicht lesen: %v", scanError)
|
|
}
|
|
|
|
if !secondDeletionTime.Equal(firstDeletionTime) {
|
|
testInstance.Error("der zweite Aufruf hat den Loeschzeitpunkt ueberschrieben")
|
|
}
|
|
|
|
if deletionReason != "erster Lauf" {
|
|
testInstance.Errorf("der Grund wurde auf %q ueberschrieben", deletionReason)
|
|
}
|
|
}
|
|
|
|
// TestMarkBackupsDeletedIgnoresEmptyList prueft den Aufruf ohne Kennungen.
|
|
func TestMarkBackupsDeletedIgnoresEmptyList(testInstance *testing.T) {
|
|
connectionPool := connectTestDatabase(testInstance)
|
|
store := NewStore(connectionPool)
|
|
|
|
repositoryIdentifier := seedRepositoryWithBackups(testInstance, connectionPool, []string{"backup-eins"})
|
|
|
|
if markError := store.MarkBackupsDeleted(context.Background(), repositoryIdentifier,
|
|
nil, nil, "nichts"); markError != nil {
|
|
testInstance.Fatalf("der leere Aufruf schlug fehl: %v", markError)
|
|
}
|
|
|
|
var deletedCount int
|
|
if scanError := connectionPool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM backups WHERE repository_id = $1 AND deleted_at IS NOT NULL`,
|
|
repositoryIdentifier).Scan(&deletedCount); scanError != nil {
|
|
testInstance.Fatalf("die Zaehlung schlug fehl: %v", scanError)
|
|
}
|
|
|
|
if deletedCount != 0 {
|
|
testInstance.Error("ein leerer Aufruf hat Backups als geloescht vermerkt")
|
|
}
|
|
}
|