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>
369 lines
14 KiB
Go
369 lines
14 KiB
Go
package backupexecutor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/packages/backupengine"
|
|
"github.com/syncova/syncova/packages/hypervisor"
|
|
"github.com/syncova/syncova/packages/jobs"
|
|
"github.com/syncova/syncova/packages/providers"
|
|
"github.com/syncova/syncova/packages/repository"
|
|
"github.com/syncova/syncova/packages/scheduler"
|
|
)
|
|
|
|
// guestArchiveEntryPath ist der Pfad des Plattenabbilds im Manifest.
|
|
//
|
|
// Fest und nicht aus dem Dateinamen des vzdump-Archivs abgeleitet: Der trägt
|
|
// einen Zeitstempel, und ein Manifestpfad, der sich bei jedem Lauf ändert,
|
|
// machte jede Deduplizierung über Backupgrenzen hinweg unmöglich — die Engine
|
|
// vergleicht Objekte über ihren Pfad.
|
|
const guestArchiveEntryPath = "guest/disk-image.vma"
|
|
|
|
// guestConfigurationEntryPath ist der Pfad der Gastkonfiguration im Manifest.
|
|
const guestConfigurationEntryPath = "guest/configuration.json"
|
|
|
|
// backupProxmoxGuest sichert einen Gast einer Virtualisierungsumgebung.
|
|
//
|
|
// Der Weg unterscheidet sich grundlegend von einer Dateisystemquelle: Es gibt
|
|
// keine Dateien zum Durchlaufen, sondern **einen** Datenstrom — das von vzdump
|
|
// erzeugte Archiv. Er wandert unverändert durch dieselbe Pipeline aus
|
|
// Chunking, Deduplizierung, Kompression und Verschlüsselung.
|
|
//
|
|
// Der Gewinn der inhaltsabhängigen Blockfindung ist hier besonders groß: Zwei
|
|
// Sicherungen derselben Maschine unterscheiden sich nur in den tatsächlich
|
|
// geänderten Bereichen, und die Deduplizierung findet sie auch dann wieder,
|
|
// wenn sich das Archiv an einer Stelle verschoben hat.
|
|
func (executor *Executor) backupProxmoxGuest(backupContext context.Context,
|
|
backupRequest sourceBackupRequest) (jobs.ExecutionResult, error) {
|
|
if executor.options.HypervisorStore == nil {
|
|
return jobs.ExecutionResult{}, &jobs.ExecutionError{
|
|
Code: "HYPERVISOR_NOT_CONFIGURED",
|
|
Message: "Für Proxmox-Quellen ist keine Virtualisierungsverwaltung eingerichtet. " +
|
|
"Ohne Schlüsselmaterial lassen sich keine Zugangsdaten ablegen.",
|
|
FailureClass: scheduler.FailureConfiguration,
|
|
}
|
|
}
|
|
|
|
if backupRequest.Source.ClusterID == nil {
|
|
return jobs.ExecutionResult{}, &jobs.ExecutionError{
|
|
Code: "CLUSTER_NOT_ASSIGNED",
|
|
Message: fmt.Sprintf("Der Quelle %q ist kein Virtualisierungsverbund zugewiesen.",
|
|
backupRequest.Source.SourceID),
|
|
FailureClass: scheduler.FailureConfiguration,
|
|
}
|
|
}
|
|
|
|
if backupRequest.Engine == nil {
|
|
return jobs.ExecutionResult{}, errors.New("fuer die sicherung eines gasts wird eine geoeffnete backup engine gebraucht")
|
|
}
|
|
|
|
connectedProvider, openError := executor.options.HypervisorStore.OpenProvider(backupContext,
|
|
*backupRequest.Source.ClusterID, backupRequest.Logger)
|
|
if openError != nil {
|
|
return jobs.ExecutionResult{}, &jobs.ExecutionError{
|
|
Code: "HYPERVISOR_UNREACHABLE",
|
|
Message: "Der Virtualisierungsverbund ist nicht erreichbar: " + openError.Error(),
|
|
FailureClass: scheduler.FailureNetwork,
|
|
Cause: openError,
|
|
}
|
|
}
|
|
|
|
defer func() {
|
|
if closeError := connectedProvider.Close(); closeError != nil {
|
|
backupRequest.Logger.Warn("die verbindung zum verbund liess sich nicht schliessen",
|
|
slog.String("grund", closeError.Error()))
|
|
}
|
|
}()
|
|
|
|
guestIdentifier := backupRequest.Source.SourceID
|
|
|
|
// Die Konfiguration wird **vor** dem Archiv gelesen.
|
|
//
|
|
// Sie ist das, was eine Wiederherstellung braucht, um die Maschine in ihrer
|
|
// Gestalt aufzubauen — falsche Netzkarte, fehlende serielle Schnittstelle,
|
|
// anderes BIOS ergäben eine bootfähige, aber unbrauchbare VM. Sie danach zu
|
|
// lesen hieße, sie nach einem stundenlangen vzdump zu holen; scheiterte sie
|
|
// dann, wäre die ganze Sicherung umsonst gewesen.
|
|
guestMetadata, metadataError := connectedProvider.Provider.GetVMMetaData(backupContext, guestIdentifier)
|
|
if metadataError != nil {
|
|
return jobs.ExecutionResult{}, &jobs.ExecutionError{
|
|
Code: "GUEST_METADATA_UNAVAILABLE",
|
|
Message: "Die Konfiguration des Gasts ließ sich nicht lesen: " + metadataError.Error(),
|
|
FailureClass: scheduler.FailureSource,
|
|
Cause: metadataError,
|
|
}
|
|
}
|
|
|
|
guestDisks, diskError := connectedProvider.Provider.GetVMDisks(backupContext, guestIdentifier)
|
|
if diskError != nil {
|
|
return jobs.ExecutionResult{}, &jobs.ExecutionError{
|
|
Code: "GUEST_DISKS_UNAVAILABLE",
|
|
Message: "Die Platten des Gasts ließen sich nicht ermitteln: " + diskError.Error(),
|
|
FailureClass: scheduler.FailureSource,
|
|
Cause: diskError,
|
|
}
|
|
}
|
|
|
|
// Eine von der Sicherung ausgenommene Platte ist keine Nebensache: Die
|
|
// Wiederherstellung liefert dann eine unvollständige Maschine, und wer es
|
|
// nicht weiß, hält sie für vollständig. Sie wird als übergangenes Objekt
|
|
// ausgewiesen und macht den Lauf damit zum Teilfehler.
|
|
var skippedDiskReasons []string
|
|
|
|
for _, singleDisk := range guestDisks {
|
|
if singleDisk.ExcludedFromBackup {
|
|
skippedDiskReasons = append(skippedDiskReasons, fmt.Sprintf(
|
|
"%s: die platte ist in proxmox von der sicherung ausgenommen (backup=0)",
|
|
singleDisk.Identifier))
|
|
}
|
|
}
|
|
|
|
chainIdentifier, chainError := executor.store.EnsureChain(backupContext,
|
|
backupRequest.Source.SourceReference(), backupRequest.RepositoryRecord.ID)
|
|
if chainError != nil {
|
|
return jobs.ExecutionResult{}, chainError
|
|
}
|
|
|
|
parentBackupID, parentBackupInRepository, parentError := executor.store.FindLatestBackup(backupContext, chainIdentifier)
|
|
if parentError != nil {
|
|
return jobs.ExecutionResult{}, parentError
|
|
}
|
|
|
|
backupIdentifier := buildBackupIdentifier(backupRequest.RunID, backupRequest.Source)
|
|
startTime := time.Now().UTC()
|
|
|
|
backupRequest.Logger.Info("gast wird gesichert",
|
|
slog.String("verbund", connectedProvider.Cluster.Name),
|
|
slog.String("gast", guestIdentifier),
|
|
slog.String("backup_id", backupIdentifier),
|
|
slog.Int("platten", len(guestDisks)),
|
|
slog.Int("ausgenommen", len(skippedDiskReasons)))
|
|
|
|
archiveStream, openArchiveError := connectedProvider.Provider.OpenDisk(backupContext,
|
|
providers.DiskReadRequest{GuestID: guestIdentifier})
|
|
if openArchiveError != nil {
|
|
return jobs.ExecutionResult{}, &jobs.ExecutionError{
|
|
Code: "GUEST_ARCHIVE_UNAVAILABLE",
|
|
Message: "Das Sicherungsarchiv des Gasts ließ sich nicht lesen: " + openArchiveError.Error(),
|
|
FailureClass: classifyProviderFailure(openArchiveError),
|
|
Cause: openArchiveError,
|
|
}
|
|
}
|
|
|
|
// Der Datenstrom wird in jedem Fall geschlossen — dabei räumt der Provider
|
|
// das Archiv auf dem Knoten ab. Ohne diesen Schritt füllte jede Sicherung
|
|
// den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie derselben
|
|
// Daten, für die keine Aufbewahrungsregel gilt.
|
|
defer func() {
|
|
if closeError := archiveStream.Close(); closeError != nil {
|
|
backupRequest.Logger.Warn("das sicherungsarchiv liess sich nicht schliessen",
|
|
slog.String("grund", closeError.Error()))
|
|
}
|
|
}()
|
|
|
|
encodedConfiguration, encodeError := json.MarshalIndent(guestConfigurationDocument{
|
|
GuestID: guestIdentifier,
|
|
ClusterName: connectedProvider.Cluster.Name,
|
|
ArchiveVolumeName: archiveFileNameFor(backupIdentifier),
|
|
Metadata: guestMetadata,
|
|
Disks: guestDisks,
|
|
CapturedAt: startTime,
|
|
}, "", " ")
|
|
if encodeError != nil {
|
|
return jobs.ExecutionResult{}, fmt.Errorf("die gastkonfiguration liess sich nicht ablegen: %w", encodeError)
|
|
}
|
|
|
|
backupType := repository.BackupTypeFull
|
|
if parentBackupInRepository != "" {
|
|
// Ein „inkrementelles" Backup ist hier eines dem Namen nach: vzdump
|
|
// liest immer die ganze Maschine, weil Proxmox geänderte Blöcke über
|
|
// die REST-API nicht herausgibt. Gespart wird ausschließlich Platz —
|
|
// durch die Deduplizierung —, keine Lesezeit. Das ist genau umgekehrt
|
|
// zur Dateisystemquelle und steht so in docs/proxmox.md.
|
|
backupType = repository.BackupTypeIncremental
|
|
}
|
|
|
|
backupOptions := backupengine.BackupOptions{
|
|
BackupID: backupIdentifier,
|
|
ChainID: chainIdentifier.String(),
|
|
ParentBackupID: parentBackupInRepository,
|
|
BackupType: backupType,
|
|
Source: repository.SourceInformation{
|
|
SourceType: string(backupRequest.Source.SourceType),
|
|
SourceID: guestIdentifier,
|
|
SourceName: backupRequest.Source.SourceName,
|
|
Hostname: guestNameFromMetadata(guestMetadata, backupRequest.Source.SourceName),
|
|
Attributes: map[string]string{
|
|
"cluster": connectedProvider.Cluster.Name,
|
|
"cluster_id": backupRequest.Source.ClusterID.String(),
|
|
"guest_id": guestIdentifier,
|
|
"firmware_type": guestMetadata.FirmwareType,
|
|
"disk_count": fmt.Sprintf("%d", len(guestDisks)),
|
|
"excluded_disks": fmt.Sprintf("%d", len(skippedDiskReasons)),
|
|
"archive_transport": string(connectedProvider.Cluster.ArchiveTransport),
|
|
},
|
|
},
|
|
CompressionLevel: executor.options.CompressionLevel,
|
|
EncryptionEnabled: executor.options.SecretStore != nil,
|
|
CreatedByVersion: executor.options.CreatedByVersion,
|
|
BandwidthLimiter: backupRequest.Limiter,
|
|
}
|
|
|
|
backupSources := []backupengine.BackupSource{
|
|
{
|
|
Path: guestConfigurationEntryPath,
|
|
EntryType: "file",
|
|
SizeBytes: int64(len(encodedConfiguration)),
|
|
ModifiedAt: startTime,
|
|
Mode: "0600",
|
|
Reader: bytes.NewReader(encodedConfiguration),
|
|
},
|
|
{
|
|
// Die Größe bleibt unbekannt: vzdump nennt sie erst, wenn es fertig
|
|
// ist, und ein geschätzter Wert im Manifest wäre eine Zahl, der
|
|
// niemand ansieht, dass sie geraten ist.
|
|
Path: guestArchiveEntryPath,
|
|
EntryType: "disk",
|
|
ModifiedAt: startTime,
|
|
Mode: "0600",
|
|
Reader: archiveStream,
|
|
},
|
|
}
|
|
|
|
backupResult, backupError := backupRequest.Engine.Backup(backupContext, backupOptions, backupSources)
|
|
if backupError != nil {
|
|
return jobs.ExecutionResult{}, backupError
|
|
}
|
|
|
|
executionResult := jobs.ExecutionResult{
|
|
BytesProcessed: backupResult.Progress.BytesProcessed,
|
|
BytesWritten: backupResult.Progress.BytesWritten,
|
|
FilesProcessed: int64(backupResult.EntryCount),
|
|
FilesSkipped: int64(len(skippedDiskReasons)),
|
|
SkipReasons: skippedDiskReasons,
|
|
}
|
|
|
|
executor.recordBackup(backupContext, recordRequest{
|
|
BackupRequest: backupRequest,
|
|
BackupIdentifier: backupIdentifier,
|
|
ChainIdentifier: chainIdentifier,
|
|
ParentBackupID: parentBackupID,
|
|
IsIncremental: backupType == repository.BackupTypeIncremental,
|
|
Result: executionResult,
|
|
StartedAt: startTime,
|
|
// Von den sechs Signalen der Phase 16 tragen hier nur die
|
|
// blockbezogenen etwas bei: Es gibt keine Dateien zu zählen, sondern
|
|
// ein Archiv. Der Anteil nicht verkleinerbarer Blöcke ist dafür das
|
|
// aussagekräftigste Signal überhaupt — verschlüsselt jemand **im**
|
|
// Gast, schlägt genau das aus.
|
|
Signals: ransomwareSignals{
|
|
IncompressibleChunks: backupResult.Progress.ChunksIncompressible,
|
|
NewChunkCount: backupResult.Progress.ChunksWritten,
|
|
},
|
|
})
|
|
|
|
backupRequest.Logger.Info("gast gesichert",
|
|
slog.String("gast", guestIdentifier),
|
|
slog.Int64("bytes_gelesen", executionResult.BytesProcessed),
|
|
slog.Int64("bytes_abgelegt", executionResult.BytesWritten),
|
|
slog.Int64("uebergangen", executionResult.FilesSkipped))
|
|
|
|
return executionResult, nil
|
|
}
|
|
|
|
// guestConfigurationDocument ist die im Backup abgelegte Gastbeschreibung.
|
|
//
|
|
// Sie liegt als eigenes Objekt im Manifest und nicht nur in dessen Attributen:
|
|
// Ein Manifest ist eine Liste von Objekten mit Blockverweisen, kein Ablageort
|
|
// für beliebige Dokumente. Und sie muss eine Wiederherstellung überstehen, die
|
|
// ohne Control Plane auskommt — das Repository ist selbstbeschreibend.
|
|
type guestConfigurationDocument struct {
|
|
// GuestID ist die Kennung des Gasts beim Provider.
|
|
GuestID string `json:"guest_id"`
|
|
// ClusterName ist der Verbund, aus dem er stammt.
|
|
ClusterName string `json:"cluster_name"`
|
|
// ArchiveVolumeName ist der Dateiname, unter dem das Archiv zurückgeschrieben wird.
|
|
ArchiveVolumeName string `json:"archive_volume_name"`
|
|
// Metadata ist die Konfiguration des Gasts.
|
|
Metadata *providers.GuestMetadata `json:"metadata"`
|
|
// Disks sind die erkannten Platten samt Ausnahmen.
|
|
Disks []providers.Disk `json:"disks"`
|
|
// CapturedAt ist der Zeitpunkt der Aufnahme.
|
|
CapturedAt time.Time `json:"captured_at"`
|
|
}
|
|
|
|
// archiveFileNameFor bildet den Dateinamen des zurückzuschreibenden Archivs.
|
|
//
|
|
// Er trägt die Backup-Kennung, damit auf dem Proxmox-Knoten erkennbar bleibt,
|
|
// welche Wiederherstellung das Archiv abgelegt hat — und damit zwei
|
|
// gleichzeitige Wiederherstellungen sich nicht dieselbe Datei überschreiben.
|
|
func archiveFileNameFor(backupIdentifier string) string {
|
|
sanitizedIdentifier := strings.Map(func(currentRune rune) rune {
|
|
switch {
|
|
case currentRune >= 'a' && currentRune <= 'z',
|
|
currentRune >= 'A' && currentRune <= 'Z',
|
|
currentRune >= '0' && currentRune <= '9',
|
|
currentRune == '-', currentRune == '_':
|
|
return currentRune
|
|
default:
|
|
return '-'
|
|
}
|
|
}, backupIdentifier)
|
|
|
|
return "syncova-restore-" + sanitizedIdentifier + ".vma"
|
|
}
|
|
|
|
// guestNameFromMetadata liefert den Namen des Gasts.
|
|
func guestNameFromMetadata(guestMetadata *providers.GuestMetadata, fallbackName string) string {
|
|
if guestMetadata == nil {
|
|
return fallbackName
|
|
}
|
|
|
|
if hostName, hasName := guestMetadata.RawConfiguration["name"]; hasName && hostName != "" {
|
|
return hostName
|
|
}
|
|
|
|
// Container tragen ihren Namen unter einem anderen Schlüssel.
|
|
if hostName, hasName := guestMetadata.RawConfiguration["hostname"]; hasName && hostName != "" {
|
|
return hostName
|
|
}
|
|
|
|
return fallbackName
|
|
}
|
|
|
|
// classifyProviderFailure ordnet einen Providerfehler einer Fehlerklasse zu.
|
|
//
|
|
// Die Einordnung entscheidet über die Wiederholung. Ein fehlender Zugriffsweg
|
|
// auf die Archive behebt sich nicht durch Warten — ihn als vorübergehend zu
|
|
// führen ergäbe eine Sicherung, die jede Nacht dreimal scheitert und dabei
|
|
// jedes Mal ein vzdump über die ganze Maschine laufen lässt.
|
|
func classifyProviderFailure(providerError error) scheduler.FailureClass {
|
|
if errors.Is(providerError, hypervisorTransportMissing) {
|
|
return scheduler.FailureConfiguration
|
|
}
|
|
|
|
if errors.Is(providerError, providers.ErrGuestNotFound) {
|
|
return scheduler.FailureSource
|
|
}
|
|
|
|
if errors.Is(providerError, providers.ErrNotSupported) {
|
|
return scheduler.FailureConfiguration
|
|
}
|
|
|
|
return scheduler.FailureTransient
|
|
}
|
|
|
|
// hypervisorTransportMissing verweist auf den fehlenden Zugriffsweg.
|
|
//
|
|
// Über eine Variable statt eines direkten Imports, damit dieses Paket nicht
|
|
// vom Proxmox-Provider abhängt — die Schichtung verlangt, dass die
|
|
// Ausführungsschleife providerneutral bleibt.
|
|
var hypervisorTransportMissing = hypervisor.ErrArchiveTransportMissing
|