Files
admin a042316d6d feat(agent): scaffold + proxmox interaction layer (slice 1)
Stand up the felhom-agent project (module gitea.dooplex.hu/admin/felhom-agent,
binary felhom-agent) and the internal/proxmox package: the typed library every
other agent module calls to talk to Proxmox.

- API-first Client (hand-rolled REST over net/http, PVEAPIToken auth) with typed
  read ops (version/nodes/status/lxc/config/storage) and async mutating ops
  (restore/vzdump/snapshot/rollback/delete-snapshot/setconfig/start/stop), each
  returning a UPID. WaitTask polls task status until stopped and asserts
  exitstatus OK (authz can surface at task exec, not the POST — phase1-2 §1.3).
- Fenced Privileged (root-CLI) backend for the THREE proven exceptions only
  (keyctl pct create, USB mount/fstab, SMART/sensors); each cites why it can't be
  the API. Fence is structural (Client never shells out, Privileged never HTTPs)
  and asserted in routing_test.go.
- TLS: SHA-256 leaf-cert pinning or CA file; insecure mode explicit + off by
  default. No blanket verification disable.
- 403 -> privilege-named APIError; failed task -> privilege-named TaskError.
- JSON config + env overrides (token never logged); slog logging.
- cmd/felhom-agent --selftest (read-only health report) + gated --selftest=task
  (reversible snapshot/rollback/delete exercise of WaitTask). No daemon loop yet.
- Types grounded in the spike findings and exact JSON shapes captured live from
  demo-felhom (PVE 9.2.2). Unit tests use a mock transport + runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:34:32 +02:00

103 lines
3.7 KiB
Go

package proxmox
import (
"context"
"errors"
"net/http"
"testing"
)
func TestAPIError_403ExtractsPrivilege(t *testing.T) {
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
return jsonResp(403, `{"message":"Permission check failed (/nodes/demo-felhom, Sys.Audit)\n"}`), nil
}}
_, err := newTestClient(d).NodeStatus(context.Background())
var ae *APIError
if !errors.As(err, &ae) {
t.Fatalf("want *APIError, got %T: %v", err, err)
}
if !ae.IsForbidden() {
t.Errorf("IsForbidden = false")
}
if ae.Privilege != "Sys.Audit" {
t.Errorf("privilege = %q, want Sys.Audit", ae.Privilege)
}
if ae.DeniedPath != "/nodes/demo-felhom" {
t.Errorf("denied path = %q", ae.DeniedPath)
}
}
func TestDecode_ListLXC(t *testing.T) {
// Exact shape captured from the live host.
body := `{"data":[{"cpu":0,"cpus":2,"disk":0,"maxdisk":10737418240,"maxmem":2147483648,"mem":0,"name":"spike-lxc","status":"stopped","type":"lxc","uptime":0,"vmid":9001}]}`
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) { return jsonResp(200, body), nil }}
gs, err := newTestClient(d).ListLXC(context.Background())
if err != nil {
t.Fatalf("ListLXC: %v", err)
}
if len(gs) != 1 {
t.Fatalf("len = %d", len(gs))
}
g := gs[0]
if g.VMID != 9001 || g.Name != "spike-lxc" || g.Status != "stopped" || g.CPUs != 2 {
t.Errorf("decoded guest wrong: %+v", g)
}
}
func TestDecode_NodeStatus(t *testing.T) {
body := `{"data":{"cpu":0.0057,"uptime":73078,"loadavg":["0.11","0.09","0.05"],"pveversion":"pve-manager/9.2.2","memory":{"total":16537989120,"used":2043027456,"free":13587857408,"available":14494961664},"rootfs":{"total":100861726720,"used":4943888384,"free":95917838336,"avail":90747101184},"cpuinfo":{"cores":4,"cpus":4,"sockets":1,"model":"Intel(R) N100"}}}`
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) { return jsonResp(200, body), nil }}
s, err := newTestClient(d).NodeStatus(context.Background())
if err != nil {
t.Fatalf("NodeStatus: %v", err)
}
if len(s.LoadAvg) != 3 || s.LoadAvg[0] != "0.11" {
t.Errorf("loadavg = %v", s.LoadAvg)
}
if s.Memory.Total != 16537989120 || s.CPUInfo.Cores != 4 {
t.Errorf("decoded node status wrong: %+v", s)
}
}
func TestDecode_GuestConfig_FeaturesAndExtra(t *testing.T) {
// keyctl must survive as a string; mpN/netN land in Extra.
body := `{"data":{"arch":"amd64","cores":2,"features":"nesting=1,keyctl=1","hostname":"spike-lxc","memory":2048,"net0":"name=eth0,bridge=vmbr0,hwaddr=BC:24:11:D1:6D:CB,ip=dhcp,type=veth","rootfs":"local-lvm:vm-9001-disk-0,size=10G","unprivileged":1,"mp0":"local-lvm:1,mp=/mnt/bulk,backup=0"}}`
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) { return jsonResp(200, body), nil }}
cfg, err := newTestClient(d).GuestConfig(context.Background(), 9001)
if err != nil {
t.Fatalf("GuestConfig: %v", err)
}
if cfg.Features != "nesting=1,keyctl=1" {
t.Errorf("features = %q", cfg.Features)
}
if cfg.Unprivileged != 1 {
t.Errorf("unprivileged = %d", cfg.Unprivileged)
}
if mp := cfg.MountPoints(); mp["mp0"] != "local-lvm:1,mp=/mnt/bulk,backup=0" {
t.Errorf("mountpoints = %v", mp)
}
if nets := cfg.Nets(); nets["net0"] == "" {
t.Errorf("nets = %v", nets)
}
// "memory" must NOT be misread as an mp/net prefix match.
if mp := cfg.MountPoints(); len(mp) != 1 {
t.Errorf("expected exactly 1 mountpoint, got %v", mp)
}
}
func TestDataString_ReturnsUPID(t *testing.T) {
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
if r.Method != http.MethodPost {
t.Errorf("method = %s", r.Method)
}
return jsonResp(200, `{"data":"`+testUPID+`"}`), nil
}}
upid, err := newTestClient(d).Snapshot(context.Background(), 9001, "s1", "")
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if upid != testUPID {
t.Errorf("upid = %q", upid)
}
}