package hypervisor import ( "bytes" "context" "encoding/json" "errors" "fmt" "log/slog" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/backupengine" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/providers" "github.com/syncova/syncova/packages/repository" ) // GuestArchiveEntryPath ist der Pfad des Plattenabbilds im Manifest. // // Er muss mit dem des Executors übereinstimmen; die Konstante steht hier, weil // die Wiederherstellung sonst gegen ein Paket der Ausführungsschicht bauen // müsste — und die Schichtung läuft in die andere Richtung. const GuestArchiveEntryPath = "guest/disk-image.vma" // GuestConfigurationEntryPath ist der Pfad der Gastkonfiguration im Manifest. const GuestConfigurationEntryPath = "guest/configuration.json" // GuestRestoreRequest beschreibt eine Wiederherstellung eines Gasts. type GuestRestoreRequest struct { // ClusterID ist der Verbund, in den zurückgeschrieben wird. // // Nicht zwingend derselbe, aus dem gesichert wurde: Eine Wiederherstellung // in einen Ersatzverbund ist genau der Fall, für den man Backups anlegt. ClusterID uuid.UUID // RepositoryPath ist der Ort des Repositorys. RepositoryPath string // BackupID ist die Kennung des Backups im Repository. BackupID string // TargetGuestID ist die Zielkennung; leer wählt die nächste freie. TargetGuestID string // TargetNode ist der Zielknoten; leer wählt den Ursprungsknoten. TargetNode string // TargetStorageID lenkt die Platten auf einen anderen Speicher. TargetStorageID string // OverwriteExisting erlaubt das Überschreiben eines vorhandenen Gasts. // // Standardmäßig aus. Ein vorhandener Gast unter der Zielkennung ist der // häufigste Weg, bei einer Wiederherstellung genau das zu vernichten, was // man retten wollte. OverwriteExisting bool // StartAfterRestore startet den Gast nach der Wiederherstellung. // // Standardmäßig aus. Eine wiederhergestellte Maschine, die sich mit // derselben Adresse ins Netz meldet wie das noch laufende Original, // richtet mehr Schaden an als der Ausfall. StartAfterRestore bool // KeepStagedArchive lässt das zurückgeschriebene Archiv auf dem Knoten liegen. KeepStagedArchive bool // ProgressCallback meldet den Fortschritt. ProgressCallback func(providers.RestoreProgress) } // GuestRestoreResult beschreibt eine abgeschlossene Wiederherstellung. type GuestRestoreResult struct { // GuestID ist die Kennung des wiederhergestellten Gasts. GuestID string `json:"guest_id"` // NodeName ist der Knoten, auf dem er liegt. NodeName string `json:"node_name"` // ArchiveVolume ist die Volumenkennung des zurückgeschriebenen Archivs. ArchiveVolume string `json:"archive_volume"` // BytesStaged ist die auf den Knoten geschriebene Datenmenge. BytesStaged int64 `json:"bytes_staged"` // Started meldet, ob der Gast gestartet wurde. Started bool `json:"started"` // Warnings sind Hinweise, die die Wiederherstellung nicht verhindert haben. // // Der wichtigste: Die Plattenzuordnung der gesicherten Konfiguration wird // bewusst nicht gesetzt — sie verwiese auf den alten Ort, und die Maschine // startete nicht. Warnings []string `json:"warnings,omitempty"` // Duration ist die Gesamtdauer. Duration time.Duration `json:"duration"` } // RestoreGuest stellt einen gesicherten Gast in einem Verbund wieder her. // // Der Weg hat drei Abschnitte, und der mittlere ist der, den es ohne Syncova // nicht gäbe: // // 1. Das Archiv wird aus dem Repository gelesen — entschlüsselt, entpackt, // jeder Block gegen seine Kennung geprüft. // 2. Es wird über den Zugriffsweg **auf einen Proxmox-Speicher geschrieben**. // Die Proxmox-API nimmt zur Wiederherstellung ausschließlich eine // Volumenkennung entgegen; einen Endpunkt zum Hochladen gibt es nicht. // 3. Proxmox stellt daraus den Gast her. // // Ohne Schritt 2 endete jede Wiederherstellung dort, wo sie anfängt: Das // Archiv läge unversehrt im Repository und käme nie auf den Knoten. func (store *Store) RestoreGuest(restoreContext context.Context, restoreRequest GuestRestoreRequest, secretStore crypto.SecretStore, baseLogger *slog.Logger) (*GuestRestoreResult, error) { startTime := time.Now() connectedProvider, openError := store.OpenProvider(restoreContext, restoreRequest.ClusterID, baseLogger) if openError != nil { return nil, openError } defer func() { _ = connectedProvider.Close() }() if connectedProvider.ArchiveWriter == nil { return nil, fmt.Errorf("%w: der eingerichtete zugriffsweg kann nicht schreiben", ErrArchiveTransportMissing) } // Das Repository wird **schreibgeschützt** geöffnet: Eine Wiederherstellung // liest nur und muss nicht auf die Schreibsperre einer laufenden Sicherung // warten (dieselbe Entscheidung wie in Phase 9). openedRepository, repositoryError := repository.Open(restoreContext, restoreRequest.RepositoryPath, repository.OpenOptions{ReadOnly: true}, baseLogger) if repositoryError != nil { return nil, fmt.Errorf("das repository war nicht erreichbar: %w", repositoryError) } defer func() { _ = openedRepository.Close() }() restoreEngine := backupengine.NewEngine(openedRepository, secretStore, baseLogger) guestConfiguration, configurationError := readGuestConfiguration(restoreContext, restoreEngine, restoreRequest.BackupID) if configurationError != nil { return nil, configurationError } archiveFileName := guestConfiguration.ArchiveVolumeName if archiveFileName == "" { // Ein Backup aus einer früheren Fassung trägt den Namen nicht. Ihn zu // raten wäre falsch; abzuleiten ist er aber eindeutig. archiveFileName = "syncova-restore-" + restoreRequest.BackupID + ".vma" } targetNode := restoreRequest.TargetNode if targetNode == "" { // Ohne ausdrücklichen Zielknoten wird der genommen, auf dem der Gast // zuletzt lag. Ein beliebiger Knoten wäre eine stille Verschiebung. targetNode = nodeFromGuestIdentifier(restoreContext, connectedProvider, guestConfiguration.GuestID) } if targetNode == "" { return nil, errors.New("es liess sich kein zielknoten bestimmen; geben sie ihn ausdruecklich an") } reportRestoreProgress(restoreRequest.ProgressCallback, "archiv wird auf den knoten geschrieben", 5, archiveFileName) archiveWriter, volumeIdentifier, createError := connectedProvider.ArchiveWriter.CreateArchive(restoreContext, targetNode, connectedProvider.Cluster.BackupStorageID, archiveFileName) if createError != nil { return nil, fmt.Errorf("das archiv liess sich auf dem knoten nicht anlegen: %w", createError) } countingWriter := &byteCountingWriter{target: archiveWriter} engineResult, restoreError := restoreEngine.Restore(restoreContext, backupengine.RestoreOptions{ BackupID: restoreRequest.BackupID, Path: GuestArchiveEntryPath, }, countingWriter) if restoreError != nil { // Der angefangene Datenstrom wird geschlossen und das halbe Archiv // entfernt: Ein unvollständiges Archiv, das Proxmox für ein gültiges // hält, ergäbe eine Maschine mit halben Daten. _ = archiveWriter.Close() store.removeStagedArchive(restoreContext, connectedProvider, targetNode, volumeIdentifier, baseLogger) return nil, fmt.Errorf("das gesicherte archiv liess sich nicht zurueckschreiben: %w", restoreError) } // Erst das Close() macht das Archiv gültig — bei SSH steckt darin auch die // Auswertung des Rückgabewerts der Gegenseite. if closeError := archiveWriter.Close(); closeError != nil { store.removeStagedArchive(restoreContext, connectedProvider, targetNode, volumeIdentifier, baseLogger) return nil, fmt.Errorf("das archiv wurde nicht vollstaendig geschrieben: %w", closeError) } baseLogger.Info("archiv auf dem knoten bereitgestellt", slog.String("knoten", targetNode), slog.String("volumen", volumeIdentifier), slog.Int64("bytes", countingWriter.written), slog.Bool("geprueft", engineResult.Verified)) reportRestoreProgress(restoreRequest.ProgressCallback, "gast wird wiederhergestellt", 50, volumeIdentifier) providerResult, providerRestoreError := connectedProvider.Provider.RestoreVM(restoreContext, providers.RestoreRequest{ TargetKind: restoreTargetKind(restoreRequest), SourceGuestID: guestConfiguration.GuestID, TargetGuestID: restoreRequest.TargetGuestID, TargetHostID: targetNode, TargetStorageID: restoreRequest.TargetStorageID, ArchiveReference: volumeIdentifier, Metadata: guestConfiguration.Metadata, StartAfterRestore: restoreRequest.StartAfterRestore, OverwriteExisting: restoreRequest.OverwriteExisting, ProgressCallback: restoreRequest.ProgressCallback, }) // Aufgeräumt wird in jedem Fall: Ein liegengebliebenes Archiv füllt den // Proxmox-Speicher mit einer unverwalteten Kopie, für die keine // Aufbewahrungsregel gilt. if !restoreRequest.KeepStagedArchive { store.removeStagedArchive(restoreContext, connectedProvider, targetNode, volumeIdentifier, baseLogger) } if providerRestoreError != nil { return nil, providerRestoreError } restoreResult := &GuestRestoreResult{ GuestID: providerResult.GuestID, NodeName: providerResult.HostID, ArchiveVolume: volumeIdentifier, BytesStaged: countingWriter.written, Started: providerResult.Started, Warnings: providerResult.Warnings, Duration: time.Since(startTime), } return restoreResult, nil } // readGuestConfiguration liest die mitgesicherte Gastbeschreibung. // // Ohne sie ließe sich die Maschine zwar mit ihren Daten, aber nicht in ihrer // Gestalt wiederherstellen — falsche Netzkarte, anderes BIOS, fehlende // serielle Schnittstelle. Eine bootfähige, aber unbrauchbare VM ist kein // Restore. func readGuestConfiguration(readContext context.Context, restoreEngine *backupengine.Engine, backupIdentifier string) (*GuestConfigurationDocument, error) { var configurationBuffer bytes.Buffer if _, restoreError := restoreEngine.Restore(readContext, backupengine.RestoreOptions{ BackupID: backupIdentifier, Path: GuestConfigurationEntryPath, }, &configurationBuffer); restoreError != nil { return nil, fmt.Errorf("die gesicherte gastkonfiguration liess sich nicht lesen: %w", restoreError) } var guestConfiguration GuestConfigurationDocument if decodeError := json.Unmarshal(configurationBuffer.Bytes(), &guestConfiguration); decodeError != nil { return nil, fmt.Errorf("die gesicherte gastkonfiguration ist unlesbar: %w", decodeError) } return &guestConfiguration, nil } // GuestConfigurationDocument ist die im Backup abgelegte Gastbeschreibung. 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 für das Zurückschreiben. 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"` } // ExcludedDiskIdentifiers benennt die nicht gesicherten Platten. // // Sie gehören in jede Ausgabe einer Wiederherstellung: Die Maschine kommt ohne // sie zurück, und wer das nicht erfährt, hält sie für vollständig. func (document GuestConfigurationDocument) ExcludedDiskIdentifiers() []string { var excludedIdentifiers []string for _, singleDisk := range document.Disks { if singleDisk.ExcludedFromBackup { excludedIdentifiers = append(excludedIdentifiers, singleDisk.Identifier) } } return excludedIdentifiers } // removeStagedArchive entfernt das bereitgestellte Archiv vom Knoten. func (store *Store) removeStagedArchive(removeContext context.Context, connectedProvider *ConnectedProvider, nodeName string, volumeIdentifier string, baseLogger *slog.Logger) { if removeError := connectedProvider.Provider.RemoveArchive(removeContext, nodeName, volumeIdentifier); removeError != nil { // Ein misslungenes Aufräumen bricht nichts ab: Die Wiederherstellung // ist die Handlung, auf die es ankommt. Es wird aber genannt — sonst // füllt sich der Speicher unbemerkt. baseLogger.Warn("das bereitgestellte archiv liess sich nicht entfernen", slog.String("volumen", volumeIdentifier), slog.String("grund", removeError.Error())) } } // nodeFromGuestIdentifier ermittelt den Knoten des ursprünglichen Gasts. // // Findet er sich nicht mehr — der Normalfall nach einem Totalverlust —, bleibt // das Ergebnis leer, und der Aufrufer muss den Zielknoten angeben. Einen // beliebigen zu nehmen wäre eine stille Verschiebung der Maschine. func nodeFromGuestIdentifier(lookupContext context.Context, connectedProvider *ConnectedProvider, guestIdentifier string) string { foundGuest, lookupError := connectedProvider.Provider.GetVMInfo(lookupContext, guestIdentifier) if lookupError != nil || foundGuest == nil { return "" } return foundGuest.HostID } // restoreTargetKind wählt die Art des Wiederherstellungsziels. // // Eine ausdrückliche Zielkennung ohne Überschreibrecht bedeutet: daneben // stellen, nicht ersetzen. Das ist der Weg, mit dem sich eine Wiederherstellung // prüfen lässt, ohne das Original anzutasten — und damit der, den ein // Wiederherstellungstest (Phase 10) auf echter Hardware nehmen würde. func restoreTargetKind(restoreRequest GuestRestoreRequest) providers.RestoreTargetKind { if restoreRequest.TargetGuestID != "" && !restoreRequest.OverwriteExisting { return providers.RestoreAsNewGuest } return providers.RestoreToOriginalHost } // reportRestoreProgress meldet den Fortschritt, sofern jemand zuhört. func reportRestoreProgress(progressCallback func(providers.RestoreProgress), stageName string, percentComplete float64, message string) { if progressCallback == nil { return } progressCallback(providers.RestoreProgress{ Stage: stageName, PercentComplete: percentComplete, Message: message, }) } // byteCountingWriter zählt die durchgereichten Bytes. // // Die Zahl stammt damit aus dem tatsächlich Geschriebenen und nicht aus einer // Angabe des Manifests: Eine Kennzahl, die nicht misst, sondern wiederholt, // bleibt auch dann richtig, wenn nichts ankam. type byteCountingWriter struct { // target ist der eigentliche Empfänger. target interface{ Write([]byte) (int, error) } // written ist die Zahl übergebener Bytes. written int64 } // Write reicht die Daten weiter und zählt mit. func (countingWriter *byteCountingWriter) Write(sourceBuffer []byte) (int, error) { writtenCount, writeError := countingWriter.target.Write(sourceBuffer) countingWriter.written += int64(writtenCount) return writtenCount, writeError }