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

98 lines
3.4 KiB
Go

package proxmox
import (
"context"
"net/http"
"testing"
)
// TestRouting_APIOpsNeverShellOut asserts the API path never invokes the
// privileged runner: API ops (read + mutating) go only through the HTTP doer.
func TestRouting_APIOpsNeverShellOut(t *testing.T) {
runner := &mockRunner{}
// If any API op tried to use a runner, it would have to be wired here — it
// cannot be, because Client has no runner field. We still assert structurally:
// run a batch of API ops with a recording doer and confirm the runner is idle.
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
// Generic OK responses sufficient for the calls below.
if r.Method == http.MethodGet {
return jsonResp(200, `{"data":[]}`), nil
}
return jsonResp(200, `{"data":"`+testUPID+`"}`), nil
}}
c := newTestClient(d)
ctx := context.Background()
_, _ = c.Version(ctx)
_, _ = c.Nodes(ctx)
_, _ = c.ListLXC(ctx)
_, _ = c.NodeStorage(ctx)
_, _ = c.Snapshot(ctx, 9001, "s1", "")
_, _ = c.Rollback(ctx, 9001, "s1")
_, _ = c.Vzdump(ctx, VzdumpOptions{VMID: 9001, Storage: "local", Mode: ModeStop})
_, _ = c.RestoreLXC(ctx, RestoreLXCOptions{VMID: 9100, Archive: "local:backup/a.tar.zst", Storage: "local-lvm"})
_, _ = c.Start(ctx, 9001)
_, _ = c.Stop(ctx, 9001)
if runner.calls != 0 {
t.Fatalf("API ops invoked the privileged runner %d time(s) — fence broken", runner.calls)
}
if d.calls == 0 {
t.Fatalf("expected API ops to use the HTTP doer")
}
}
// TestRouting_PrivilegedOpsNeverHTTP asserts the fenced root path never makes an
// HTTP call: Privileged ops go only through the runner.
func TestRouting_PrivilegedOpsNeverHTTP(t *testing.T) {
d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) {
t.Fatalf("privileged op made an HTTP call to %s — fence broken", r.URL)
return nil, nil
}}
_ = d // a Privileged has no doer field; this doer is unreachable by construction.
runner := &mockRunner{out: []byte(`{"ok":true}`)}
p := NewPrivileged(runner, "demo-felhom")
ctx := context.Background()
if err := p.CreateGoldenLXC(ctx, GoldenLXCSpec{VMID: 9999, OSTemplate: "local:vztmpl/x.tar.zst", Storage: "local-lvm"}); err != nil {
t.Fatalf("CreateGoldenLXC: %v", err)
}
if err := p.MountUSBByUUID(ctx, "1234-ABCD", "/mnt/usb"); err != nil {
t.Fatalf("MountUSBByUUID: %v", err)
}
if _, err := p.SMART(ctx, "/dev/sda"); err != nil {
t.Fatalf("SMART: %v", err)
}
if _, err := p.Sensors(ctx); err != nil {
t.Fatalf("Sensors: %v", err)
}
if runner.calls == 0 {
t.Fatalf("expected privileged ops to use the runner")
}
}
// TestPrivileged_CreateGoldenForcesKeyctl asserts the golden create always carries
// the keyctl feature flag (the whole reason it is root-fenced).
func TestPrivileged_CreateGoldenForcesKeyctl(t *testing.T) {
runner := &mockRunner{}
p := NewPrivileged(runner, "demo-felhom")
if err := p.CreateGoldenLXC(context.Background(), GoldenLXCSpec{
VMID: 9999, OSTemplate: "local:vztmpl/x.tar.zst", Storage: "local-lvm", RootFSGB: 8,
}); err != nil {
t.Fatalf("CreateGoldenLXC: %v", err)
}
if runner.lastCmd != "pct" {
t.Errorf("cmd = %q, want pct", runner.lastCmd)
}
var sawFeatures bool
for i, a := range runner.lastArg {
if a == "--features" && i+1 < len(runner.lastArg) && runner.lastArg[i+1] == "nesting=1,keyctl=1" {
sawFeatures = true
}
}
if !sawFeatures {
t.Errorf("pct create args missing keyctl features: %v", runner.lastArg)
}
}