Files
felhom-agent/internal/proxmox/task_test.go
T
admin 6e86483185 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>
2026-06-09 19:30:03 +02:00

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)
}
}