6e86483185
Fixes the crying-wolf false-fail surfaced by the live hub-enrollment runbook: PVE's guest-start task exits "WARNINGS: 1" for the benign systemd-nesting advisory, and WaitTask treated any non-OK exitstatus as failure, so the verdict was decided by an advisory exit code before the real boot check ran. Every modern-distro restore-test reported pass:false. - proxmox.WaitOptions.AllowWarnings (opt-in; default keeps all callers strict) - restore-test start step accepts warnings, surfaces them, verdict stays waitRunning - RestoreTestResult.StartWarnings/.WarningsRecognized + version-free "enable nesting" recognizer (can't rot back at systemd 258+); GuestAPI.TaskLogTail - hub.RestoreTest.warnings/.warnings_recognized wire fields (consumed by hub v0.7.5) - scheduler logs clean / passed-with-recognized / passed-with-unrecognized warnings - tests: WaitTask warnings matrix; restore-test pass/fail-on-liveness; version-free regression guard (systemd 256-300) Single agent bump 0.6.0 -> 0.7.0 covering the agent half of both task phases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
5.0 KiB
Go
157 lines
5.0 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// TaskStatus is GET /nodes/{node}/tasks/{upid}/status. While the task runs,
|
|
// Status == "running" and ExitStatus is empty; once it stops, Status == "stopped"
|
|
// and ExitStatus is "OK" or an error string (e.g. a 403 permission message).
|
|
type TaskStatus struct {
|
|
UPID string `json:"upid"`
|
|
ID string `json:"id"`
|
|
Node string `json:"node"`
|
|
Type string `json:"type"`
|
|
User string `json:"user"`
|
|
Status string `json:"status"` // "running" | "stopped"
|
|
ExitStatus string `json:"exitstatus"` // present once stopped
|
|
PID int64 `json:"pid"`
|
|
StartTime int64 `json:"starttime"`
|
|
}
|
|
|
|
// Running reports whether the task is still executing.
|
|
func (t TaskStatus) Running() bool { return t.Status == "running" }
|
|
|
|
// OK reports whether the task stopped successfully.
|
|
func (t TaskStatus) OK() bool { return t.Status == "stopped" && t.ExitStatus == "OK" }
|
|
|
|
// taskLogLine is one entry of GET /nodes/{node}/tasks/{upid}/log: {"n":N,"t":"..."}.
|
|
type taskLogLine struct {
|
|
N int `json:"n"`
|
|
T string `json:"t"`
|
|
}
|
|
|
|
// WaitOptions tunes WaitTask polling. Zero value yields sane defaults.
|
|
type WaitOptions struct {
|
|
// Interval is the first poll gap (default 1s).
|
|
Interval time.Duration
|
|
// MaxInterval caps the backed-off gap (default 5s).
|
|
MaxInterval time.Duration
|
|
// Timeout bounds the whole wait (default 10m). Restore/vzdump can be slow;
|
|
// callers may raise it. A zero/elapsed context deadline also stops the wait.
|
|
Timeout time.Duration
|
|
// AllowWarnings accepts a task that completes with a "WARNINGS: N" exitstatus as
|
|
// success (returning the TaskStatus with ExitStatus intact, nil error), instead of
|
|
// the default hard failure. Opt-in PER CALL — the default (false) keeps every existing
|
|
// caller strict, because vzdump/restore/destroy warnings can be meaningful. Only the
|
|
// restore-test's guest-start step opts in (a start advisory like the systemd-nesting
|
|
// notice must not false-fail a guest that actually boots; doc 03 §8). A non-WARNINGS
|
|
// non-OK exit is still a *TaskError regardless of this flag.
|
|
AllowWarnings bool
|
|
}
|
|
|
|
func (o WaitOptions) withDefaults() WaitOptions {
|
|
if o.Interval <= 0 {
|
|
o.Interval = 1 * time.Second
|
|
}
|
|
if o.MaxInterval <= 0 {
|
|
o.MaxInterval = 5 * time.Second
|
|
}
|
|
if o.Timeout <= 0 {
|
|
o.Timeout = 10 * time.Minute
|
|
}
|
|
return o
|
|
}
|
|
|
|
// TaskStatusOnce fetches the current task status (one HTTP call).
|
|
func (c *Client) TaskStatusOnce(ctx context.Context, upid string) (TaskStatus, error) {
|
|
u, err := ParseUPID(upid)
|
|
if err != nil {
|
|
return TaskStatus{}, err
|
|
}
|
|
var st TaskStatus
|
|
path := fmt.Sprintf("/nodes/%s/tasks/%s/status", u.Node, urlEscape(upid))
|
|
if err := c.get(ctx, path, &st); err != nil {
|
|
return TaskStatus{}, err
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// TaskLogTail fetches up to limit trailing log lines for a task (for diagnosis).
|
|
func (c *Client) TaskLogTail(ctx context.Context, upid string, limit int) ([]string, error) {
|
|
u, err := ParseUPID(upid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
var lines []taskLogLine
|
|
path := fmt.Sprintf("/nodes/%s/tasks/%s/log?limit=%d", u.Node, urlEscape(upid), limit)
|
|
if err := c.get(ctx, path, &lines); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]string, 0, len(lines))
|
|
for _, l := range lines {
|
|
out = append(out, l.T)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// WaitTask polls a task until it stops, then asserts exitstatus == "OK". On any
|
|
// non-OK exit it returns a *TaskError carrying the exitstatus, the parsed
|
|
// privilege (if it was a permission failure), and a tail of the task log.
|
|
//
|
|
// This is the contract for EVERY mutating op: the POST's HTTP 200 is not proof of
|
|
// success — authorization can fail at task execution (phase1-2 §1.3).
|
|
func (c *Client) WaitTask(ctx context.Context, upid string, opts WaitOptions) (TaskStatus, error) {
|
|
opts = opts.withDefaults()
|
|
if _, err := ParseUPID(upid); err != nil {
|
|
return TaskStatus{}, err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
|
defer cancel()
|
|
|
|
interval := opts.Interval
|
|
timer := time.NewTimer(0) // first poll immediately
|
|
defer timer.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return TaskStatus{}, fmt.Errorf("proxmox: waiting for task %s: %w", upid, ctx.Err())
|
|
case <-timer.C:
|
|
}
|
|
|
|
st, err := c.TaskStatusOnce(ctx, upid)
|
|
if err != nil {
|
|
return TaskStatus{}, err
|
|
}
|
|
if st.Running() || st.Status == "" {
|
|
// back off, capped
|
|
interval *= 2
|
|
if interval > opts.MaxInterval {
|
|
interval = opts.MaxInterval
|
|
}
|
|
timer.Reset(interval)
|
|
continue
|
|
}
|
|
// stopped
|
|
if st.ExitStatus == "OK" {
|
|
return st, nil
|
|
}
|
|
// Warnings-accepted path (opt-in): a "WARNINGS: N" exit is returned as success with
|
|
// ExitStatus intact, so the caller can fetch/surface the warning text from the log
|
|
// without the test/op failing on an advisory. Any other non-OK exit still fails.
|
|
if opts.AllowWarnings && strings.HasPrefix(st.ExitStatus, "WARNINGS") {
|
|
return st, nil
|
|
}
|
|
tail, _ := c.TaskLogTail(ctx, upid, 20) // best-effort
|
|
return st, newTaskError(upid, st.ExitStatus, tail)
|
|
}
|
|
}
|