package proxmox import ( "context" "errors" "fmt" "log/slog" "net/url" "sort" "strconv" "strings" "time" "github.com/syncova/syncova/packages/providers" ) // ErrTargetGuestExists meldet eine bereits belegte Zielkennung. var ErrTargetGuestExists = errors.New("unter der zielkennung besteht bereits ein gast") // ErrNoFreeGuestIdentifier meldet einen erschöpften Kennungsbereich. var ErrNoFreeGuestIdentifier = errors.New("es war keine freie gastkennung zu finden") // minimumUserGuestID ist die kleinste von Proxmox für Gäste zugelassene Kennung. // // Die Zahlen darunter sind reserviert; eine Wiederherstellung dorthin wird // abgelehnt. const minimumUserGuestID = 100 // maximumUserGuestID ist die größte zulässige Gastkennung. const maximumUserGuestID = 999999999 // RestoreVM stellt einen Gast wieder her. // // Die drei Zielarten aus SYNCOVA_ARCHITECTURE.md §12 unterscheiden sich nur in // wenigen Feldern, führen aber zu sehr verschiedenen Risiken: // // - Ursprungsort: überschreibt die vorhandene Maschine — der gefährlichste // Fall, deshalb verlangt er ausdrückliche Zustimmung. // - Anderer Wirt: dieselbe Kennung auf einem anderen Knoten. // - Neuer Gast: lässt das Original unberührt. Das ist der Weg für einen // Wiederherstellungstest, ohne den Betrieb anzufassen. func (provider *Provider) RestoreVM(restoreContext context.Context, restoreRequest providers.RestoreRequest) (*providers.RestoreResult, error) { if provider.client == nil { return nil, providers.ErrNotConnected } startTime := time.Now() if strings.TrimSpace(restoreRequest.ArchiveReference) == "" { return nil, errors.New("es wurde kein wiederherzustellendes archiv angegeben") } restorePlan, planError := provider.buildRestorePlan(restoreContext, restoreRequest) if planError != nil { return nil, planError } reportProgress(restoreRequest.ProgressCallback, "vorbereitung", 0, fmt.Sprintf("Ziel: %s %d auf Knoten %s", restorePlan.APISegment, restorePlan.TargetVMID, restorePlan.TargetNode)) formValues := url.Values{} formValues.Set("vmid", strconv.Itoa(restorePlan.TargetVMID)) formValues.Set("archive", restoreRequest.ArchiveReference) // Der Gast wird nie unaufgefordert gestartet. Eine wiederhergestellte // Maschine, die sich mit derselben Adresse ins Netz meldet wie das noch // laufende Original, richtet mehr Schaden an als der Ausfall. formValues.Set("start", "0") if restorePlan.OverwriteExisting { formValues.Set("force", "1") } if restoreRequest.TargetStorageID != "" { formValues.Set("storage", restoreRequest.TargetStorageID) } restorePath := fmt.Sprintf("/nodes/%s/%s", url.PathEscape(restorePlan.TargetNode), restorePlan.APISegment) provider.logger.Info("wiederherstellung angestoßen", slog.String("ziel_art", string(restoreRequest.TargetKind)), slog.Int("ziel_vmid", restorePlan.TargetVMID), slog.String("ziel_knoten", restorePlan.TargetNode), slog.Bool("ueberschreibt", restorePlan.OverwriteExisting)) var taskIdentifier TaskIdentifier if createError := provider.client.post(restoreContext, restorePath, formValues, &taskIdentifier); createError != nil { return nil, fmt.Errorf("die wiederherstellung konnte nicht angestoßen werden: %w", createError) } reportProgress(restoreRequest.ProgressCallback, "wiederherstellung", 10, string(taskIdentifier)) // Keine Zeitgrenze: das Zurückspielen mehrerer Terabyte dauert. taskStatus, waitError := provider.client.WaitForTask(restoreContext, taskIdentifier, TaskWaitOptions{ PollInterval: provider.options.TaskPollInterval, ProgressCallback: func(currentStatus TaskStatus) { reportProgress(restoreRequest.ProgressCallback, "wiederherstellung", 50, currentStatus.Status) }, }) if waitError != nil { return nil, waitError } restoreResult := &providers.RestoreResult{ GuestID: formatGuestIdentifier(restorePlan.APISegment, restorePlan.TargetVMID), HostID: restorePlan.TargetNode, Warnings: restorePlan.Warnings, } if taskStatus.HasWarnings() { restoreResult.Warnings = append(restoreResult.Warnings, "Proxmox meldete: "+taskStatus.ExitStatus) } // Die Konfiguration wird nach dem Zurückspielen der Daten gesetzt. Vorher // ginge es nicht: Der Gast existiert noch nicht. if restoreRequest.Metadata != nil { reportProgress(restoreRequest.ProgressCallback, "konfiguration", 80, "") configurationWarnings, configurationError := provider.applyMetadata(restoreContext, restorePlan, restoreRequest.Metadata) if configurationError != nil { // Die Daten liegen bereits richtig. Ein Fehler hier macht die // Wiederherstellung unvollständig, nicht wertlos — deshalb wird er // gemeldet, ohne das Ergebnis zu verwerfen. return restoreResult, fmt.Errorf("die daten wurden zurückgespielt, die konfiguration jedoch nicht vollständig gesetzt: %w", configurationError) } restoreResult.Warnings = append(restoreResult.Warnings, configurationWarnings...) } if restoreRequest.StartAfterRestore { reportProgress(restoreRequest.ProgressCallback, "start", 95, "") if startError := provider.startGuest(restoreContext, restorePlan); startError != nil { return restoreResult, fmt.Errorf("der gast wurde wiederhergestellt, ließ sich aber nicht starten: %w", startError) } restoreResult.Started = true } restoreResult.Duration = time.Since(startTime) reportProgress(restoreRequest.ProgressCallback, "abgeschlossen", 100, "") provider.logger.Info("wiederherstellung abgeschlossen", slog.String("gast", restoreResult.GuestID), slog.String("knoten", restoreResult.HostID), slog.Bool("gestartet", restoreResult.Started), slog.String("dauer", restoreResult.Duration.String())) return restoreResult, nil } // restorePlan ist die aufgelöste Zielbeschreibung einer Wiederherstellung. type restorePlan struct { // TargetNode ist der Zielknoten. TargetNode string // TargetVMID ist die Zielkennung. TargetVMID int // APISegment ist "qemu" oder "lxc". APISegment string // OverwriteExisting meldet das Überschreiben eines vorhandenen Gasts. OverwriteExisting bool // Warnings sind Hinweise, die den Aufrufer erreichen müssen. Warnings []string } // buildRestorePlan löst die Zielbeschreibung auf und prüft sie. // // Hier liegen die Schutzmaßnahmen: Ohne sie überschriebe eine // Wiederherstellung an den Ursprungsort eine laufende Maschine, und eine // Wiederherstellung als neuer Gast könnte auf einer belegten Kennung landen. func (provider *Provider) buildRestorePlan(planContext context.Context, restoreRequest providers.RestoreRequest) (*restorePlan, error) { apiSegment, sourceVMID, parseError := parseGuestIdentifier(restoreRequest.SourceGuestID) if parseError != nil { return nil, parseError } builtPlan := &restorePlan{ APISegment: apiSegment, Warnings: make([]string, 0), } existingGuests, listError := provider.ListVMs(planContext, "") if listError != nil { return nil, listError } occupiedIdentifiers := collectOccupiedIdentifiers(existingGuests) switch restoreRequest.TargetKind { case providers.RestoreToOriginalHost, providers.RestoreToAlternateHost: builtPlan.TargetVMID = sourceVMID if restoreRequest.TargetGuestID != "" { _, requestedVMID, requestedError := parseGuestIdentifier(restoreRequest.TargetGuestID) if requestedError != nil { return nil, requestedError } builtPlan.TargetVMID = requestedVMID } targetNode, nodeError := provider.resolveTargetNode(planContext, restoreRequest, sourceVMID, apiSegment) if nodeError != nil { return nil, nodeError } builtPlan.TargetNode = targetNode // Der eigentliche Schutz: Eine bestehende Maschine wird nur mit // ausdrücklicher Zustimmung überschrieben, und niemals im Lauf. if _, isOccupied := occupiedIdentifiers[builtPlan.TargetVMID]; isOccupied { if !restoreRequest.OverwriteExisting { return nil, fmt.Errorf("%w (%d); zum überschreiben ist die ausdrückliche zustimmung nötig", ErrTargetGuestExists, builtPlan.TargetVMID) } if runningError := provider.refuseIfRunning(planContext, builtPlan.TargetNode, apiSegment, builtPlan.TargetVMID); runningError != nil { return nil, runningError } builtPlan.OverwriteExisting = true builtPlan.Warnings = append(builtPlan.Warnings, fmt.Sprintf("Der bestehende Gast %d wurde überschrieben.", builtPlan.TargetVMID)) } case providers.RestoreAsNewGuest: // Eine neue Maschine erhält eine freie Kennung. Das Original bleibt // unberührt — das ist der Weg für einen Wiederherstellungstest. if restoreRequest.TargetGuestID != "" { _, requestedVMID, requestedError := parseGuestIdentifier(restoreRequest.TargetGuestID) if requestedError != nil { return nil, requestedError } if _, isOccupied := occupiedIdentifiers[requestedVMID]; isOccupied { return nil, fmt.Errorf("%w (%d)", ErrTargetGuestExists, requestedVMID) } builtPlan.TargetVMID = requestedVMID } else { freeIdentifier, identifierError := findFreeGuestIdentifier(occupiedIdentifiers, sourceVMID) if identifierError != nil { return nil, identifierError } builtPlan.TargetVMID = freeIdentifier } targetNode, nodeError := provider.resolveTargetNode(planContext, restoreRequest, sourceVMID, apiSegment) if nodeError != nil { return nil, nodeError } builtPlan.TargetNode = targetNode // Die MAC-Adresse ist der Grund für diese Warnung: Zwei Maschinen mit // derselben Adresse im selben Netz stören einander. Wer als neuen Gast // wiederherstellt, muss das wissen. builtPlan.Warnings = append(builtPlan.Warnings, fmt.Sprintf("Der Gast wurde als neue Maschine %d angelegt. Prüfen Sie die Netzwerkeinstellungen, "+ "bevor Sie ihn starten: er trägt dieselben MAC- und IP-Adressen wie das Original.", builtPlan.TargetVMID)) default: return nil, fmt.Errorf("die zielart %q ist unbekannt", restoreRequest.TargetKind) } if builtPlan.TargetVMID < minimumUserGuestID { return nil, fmt.Errorf("die kennung %d liegt im reservierten bereich; zulässig sind %d bis %d", builtPlan.TargetVMID, minimumUserGuestID, maximumUserGuestID) } return builtPlan, nil } // resolveTargetNode ermittelt den Zielknoten. func (provider *Provider) resolveTargetNode(nodeContext context.Context, restoreRequest providers.RestoreRequest, sourceVMID int, apiSegment string) (string, error) { if restoreRequest.TargetHostID != "" { availableHosts, hostError := provider.ListHosts(nodeContext, "") if hostError != nil { return "", hostError } for _, availableHost := range availableHosts { if availableHost.Identifier != restoreRequest.TargetHostID { continue } // Ein Knoten, der nicht antwortet, nimmt keine Wiederherstellung // an. Das jetzt zu erkennen erspart einen Abbruch nach Stunden. if !availableHost.Online { return "", fmt.Errorf("der zielknoten %q ist nicht erreichbar", restoreRequest.TargetHostID) } return availableHost.Identifier, nil } return "", fmt.Errorf("der zielknoten %q gehört nicht zu diesem verbund", restoreRequest.TargetHostID) } // Ohne Angabe wird der Ursprungsknoten gewählt. sourceLocation, locateError := provider.locateGuest(nodeContext, formatGuestIdentifier(apiSegment, sourceVMID)) if locateError == nil { return sourceLocation.NodeName, nil } // Der Ursprungsgast ist verschwunden — der Normalfall nach einem Verlust. // Dann wird der erste erreichbare Knoten genommen. availableHosts, hostError := provider.ListHosts(nodeContext, "") if hostError != nil { return "", hostError } for _, availableHost := range availableHosts { if availableHost.Online { return availableHost.Identifier, nil } } return "", errors.New("es war kein erreichbarer knoten für die wiederherstellung zu finden") } // refuseIfRunning verweigert das Überschreiben einer laufenden Maschine. // // Proxmox lehnt das ohnehin ab; die Prüfung hier liefert die verständliche // Begründung, statt den Aufrufer mit einer Meldung aus dem Aufgabenprotokoll // allein zu lassen. func (provider *Provider) refuseIfRunning(checkContext context.Context, nodeName string, apiSegment string, targetVMID int) error { currentStatus, statusError := provider.fetchGuestStatus(checkContext, guestLocation{ NodeName: nodeName, VMID: targetVMID, APISegment: apiSegment, }) if statusError != nil { return statusError } if currentStatus.Status == "running" { return fmt.Errorf("%w: der gast %d läuft und würde beim wiederherstellen überschrieben; halten Sie ihn zuerst an", providers.ErrGuestRunning, targetVMID) } if currentStatus.Lock != "" { return fmt.Errorf("der gast %d ist durch den vorgang %q gesperrt; die wiederherstellung würde ihn beschädigen", targetVMID, currentStatus.Lock) } return nil } // collectOccupiedIdentifiers sammelt die belegten Gastkennungen. func collectOccupiedIdentifiers(existingGuests []providers.Guest) map[int]struct{} { occupiedIdentifiers := make(map[int]struct{}, len(existingGuests)) for _, existingGuest := range existingGuests { _, numericID, parseError := parseGuestIdentifier(existingGuest.Identifier) if parseError != nil { continue } // Die Art bleibt unberücksichtigt: Proxmox vergibt Kennungen über QEMU // und LXC hinweg nur einmal. occupiedIdentifiers[numericID] = struct{}{} } return occupiedIdentifiers } // findFreeGuestIdentifier sucht die nächste freie Kennung. // // Gesucht wird oberhalb der Ursprungskennung: die wiederhergestellte Maschine // steht damit in der Übersicht neben ihrem Original und ist als dessen Kopie // erkennbar. func findFreeGuestIdentifier(occupiedIdentifiers map[int]struct{}, preferredStart int) (int, error) { searchStart := preferredStart + 1 if searchStart < minimumUserGuestID { searchStart = minimumUserGuestID } for candidateIdentifier := searchStart; candidateIdentifier <= maximumUserGuestID; candidateIdentifier++ { if _, isOccupied := occupiedIdentifiers[candidateIdentifier]; !isOccupied { return candidateIdentifier, nil } } return 0, ErrNoFreeGuestIdentifier } // nonRestorableProperties sind Konfigurationsfelder, die nicht gesetzt werden. // // Sie beschreiben die Ablage der Platten und werden von der Wiederherstellung // selbst bestimmt. Würde man sie überschreiben, verwiese die Konfiguration auf // Datenträger am alten Ort — die Maschine startete nicht. var nonRestorableProperties = map[string]bool{ "digest": true, "vmgenid": true, "meta": true, "parent": true, "snaptime": true, "lock": true, } // isDiskProperty meldet ein Feld, das einen Datenträger beschreibt. func isDiskProperty(propertyName string) bool { return diskPropertyPattern.MatchString(propertyName) } // applyMetadata setzt die gesicherte Konfiguration auf den wiederhergestellten Gast. // // Ohne diesen Schritt hätte die Maschine ihre Daten, aber nicht ihre Gestalt: // falsches Kartenmodell, fehlende serielle Schnittstelle, anderes BIOS. Eine // bootfähige, aber unbrauchbare VM ist kein gelungener Restore. func (provider *Provider) applyMetadata(metadataContext context.Context, plan *restorePlan, guestMetadata *providers.GuestMetadata) ([]string, error) { if len(guestMetadata.RawConfiguration) == 0 { return nil, nil } formValues := url.Values{} skippedProperties := make([]string, 0) // Die feste Reihenfolge macht zwei Läufe vergleichbar und Fehler // nachvollziehbar. propertyNames := make([]string, 0, len(guestMetadata.RawConfiguration)) for propertyName := range guestMetadata.RawConfiguration { propertyNames = append(propertyNames, propertyName) } sort.Strings(propertyNames) for _, propertyName := range propertyNames { if nonRestorableProperties[propertyName] { continue } // Plattenfelder bestimmt die Wiederherstellung selbst. if isDiskProperty(propertyName) { skippedProperties = append(skippedProperties, propertyName) continue } propertyValue := guestMetadata.RawConfiguration[propertyName] if propertyValue == "" { continue } formValues.Set(propertyName, propertyValue) } if len(formValues) == 0 { return nil, nil } configurationPath := fmt.Sprintf("/nodes/%s/%s/%d/config", url.PathEscape(plan.TargetNode), plan.APISegment, plan.TargetVMID) if configurationError := provider.client.put(metadataContext, configurationPath, formValues, nil); configurationError != nil { return nil, fmt.Errorf("die konfiguration konnte nicht gesetzt werden: %w", configurationError) } warnings := make([]string, 0, 1) if len(skippedProperties) > 0 { // Kein stiller Vorgang: Der Anwender erfährt, welche Felder die // Wiederherstellung selbst bestimmt hat. warnings = append(warnings, fmt.Sprintf( "Die Plattenzuordnung stammt aus dem Archiv, nicht aus der gesicherten Konfiguration (%s).", strings.Join(skippedProperties, ", "))) } return warnings, nil } // startGuest startet einen wiederhergestellten Gast. func (provider *Provider) startGuest(startContext context.Context, plan *restorePlan) error { startPath := fmt.Sprintf("/nodes/%s/%s/%d/status/start", url.PathEscape(plan.TargetNode), plan.APISegment, plan.TargetVMID) var taskIdentifier TaskIdentifier if startError := provider.client.post(startContext, startPath, url.Values{}, &taskIdentifier); startError != nil { return startError } _, waitError := provider.client.WaitForTask(startContext, taskIdentifier, TaskWaitOptions{ Timeout: defaultSnapshotTimeout, PollInterval: provider.options.TaskPollInterval, }) return waitError } // reportProgress meldet den Fortschritt, sofern ein Empfänger eingerichtet ist. func reportProgress(progressCallback func(providers.RestoreProgress), stageName string, percentComplete float64, message string) { if progressCallback == nil { return } progressCallback(providers.RestoreProgress{ Stage: stageName, PercentComplete: percentComplete, Message: message, }) }