Files
felhom-agent/internal/proxmox/pool_test.go
T
admin ca0b169a4e feat(reconcile): re-assert pool membership after restore-over-existing (campaign-2 R2, v0.74.0)
Pool membership is what lets the pool-scoped token reach a guest; pct restore
--pool sets it only at CREATE, so a restore over an existing VMID drops the guest
from the felhom pool and 403s the next restore-test/DR on VM.Audit. This empty-pool
state is the true root cause of the campaign's "R1" (bind-mount restore failing was
a symptom — restore-test's existing bind neutralization never ran without config-read).

Add Client.PoolAddVMID (PUT /pools, additive+idempotent, Pool.Allocate) and call it
in bring-up after liveness when spec.Pool!="" — warn-not-fail on a hiccup (liveness
wins). B3 scratch-teardown 403 diagnosed as a cascade (restoretest already passes
Pool). Role/ACL untouched. Tests + red-proof.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-07 18:42:19 +02:00

97 lines
2.9 KiB
Go

package proxmox
import (
"context"
"io"
"net/http"
"testing"
)
// PoolAddVMID issues PUT /pools/{pool} with vms={vmid} and treats an already-member error as success.
func TestPoolAddVMID_PUTShape(t *testing.T) {
var gotMethod, gotPath, gotVMS string
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
gotMethod = r.Method
gotPath = r.URL.Path
body, _ := io.ReadAll(r.Body) // the client encodes url.Values into the request body
for _, kv := range splitAmp(string(body)) {
if k, v, ok := cut(kv, "="); ok && k == "vms" {
gotVMS = v
}
}
return jsonResp(http.StatusOK, `{"data":null}`), nil
}}
c := newTestClient(d)
if err := c.PoolAddVMID(context.Background(), "felhom", 9201); err != nil {
t.Fatalf("PoolAddVMID: %v", err)
}
if gotMethod != http.MethodPut {
t.Errorf("method = %q, want PUT", gotMethod)
}
if gotPath != "/api2/json/pools/felhom" {
t.Errorf("path = %q, want /api2/json/pools/felhom", gotPath)
}
if gotVMS != "9201" {
t.Errorf("vms param = %q, want 9201", gotVMS)
}
}
// Idempotent: an "already in pool" error from PVE is swallowed as success.
func TestPoolAddVMID_IdempotentOnAlreadyMember(t *testing.T) {
d := &mockDoer{fn: func(_ *http.Request) (*http.Response, error) {
return jsonResp(http.StatusInternalServerError, `{"data":null,"errors":{"vms":"VM 9201 is already in pool 'felhom'"}}`), nil
}}
c := newTestClient(d)
if err := c.PoolAddVMID(context.Background(), "felhom", 9201); err != nil {
t.Fatalf("already-member should be idempotent success, got: %v", err)
}
}
// A real failure (e.g. a 403 with no "already") is surfaced, not swallowed.
func TestPoolAddVMID_RealErrorSurfaces(t *testing.T) {
d := &mockDoer{fn: func(_ *http.Request) (*http.Response, error) {
return jsonResp(http.StatusForbidden, `Permission check failed (/pool/felhom, Pool.Allocate)`), nil
}}
c := newTestClient(d)
if err := c.PoolAddVMID(context.Background(), "felhom", 9201); err == nil {
t.Fatal("expected a real 403 to surface, got nil")
}
}
func TestPoolAddVMID_Validation(t *testing.T) {
c := newTestClient(&mockDoer{fn: func(_ *http.Request) (*http.Response, error) {
t.Fatal("should not issue a request on bad input")
return nil, nil
}})
if err := c.PoolAddVMID(context.Background(), "", 9201); err == nil {
t.Error("empty pool should error")
}
if err := c.PoolAddVMID(context.Background(), "felhom", 0); err == nil {
t.Error("zero vmid should error")
}
}
// tiny helpers (avoid pulling strings.Split into an assertion path that could mask a bug)
func splitAmp(s string) []string {
var out []string
cur := ""
for _, r := range s {
if r == '&' {
out = append(out, cur)
cur = ""
continue
}
cur += string(r)
}
return append(out, cur)
}
func cut(s, sep string) (string, string, bool) {
for i := 0; i+len(sep) <= len(s); i++ {
if s[i:i+len(sep)] == sep {
return s[:i], s[i+len(sep):], true
}
}
return s, "", false
}