b527430ec7
The guest-level backup layer + the journaled self-restore-test (restore→boot→verify→ teardown) that closes "a backup you haven't restored isn't a backup". All benign (reuses the slice-4 classifier/gate/journal; no new destructive class/crypto). Local target only; PBS = Phase B. Restore to a NEW guest only. Backups crash-consistent. - proxmox: DestroyLXC, VzdumpOptions.Notes (notes-template), LatestBackupVolID. - reconcile: Engine.RunRestoreTest (journal Scratch entry BEFORE mutation; net link-down pre-boot; defer teardown always; benign gated destroy) + Recover extended to reap a leaked scratch guest (Scratch flag, special-cased before the UPID path; idempotent). - internal/backup: runner (vzdump + archive resolve + bulk-gap = backup!=1) + cadence scheduler (4th daemon goroutine, default 24h) + in-memory report store. - hub: Backup/RestoreTest filled; collector seams; cross-repo golden byte-identical + bidirectional key-set tests; hub handler logs a FAILED restore-test prominently. - config BackupConfig (band 990000-990009 default); --selftest=backup / restore-test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
264 lines
8.0 KiB
Go
264 lines
8.0 KiB
Go
package reconcile
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
|
)
|
|
|
|
// fakeAPI is a configurable GuestAPI for engine tests: it records mutating calls and
|
|
// returns canned UPIDs (""=synchronous, non-empty=async) and WaitTask verdicts.
|
|
type fakeAPI struct {
|
|
mu sync.Mutex
|
|
lxc []proxmox.Guest
|
|
cfg map[int]proxmox.GuestConfig
|
|
|
|
startUPID, stopUPID, setUPID, resizeUPID string
|
|
startErr, stopErr, setErr, resizeErr error
|
|
restoreUPID, destroyUPID string
|
|
restoreErr, destroyErr error
|
|
// status maps vmid -> the Guest returned by GuestStatus (default running if absent).
|
|
status map[int]proxmox.Guest
|
|
// waitFunc maps a UPID to a (status, err); default = OK. Mirrors the real client,
|
|
// which errors on a non-OK exitstatus.
|
|
waitFunc func(upid string) (proxmox.TaskStatus, error)
|
|
// statusFunc backs TaskStatusOnce (crash recovery); default = stopped/OK.
|
|
statusFunc func(upid string) (proxmox.TaskStatus, error)
|
|
|
|
starts []int
|
|
stops []int
|
|
sets []setCall
|
|
resizes []resizeCall
|
|
restores []proxmox.RestoreLXCOptions
|
|
destroys []int
|
|
waits []string
|
|
listErr error
|
|
}
|
|
|
|
type resizeCall struct {
|
|
vmid int
|
|
disk, size string
|
|
}
|
|
|
|
func (f *fakeAPI) RestoreLXC(_ context.Context, opts proxmox.RestoreLXCOptions) (string, error) {
|
|
f.mu.Lock()
|
|
f.restores = append(f.restores, opts)
|
|
f.mu.Unlock()
|
|
return f.restoreUPID, f.restoreErr
|
|
}
|
|
|
|
func (f *fakeAPI) DestroyLXC(_ context.Context, vmid int) (string, error) {
|
|
f.mu.Lock()
|
|
f.destroys = append(f.destroys, vmid)
|
|
f.mu.Unlock()
|
|
return f.destroyUPID, f.destroyErr
|
|
}
|
|
|
|
func (f *fakeAPI) GuestStatus(_ context.Context, vmid int) (proxmox.Guest, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if g, ok := f.status[vmid]; ok {
|
|
return g, nil
|
|
}
|
|
return proxmox.Guest{VMID: vmid, Status: "running"}, nil
|
|
}
|
|
|
|
func (f *fakeAPI) TaskStatusOnce(_ context.Context, upid string) (proxmox.TaskStatus, error) {
|
|
if f.statusFunc != nil {
|
|
return f.statusFunc(upid)
|
|
}
|
|
return proxmox.TaskStatus{UPID: upid, Status: "stopped", ExitStatus: "OK"}, nil
|
|
}
|
|
|
|
type setCall struct {
|
|
vmid int
|
|
params map[string]string
|
|
}
|
|
|
|
func (f *fakeAPI) ListLXC(context.Context) ([]proxmox.Guest, error) {
|
|
if f.listErr != nil {
|
|
return nil, f.listErr
|
|
}
|
|
return f.lxc, nil
|
|
}
|
|
|
|
func (f *fakeAPI) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) {
|
|
c, ok := f.cfg[vmid]
|
|
if !ok {
|
|
return proxmox.GuestConfig{}, errors.New("no config")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (f *fakeAPI) Start(_ context.Context, vmid int) (string, error) {
|
|
f.mu.Lock()
|
|
f.starts = append(f.starts, vmid)
|
|
f.mu.Unlock()
|
|
return f.startUPID, f.startErr
|
|
}
|
|
|
|
func (f *fakeAPI) Stop(_ context.Context, vmid int) (string, error) {
|
|
f.mu.Lock()
|
|
f.stops = append(f.stops, vmid)
|
|
f.mu.Unlock()
|
|
return f.stopUPID, f.stopErr
|
|
}
|
|
|
|
func (f *fakeAPI) SetConfig(_ context.Context, vmid int, params map[string]string) (string, error) {
|
|
f.mu.Lock()
|
|
f.sets = append(f.sets, setCall{vmid, params})
|
|
f.mu.Unlock()
|
|
return f.setUPID, f.setErr
|
|
}
|
|
|
|
func (f *fakeAPI) ResizeLXC(_ context.Context, vmid int, disk, size string) (string, error) {
|
|
f.mu.Lock()
|
|
f.resizes = append(f.resizes, resizeCall{vmid, disk, size})
|
|
f.mu.Unlock()
|
|
return f.resizeUPID, f.resizeErr
|
|
}
|
|
|
|
func (f *fakeAPI) WaitTask(_ context.Context, upid string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) {
|
|
f.mu.Lock()
|
|
f.waits = append(f.waits, upid)
|
|
f.mu.Unlock()
|
|
if f.waitFunc != nil {
|
|
return f.waitFunc(upid)
|
|
}
|
|
return proxmox.TaskStatus{Status: "stopped", ExitStatus: "OK"}, nil
|
|
}
|
|
|
|
func newEngine(t *testing.T, api GuestAPI, provider DesiredProvider) (*Engine, *Journal, *Queue) {
|
|
t.Helper()
|
|
jp := filepath.Join(t.TempDir(), "journal.log")
|
|
j, err := OpenJournal(jp)
|
|
if err != nil {
|
|
t.Fatalf("OpenJournal: %v", err)
|
|
}
|
|
t.Cleanup(func() { j.Close() })
|
|
q := NewQueue()
|
|
t.Cleanup(q.Close)
|
|
e := NewEngine(EngineOptions{API: api, Queue: q, Journal: j, Provider: provider})
|
|
return e, j, q
|
|
}
|
|
|
|
func TestEngine_EmptyProviderNoMutations(t *testing.T) {
|
|
api := &fakeAPI{
|
|
lxc: []proxmox.Guest{{VMID: 100, Status: "running"}},
|
|
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2}},
|
|
}
|
|
e, _, _ := newEngine(t, api, EmptyProvider{})
|
|
res, err := e.Reconcile(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Reconcile: %v", err)
|
|
}
|
|
if res.Planned != 0 || res.Executed != 0 {
|
|
t.Errorf("EmptyProvider should plan nothing, got %+v", res)
|
|
}
|
|
if len(api.starts)+len(api.stops)+len(api.sets) != 0 {
|
|
t.Errorf("EmptyProvider mutated Proxmox: starts=%v stops=%v sets=%v", api.starts, api.stops, api.sets)
|
|
}
|
|
}
|
|
|
|
func TestEngine_AsyncStartWaitsTask(t *testing.T) {
|
|
api := &fakeAPI{
|
|
lxc: []proxmox.Guest{{VMID: 100, Status: "stopped"}},
|
|
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2}},
|
|
startUPID: "UPID:demo:start:100:",
|
|
}
|
|
e, j, _ := newEngine(t, api, StaticProvider{State: desired(DesiredGuest{VMID: 100, Run: RunRunning})})
|
|
res, err := e.Reconcile(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Reconcile: %v", err)
|
|
}
|
|
if res.Executed != 1 || res.Failed != 0 {
|
|
t.Fatalf("want 1 executed, got %+v", res)
|
|
}
|
|
if len(api.starts) != 1 || api.starts[0] != 100 {
|
|
t.Errorf("expected Start(100), got %v", api.starts)
|
|
}
|
|
if len(api.waits) != 1 {
|
|
t.Errorf("async op must WaitTask, got waits=%v", api.waits)
|
|
}
|
|
if len(j.InFlight()) != 0 {
|
|
t.Errorf("no ops should be in-flight after success: %+v", j.InFlight())
|
|
}
|
|
}
|
|
|
|
func TestEngine_SynchronousSetConfigNoWait(t *testing.T) {
|
|
// Empty UPID = PVE applied synchronously (slice-4 proven for description). Must be
|
|
// treated as success WITHOUT a WaitTask call.
|
|
api := &fakeAPI{
|
|
lxc: []proxmox.Guest{{VMID: 100, Status: "stopped"}},
|
|
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2}},
|
|
setUPID: "", // synchronous
|
|
}
|
|
e, _, _ := newEngine(t, api, StaticProvider{State: desired(
|
|
DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(2048)}})})
|
|
res, err := e.Reconcile(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Reconcile: %v", err)
|
|
}
|
|
if res.Executed != 1 {
|
|
t.Fatalf("want 1 executed, got %+v", res)
|
|
}
|
|
if len(api.sets) != 1 || api.sets[0].params["cores"] != "4" {
|
|
t.Errorf("expected SetConfig cores=4, got %v", api.sets)
|
|
}
|
|
if len(api.waits) != 0 {
|
|
t.Errorf("synchronous op must NOT WaitTask, got waits=%v", api.waits)
|
|
}
|
|
}
|
|
|
|
func TestEngine_WaitTaskFailureCountsFailed(t *testing.T) {
|
|
api := &fakeAPI{
|
|
lxc: []proxmox.Guest{{VMID: 100, Status: "stopped"}},
|
|
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2}},
|
|
startUPID: "UPID:demo:start:100:",
|
|
waitFunc: func(string) (proxmox.TaskStatus, error) {
|
|
return proxmox.TaskStatus{Status: "stopped", ExitStatus: "got 403"}, errors.New("task failed: got 403")
|
|
},
|
|
}
|
|
e, j, _ := newEngine(t, api, StaticProvider{State: desired(DesiredGuest{VMID: 100, Run: RunRunning})})
|
|
res, err := e.Reconcile(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Reconcile (pass): %v", err)
|
|
}
|
|
if res.Failed != 1 || res.Executed != 0 {
|
|
t.Fatalf("want 1 failed, got %+v", res)
|
|
}
|
|
// The failed op is journaled terminal (failed), not left in-flight.
|
|
if len(j.InFlight()) != 0 {
|
|
t.Errorf("failed op should be terminal, in-flight=%+v", j.InFlight())
|
|
}
|
|
}
|
|
|
|
func TestEngine_PostErrorCountsFailed(t *testing.T) {
|
|
api := &fakeAPI{
|
|
lxc: []proxmox.Guest{{VMID: 100, Status: "stopped"}},
|
|
cfg: map[int]proxmox.GuestConfig{100: {Cores: 2}},
|
|
startErr: errors.New("connection refused"),
|
|
}
|
|
e, _, _ := newEngine(t, api, StaticProvider{State: desired(DesiredGuest{VMID: 100, Run: RunRunning})})
|
|
res, _ := e.Reconcile(context.Background())
|
|
if res.Failed != 1 {
|
|
t.Fatalf("want 1 failed on POST error, got %+v", res)
|
|
}
|
|
if len(api.waits) != 0 {
|
|
t.Errorf("POST error must not reach WaitTask, got %v", api.waits)
|
|
}
|
|
}
|
|
|
|
func TestEngine_ListErrorIsPassFailure(t *testing.T) {
|
|
api := &fakeAPI{listErr: errors.New("api down")}
|
|
e, _, _ := newEngine(t, api, StaticProvider{State: desired(DesiredGuest{VMID: 100, Run: RunRunning})})
|
|
if _, err := e.Reconcile(context.Background()); err == nil {
|
|
t.Error("expected a pass-level error when actual state can't be read")
|
|
}
|
|
}
|