v0.90.0 — guest RAM resize (R-24) + fast-tick-until-convergence (R-28)
MinAgent coupling: felhom-controller v0.143.0 gates its guest-memory-resize UI on this agent (FeatureGuestMemoryResize, MinAgent 0.90.0). R-24 guest RAM resize (internal/localapi/guestmemory.go): self-scoped GET/POST /guest/memory. Agent enforces every bound FRESH per request (min 2048, max host_total-2048, shrink floor max(2048, usage+512)); applies via PVE SetConfig — live cgroup apply, no reboot (Phase-0 proven on the nested demo box). Verify-after-apply re-reads maxmem before claiming success. New narrow MemoryOps seam (GuestAPI untouched); Options.Memory nil -> 503. Memory only. R-28 fast-tick (internal/fasttick): while any desired-state item is unapplied - including the pre-tunnel window a hub poke can't reach - pulse the shared out-of-band trigger every 30s, self-disarm on convergence. Four cached sources (desired-gen==0, reconcile Planned-Pending>0, pbsdr waiting_secret only, wgtunnel desired-not-operational); LOUD pbsdr states + pending_signature excluded. Seams: reconcile.Engine.LastResult() + wgtunnel.Manager.TunnelConvergence() (cached, no per-tick exec). Guests-0/0: hypothesis REFUTED live (9201 IS a pool member; 0/0 was the pre-provision window; PoolAddVMID re-assert already covers restore-over-existing). No code change; the fast-tick mitigates the window. Tests + red-proofs (i floor guard, ii max guard, iii always-pulse) all restored green.
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
)
|
||||
|
||||
// fakeMemory is a MemoryOps fake: it holds the guest's allocation/usage + host total, counts
|
||||
// SetConfig calls (so refusals can prove the non-effect), and — when applyReflect is set — makes a
|
||||
// SetConfig update the observed maxmem so the post-apply verify read passes.
|
||||
type fakeMemory struct {
|
||||
mu sync.Mutex
|
||||
allocMB int64
|
||||
usageBytes int64
|
||||
maxmemBytes int64
|
||||
hostTotalB int64
|
||||
status string
|
||||
setCalls int
|
||||
lastParams map[string]string
|
||||
setUPID string
|
||||
setErr error
|
||||
applyReflect bool
|
||||
}
|
||||
|
||||
func (f *fakeMemory) GuestConfig(_ context.Context, _ int) (proxmox.GuestConfig, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return proxmox.GuestConfig{Memory: f.allocMB}, nil
|
||||
}
|
||||
func (f *fakeMemory) GuestStatus(_ context.Context, vmid int) (proxmox.Guest, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return proxmox.Guest{VMID: vmid, Mem: f.usageBytes, MaxMem: f.maxmemBytes, Status: f.status}, nil
|
||||
}
|
||||
func (f *fakeMemory) NodeStatus(_ context.Context) (proxmox.NodeStatus, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var ns proxmox.NodeStatus
|
||||
ns.Memory.Total = f.hostTotalB
|
||||
return ns, nil
|
||||
}
|
||||
func (f *fakeMemory) SetConfig(_ context.Context, _ int, params map[string]string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.setCalls++
|
||||
f.lastParams = params
|
||||
if f.setErr != nil {
|
||||
return "", f.setErr
|
||||
}
|
||||
if f.applyReflect {
|
||||
if mb, ok := params["memory"]; ok {
|
||||
var v int64
|
||||
for _, c := range mb {
|
||||
v = v*10 + int64(c-'0')
|
||||
}
|
||||
f.allocMB = v
|
||||
f.maxmemBytes = v * mib
|
||||
}
|
||||
}
|
||||
return f.setUPID, nil
|
||||
}
|
||||
func (f *fakeMemory) WaitTask(_ context.Context, _ string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) {
|
||||
return proxmox.TaskStatus{Status: "stopped", ExitStatus: "OK"}, nil
|
||||
}
|
||||
func (f *fakeMemory) calls() int { f.mu.Lock(); defer f.mu.Unlock(); return f.setCalls }
|
||||
|
||||
// memServer builds a valid server with the memory fake wired and token "A" → guest 8200.
|
||||
func memServer(t *testing.T, f *fakeMemory) *Server {
|
||||
t.Helper()
|
||||
s := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil)
|
||||
s.mem = f
|
||||
return s
|
||||
}
|
||||
|
||||
// stdFixture: allocated 8192 MB, usage 3000 MB, host_total 16384 MB, running.
|
||||
func stdFixture() *fakeMemory {
|
||||
return &fakeMemory{
|
||||
allocMB: 8192,
|
||||
usageBytes: 3000 * mib,
|
||||
maxmemBytes: 8192 * mib,
|
||||
hostTotalB: 16384 * mib,
|
||||
status: "running",
|
||||
applyReflect: true,
|
||||
}
|
||||
}
|
||||
|
||||
type memResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
VMID int `json:"vmid"`
|
||||
AllocatedMB int64 `json:"allocated_mb"`
|
||||
UsageMB int64 `json:"usage_mb"`
|
||||
HostTotalMB int64 `json:"host_total_mb"`
|
||||
MinMB int64 `json:"min_mb"`
|
||||
MaxMB int64 `json:"max_mb"`
|
||||
FloorMB int64 `json:"floor_mb"`
|
||||
Running bool `json:"running"`
|
||||
Code string `json:"code"`
|
||||
OldMB int64 `json:"old_mb"`
|
||||
NewMB int64 `json:"new_mb"`
|
||||
Unchanged bool `json:"unchanged"`
|
||||
} `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func decodeMem(t *testing.T, body []byte) memResp {
|
||||
t.Helper()
|
||||
var m memResp
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
t.Fatalf("decode response %q: %v", body, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Scenario C — GET /guest/memory: every field agent-computed, in MB.
|
||||
func TestGuestMemory_GET(t *testing.T) {
|
||||
f := stdFixture()
|
||||
h := memServer(t, f).Handler()
|
||||
w := do(t, h, "GET", "/guest/memory", "A", "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET = %d (%s), want 200", w.Code, w.Body.String())
|
||||
}
|
||||
d := decodeMem(t, w.Body.Bytes()).Data
|
||||
if d.AllocatedMB != 8192 || d.UsageMB != 3000 || d.HostTotalMB != 16384 ||
|
||||
d.MinMB != 2048 || d.MaxMB != 14336 || d.FloorMB != 3512 || !d.Running {
|
||||
t.Errorf("GET fields wrong: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario A — grow + shrink happy paths: exactly one SetConfig, correct param, success.
|
||||
func TestGuestMemory_GrowAndShrink(t *testing.T) {
|
||||
t.Run("grow", func(t *testing.T) {
|
||||
f := stdFixture()
|
||||
h := memServer(t, f).Handler()
|
||||
w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":12288}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("grow = %d (%s), want 200", w.Code, w.Body.String())
|
||||
}
|
||||
d := decodeMem(t, w.Body.Bytes()).Data
|
||||
if d.OldMB != 8192 || d.NewMB != 12288 {
|
||||
t.Errorf("grow result = %+v", d)
|
||||
}
|
||||
if f.calls() != 1 {
|
||||
t.Errorf("SetConfig calls = %d, want 1", f.calls())
|
||||
}
|
||||
if f.lastParams["memory"] != "12288" {
|
||||
t.Errorf("SetConfig param = %q, want 12288", f.lastParams["memory"])
|
||||
}
|
||||
})
|
||||
t.Run("shrink above floor", func(t *testing.T) {
|
||||
f := stdFixture() // floor = max(2048, 3000+512) = 3512
|
||||
h := memServer(t, f).Handler()
|
||||
w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":4096}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("shrink = %d (%s), want 200 (4096 >= floor 3512)", w.Code, w.Body.String())
|
||||
}
|
||||
if f.calls() != 1 {
|
||||
t.Errorf("SetConfig calls = %d, want 1", f.calls())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Scenario B — the ruled refusals: 412 with the code, and SetConfig NEVER called.
|
||||
func TestGuestMemory_Refusals(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, body, code string
|
||||
}{
|
||||
{"below_min", `{"memory_mb":1024}`, "below_min"},
|
||||
{"above_max", `{"memory_mb":15000}`, "above_max"},
|
||||
{"below_usage_floor", `{"memory_mb":3300}`, "below_usage_floor"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f := stdFixture()
|
||||
h := memServer(t, f).Handler()
|
||||
w := do(t, h, "POST", "/guest/memory", "A", c.body)
|
||||
if w.Code != http.StatusPreconditionFailed {
|
||||
t.Fatalf("%s = %d (%s), want 412", c.name, w.Code, w.Body.String())
|
||||
}
|
||||
d := decodeMem(t, w.Body.Bytes()).Data
|
||||
if d.Code != c.code {
|
||||
t.Errorf("code = %q, want %q", d.Code, c.code)
|
||||
}
|
||||
if f.calls() != 0 {
|
||||
t.Errorf("SetConfig called %d times on a refusal — must be 0", f.calls())
|
||||
}
|
||||
// The refusal carries fresh bounds so the UI re-renders honestly.
|
||||
if d.MinMB != 2048 || d.MaxMB != 14336 || d.FloorMB != 3512 {
|
||||
t.Errorf("refusal bounds wrong: min=%d max=%d floor=%d", d.MinMB, d.MaxMB, d.FloorMB)
|
||||
}
|
||||
if c.code == "below_usage_floor" && d.UsageMB != 3000 {
|
||||
t.Errorf("below_usage_floor usage_mb = %d, want 3000", d.UsageMB)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// B4 — cross-guest body vmid → 403, SetConfig not called.
|
||||
func TestGuestMemory_CrossGuestRefused(t *testing.T) {
|
||||
f := stdFixture()
|
||||
h := memServer(t, f).Handler()
|
||||
w := do(t, h, "POST", "/guest/memory", "A", `{"vmid":9999,"memory_mb":10000}`)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-guest = %d, want 403", w.Code)
|
||||
}
|
||||
if f.calls() != 0 {
|
||||
t.Errorf("SetConfig called on a cross-guest refusal (%d)", f.calls())
|
||||
}
|
||||
}
|
||||
|
||||
// B5 — bounds are re-read FRESH per request: raising usage between the GET and the POST makes a
|
||||
// previously-valid shrink target fall below the NEW floor (a stale UI can't smuggle an old floor).
|
||||
func TestGuestMemory_FreshBoundsPerRequest(t *testing.T) {
|
||||
f := stdFixture() // usage 3000 → floor 3512
|
||||
h := memServer(t, f).Handler()
|
||||
// GET sees floor 3512; target 3600 would be a valid shrink under it.
|
||||
if d := decodeMem(t, do(t, h, "GET", "/guest/memory", "A", "").Body.Bytes()).Data; d.FloorMB != 3512 {
|
||||
t.Fatalf("initial floor = %d, want 3512", d.FloorMB)
|
||||
}
|
||||
// Usage climbs to 3600 MB → floor becomes 4112. The POST must read this FRESH.
|
||||
f.mu.Lock()
|
||||
f.usageBytes = 3600 * mib
|
||||
f.mu.Unlock()
|
||||
w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":3700}`)
|
||||
if w.Code != http.StatusPreconditionFailed {
|
||||
t.Fatalf("post = %d (%s), want 412 (fresh floor 4112 > 3700)", w.Code, w.Body.String())
|
||||
}
|
||||
d := decodeMem(t, w.Body.Bytes()).Data
|
||||
if d.Code != "below_usage_floor" || d.FloorMB != 4112 {
|
||||
t.Errorf("fresh-bounds refusal = code %q floor %d, want below_usage_floor / 4112", d.Code, d.FloorMB)
|
||||
}
|
||||
if f.calls() != 0 {
|
||||
t.Errorf("SetConfig called (%d) despite fresh-floor refusal", f.calls())
|
||||
}
|
||||
}
|
||||
|
||||
// target == current allocation → success no-op, no SetConfig.
|
||||
func TestGuestMemory_UnchangedNoOp(t *testing.T) {
|
||||
f := stdFixture()
|
||||
h := memServer(t, f).Handler()
|
||||
w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":8192}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("no-op = %d (%s), want 200", w.Code, w.Body.String())
|
||||
}
|
||||
d := decodeMem(t, w.Body.Bytes()).Data
|
||||
if !d.Unchanged || f.calls() != 0 {
|
||||
t.Errorf("no-op should not SetConfig: unchanged=%v calls=%d", d.Unchanged, f.calls())
|
||||
}
|
||||
}
|
||||
|
||||
// Verify-after-apply: SetConfig "succeeds" but the maxmem never reflects the target → 502, no false
|
||||
// success (a pending/reboot-required outcome is caught, never claimed as done).
|
||||
func TestGuestMemory_ApplyNotReflected(t *testing.T) {
|
||||
f := stdFixture()
|
||||
f.applyReflect = false // SetConfig returns ok but the guest keeps its old maxmem
|
||||
h := memServer(t, f).Handler()
|
||||
w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":10000}`)
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("unreflected apply = %d (%s), want 502", w.Code, w.Body.String())
|
||||
}
|
||||
if f.calls() != 1 {
|
||||
t.Errorf("SetConfig calls = %d, want 1 (it was attempted)", f.calls())
|
||||
}
|
||||
}
|
||||
|
||||
// Not configured (nil Memory) → 503 on both routes.
|
||||
func TestGuestMemory_NotConfigured(t *testing.T) {
|
||||
s := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil) // mem stays nil
|
||||
h := s.Handler()
|
||||
if w := do(t, h, "GET", "/guest/memory", "A", ""); w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("GET nil-mem = %d, want 503", w.Code)
|
||||
}
|
||||
if w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":9000}`); w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("POST nil-mem = %d, want 503", w.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user