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>
280 lines
9.7 KiB
Go
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
|
|
}
|