Files
felhom-agent/internal/proxmox/types.go
T
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

165 lines
5.5 KiB
Go

package proxmox
import "encoding/json"
// Types mirror the exact JSON shapes captured from the live demo host
// (demo-felhom, PVE 9.2.2, 2026-06-08) via `pvesh get ... --output-format json`.
// Decoding ignores unknown fields, so we depend only on the fields we use.
// Version is GET /version.
type Version struct {
Release string `json:"release"` // "9.2"
RepoID string `json:"repoid"`
Version string `json:"version"` // "9.2.2"
}
// Node is one entry of GET /nodes.
type Node struct {
Node string `json:"node"` // node name, e.g. "demo-felhom"
Status string `json:"status"` // "online"
CPU float64 `json:"cpu"` // load fraction 0..1
MaxCPU int `json:"maxcpu"`
Mem int64 `json:"mem"`
MaxMem int64 `json:"maxmem"`
Disk int64 `json:"disk"`
MaxDisk int64 `json:"maxdisk"`
Uptime int64 `json:"uptime"`
SSLFingerprint string `json:"ssl_fingerprint"`
}
// NodeStatus is GET /nodes/{node}/status (host metrics; needs Sys.Audit).
type NodeStatus struct {
CPU float64 `json:"cpu"` // load fraction 0..1
Uptime int64 `json:"uptime"`
LoadAvg []string `json:"loadavg"` // 1/5/15-min, as strings in the API
PVEVersion string `json:"pveversion"`
KVersion string `json:"kversion"`
Memory struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
Free int64 `json:"free"`
Available int64 `json:"available"`
} `json:"memory"`
RootFS struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
Free int64 `json:"free"`
Avail int64 `json:"avail"`
} `json:"rootfs"`
Swap struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
Free int64 `json:"free"`
} `json:"swap"`
CPUInfo struct {
Cores int `json:"cores"`
CPUs int `json:"cpus"`
Sockets int `json:"sockets"`
Model string `json:"model"`
} `json:"cpuinfo"`
}
// Guest is one entry of GET /nodes/{node}/lxc and the body of
// GET /nodes/{node}/lxc/{vmid}/status/current. The status/current response has no
// vmid field (it is in the path), so callers set VMID from the request argument.
type Guest struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"` // "running" | "stopped"
Type string `json:"type"` // "lxc"
CPUs int `json:"cpus"`
CPU float64 `json:"cpu"`
Mem int64 `json:"mem"`
MaxMem int64 `json:"maxmem"`
Disk int64 `json:"disk"`
MaxDisk int64 `json:"maxdisk"`
Uptime int64 `json:"uptime"`
}
// GuestConfig is GET /nodes/{node}/lxc/{vmid}/config. The config surface is
// dynamic (net0..netN, mp0..mpN, unusedN), so known fields are typed and the full
// raw map is preserved in Extra for the dynamic ones.
type GuestConfig struct {
Hostname string `json:"hostname"`
Arch string `json:"arch"`
Cores int `json:"cores"`
Memory int64 `json:"memory"`
Swap int64 `json:"swap"`
OSType string `json:"ostype"`
RootFS string `json:"rootfs"`
Features string `json:"features"` // e.g. "nesting=1,keyctl=1"
Unprivileged int `json:"unprivileged"` // 1 if unprivileged
Digest string `json:"digest"`
// Extra holds every field as raw JSON, including the dynamic netN/mpN/unusedN
// keys not promoted above.
Extra map[string]json.RawMessage `json:"-"`
}
// UnmarshalJSON fills both the typed known fields and the raw Extra map.
func (g *GuestConfig) UnmarshalJSON(b []byte) error {
type alias GuestConfig // avoid recursion
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*g = GuestConfig(a)
return json.Unmarshal(b, &g.Extra)
}
// MountPoints returns the mpN entries (e.g. "mp0" -> "local-lvm:1,mp=/mnt/mp1,backup=0")
// pulled from Extra. Relevant for later slices' bulk-volume placement.
func (g *GuestConfig) MountPoints() map[string]string {
return g.prefixed("mp")
}
// Nets returns the netN entries from Extra.
func (g *GuestConfig) Nets() map[string]string {
return g.prefixed("net")
}
func (g *GuestConfig) prefixed(prefix string) map[string]string {
out := map[string]string{}
for k, raw := range g.Extra {
if len(k) <= len(prefix) || k[:len(prefix)] != prefix {
continue
}
// require the suffix to be a digit (mp0, net0 — not "memory")
if c := k[len(prefix)]; c < '0' || c > '9' {
continue
}
var s string
if json.Unmarshal(raw, &s) == nil {
out[k] = s
}
}
return out
}
// Storage is one entry of GET /storage (cluster) and GET /nodes/{node}/storage
// (the latter adds usage fields). Unused fields stay zero.
type Storage struct {
Storage string `json:"storage"`
Type string `json:"type"` // "dir" | "lvmthin" | "nfs" | "cifs" | "pbs"
Content string `json:"content"` // comma list, e.g. "vztmpl,backup,iso,import"
Path string `json:"path,omitempty"`
Total int64 `json:"total,omitempty"`
Used int64 `json:"used,omitempty"`
Avail int64 `json:"avail,omitempty"`
Active int `json:"active,omitempty"`
Enabled int `json:"enabled,omitempty"`
Shared int `json:"shared,omitempty"`
UsedFraction float64 `json:"used_fraction,omitempty"`
}
// StorageContent is one entry of GET /nodes/{node}/storage/{store}/content
// (e.g. vzdump archives, CT templates, guest volumes).
type StorageContent struct {
VolID string `json:"volid"` // e.g. "local:backup/vzdump-lxc-9001-...tar.zst"
Content string `json:"content"`
Format string `json:"format"`
Size int64 `json:"size"`
CTime int64 `json:"ctime"`
VMID int `json:"vmid,omitempty"`
}