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>
98 lines
3.4 KiB
Go
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)
|
|
}
|
|
}
|