package proxmox import ( "context" "errors" "fmt" "io" "log/slog" "net/url" "strings" "time" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/providers" ) // ProviderName ist der Name dieses Providers. const ProviderName = "proxmox-ve" // ArchiveTransport holt eine von vzdump erzeugte Archivdatei vom Knoten. // // Diese Schnittstelle ist die ehrliche Antwort auf eine Lücke der Proxmox-API: // Sie kann eine Sicherung anstoßen, aber die entstandene Datei nicht // herausgeben. Es gibt keinen REST-Endpunkt, der den Inhalt eines Datenträgers // oder eines Sicherungsarchivs ausliefert. // // Damit bleiben drei Wege, und alle brauchen Zugriff jenseits der REST-API: // // 1. Syncova läuft auf dem Proxmox-Knoten — dann genügt ein Dateizugriff. // 2. Das Sicherungsziel ist eine gemeinsame Freigabe (NFS, CIFS), die beide // Seiten sehen. // 3. Zugriff über SSH auf den Knoten. // // Statt einen dieser Wege festzuschreiben, steht hier eine Naht. Wer sie nicht // füllt, bekommt einen klaren Fehler und kein halbes Backup. type ArchiveTransport interface { // OpenArchive öffnet ein Sicherungsarchiv zum Lesen. // // volumeIdentifier ist die Proxmox-Bezeichnung, etwa // "local:backup/vzdump-qemu-100-2026_08_11-02_00_00.vma.zst". OpenArchive(openContext context.Context, nodeName string, volumeIdentifier string) (io.ReadCloser, error) } // ProviderOptions steuern den Proxmox-Provider. type ProviderOptions struct { // ClientOptions beschreiben den Zugang. ClientOptions ClientOptions // ArchiveTransport holt die Sicherungsarchive vom Knoten. // // Fehlt er, meldet OpenDisk einen Fehler statt einer leeren Sicherung. ArchiveTransport ArchiveTransport // BackupStorageID ist der Speicher, auf den vzdump schreibt. BackupStorageID string // KeepArchiveOnNode laesst das vzdump-Archiv nach der Uebernahme liegen. // // Standardmaessig aus: Ein liegengebliebenes Archiv fuellt den // Proxmox-Speicher mit einer zweiten, unverwalteten Kopie derselben Daten, // fuer die keine Aufbewahrungsregel gilt. Einschalten ist sinnvoll, wenn // Proxmox selbst eine Aufbewahrung auf diesem Speicher fuehrt oder waehrend // der Fehlersuche. KeepArchiveOnNode bool // TaskPollInterval ist der Abstand zwischen zwei Statusabfragen; 0 wählt // den Standard von zwei Sekunden. // // Einstellbar, weil der richtige Wert vom Aufbau abhängt: Ein Verbund mit // hunderten gleichzeitigen Aufgaben verträgt kein Sekundentakt, eine // Testumgebung dagegen soll nicht künstlich warten. TaskPollInterval time.Duration } // Provider setzt providers.VirtualizationProvider für Proxmox VE um. type Provider struct { // client spricht die REST-API. client *Client // options sind die Einstellungen. options ProviderOptions // logger protokolliert den Verlauf. logger *slog.Logger } // Sicherstellen, dass die Schnittstelle vollständig erfüllt wird. Ohne diese // Zeile fiele eine fehlende Methode erst dort auf, wo der Provider eingesetzt // wird — möglicherweise erst zur Laufzeit. var _ providers.VirtualizationProvider = (*Provider)(nil) // NewProvider erzeugt den Proxmox-Provider. func NewProvider(providerOptions ProviderOptions, baseLogger *slog.Logger) (*Provider, error) { proxmoxClient, clientError := NewClient(providerOptions.ClientOptions, baseLogger) if clientError != nil { return nil, clientError } return &Provider{ client: proxmoxClient, options: providerOptions, logger: logging.WithComponent(baseLogger, "proxmox-provider"), }, nil } // Name benennt den Provider. func (provider *Provider) Name() string { return ProviderName } // Connect prüft Erreichbarkeit und Anmeldedaten. // // Geprüft wird mit einem lesenden Aufruf, der jedes Token beantworten kann. // Ein Verbindungsaufbau, der nichts abfragt, meldete Erfolg, während das Token // in Wahrheit keine Rechte hat — der Fehler fiele erst mitten in der Sicherung // auf. func (provider *Provider) Connect(connectContext context.Context) error { var versionInformation struct { Version string `json:"version"` Release string `json:"release"` } if versionError := provider.client.get(connectContext, "/version", &versionInformation); versionError != nil { var apiError *APIError if errors.As(versionError, &apiError) && apiError.IsAuthenticationFailure() { return fmt.Errorf("proxmox wies das api-token ab; prüfen Sie Kennung, Geheimnis und die Rechte des Tokens: %w", versionError) } return fmt.Errorf("die verbindung zu proxmox kam nicht zustande: %w", versionError) } provider.logger.Info("mit proxmox verbunden", slog.String("version", versionInformation.Version), slog.String("release", versionInformation.Release)) return nil } // Disconnect gibt die Verbindung frei. // // Ein API-Token hat keine Sitzung, die zu beenden wäre; freigegeben werden nur // die offenen Verbindungen des HTTP-Clients. func (provider *Provider) Disconnect() error { if provider.client == nil { return nil } provider.client.httpClient.CloseIdleConnections() return nil } // defaultSnapshotTimeout begrenzt das Anlegen eines Abbilds. // // Anders als bei vzdump ist hier eine Grenze richtig: ein Snapshot ist eine // Sache von Sekunden. Dauert er Minuten, stimmt etwas nicht — meist wartet der // Gastdienst auf eine Anwendung, die sich nicht ruhigstellen lässt. const defaultSnapshotTimeout = 10 * time.Minute // CreateSnapshot legt ein Zeitpunktabbild an. func (provider *Provider) CreateSnapshot(snapshotContext context.Context, guestID string, snapshotOptions providers.SnapshotOptions) (*providers.Snapshot, error) { guestLocation, locateError := provider.locateGuest(snapshotContext, guestID) if locateError != nil { return nil, locateError } if strings.TrimSpace(snapshotOptions.Name) == "" { return nil, errors.New("ein snapshot braucht einen namen") } // Der erreichte Konsistenzgrad wird ermittelt, bevor das Abbild entsteht. // Ihn hinterher zu behaupten wäre eine Vermutung; er gehört ins Manifest // und muss stimmen. achievedConsistency, consistencyWarning, consistencyError := provider.determineConsistency(snapshotContext, guestLocation, snapshotOptions) if consistencyError != nil { return nil, consistencyError } if consistencyWarning != "" { provider.logger.Warn("die angeforderte konsistenzstufe war nicht erreichbar", slog.String("gast", guestID), slog.String("grund", consistencyWarning), slog.String("erreicht", string(achievedConsistency))) } formValues := url.Values{} formValues.Set("snapname", snapshotOptions.Name) if snapshotOptions.Description != "" { formValues.Set("description", snapshotOptions.Description) } // LXC kennt kein Arbeitsspeicherabbild. if snapshotOptions.IncludeMemory && guestLocation.GuestType == providers.GuestTypeVirtualMachine { formValues.Set("vmstate", "1") } snapshotPath := fmt.Sprintf("/nodes/%s/%s/%d/snapshot", url.PathEscape(guestLocation.NodeName), guestLocation.APISegment, guestLocation.VMID) var taskIdentifier TaskIdentifier if createError := provider.client.post(snapshotContext, snapshotPath, formValues, &taskIdentifier); createError != nil { return nil, fmt.Errorf("das abbild %q konnte nicht angelegt werden: %w", snapshotOptions.Name, createError) } waitTimeout := snapshotOptions.Timeout if waitTimeout <= 0 { waitTimeout = defaultSnapshotTimeout } if _, waitError := provider.client.WaitForTask(snapshotContext, taskIdentifier, TaskWaitOptions{ Timeout: waitTimeout, PollInterval: provider.options.TaskPollInterval, }); waitError != nil { return nil, fmt.Errorf("das abbild %q wurde angestoßen, kam aber nicht zustande: %w", snapshotOptions.Name, waitError) } provider.logger.Info("abbild angelegt", slog.String("gast", guestID), slog.String("abbild", snapshotOptions.Name), slog.String("konsistenz", string(achievedConsistency))) return &providers.Snapshot{ Identifier: snapshotOptions.Name, GuestID: guestID, CreatedAt: time.Now().UTC(), Description: snapshotOptions.Description, IncludesMemory: snapshotOptions.IncludeMemory && guestLocation.GuestType == providers.GuestTypeVirtualMachine, ConsistencyLevel: achievedConsistency, }, nil } // determineConsistency ermittelt die tatsächlich erreichbare Konsistenzstufe. // // Die Stufe wird nie beschönigt: Wer anwendungskonsistent verlangt und einen // Gast ohne laufenden Gastdienst sichert, bekommt „crash consistent" ins // Manifest geschrieben und eine Warnung ins Protokoll. Ein Backup, das mehr // verspricht als es hält, ist schlimmer als eines, das seine Grenzen kennt. func (provider *Provider) determineConsistency(consistencyContext context.Context, location guestLocation, snapshotOptions providers.SnapshotOptions) (providers.ConsistencyLevel, string, error) { // Ein angehaltener Gast schreibt nicht mehr; sein Abbild ist damit // vollständig konsistent, ganz ohne Gastdienst. guestInformation, statusError := provider.fetchGuestStatus(consistencyContext, location) if statusError != nil { return "", "", statusError } if guestInformation.Status != "running" { return providers.ConsistencyApplicationConsistent, "", nil } if !snapshotOptions.QuiesceGuest { if snapshotOptions.IncludeMemory && location.GuestType == providers.GuestTypeVirtualMachine { // Mit Arbeitsspeicher ist der Zustand vollständig eingefroren. return providers.ConsistencyFilesystemConsistent, "", nil } return providers.ConsistencyCrashConsistent, "", nil } rawConfiguration, configurationError := provider.fetchRawConfiguration(consistencyContext, location) if configurationError != nil { return "", "", configurationError } if !strings.HasPrefix(rawConfiguration["agent"], "1") { return providers.ConsistencyCrashConsistent, "Der QEMU-Gastdienst ist für diesen Gast nicht eingerichtet; die Anwendungen lassen sich nicht ruhigstellen.", nil } return providers.ConsistencyApplicationConsistent, "", nil } // guestStatus ist der Laufzeitstand eines Gasts. type guestStatus struct { // Status ist der Betriebszustand. Status string `json:"status"` // QMPStatus ist der feinere QEMU-Zustand. QMPStatus string `json:"qmpstatus"` // Lock benennt eine laufende Sperre, etwa "backup" oder "migrate". Lock string `json:"lock"` } // fetchGuestStatus holt den Laufzeitstand eines Gasts. func (provider *Provider) fetchGuestStatus(statusContext context.Context, location guestLocation) (*guestStatus, error) { statusPath := fmt.Sprintf("/nodes/%s/%s/%d/status/current", url.PathEscape(location.NodeName), location.APISegment, location.VMID) var currentStatus guestStatus if statusError := provider.client.get(statusContext, statusPath, ¤tStatus); statusError != nil { return nil, fmt.Errorf("der zustand von %d war nicht abrufbar: %w", location.VMID, statusError) } return ¤tStatus, nil } // RemoveSnapshot entfernt ein Zeitpunktabbild. func (provider *Provider) RemoveSnapshot(removeContext context.Context, guestID string, snapshotID string) error { guestLocation, locateError := provider.locateGuest(removeContext, guestID) if locateError != nil { return locateError } removePath := fmt.Sprintf("/nodes/%s/%s/%d/snapshot/%s", url.PathEscape(guestLocation.NodeName), guestLocation.APISegment, guestLocation.VMID, url.PathEscape(snapshotID)) var taskIdentifier TaskIdentifier if deleteError := provider.client.delete(removeContext, removePath, &taskIdentifier); deleteError != nil { return fmt.Errorf("das abbild %q konnte nicht entfernt werden: %w", snapshotID, deleteError) } // Auf das Ende wird gewartet: Ein zurückbleibendes Abbild wächst mit jedem // Schreibvorgang des Gasts weiter und füllt still den Speicher. Sie sind // die häufigste Ursache voller Proxmox-Datenträger. if _, waitError := provider.client.WaitForTask(removeContext, taskIdentifier, TaskWaitOptions{ Timeout: defaultSnapshotTimeout, PollInterval: provider.options.TaskPollInterval, }); waitError != nil { return fmt.Errorf("das abbild %q wurde zum entfernen angestoßen, verschwand aber nicht: %w", snapshotID, waitError) } provider.logger.Info("abbild entfernt", slog.String("gast", guestID), slog.String("abbild", snapshotID)) return nil } // ReadChangedBlocks ermittelt geänderte Bereiche. // // Proxmox VE bietet über die REST-API keine Nachverfolgung geänderter Blöcke. // Die Schmutzbitmap von QEMU ist ausschließlich über das Sicherungsprotokoll // des Proxmox Backup Servers oder über QMP am Knoten zugänglich — beides ist // kein REST-Endpunkt. // // Deshalb wird hier ErrNotSupported gemeldet. Eine leere Bereichsliste // zurückzugeben wäre der bequeme Weg und der schlimmste: die Sicherung hielte // jede Platte für unverändert und schriebe ein leeres Backup, das aussähe wie // ein gelungenes (PROMPT.md §138). func (provider *Provider) ReadChangedBlocks(_ context.Context, guestID string, diskIdentifier string, _ string) (*providers.ChangedBlockResult, error) { return nil, fmt.Errorf("%w: proxmox ve gibt geänderte blöcke nicht über die rest-api heraus (gast %s, platte %s)", providers.ErrNotSupported, guestID, diskIdentifier) } // ErrArchiveTransportMissing meldet einen fehlenden Weg an die Archivdateien. var ErrArchiveTransportMissing = errors.New("es ist kein zugriffsweg auf die sicherungsarchive des knotens eingerichtet") // OpenDisk öffnet die Plattendaten eines Gasts. // // Der Weg führt über vzdump: Proxmox erzeugt ein Archiv auf einem Speicher des // Knotens, das anschließend als Datenstrom gelesen wird. Der Umweg ist keine // Nachlässigkeit, sondern die Folge der API-Lücke, die bei ArchiveTransport // erklärt ist. // // Der Datenstrom umfasst alle nicht ausgenommenen Platten des Gasts; die // Angabe einer einzelnen Platte ist damit nicht erfüllbar. Das wird gemeldet, // nicht stillschweigend übergangen. func (provider *Provider) OpenDisk(openContext context.Context, readRequest providers.DiskReadRequest) (io.ReadCloser, error) { if provider.options.ArchiveTransport == nil { return nil, fmt.Errorf("%w: ohne ihn lässt sich der inhalt der platten nicht lesen", ErrArchiveTransportMissing) } if len(readRequest.Ranges) > 0 { return nil, fmt.Errorf("%w: vzdump liefert immer das ganze archiv, keine einzelnen bereiche", providers.ErrNotSupported) } guestLocation, locateError := provider.locateGuest(openContext, readRequest.GuestID) if locateError != nil { return nil, locateError } archiveVolume, dumpError := provider.runVzdump(openContext, guestLocation) if dumpError != nil { return nil, dumpError } archiveReader, openError := provider.options.ArchiveTransport.OpenArchive(openContext, guestLocation.NodeName, archiveVolume) if openError != nil { // Das Archiv liegt auf dem Knoten und wird nicht mehr gebraucht. provider.cleanupArchive(openContext, guestLocation.NodeName, archiveVolume) return nil, openError } // Aufgeraeumt wird erst, wenn der Aufrufer fertig gelesen hat. // // Frueher zu loeschen zoege dem Leser die Datei unter den Fuessen weg; // spaeter — also gar nicht — fuellte jede Sicherung den Proxmox-Speicher mit // einer zweiten, unverwalteten Kopie derselben Daten, fuer die keine // Aufbewahrungsregel gilt. return &archiveReadCloser{ reader: archiveReader, provider: provider, nodeName: guestLocation.NodeName, volumeID: archiveVolume, keepOnNode: provider.options.KeepArchiveOnNode, }, nil } // archiveReadCloser raeumt das Proxmox-Archiv nach dem Lesen auf. type archiveReadCloser struct { // reader ist der Datenstrom des Archivs. reader io.ReadCloser // provider entfernt das Archiv. provider *Provider // nodeName ist der Knoten, auf dem es liegt. nodeName string // volumeID benennt das Archiv. volumeID string // keepOnNode laesst das Archiv liegen. keepOnNode bool } // Read liest aus dem Archiv. func (archiveCloser *archiveReadCloser) Read(targetBuffer []byte) (int, error) { return archiveCloser.reader.Read(targetBuffer) } // Close schliesst den Datenstrom und entfernt das Archiv. func (archiveCloser *archiveReadCloser) Close() error { closeError := archiveCloser.reader.Close() if !archiveCloser.keepOnNode { archiveCloser.provider.cleanupArchive(context.Background(), archiveCloser.nodeName, archiveCloser.volumeID) } return closeError } // cleanupArchive entfernt ein Archiv und meldet einen Fehlschlag. // // Ein Fehler beim Aufraeumen ist kein Grund, die Sicherung als gescheitert zu // werten: Die Daten liegen bereits im Repository. Er wird protokolliert, damit // niemand den vollen Proxmox-Speicher fuer ein Raetsel haelt. func (provider *Provider) cleanupArchive(cleanupContext context.Context, nodeName string, volumeIdentifier string) { if removeError := provider.RemoveArchive(cleanupContext, nodeName, volumeIdentifier); removeError != nil { provider.logger.Warn("das proxmox-sicherungsarchiv liess sich nicht entfernen; "+ "es belegt weiter platz auf dem knoten", slog.String("knoten", nodeName), slog.String("archiv", volumeIdentifier), slog.String("grund", removeError.Error())) return } provider.logger.Info("das proxmox-sicherungsarchiv wurde nach der uebernahme entfernt", slog.String("knoten", nodeName), slog.String("archiv", volumeIdentifier)) } // runVzdump stößt eine Proxmox-Sicherung an und liefert den Namen des Archivs. func (provider *Provider) runVzdump(dumpContext context.Context, location guestLocation) (string, error) { if strings.TrimSpace(provider.options.BackupStorageID) == "" { return "", errors.New("es wurde kein speicher für die proxmox-sicherungsarchive angegeben") } formValues := url.Values{} formValues.Set("vmid", fmt.Sprintf("%d", location.VMID)) formValues.Set("storage", provider.options.BackupStorageID) // "snapshot" sichert den laufenden Gast ohne ihn anzuhalten. "stop" wäre // konsistenter, hielte aber den Betrieb an — das darf ein Backup nicht von // sich aus tun. formValues.Set("mode", "snapshot") // Die Kompression übernimmt die Backup Engine. Zweimal zu komprimieren // kostet Zeit und bringt nichts. formValues.Set("compress", "0") formValues.Set("remove", "0") dumpPath := fmt.Sprintf("/nodes/%s/vzdump", url.PathEscape(location.NodeName)) var taskIdentifier TaskIdentifier if dumpError := provider.client.post(dumpContext, dumpPath, formValues, &taskIdentifier); dumpError != nil { return "", fmt.Errorf("die proxmox-sicherung von %d konnte nicht angestoßen werden: %w", location.VMID, dumpError) } provider.logger.Info("proxmox-sicherung läuft", slog.Int("vmid", location.VMID), slog.String("aufgabe", string(taskIdentifier))) // Keine Zeitgrenze: ein vzdump über mehrere Terabyte läuft Stunden. Die // Grenze gehört in den Auftrag, nicht in den Provider. taskStatus, waitError := provider.client.WaitForTask(dumpContext, taskIdentifier, TaskWaitOptions{ PollInterval: provider.options.TaskPollInterval, }) if waitError != nil { return "", waitError } if taskStatus.HasWarnings() { provider.logger.Warn("die proxmox-sicherung meldete warnungen", slog.Int("vmid", location.VMID), slog.String("ergebnis", taskStatus.ExitStatus)) } return provider.findLatestArchive(dumpContext, location) } // findLatestArchive sucht das zuletzt erzeugte Archiv eines Gasts. func (provider *Provider) findLatestArchive(searchContext context.Context, location guestLocation) (string, error) { contentPath := fmt.Sprintf("/nodes/%s/storage/%s/content?content=backup&vmid=%d", url.PathEscape(location.NodeName), url.PathEscape(provider.options.BackupStorageID), location.VMID) var archiveEntries []struct { Volume string `json:"volid"` CreatedAt int64 `json:"ctime"` SizeBytes int64 `json:"size"` } if listError := provider.client.get(searchContext, contentPath, &archiveEntries); listError != nil { return "", fmt.Errorf("die archivliste war nicht abrufbar: %w", listError) } var newestVolume string var newestTime int64 for _, archiveEntry := range archiveEntries { if archiveEntry.CreatedAt > newestTime { newestTime = archiveEntry.CreatedAt newestVolume = archiveEntry.Volume } } if newestVolume == "" { // Die Aufgabe meldete Erfolg, das Archiv fehlt: hier wird abgebrochen // statt ein leeres Backup zu erzeugen. return "", fmt.Errorf("die proxmox-sicherung von %d meldete erfolg, auf %s liegt aber kein archiv", location.VMID, provider.options.BackupStorageID) } return newestVolume, nil } // SetArchiveTransport hinterlegt den Zugriffsweg zu den Sicherungsarchiven. // // Nachträglich und nicht über die Optionen, weil der SSH-Weg den Provider // selbst braucht: Er löst die Proxmox-Speicherkennung über dessen API in einen // Pfad auf. Beides zugleich im Konstruktor zu verlangen ergäbe eine // Henne-Ei-Lage — der Transport bräuchte den Provider, der Provider den // Transport. // // Ist bereits ein Zugriffsweg gesetzt, wird er ersetzt. Der Aufruf gehört vor // den ersten OpenDisk; danach ist er wirkungslos für bereits geöffnete // Datenströme. func (provider *Provider) SetArchiveTransport(archiveTransport ArchiveTransport) { provider.options.ArchiveTransport = archiveTransport }