a042316d6d
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>
103 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|