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>
501 lines
18 KiB
Go
501 lines
18 KiB
Go
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,
|
|
})
|
|
}
|