ce1a4b4758
The wrapper gains layer "pve" (slow lane): the host's Proxmox userspace packages only — origin "Proxmox Debian Repository", never a kernel / boot / firmware / microcode name (R14), no removal, no undo, a new package only from an allow-list; authority = a signed os_pve_step or the root-owned ring-0 mark. The night leg runs it in ring 0 after a healthy host step; ring 1 only by a signed job (PVEStepExecutor). While it runs, the agent's own /etc/pve writes (every non-GET API call, pct config verbs, pvesm, pveum, felhom-pbs-apply) wait on internal/pvegate. Health = the host rule + unchanged container ids + pveversion reads the installed pve-manager. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
153 lines
7.1 KiB
Go
153 lines
7.1 KiB
Go
package osupdate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/pvegate"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
|
)
|
|
|
|
// ---- the Proxmox package step (R-812 option A, `09` §3 decision 163, `11` §5.10) ----
|
|
|
|
// Ring 0: after a healthy host step the leg runs the pve layer — slow lane, select pending-pve — while holding the
|
|
// /etc/pve write gate; the report carries only Proxmox userspace packages and pve-manager's version.
|
|
//
|
|
// COMPANION RED-PROOF (observed): call runLayer instead of runPVE in Run → "the /etc/pve write gate was not held".
|
|
func TestPVE_Ring0PlanGateAndReport(t *testing.T) {
|
|
w := &fakeWrapper{t: t, applyRep: map[string]WrapperReport{LayerPVE: {
|
|
Upgraded: []Package{{Name: "pve-manager", Version: "9.2.21"}},
|
|
Installed: []Package{{Name: "pve-manager", Version: "9.2.21", Origin: "Proxmox"},
|
|
{Name: "proxmox-kernel-helper", Version: "9.0.4", Origin: "Proxmox"}, {Name: "libc6", Version: "u4", Origin: "Debian"}},
|
|
Pending: []Pending{{Name: "qemu-server", From: "9.0.1", To: "9.0.9", Origin: []string{PVEOrigin}},
|
|
{Name: "proxmox-kernel-7.0", From: "7.0.2", To: "7.0.14", Origin: []string{PVEOrigin}},
|
|
{Name: "libc6", From: "u3", To: "u4", Origin: []string{"Debian"}}},
|
|
PVEManager: "9.2.21", Authority: "ring0"}}}
|
|
l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true})
|
|
p := l.Run(context.Background(), 9201, "night")
|
|
var pp map[string]any
|
|
for _, x := range w.plans {
|
|
if x["layer"] == LayerPVE {
|
|
pp = x
|
|
}
|
|
}
|
|
if pp == nil || pp["lane"] != "slow" || pp["select"] != "pending-pve" {
|
|
t.Fatalf("pve plan = %v (calls %s)", pp, calls(w))
|
|
}
|
|
if !w.pveGateHeld {
|
|
t.Fatal("the /etc/pve write gate was not held while the pve step ran")
|
|
}
|
|
if pvegate.Stepping() {
|
|
t.Fatal("the gate must be released after the step")
|
|
}
|
|
r := p.PVE
|
|
if r.Outcome != "applied" || !r.Healthy || r.PVEManager != "9.2.21" {
|
|
t.Fatalf("pve report = %+v", r)
|
|
}
|
|
if len(r.Installed) != 1 || r.Installed[0].Name != "pve-manager" || len(r.Pending) != 1 || r.Pending[0].Name != "qemu-server" {
|
|
t.Fatalf("the pve report must carry Proxmox userspace only: installed=%v pending=%v", r.Installed, r.Pending)
|
|
}
|
|
if h.reports[len(h.reports)-1].Layer != LayerPVE {
|
|
t.Fatalf("the hub must get the pve report: %+v", h.reports)
|
|
}
|
|
}
|
|
|
|
// Ring 1 never takes a Proxmox step in the night leg.
|
|
func TestPVE_Ring1NightLegNeverSteps(t *testing.T) {
|
|
w := &fakeWrapper{t: t}
|
|
l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true})
|
|
if p := l.Run(context.Background(), 9201, "night"); p.PVE.Layer != "" || strings.Contains(calls(w), "pve") {
|
|
t.Fatalf("ring 1 took a pve step: %s", calls(w))
|
|
}
|
|
}
|
|
|
|
// No healthy host step (a BYO box, or an unhealthy host step) → no pve step.
|
|
func TestPVE_SkippedWithoutAHealthyHostStep(t *testing.T) {
|
|
w := &fakeWrapper{t: t}
|
|
l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true})
|
|
l.Appliance = false
|
|
if p := l.Run(context.Background(), 9201, "night"); p.PVE.Layer != "" || strings.Contains(calls(w), "pve") {
|
|
t.Fatalf("a BYO box took a pve step: %s", calls(w))
|
|
}
|
|
w2 := &fakeWrapper{t: t}
|
|
l2, _ := newLeg(t, w2, &hub.WireOSUpdate{Ring: 0, Enabled: true})
|
|
l2.Tunnel = fakeTunnel{"stopped"} // the host step reads unhealthy
|
|
w2.applyRep = map[string]WrapperReport{LayerHost: {Upgraded: []Package{{Name: "libc6", Version: "u4"}}}}
|
|
if p := l2.Run(context.Background(), 9201, "night"); p.PVE.Layer != "" || strings.Contains(calls(w2), "pve") {
|
|
t.Fatalf("a pve step ran after an unhealthy host step: %s", calls(w2))
|
|
}
|
|
}
|
|
|
|
// A write in flight that never finishes makes the pve step give up (failed), never run without the gate.
|
|
func TestPVE_GivesUpWhenAWriteDoesNotFinish(t *testing.T) {
|
|
old := pveDrainWait
|
|
pveDrainWait = 50_000_000 // 50 ms
|
|
defer func() { pveDrainWait = old }()
|
|
rel, _, _ := pvegate.Write(context.Background())
|
|
defer rel()
|
|
w := &fakeWrapper{t: t}
|
|
l, _ := newLeg(t, w, &hub.WireOSUpdate{Ring: 0, Enabled: true})
|
|
p := l.Run(context.Background(), 9201, "night")
|
|
if p.PVE.Outcome != "failed" || strings.Contains(calls(w), "pve") {
|
|
t.Fatalf("the pve step must fail without a wrapper call: %+v calls=%s", p.PVE, calls(w))
|
|
}
|
|
}
|
|
|
|
// THE pve health rule. COMPANION RED-PROOF (observed): drop the container-id loop or the pve-manager check in
|
|
// PVEHealthVerdict → the matching case below fails.
|
|
func TestPVEHealthVerdict(t *testing.T) {
|
|
before, after := hostOK(), hostOK()
|
|
before.Guest.Containers["app"] = Container{State: "running", Health: "healthy", ID: "a1"}
|
|
after.Guest.Containers["app"] = Container{State: "running", Health: "healthy", ID: "a1"}
|
|
if ok, why := PVEHealthVerdict(before, after, hub.TunnelRunning, "9.2.21", "9.2.21"); !ok {
|
|
t.Fatalf("healthy step read unhealthy: %s", why)
|
|
}
|
|
if ok, _ := PVEHealthVerdict(before, after, hub.TunnelRunning, "9.2.21", "9.2.2"); ok {
|
|
t.Fatal("pveversion still on the old pve-manager must fail")
|
|
}
|
|
after.Guest.Containers["app"] = Container{State: "running", Health: "healthy", ID: "b2"}
|
|
if ok, why := PVEHealthVerdict(before, after, hub.TunnelRunning, "", "9.2.2"); ok || !strings.Contains(why, "id changed") {
|
|
t.Fatalf("an app restarted by the Proxmox step must fail, got ok=%v %q", ok, why)
|
|
}
|
|
}
|
|
|
|
// The signed executor hands the RAW envelope and the exact list to the wrapper's pve layer.
|
|
func TestPVEStepExecutor_PassesTheSignedEnvelope(t *testing.T) {
|
|
w := &fakeWrapper{t: t, applyRep: map[string]WrapperReport{LayerPVE: {
|
|
Upgraded: []Package{{Name: "pve-manager", Version: "9.2.21"}}, PVEManager: "9.2.21", Authority: "signed"}}}
|
|
l, h := newLeg(t, w, &hub.WireOSUpdate{Ring: 1, Enabled: true})
|
|
e := PVEStepExecutor{Leg: l, Guest: func(context.Context) (int, error) { return 9201, nil }}
|
|
params, _ := json.Marshal(PVEStepParams{ReleaseID: "os-pve-1", Packages: []Package{{Name: "pve-manager", Version: "9.2.21", Origin: PVEOrigin}}})
|
|
ctx := signedjobs.WithSignedOp(context.Background(), &reconcile.SignedOp{Blob: []byte(`{"op":"os_pve_step"}`), Sig: []byte("SIG")})
|
|
if err := e.Execute(ctx, OpPVEStep, params); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pp := w.plans[len(w.plans)-1]
|
|
sg, _ := pp["signed"].(map[string]any)
|
|
if pp["layer"] != LayerPVE || pp["lane"] != "slow" || pp["release_id"] != "os-pve-1" || sg == nil ||
|
|
sg["blob_b64"] != base64.StdEncoding.EncodeToString([]byte(`{"op":"os_pve_step"}`)) || sg["sig"] != "SIG" {
|
|
t.Fatalf("pve plan = %v", pp)
|
|
}
|
|
if !w.pveGateHeld || calls(w) != "pve:apply" || len(h.reports) != 1 || h.reports[0].Trigger != "signed" {
|
|
t.Fatalf("gate=%v calls=%s reports=%+v", w.pveGateHeld, calls(w), h.reports)
|
|
}
|
|
if err := e.Execute(context.Background(), OpPVEStep, params); err == nil {
|
|
t.Fatal("no envelope must refuse")
|
|
}
|
|
if err := e.Execute(context.Background(), OpDockerStep, params); err != signedjobs.ErrNoExecutor {
|
|
t.Fatalf("another op must pass through the chain: %v", err)
|
|
}
|
|
}
|
|
|
|
// os_pve_step is never benign.
|
|
func TestPVEStep_IsDestructiveClass(t *testing.T) {
|
|
if reconcile.Classify(reconcile.ClassOSPVEStep, reconcile.Provenance{}) != reconcile.Destructive {
|
|
t.Fatal("os_pve_step must be destructive-class (signed, operational key)")
|
|
}
|
|
}
|