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 }