syncova-backup/packages/providers/proxmox/task.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

280 lines
9.7 KiB
Go

package proxmox
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strconv"
"strings"
"time"
)
// TaskIdentifier ist eine Proxmox-Aufgabenkennung (UPID).
//
// Nahezu jeder ändernde Aufruf in Proxmox arbeitet asynchron: Der Aufruf
// antwortet sofort mit einer solchen Kennung, während die eigentliche Arbeit
// im Hintergrund läuft. Wer die Antwort für das Ergebnis hält, meldet einen
// Snapshot als angelegt, bevor er existiert — und sichert im schlimmsten Fall
// gegen ein Abbild, das gerade erst entsteht.
type TaskIdentifier string
// taskDescriptor sind die aus einer UPID gewonnenen Angaben.
//
// Aufbau: UPID:node:pid:pstart:starttime:type:id:user:
type taskDescriptor struct {
// NodeName ist der Knoten, auf dem die Aufgabe läuft.
//
// Er wird gebraucht, weil der Statusendpunkt knotenbezogen ist: eine
// Aufgabe auf Knoten B lässt sich nicht über Knoten A abfragen.
NodeName string
// TaskType benennt die Art der Aufgabe, etwa qmsnapshot oder vzdump.
TaskType string
// ObjectID ist der bearbeitete Gegenstand, meist die Gastkennung.
ObjectID string
}
// ErrMalformedTaskIdentifier meldet eine unbrauchbare Aufgabenkennung.
var ErrMalformedTaskIdentifier = errors.New("die aufgabenkennung von proxmox hat eine unerwartete gestalt")
// parseTaskIdentifier zerlegt eine UPID.
func parseTaskIdentifier(taskIdentifier TaskIdentifier) (taskDescriptor, error) {
identifierParts := strings.Split(string(taskIdentifier), ":")
// Erwartet werden acht durch Doppelpunkt getrennte Teile plus ein leerer
// Rest hinter dem abschließenden Doppelpunkt.
const minimumIdentifierParts = 8
if len(identifierParts) < minimumIdentifierParts || identifierParts[0] != "UPID" {
return taskDescriptor{}, fmt.Errorf("%w: %q", ErrMalformedTaskIdentifier, taskIdentifier)
}
return taskDescriptor{
NodeName: identifierParts[1],
TaskType: identifierParts[5],
ObjectID: identifierParts[6],
}, nil
}
// TaskStatus ist der Stand einer Proxmox-Aufgabe.
type TaskStatus struct {
// Status ist "running" oder "stopped".
Status string `json:"status"`
// ExitStatus ist das Ergebnis einer beendeten Aufgabe.
//
// "OK" bedeutet Erfolg; jeder andere Wert ist die Fehlermeldung selbst.
// Proxmox meldet Fehler hier und nicht über den HTTP-Status — ein
// erfolgreich abgefragter Status kann eine gescheiterte Aufgabe
// beschreiben.
ExitStatus string `json:"exitstatus"`
// TaskType benennt die Art der Aufgabe.
TaskType string `json:"type"`
// NodeName ist der ausführende Knoten.
NodeName string `json:"node"`
// StartTime ist der Beginn als Unix-Zeitstempel.
StartTime int64 `json:"starttime"`
// ProgressText ist die zuletzt gemeldete Zeile, sofern abgefragt.
ProgressText string `json:"-"`
}
// IsFinished meldet eine beendete Aufgabe.
func (taskStatus *TaskStatus) IsFinished() bool {
return taskStatus.Status == "stopped"
}
// IsSuccessful meldet eine erfolgreich beendete Aufgabe.
//
// Proxmox kennt neben "OK" auch Warnungen der Form "OK (with warnings)". Sie
// gelten als Erfolg, werden vom Aufrufer aber ausgewiesen: eine übergangene
// Platte ist eine Warnung und kein Nichts.
func (taskStatus *TaskStatus) IsSuccessful() bool {
return taskStatus.IsFinished() && strings.HasPrefix(taskStatus.ExitStatus, "OK")
}
// HasWarnings meldet eine mit Warnungen beendete Aufgabe.
func (taskStatus *TaskStatus) HasWarnings() bool {
return taskStatus.IsSuccessful() && taskStatus.ExitStatus != "OK"
}
// TaskFailedError meldet eine gescheiterte Proxmox-Aufgabe.
type TaskFailedError struct {
// TaskIdentifier ist die Kennung der Aufgabe.
TaskIdentifier TaskIdentifier
// TaskType benennt die Art der Aufgabe.
TaskType string
// ExitStatus ist die Meldung von Proxmox.
ExitStatus string
// LogTail sind die letzten Protokollzeilen der Aufgabe.
//
// Der Exit-Status ist oft nur ein Satz; die Ursache steht im Protokoll.
// Sie hier mitzugeben erspart den Gang in die Proxmox-Oberfläche.
LogTail []string
}
// Error erfüllt die Fehlerschnittstelle.
func (taskError *TaskFailedError) Error() string {
baseMessage := fmt.Sprintf("die proxmox-aufgabe %s (%s) scheiterte: %s",
taskError.TaskIdentifier, taskError.TaskType, taskError.ExitStatus)
if len(taskError.LogTail) == 0 {
return baseMessage
}
return baseMessage + "\nLetzte Protokollzeilen:\n " + strings.Join(taskError.LogTail, "\n ")
}
// TaskWaitOptions steuern das Warten auf eine Aufgabe.
type TaskWaitOptions struct {
// PollInterval ist der Abstand zwischen zwei Abfragen; 0 wählt den Standard.
PollInterval time.Duration
// Timeout begrenzt die Gesamtwartezeit; 0 bedeutet unbegrenzt.
//
// Unbegrenzt ist der richtige Standard: Ein vzdump über eine Platte mit
// mehreren Terabyte läuft Stunden. Eine willkürliche Grenze bräche die
// Sicherung genau bei den großen Maschinen ab, für die man sie am
// nötigsten braucht. Die Zeitgrenze gehört in den Auftrag, nicht hierher.
Timeout time.Duration
// ProgressCallback meldet den Fortschritt bei jeder Abfrage.
ProgressCallback func(TaskStatus)
}
// defaultTaskPollInterval ist der Standardabstand zwischen zwei Statusabfragen.
//
// Zwei Sekunden sind ein Ausgleich: häufiger belastet die Proxmox-API ohne
// Erkenntnisgewinn, seltener lässt kurze Aufgaben unnötig lange dauern.
const defaultTaskPollInterval = 2 * time.Second
// WaitForTask wartet, bis eine Aufgabe beendet ist.
//
// Der Abbruch über den Kontext beendet nur das Warten, nicht die Aufgabe
// selbst: Proxmox führt sie zu Ende. Das ist beabsichtigt — eine halb
// abgebrochene Wiederherstellung wäre schlimmer als eine zu Ende geführte.
// Wer sie wirklich beenden will, ruft StopTask auf.
func (client *Client) WaitForTask(waitContext context.Context, taskIdentifier TaskIdentifier, waitOptions TaskWaitOptions) (*TaskStatus, error) {
descriptor, parseError := parseTaskIdentifier(taskIdentifier)
if parseError != nil {
return nil, parseError
}
pollInterval := waitOptions.PollInterval
if pollInterval <= 0 {
pollInterval = defaultTaskPollInterval
}
effectiveContext := waitContext
if waitOptions.Timeout > 0 {
var cancelWait context.CancelFunc
effectiveContext, cancelWait = context.WithTimeout(waitContext, waitOptions.Timeout)
defer cancelWait()
}
statusPath := fmt.Sprintf("/nodes/%s/tasks/%s/status",
url.PathEscape(descriptor.NodeName), url.PathEscape(string(taskIdentifier)))
client.logger.Debug("warte auf proxmox-aufgabe",
slog.String("aufgabe", string(taskIdentifier)),
slog.String("art", descriptor.TaskType),
slog.String("knoten", descriptor.NodeName))
for {
var taskStatus TaskStatus
if statusError := client.get(effectiveContext, statusPath, &taskStatus); statusError != nil {
return nil, fmt.Errorf("der stand der aufgabe %s war nicht abrufbar: %w", taskIdentifier, statusError)
}
if waitOptions.ProgressCallback != nil {
waitOptions.ProgressCallback(taskStatus)
}
if taskStatus.IsFinished() {
if !taskStatus.IsSuccessful() {
// Die Ursache steht im Aufgabenprotokoll, nicht im Exit-Status.
logTail := client.fetchTaskLogTail(waitContext, descriptor.NodeName, taskIdentifier)
return &taskStatus, &TaskFailedError{
TaskIdentifier: taskIdentifier,
TaskType: descriptor.TaskType,
ExitStatus: taskStatus.ExitStatus,
LogTail: logTail,
}
}
client.logger.Debug("proxmox-aufgabe beendet",
slog.String("aufgabe", string(taskIdentifier)),
slog.String("ergebnis", taskStatus.ExitStatus))
return &taskStatus, nil
}
select {
case <-effectiveContext.Done():
return &taskStatus, fmt.Errorf("das warten auf die aufgabe %s wurde beendet, die aufgabe läuft auf dem knoten weiter: %w",
taskIdentifier, effectiveContext.Err())
case <-time.After(pollInterval):
}
}
}
// maximumLogTailLines begrenzt die abgerufenen Protokollzeilen.
const maximumLogTailLines = 25
// fetchTaskLogTail holt die letzten Protokollzeilen einer Aufgabe.
//
// Schlägt der Abruf fehl, bleibt die Liste leer: Die eigentliche Fehlermeldung
// darf nicht daran scheitern, dass ihre Erläuterung nicht abrufbar war.
func (client *Client) fetchTaskLogTail(logContext context.Context, nodeName string, taskIdentifier TaskIdentifier) []string {
logPath := fmt.Sprintf("/nodes/%s/tasks/%s/log?limit=%d&start=0",
url.PathEscape(nodeName), url.PathEscape(string(taskIdentifier)), maximumLogTailLines)
var logEntries []struct {
LineNumber int `json:"n"`
Text string `json:"t"`
}
if logError := client.get(logContext, logPath, &logEntries); logError != nil {
client.logger.Debug("das aufgabenprotokoll war nicht abrufbar",
slog.String("aufgabe", string(taskIdentifier)),
slog.String("grund", logError.Error()))
return nil
}
collectedLines := make([]string, 0, len(logEntries))
for _, logEntry := range logEntries {
trimmedText := strings.TrimSpace(logEntry.Text)
if trimmedText != "" {
collectedLines = append(collectedLines, trimmedText)
}
}
return collectedLines
}
// StopTask bricht eine laufende Proxmox-Aufgabe ab.
func (client *Client) StopTask(stopContext context.Context, taskIdentifier TaskIdentifier) error {
descriptor, parseError := parseTaskIdentifier(taskIdentifier)
if parseError != nil {
return parseError
}
stopPath := fmt.Sprintf("/nodes/%s/tasks/%s",
url.PathEscape(descriptor.NodeName), url.PathEscape(string(taskIdentifier)))
return client.delete(stopContext, stopPath, nil)
}
// parseOptionalInteger liest eine Zahl aus einem Konfigurationswert.
//
// Proxmox liefert Zahlen je nach Endpunkt als Zahl oder als Zeichenkette. Ein
// nicht deutbarer Wert ergibt 0 und keinen Fehler: eine unlesbare Nebenangabe
// darf keine Erfassung verhindern.
func parseOptionalInteger(rawValue string) int64 {
parsedValue, parseError := strconv.ParseInt(strings.TrimSpace(rawValue), 10, 64)
if parseError != nil {
return 0
}
return parsedValue
}