restore-test: verdict is liveness, not start-task exitstatus (v0.7.0)
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>
This commit is contained in:
@@ -3,6 +3,7 @@ package proxmox
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,14 @@ type WaitOptions struct {
|
||||
// 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 {
|
||||
@@ -135,6 +144,12 @@ func (c *Client) WaitTask(ctx context.Context, upid string, opts WaitOptions) (T
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,55 @@ func TestWaitTask_FailedSurfacesPrivilege(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTask_AllowWarnings_Accepts(t *testing.T) {
|
||||
// A start task that completes with the systemd-nesting advisory exits "WARNINGS: 1".
|
||||
// With AllowWarnings, that's success and ExitStatus is returned intact for the caller.
|
||||
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"data":{"upid":"`+testUPID+`","status":"stopped","exitstatus":"WARNINGS: 1"}}`), nil
|
||||
}}
|
||||
opts := fastWait
|
||||
opts.AllowWarnings = true
|
||||
st, err := newTestClient(d).WaitTask(context.Background(), testUPID, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("AllowWarnings should accept WARNINGS: %v", err)
|
||||
}
|
||||
if st.ExitStatus != "WARNINGS: 1" {
|
||||
t.Errorf("ExitStatus = %q, want %q (must be returned intact so the caller can read it)", st.ExitStatus, "WARNINGS: 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTask_AllowWarnings_RealErrorStillFails(t *testing.T) {
|
||||
// AllowWarnings must NOT swallow a genuine non-WARNINGS failure.
|
||||
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(r.URL.Path, "/log") {
|
||||
return jsonResp(200, `{"data":[{"n":1,"t":"TASK ERROR: 403 Permission check failed (/vms/9000, VM.PowerMgmt)"}]}`), nil
|
||||
}
|
||||
return jsonResp(200, `{"data":{"upid":"`+testUPID+`","status":"stopped","exitstatus":"403 Permission check failed (/vms/9000, VM.PowerMgmt)"}}`), nil
|
||||
}}
|
||||
opts := fastWait
|
||||
opts.AllowWarnings = true
|
||||
_, err := newTestClient(d).WaitTask(context.Background(), testUPID, opts)
|
||||
var te *TaskError
|
||||
if !errors.As(err, &te) {
|
||||
t.Fatalf("a real error must still be *TaskError even with AllowWarnings, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTask_DefaultRejectsWarnings(t *testing.T) {
|
||||
// Default (AllowWarnings:false) keeps every existing caller strict: WARNINGS → *TaskError.
|
||||
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(r.URL.Path, "/log") {
|
||||
return jsonResp(200, `{"data":[{"n":1,"t":"WARN: Systemd 257 detected. You may need to enable nesting."}]}`), nil
|
||||
}
|
||||
return jsonResp(200, `{"data":{"upid":"`+testUPID+`","status":"stopped","exitstatus":"WARNINGS: 1"}}`), nil
|
||||
}}
|
||||
_, err := newTestClient(d).WaitTask(context.Background(), testUPID, fastWait) // AllowWarnings:false
|
||||
var te *TaskError
|
||||
if !errors.As(err, &te) {
|
||||
t.Fatalf("default must still fail on WARNINGS (existing callers unaffected), got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTask_Timeout(t *testing.T) {
|
||||
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"data":{"upid":"`+testUPID+`","status":"running"}}`), nil
|
||||
|
||||
Reference in New Issue
Block a user