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>
544 lines
21 KiB
Go
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, ¤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
|
|
}
|