syncova-backup/packages/providers/proxmox/provider.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

544 lines
21 KiB
Go

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, &currentStatus); statusError != nil {
return nil, fmt.Errorf("der zustand von %d war nicht abrufbar: %w", location.VMID, statusError)
}
return &currentStatus, 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
}