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>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user