027948bf3f
Phase-1 live probe (felhom-hetzner) proved backup/restore/list/isolation over the tunnel with a per-customer DatastoreBackup token, but the agent's PBS client was namespace-unaware: Snapshots hit the datastore root (403 for a scoped token) and Verify was whole-datastore (needs Datastore.Verify ~ admin). Operator- approved fix. - pbs.Config.Namespace + Client.namespace; Snapshots appends ?ns=; Verify sends ns= (ns-scoped verify works with DatastoreBackup on the own ns — no admin widening, Phase-1 confirmed). Root-ns clients unchanged (whole-datastore). - proxmox.Storage.Namespace (parsed from /storage `namespace`). - pbsTargetsFromPVE threads s.Namespace into the client. Confirmed tenant ACL: DatastoreBackup on /datastore/felhom-offsite/<ns> (NOT /ns/<ns>) to BOTH felhom@pbs (user) AND felhom@pbs!<ns> (token) — PBS privsep = intersection; isolation holds (cross-ns 403 proven). TestClient_NamespaceScoping red-proofed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
236 lines
8.5 KiB
Go
236 lines
8.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"`
|
|
}
|
|
|
|
// PoolInfo is GET /pools/{poolid} — the pool's identity + membership. The stale-lock recovery
|
|
// uses it as the ownership registry: only pool members are ever scanned (audit A1).
|
|
type PoolInfo struct {
|
|
PoolID string `json:"poolid"`
|
|
Members []PoolMember `json:"members"`
|
|
}
|
|
|
|
// PoolMember is one entry of PoolInfo.Members. A pool can hold guests AND storages; storage
|
|
// entries carry type "storage" and no vmid, so membership checks must filter on both (spike
|
|
// SPIKE-a1-pool-membership-read §8).
|
|
type PoolMember struct {
|
|
VMID int `json:"vmid"`
|
|
Type string `json:"type"` // "lxc" | "qemu" | "storage"
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// Lock returns the guest's current lock ("backup", "snapshot-delete", "migrate", …) from the config,
|
|
// or "" when unlocked. An interrupted vzdump leaves a "backup" or "snapshot-delete" lock — the signal
|
|
// the startup stale-lock recovery (F2-b) keys on.
|
|
func (g *GuestConfig) Lock() string {
|
|
raw, ok := g.Extra["lock"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var s string
|
|
if json.Unmarshal(raw, &s) != nil {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
// OnBoot reports whether the guest is configured to auto-start at host boot (onboot:1). PVE omits the
|
|
// key when 0, so an absent key reads as false.
|
|
func (g *GuestConfig) OnBoot() bool {
|
|
raw, ok := g.Extra["onboot"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
var n int
|
|
if json.Unmarshal(raw, &n) != nil {
|
|
return false
|
|
}
|
|
return n == 1
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Snapshot is one entry of GET /nodes/{node}/lxc/{vmid}/snapshot. The list always includes the
|
|
// synthetic "current" pseudo-snapshot; named entries are real snapshots. An interrupted vzdump
|
|
// snapshot-mode backup leaves a dangling snapshot named exactly "vzdump".
|
|
type Snapshot struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
SnapTime int64 `json:"snaptime,omitempty"`
|
|
Parent string `json:"parent,omitempty"`
|
|
}
|
|
|
|
// Storage is one entry of GET /storage (cluster) and GET /nodes/{node}/storage
|
|
// (the latter adds usage fields). Unused fields stay zero.
|
|
//
|
|
// The lower block (Server/Export/Share/Datastore/Fingerprint/VGName/ThinPool) are the
|
|
// type-specific config fields the cluster /storage definition carries; they are the
|
|
// source for slice-5's deterministic durable_id derivation (server:export for NFS/CIFS,
|
|
// repo+fingerprint for PBS, vg/pool for lvmthin). Additive parse-only fields — decoding
|
|
// ignores unknown keys, so a storage type that lacks one simply leaves it 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"`
|
|
|
|
// Type-specific config (durable_id sources).
|
|
Server string `json:"server,omitempty"` // nfs/cifs/pbs server host
|
|
Export string `json:"export,omitempty"` // nfs export path
|
|
Share string `json:"share,omitempty"` // cifs share name
|
|
Datastore string `json:"datastore,omitempty"` // pbs datastore name
|
|
Fingerprint string `json:"fingerprint,omitempty"` // pbs server cert fingerprint
|
|
Username string `json:"username,omitempty"` // pbs auth id, e.g. "felhom@pbs!n100"
|
|
Namespace string `json:"namespace,omitempty"` // pbs namespace ("" = root; per-customer tenancy = S4)
|
|
VGName string `json:"vgname,omitempty"` // lvm/lvmthin volume group
|
|
ThinPool string `json:"thinpool,omitempty"` // lvmthin pool LV name
|
|
}
|
|
|
|
// 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"`
|
|
}
|