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