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>
131 lines
5.1 KiB
Go
131 lines
5.1 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const testUPID = "UPID:demo-felhom:00026454:004E3431:6A265E53:vzsnapshot:9001:root@pam:"
|
|
|
|
// fastWait keeps tests quick.
|
|
var fastWait = WaitOptions{Interval: time.Millisecond, MaxInterval: 2 * time.Millisecond, Timeout: time.Second}
|
|
|
|
func TestWaitTask_RunningThenOK(t *testing.T) {
|
|
var n int
|
|
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
|
|
n++
|
|
if n == 1 {
|
|
return jsonResp(200, `{"data":{"upid":"`+testUPID+`","status":"running"}}`), nil
|
|
}
|
|
return jsonResp(200, `{"data":{"upid":"`+testUPID+`","status":"stopped","exitstatus":"OK"}}`), nil
|
|
}}
|
|
st, err := newTestClient(d).WaitTask(context.Background(), testUPID, fastWait)
|
|
if err != nil {
|
|
t.Fatalf("WaitTask: %v", err)
|
|
}
|
|
if !st.OK() {
|
|
t.Errorf("status not OK: %+v", st)
|
|
}
|
|
}
|
|
|
|
func TestWaitTask_FailedSurfacesPrivilege(t *testing.T) {
|
|
// vzdump against an unauthorized vmid: 200+UPID, then the 403 in exitstatus.
|
|
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.Backup)"}]}`), nil
|
|
}
|
|
return jsonResp(200, `{"data":{"upid":"`+testUPID+`","status":"stopped","exitstatus":"403 Permission check failed (/vms/9000, VM.Backup)"}}`), nil
|
|
}}
|
|
_, err := newTestClient(d).WaitTask(context.Background(), testUPID, fastWait)
|
|
var te *TaskError
|
|
if !errors.As(err, &te) {
|
|
t.Fatalf("want *TaskError, got %T: %v", err, err)
|
|
}
|
|
if te.Privilege != "VM.Backup" {
|
|
t.Errorf("privilege = %q, want VM.Backup", te.Privilege)
|
|
}
|
|
if te.DeniedPath != "/vms/9000" {
|
|
t.Errorf("denied path = %q", te.DeniedPath)
|
|
}
|
|
if len(te.LogTail) == 0 {
|
|
t.Errorf("expected a log tail")
|
|
}
|
|
}
|
|
|
|
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
|
|
}}
|
|
opts := WaitOptions{Interval: time.Millisecond, MaxInterval: time.Millisecond, Timeout: 30 * time.Millisecond}
|
|
_, err := newTestClient(d).WaitTask(context.Background(), testUPID, opts)
|
|
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("want deadline-exceeded, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWaitTask_CtxCancel(t *testing.T) {
|
|
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
|
|
return jsonResp(200, `{"data":{"upid":"`+testUPID+`","status":"running"}}`), nil
|
|
}}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() { time.Sleep(20 * time.Millisecond); cancel() }()
|
|
opts := WaitOptions{Interval: time.Millisecond, MaxInterval: time.Millisecond, Timeout: time.Minute}
|
|
_, err := newTestClient(d).WaitTask(ctx, testUPID, opts)
|
|
if err == nil || !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("want canceled, got %v", err)
|
|
}
|
|
}
|