Files
Pulse/pkg/proxmox/client.go
2026-01-28 16:52:50 +00:00

2039 lines
58 KiB
Go

package proxmox
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode"
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
"github.com/rs/zerolog/log"
)
// FlexInt handles JSON fields that can be int, float, or string (for cpulimit support)
type FlexInt int
func (f *FlexInt) UnmarshalJSON(data []byte) error {
// Try to unmarshal as int first
var i int
if err := json.Unmarshal(data, &i); err == nil {
*f = FlexInt(i)
return nil
}
// Try as float (handles cpulimit like 1.5)
var fl float64
if err := json.Unmarshal(data, &fl); err == nil {
*f = FlexInt(int(fl))
return nil
}
// If that fails, try as string
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
// Parse string to float first (handles "1.5" format from cpulimit)
floatVal, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
// Convert to int
*f = FlexInt(int(floatVal))
return nil
}
func coerceUint64(field string, value interface{}) (uint64, error) {
switch v := value.(type) {
case nil:
return 0, nil
case float64:
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0, fmt.Errorf("invalid float value for %s", field)
}
if v <= 0 {
return 0, nil
}
if v >= math.MaxUint64 {
return math.MaxUint64, nil
}
return uint64(math.Round(v)), nil
case int:
if v < 0 {
return 0, nil
}
return uint64(v), nil
case int64:
if v < 0 {
return 0, nil
}
return uint64(v), nil
case int32:
if v < 0 {
return 0, nil
}
return uint64(v), nil
case uint32:
return uint64(v), nil
case uint64:
return v, nil
case json.Number:
return coerceUint64(field, string(v))
case string:
s := strings.TrimSpace(v)
if s == "" || strings.EqualFold(s, "null") {
return 0, nil
}
s = strings.Trim(s, "\"'")
s = strings.TrimSpace(s)
if s == "" || strings.EqualFold(s, "null") {
return 0, nil
}
s = strings.ReplaceAll(s, ",", "")
if strings.ContainsAny(s, ".eE") {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse float for %s: %w", field, err)
}
return coerceUint64(field, f)
}
val, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse uint for %s: %w", field, err)
}
return val, nil
default:
return 0, fmt.Errorf("unsupported type %T for field %s", value, field)
}
}
// Client represents a Proxmox VE API client
type Client struct {
baseURL string
httpClient *http.Client
auth auth
config ClientConfig
}
// ClientConfig holds configuration for the Proxmox client
type ClientConfig struct {
Host string
User string
Password string
TokenName string
TokenValue string
Fingerprint string
VerifySSL bool
Timeout time.Duration
}
// auth represents authentication details
type auth struct {
user string
realm string
ticket string
csrfToken string
tokenName string
tokenValue string
expiresAt time.Time
}
// NewClient creates a new Proxmox VE API client
func NewClient(cfg ClientConfig) (*Client, error) {
var user, realm string
// Log what auth method we're using
log.Debug().
Str("host", cfg.Host).
Bool("hasToken", cfg.TokenName != "").
Bool("hasPassword", cfg.Password != "").
Str("tokenName", cfg.TokenName).
Str("user", cfg.User).
Msg("Creating Proxmox client")
// For token authentication, we don't need user@realm format
if cfg.TokenName != "" && cfg.TokenValue != "" {
// Extract user and realm from token name (format: user@realm!tokenname)
if strings.Contains(cfg.TokenName, "@") && strings.Contains(cfg.TokenName, "!") {
parts := strings.Split(cfg.TokenName, "!")
if len(parts) == 2 {
userRealm := parts[0]
userRealmParts := strings.Split(userRealm, "@")
if len(userRealmParts) == 2 {
user = userRealmParts[0]
realm = userRealmParts[1]
}
}
}
} else {
// For password authentication, parse user and realm from User field
parts := strings.Split(cfg.User, "@")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid user format, expected user@realm")
}
user = parts[0]
realm = parts[1]
}
// Create HTTP client with proper TLS configuration
// Use configured timeout or default to 60 seconds
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 60 * time.Second
}
httpClient := tlsutil.CreateHTTPClientWithTimeout(cfg.VerifySSL, cfg.Fingerprint, timeout)
// Extract just the token name part for API token authentication
tokenName := cfg.TokenName
if cfg.TokenName != "" && strings.Contains(cfg.TokenName, "!") {
parts := strings.Split(cfg.TokenName, "!")
if len(parts) == 2 {
tokenName = parts[1] // Just the token name part (e.g., "pulse-token")
}
}
log.Debug().
Str("user", user).
Str("realm", realm).
Bool("hasToken", cfg.TokenValue != "").
Msg("Proxmox client configured")
client := &Client{
baseURL: strings.TrimSuffix(cfg.Host, "/") + "/api2/json",
httpClient: httpClient,
config: cfg,
auth: auth{
user: user,
realm: realm,
tokenName: tokenName,
tokenValue: cfg.TokenValue,
},
}
// Authenticate if using password
if cfg.Password != "" && cfg.TokenName == "" {
if err := client.authenticate(context.Background()); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
}
return client, nil
}
// authenticate performs password-based authentication
func (c *Client) authenticate(ctx context.Context) error {
username := c.auth.user + "@" + c.auth.realm
password := c.config.Password
if err := c.authenticateJSON(ctx, username, password); err == nil {
return nil
} else if shouldFallbackToForm(err) {
return c.authenticateForm(ctx, username, password)
} else {
return err
}
}
func (c *Client) authenticateJSON(ctx context.Context, username, password string) error {
payload := map[string]string{
"username": username,
"password": password,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/access/ticket", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return c.handleAuthResponse(resp)
}
func (c *Client) authenticateForm(ctx context.Context, username, password string) error {
data := url.Values{
"username": {username},
"password": {password},
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/access/ticket", strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return c.handleAuthResponse(resp)
}
func (c *Client) handleAuthResponse(resp *http.Response) error {
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return &authHTTPError{status: resp.StatusCode, body: string(body)}
}
var result struct {
Data struct {
Ticket string `json:"ticket"`
CSRFPreventionToken string `json:"CSRFPreventionToken"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
c.auth.ticket = result.Data.Ticket
c.auth.csrfToken = result.Data.CSRFPreventionToken
c.auth.expiresAt = time.Now().Add(2 * time.Hour) // PVE tickets expire after 2 hours
return nil
}
type authHTTPError struct {
status int
body string
}
func (e *authHTTPError) Error() string {
if e.status == http.StatusUnauthorized || e.status == http.StatusForbidden {
return fmt.Sprintf("authentication failed (status %d): %s", e.status, e.body)
}
return fmt.Sprintf("authentication failed: %s", e.body)
}
func shouldFallbackToForm(err error) bool {
if authErr, ok := err.(*authHTTPError); ok {
switch authErr.status {
case http.StatusBadRequest, http.StatusUnsupportedMediaType:
return true
}
}
return false
}
// request performs an API request
func (c *Client) request(ctx context.Context, method, path string, data url.Values) (*http.Response, error) {
// Re-authenticate if needed
if c.config.Password != "" && c.auth.tokenName == "" && time.Now().After(c.auth.expiresAt) {
if err := c.authenticate(ctx); err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
}
var body io.Reader
if data != nil {
body = strings.NewReader(data.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return nil, err
}
// Set headers
if data != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
// Set authentication
if c.auth.tokenName != "" && c.auth.tokenValue != "" {
// API token authentication
authHeader := fmt.Sprintf("PVEAPIToken=%s@%s!%s=%s",
c.auth.user, c.auth.realm, c.auth.tokenName, c.auth.tokenValue)
req.Header.Set("Authorization", authHeader)
// NEVER log the actual token value - only log that we're using token auth
maskedHeader := fmt.Sprintf("PVEAPIToken=%s@%s!%s=***",
c.auth.user, c.auth.realm, c.auth.tokenName)
log.Debug().
Str("authHeader", maskedHeader).
Str("url", req.URL.String()).
Msg("Setting API token authentication")
} else if c.auth.ticket != "" {
// Ticket authentication
req.Header.Set("Cookie", "PVEAuthCookie="+c.auth.ticket)
if method != "GET" && c.auth.csrfToken != "" {
req.Header.Set("CSRFPreventionToken", c.auth.csrfToken)
}
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
// Check for errors
if resp.StatusCode >= 400 {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// Create base error with helpful guidance for common issues
var err error
if resp.StatusCode == 403 && c.config.TokenName != "" {
// Special case for 403 with API token - this is usually a permission issue
err = fmt.Errorf("API error 403 (Forbidden): The API token does not have sufficient permissions. Note: In Proxmox GUI, permissions must be set on the USER (not just the token). Please verify the user '%s@%s' has the required permissions", c.auth.user, c.auth.realm)
} else if resp.StatusCode == 595 {
// 595 can mean authentication failed OR trying to access an offline node in a cluster
// Check if this is a node-specific endpoint
if strings.Contains(req.URL.Path, "/nodes/") && strings.Count(req.URL.Path, "/") > 3 {
// This looks like a node-specific resource request
err = fmt.Errorf("API error 595: Cannot access node resource - node may be offline or credentials may be invalid")
} else {
err = fmt.Errorf("API error 595: Authentication failed - please check your credentials")
}
} else if resp.StatusCode == 401 {
err = fmt.Errorf("API error 401 (Unauthorized): Invalid credentials or token")
} else {
err = fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
// Log auth issues for debugging (595 is Proxmox "no ticket" error)
if resp.StatusCode == 595 || resp.StatusCode == 401 || resp.StatusCode == 403 {
log.Warn().
Str("url", req.URL.String()).
Int("status", resp.StatusCode).
Bool("hasToken", c.config.TokenName != "").
Bool("hasPassword", c.config.Password != "").
Str("tokenName", c.config.TokenName).
Msg("Proxmox authentication error")
}
// Wrap with appropriate error type
if resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode == 595 {
// Import errors package at top of file
return nil, fmt.Errorf("authentication error: %w", err)
}
return nil, err
}
return resp, nil
}
// get performs a GET request
func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
return c.request(ctx, "GET", path, nil)
}
// Node represents a Proxmox VE node
type Node struct {
Node string `json:"node"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
MaxCPU int `json:"maxcpu"`
Mem uint64 `json:"mem"`
MaxMem uint64 `json:"maxmem"`
Disk uint64 `json:"disk"`
MaxDisk uint64 `json:"maxdisk"`
Uptime uint64 `json:"uptime"`
Level string `json:"level"`
}
// NodeRRDPoint represents a single RRD datapoint for a node.
type NodeRRDPoint struct {
Time int64 `json:"time"`
MemTotal *float64 `json:"memtotal,omitempty"`
MemUsed *float64 `json:"memused,omitempty"`
MemAvailable *float64 `json:"memavailable,omitempty"`
}
// GuestRRDPoint represents a single RRD datapoint for a VM or LXC container.
type GuestRRDPoint struct {
Time int64 `json:"time"`
MaxMem *float64 `json:"maxmem,omitempty"`
MemUsed *float64 `json:"memused,omitempty"`
MemAvailable *float64 `json:"memavailable,omitempty"`
}
// NodeStatus represents detailed node status from /nodes/{node}/status endpoint
// This endpoint provides real-time metrics that update every second
type NodeStatus struct {
CPU float64 `json:"cpu"` // Real-time CPU usage (0-1)
Memory *MemoryStatus `json:"memory"` // Real-time memory stats
Swap *SwapStatus `json:"swap"` // Swap usage
LoadAvg []interface{} `json:"loadavg"` // Can be float64 or string
KernelVersion string `json:"kversion"`
PVEVersion string `json:"pveversion"`
CPUInfo *CPUInfo `json:"cpuinfo"`
RootFS *RootFS `json:"rootfs"`
Uptime uint64 `json:"uptime"` // Uptime in seconds
Wait float64 `json:"wait"` // IO wait
IODelay float64 `json:"iodelay"` // IO delay
Idle float64 `json:"idle"` // CPU idle time
}
// MemoryStatus represents real-time memory information
type MemoryStatus struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Free uint64 `json:"free"`
Available uint64 `json:"available"` // Memory available for allocation (excludes non-reclaimable cache)
Avail uint64 `json:"avail"` // Older Proxmox field name for available memory
Buffers uint64 `json:"buffers"` // Reclaimable buffers
Cached uint64 `json:"cached"` // Reclaimable page cache
Shared uint64 `json:"shared"` // Shared memory (informational)
}
func (m *MemoryStatus) UnmarshalJSON(data []byte) error {
type rawMemoryStatus struct {
Total interface{} `json:"total"`
Used interface{} `json:"used"`
Free interface{} `json:"free"`
Available interface{} `json:"available"`
Avail interface{} `json:"avail"`
Buffers interface{} `json:"buffers"`
Cached interface{} `json:"cached"`
Shared interface{} `json:"shared"`
}
var raw rawMemoryStatus
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
total, err := coerceUint64("total", raw.Total)
if err != nil {
return err
}
used, err := coerceUint64("used", raw.Used)
if err != nil {
return err
}
free, err := coerceUint64("free", raw.Free)
if err != nil {
return err
}
available, err := coerceUint64("available", raw.Available)
if err != nil {
return err
}
avail, err := coerceUint64("avail", raw.Avail)
if err != nil {
return err
}
buffers, err := coerceUint64("buffers", raw.Buffers)
if err != nil {
return err
}
cached, err := coerceUint64("cached", raw.Cached)
if err != nil {
return err
}
shared, err := coerceUint64("shared", raw.Shared)
if err != nil {
return err
}
*m = MemoryStatus{
Total: total,
Used: used,
Free: free,
Available: available,
Avail: avail,
Buffers: buffers,
Cached: cached,
Shared: shared,
}
return nil
}
// EffectiveAvailable returns the best-effort estimate of reclaimable memory.
// Prefer the dedicated "available"/"avail" fields when present, otherwise derive
// from free + buffers + cached which mirrors Linux's MemAvailable calculation.
func (m *MemoryStatus) EffectiveAvailable() uint64 {
if m == nil {
return 0
}
if m.Available > 0 {
return m.Available
}
if m.Avail > 0 {
return m.Avail
}
derived := m.Free + m.Buffers + m.Cached
if m.Total > 0 && m.Used > 0 && m.Total >= m.Used {
availableFromUsed := m.Total - m.Used
if availableFromUsed > derived {
derived = availableFromUsed
}
}
if derived == 0 {
return 0
}
// Cap at total to guard against over-reporting when buffers/cached exceed total.
if m.Total > 0 && derived > m.Total {
return m.Total
}
return derived
}
// SwapStatus represents swap information
type SwapStatus struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Free uint64 `json:"free"`
}
// RootFS represents root filesystem information
type RootFS struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Free uint64 `json:"avail"`
}
// CPUInfo represents CPU information
type CPUInfo struct {
Model string `json:"model"`
Cores int `json:"cores"`
Sockets int `json:"sockets"`
MHz interface{} `json:"mhz"` // Can be string or number
}
// GetMHzString returns MHz as a string
func (c *CPUInfo) GetMHzString() string {
if c.MHz == nil {
return ""
}
switch v := c.MHz.(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
case int:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprintf("%v", v)
}
}
// GetNodes returns all nodes in the cluster
func (c *Client) GetNodes(ctx context.Context) ([]Node, error) {
resp, err := c.get(ctx, "/nodes")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Node `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetNodeStatus returns detailed status for a specific node
func (c *Client) GetNodeStatus(ctx context.Context, node string) (*NodeStatus, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/status", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data NodeStatus `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
// GetNodeRRDData retrieves RRD metrics for a node.
func (c *Client) GetNodeRRDData(ctx context.Context, node, timeframe, cf string, ds []string) ([]NodeRRDPoint, error) {
if timeframe == "" {
timeframe = "hour"
}
if cf == "" {
cf = "AVERAGE"
}
params := url.Values{}
params.Set("timeframe", timeframe)
params.Set("cf", cf)
if len(ds) > 0 {
params.Set("ds", strings.Join(ds, ","))
}
path := fmt.Sprintf("/nodes/%s/rrddata", url.PathEscape(node))
if query := params.Encode(); query != "" {
path = fmt.Sprintf("%s?%s", path, query)
}
resp, err := c.get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []NodeRRDPoint `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetLXCRRDData retrieves RRD metrics for an LXC container.
func (c *Client) GetLXCRRDData(ctx context.Context, node string, vmid int, timeframe, cf string, ds []string) ([]GuestRRDPoint, error) {
if timeframe == "" {
timeframe = "hour"
}
if cf == "" {
cf = "AVERAGE"
}
params := url.Values{}
params.Set("timeframe", timeframe)
params.Set("cf", cf)
if len(ds) > 0 {
params.Set("ds", strings.Join(ds, ","))
}
path := fmt.Sprintf("/nodes/%s/lxc/%d/rrddata", url.PathEscape(node), vmid)
if query := params.Encode(); query != "" {
path = fmt.Sprintf("%s?%s", path, query)
}
resp, err := c.get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []GuestRRDPoint `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// VM represents a Proxmox VE virtual machine
type VM struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Node string `json:"node"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
CPUs int `json:"cpus"`
Mem uint64 `json:"mem"`
MaxMem uint64 `json:"maxmem"`
Disk uint64 `json:"disk"`
MaxDisk uint64 `json:"maxdisk"`
NetIn uint64 `json:"netin"`
NetOut uint64 `json:"netout"`
DiskRead uint64 `json:"diskread"`
DiskWrite uint64 `json:"diskwrite"`
Uptime uint64 `json:"uptime"`
Template int `json:"template"`
Tags string `json:"tags"`
Lock string `json:"lock"`
Agent int `json:"agent"`
}
// Container represents a Proxmox VE LXC container
type Container struct {
VMID FlexInt `json:"vmid"` // Changed to FlexInt to handle string VMIDs from some Proxmox versions
Name string `json:"name"`
Node string `json:"node"`
Status string `json:"status"`
CPU float64 `json:"cpu"`
CPUs FlexInt `json:"cpus"`
Mem uint64 `json:"mem"`
MaxMem uint64 `json:"maxmem"`
Swap uint64 `json:"swap"`
MaxSwap uint64 `json:"maxswap"`
Disk uint64 `json:"disk"`
MaxDisk uint64 `json:"maxdisk"`
NetIn uint64 `json:"netin"`
NetOut uint64 `json:"netout"`
DiskRead uint64 `json:"diskread"`
DiskWrite uint64 `json:"diskwrite"`
Uptime uint64 `json:"uptime"`
Template int `json:"template"`
Tags string `json:"tags"`
Lock string `json:"lock"`
Hostname string `json:"hostname,omitempty"`
IP string `json:"ip,omitempty"`
IP6 string `json:"ip6,omitempty"`
IPv4 json.RawMessage `json:"ipv4,omitempty"`
IPv6 json.RawMessage `json:"ipv6,omitempty"`
Network map[string]ContainerNetworkConfig `json:"network,omitempty"`
DiskInfo map[string]ContainerDiskUsage `json:"diskinfo,omitempty"`
RootFS string `json:"rootfs,omitempty"`
}
// ContainerNetworkConfig captures basic container network status information.
type ContainerNetworkConfig struct {
Name string `json:"name,omitempty"`
HWAddr string `json:"hwaddr,omitempty"`
Bridge string `json:"bridge,omitempty"`
Method string `json:"method,omitempty"`
Type string `json:"type,omitempty"`
IP interface{} `json:"ip,omitempty"`
IP6 interface{} `json:"ip6,omitempty"`
IPv4 interface{} `json:"ipv4,omitempty"`
IPv6 interface{} `json:"ipv6,omitempty"`
Firewall interface{} `json:"firewall,omitempty"`
Tag interface{} `json:"tag,omitempty"`
}
// ContainerDiskUsage captures disk usage details returned by the LXC status API.
type ContainerDiskUsage struct {
Total uint64 `json:"total,omitempty"`
Used uint64 `json:"used,omitempty"`
}
// ContainerInterfaceAddress describes an IP entry associated with a container interface.
type ContainerInterfaceAddress struct {
Address string `json:"ip-address"`
Type string `json:"ip-address-type"`
Prefix string `json:"prefix"`
}
// ContainerInterface describes a container network interface returned by Proxmox.
type ContainerInterface struct {
Name string `json:"name"`
HWAddr string `json:"hwaddr"`
Inet string `json:"inet,omitempty"`
IPAddresses []ContainerInterfaceAddress `json:"ip-addresses,omitempty"`
}
// NodeNetworkInterface describes a network interface on a Proxmox node.
type NodeNetworkInterface struct {
Iface string `json:"iface"` // Interface name (e.g., "eth0", "vmbr0")
Type string `json:"type"` // Type (e.g., "eth", "bridge", "bond")
Address string `json:"address,omitempty"` // IPv4 address
Address6 string `json:"address6,omitempty"` // IPv6 address
Netmask string `json:"netmask,omitempty"` // IPv4 netmask
CIDR string `json:"cidr,omitempty"` // CIDR notation (e.g., "10.1.1.5/24")
Active int `json:"active"` // 1 if active
}
// GetNodeNetworkInterfaces returns the network interfaces configured on a Proxmox node.
// This can be used to find all IPs available on a node for connection purposes.
func (c *Client) GetNodeNetworkInterfaces(ctx context.Context, node string) ([]NodeNetworkInterface, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/network", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get node network interfaces (status %d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result struct {
Data []NodeNetworkInterface `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetContainerConfig returns the configuration of a specific container
func (c *Client) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/config", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if result.Data == nil {
result.Data = make(map[string]interface{})
}
return result.Data, nil
}
// Storage represents a Proxmox VE storage
type Storage struct {
Storage string `json:"storage"`
Type string `json:"type"`
Content string `json:"content"`
Active int `json:"active"`
Enabled int `json:"enabled"`
Shared int `json:"shared"`
Nodes string `json:"nodes,omitempty"`
Path string `json:"path,omitempty"`
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Available uint64 `json:"avail"`
}
// StorageContent represents content in a storage
type StorageContent struct {
Volid string `json:"volid"`
Content string `json:"content"`
CTime int64 `json:"ctime"`
Format string `json:"format"`
Size uint64 `json:"size"`
Used uint64 `json:"used"`
VMID int `json:"vmid"`
Notes string `json:"notes"`
Protected int `json:"protected"`
Encryption string `json:"encryption"`
Verification map[string]interface{} `json:"verification"` // PBS verification info
Verified int `json:"verified"` // Simple verified flag
}
// Snapshot represents a VM or container snapshot
type Snapshot struct {
Name string `json:"name"`
Description string `json:"description"`
SnapTime int64 `json:"snaptime"`
Parent string `json:"parent"`
VMID int `json:"vmid"`
}
// GetVMs returns all VMs on a specific node
func (c *Client) GetVMs(ctx context.Context, node string) ([]VM, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []VM `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetContainers returns all LXC containers on a specific node
func (c *Client) GetContainers(ctx context.Context, node string) ([]Container, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Container `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetStorage returns storage information for a specific node
func (c *Client) GetStorage(ctx context.Context, node string) ([]Storage, error) {
// Storage queries can take longer on large clusters or slow storage backends
// Create a new context with shorter timeout for storage API calls
// Storage endpoints can hang when NFS/network storage is unavailable
// Using 30s timeout as a balance between responsiveness and reliability
storageCtx := ctx
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 30*time.Second {
var cancel context.CancelFunc
storageCtx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
}
resp, err := c.get(storageCtx, fmt.Sprintf("/nodes/%s/storage", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Storage `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetAllStorage returns storage information across all nodes
func (c *Client) GetAllStorage(ctx context.Context) ([]Storage, error) {
// Storage queries can take longer on large clusters
// Create a new context with shorter timeout for storage API calls
// Storage endpoints can hang when NFS/network storage is unavailable
// Using 30s timeout as a balance between responsiveness and reliability
storageCtx := ctx
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 30*time.Second {
var cancel context.CancelFunc
storageCtx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
}
resp, err := c.get(storageCtx, "/storage")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Storage `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// Task represents a Proxmox task
type Task struct {
UPID string `json:"upid"`
Node string `json:"node"`
PID int `json:"pid"`
PStart int64 `json:"pstart"`
StartTime int64 `json:"starttime"`
Type string `json:"type"`
ID string `json:"id"`
User string `json:"user"`
Status string `json:"status,omitempty"`
EndTime int64 `json:"endtime,omitempty"`
}
// GetNodeTasks gets tasks for a specific node
func (c *Client) GetNodeTasks(ctx context.Context, node string) ([]Task, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/tasks", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Task `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetBackupTasks gets all backup tasks across all nodes
func (c *Client) GetBackupTasks(ctx context.Context) ([]Task, error) {
// First get all nodes
nodes, err := c.GetNodes(ctx)
if err != nil {
return nil, err
}
var allTasks []Task
for _, node := range nodes {
if node.Status != "online" {
continue
}
tasks, err := c.GetNodeTasks(ctx, node.Node)
if err != nil {
// Log error but continue with other nodes
continue
}
// Filter for backup tasks
for _, task := range tasks {
if task.Type == "vzdump" {
allTasks = append(allTasks, task)
}
}
}
return allTasks, nil
}
// GetContainerInterfaces returns the network interfaces (with IPs) for a container.
func (c *Client) GetContainerInterfaces(ctx context.Context, node string, vmid int) ([]ContainerInterface, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/interfaces", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get container interfaces (status %d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result struct {
Data []ContainerInterface `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetStorageContent returns the content of a specific storage
func (c *Client) GetStorageContent(ctx context.Context, node, storage string) ([]StorageContent, error) {
// Storage content queries can take longer on large storages, especially PBS
// with encrypted backups which can take 10-20+ seconds to enumerate.
// Using 60s timeout to accommodate slow PBS storage backends while still
// preventing indefinite hangs on unavailable NFS/network storage.
storageCtx := ctx
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 60*time.Second {
var cancel context.CancelFunc
storageCtx, cancel = context.WithTimeout(ctx, 60*time.Second)
defer cancel()
}
resp, err := c.get(storageCtx, fmt.Sprintf("/nodes/%s/storage/%s/content", node, storage))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []StorageContent `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Filter for backup content only
var backups []StorageContent
for _, content := range result.Data {
if content.Content == "backup" || content.Content == "vztmpl" {
backups = append(backups, content)
}
}
return backups, nil
}
// GetVMSnapshots returns snapshots for a specific VM
func (c *Client) GetVMSnapshots(ctx context.Context, node string, vmid int) ([]Snapshot, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/snapshot", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Snapshot `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Filter out the 'current' snapshot which is not a real snapshot
var snapshots []Snapshot
for _, snap := range result.Data {
if snap.Name != "current" {
snap.VMID = vmid
snapshots = append(snapshots, snap)
}
}
return snapshots, nil
}
// GetContainerSnapshots returns snapshots for a specific container
func (c *Client) GetContainerSnapshots(ctx context.Context, node string, vmid int) ([]Snapshot, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/snapshot", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Snapshot `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Filter out the 'current' snapshot which is not a real snapshot
var snapshots []Snapshot
for _, snap := range result.Data {
if snap.Name != "current" {
snap.VMID = vmid
snapshots = append(snapshots, snap)
}
}
return snapshots, nil
}
// ClusterStatus represents the cluster status response
type ClusterStatus struct {
Type string `json:"type"` // "cluster" or "node"
ID string `json:"id"` // Node ID or cluster name
Name string `json:"name"` // Node name
IP string `json:"ip"` // Node IP address
Local int `json:"local"` // 1 if this is the local node
Nodeid int `json:"nodeid"` // Node ID in cluster
Online int `json:"online"` // 1 if online
Level string `json:"level"` // Connection level
Quorate int `json:"quorate"` // 1 if cluster has quorum
}
// GetClusterStatus returns the cluster status including all nodes
func (c *Client) GetClusterStatus(ctx context.Context) ([]ClusterStatus, error) {
resp, err := c.get(ctx, "/cluster/status")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []ClusterStatus `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// IsClusterMember checks if this node is part of a cluster
func (c *Client) IsClusterMember(ctx context.Context) (bool, error) {
status, err := c.GetClusterStatus(ctx)
if err != nil {
// If we can't get cluster status, assume it's not a cluster
// This prevents treating API errors as cluster membership
return false, nil
}
// Check for explicit cluster entry (most reliable indicator)
for _, s := range status {
if s.Type == "cluster" {
// Found a cluster entry - this is definitely a cluster
return true, nil
}
}
// Fallback: If we have more than one node entry, it's likely a cluster
// (though this shouldn't happen without a cluster entry)
nodeCount := 0
for _, s := range status {
if s.Type == "node" {
nodeCount++
}
}
return nodeCount > 1, nil
}
// GetVMConfig returns the configuration for a specific VM
func (c *Client) GetVMConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/config", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetVMAgentInfo returns guest agent information for a VM if available
func (c *Client) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/get-osinfo", node, vmid))
if err != nil {
// Guest agent might not be installed or running
return nil, err
}
defer resp.Body.Close()
var result struct {
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetVMAgentVersion returns the guest agent version information for a VM if available.
func (c *Client) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/info", node, vmid))
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
Data struct {
Result map[string]interface{} `json:"result"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
extractVersion := func(val interface{}) string {
switch v := val.(type) {
case string:
return strings.TrimSpace(v)
case map[string]interface{}:
if ver, ok := v["version"]; ok {
if s, ok := ver.(string); ok {
return strings.TrimSpace(s)
}
}
}
return ""
}
if result.Data.Result != nil {
if version := extractVersion(result.Data.Result["version"]); version != "" {
return version, nil
}
if qemuGA, ok := result.Data.Result["qemu-ga"]; ok {
if version := extractVersion(qemuGA); version != "" {
return version, nil
}
}
}
return "", nil
}
// VMFileSystem represents filesystem information from QEMU guest agent
type VMFileSystem struct {
Name string `json:"name"`
Type string `json:"type"`
Mountpoint string `json:"mountpoint"`
TotalBytes uint64 `json:"total-bytes"`
UsedBytes uint64 `json:"used-bytes"`
Disk string // Extracted disk device name for duplicate detection
DiskRaw []interface{} `json:"disk"` // Raw disk device info from API
}
func (fs *VMFileSystem) UnmarshalJSON(data []byte) error {
type rawVMFileSystem struct {
Name string `json:"name"`
Type string `json:"type"`
Mountpoint string `json:"mountpoint"`
TotalBytes interface{} `json:"total-bytes"`
UsedBytes interface{} `json:"used-bytes"`
DiskRaw []interface{} `json:"disk"`
}
var raw rawVMFileSystem
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
total, err := parseUint64Flexible(raw.TotalBytes)
if err != nil {
return err
}
used, err := parseUint64Flexible(raw.UsedBytes)
if err != nil {
return err
}
fs.Name = raw.Name
fs.Type = raw.Type
fs.Mountpoint = raw.Mountpoint
fs.TotalBytes = total
fs.UsedBytes = used
fs.DiskRaw = raw.DiskRaw
fs.Disk = ""
return nil
}
func parseUint64Flexible(value interface{}) (uint64, error) {
switch v := value.(type) {
case nil:
return 0, nil
case uint64:
return v, nil
case int:
if v < 0 {
return 0, nil
}
return uint64(v), nil
case int64:
if v < 0 {
return 0, nil
}
return uint64(v), nil
case float64:
if v < 0 {
return 0, nil
}
return uint64(v), nil
case json.Number:
return parseUint64Flexible(v.String())
case string:
s := strings.TrimSpace(v)
if s == "" {
return 0, nil
}
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
u, err := strconv.ParseUint(s[2:], 16, 64)
if err != nil {
return 0, err
}
return u, nil
}
if strings.ContainsAny(s, ".eE") {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, err
}
if f < 0 {
return 0, nil
}
return uint64(f), nil
}
u, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, err
}
return u, nil
default:
return 0, fmt.Errorf("unsupported type %T for uint64 conversion", value)
}
}
type VMIpAddress struct {
Address string `json:"ip-address"`
Prefix int `json:"prefix"`
}
type VMNetworkInterface struct {
Name string `json:"name"`
HardwareAddr string `json:"hardware-address"`
IPAddresses []VMIpAddress `json:"ip-addresses"`
Statistics interface{} `json:"statistics,omitempty"`
HasIp4Gateway bool `json:"has-ipv4-synth-gateway,omitempty"`
HasIp6Gateway bool `json:"has-ipv6-synth-gateway,omitempty"`
}
// GetVMFSInfo returns filesystem information from QEMU guest agent
func (c *Client) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFileSystem, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/get-fsinfo", node, vmid))
if err != nil {
// Guest agent might not be installed or running
return nil, err
}
defer resp.Body.Close()
// First, read the response body into bytes so we can try multiple unmarshal attempts
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Log the raw response for debugging
log.Debug().
Str("node", node).
Int("vmid", vmid).
Str("response", string(bodyBytes)).
Msg("Raw response from guest agent get-fsinfo")
// Try to unmarshal as an array first (expected format)
var arrayResult struct {
Data struct {
Result []VMFileSystem `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &arrayResult); err == nil && arrayResult.Data.Result != nil {
// Post-process to extract disk device names
for i := range arrayResult.Data.Result {
fs := &arrayResult.Data.Result[i]
// Extract disk device name from the DiskRaw field
if len(fs.DiskRaw) > 0 {
// The disk field usually contains device info as a map
if diskMap, ok := fs.DiskRaw[0].(map[string]interface{}); ok {
// Try to get the device name from various possible fields
if dev, ok := diskMap["dev"].(string); ok {
fs.Disk = dev
} else if serial, ok := diskMap["serial"].(string); ok {
fs.Disk = serial
} else if bus, ok := diskMap["bus-type"].(string); ok {
if target, ok := diskMap["target"].(float64); ok {
fs.Disk = fmt.Sprintf("%s-%d", bus, int(target))
}
}
}
}
// If we still don't have a disk identifier, use the mountpoint as a fallback
if fs.Disk == "" && fs.Mountpoint != "" {
// For root filesystem, use a special identifier
if fs.Mountpoint == "/" {
fs.Disk = "root-filesystem"
} else {
// For Windows, normalize drive letters to prevent duplicate counting
// Windows guest agent can return multiple directory entries (C:\, C:\Users, C:\Windows)
// all on the same physical drive. Without disk[] metadata, we must deduplicate by drive letter.
isWindowsDrive := len(fs.Mountpoint) >= 2 && fs.Mountpoint[1] == ':' && strings.Contains(fs.Mountpoint, "\\")
if isWindowsDrive {
// Use drive letter as identifier (e.g., "C:" for C:\, C:\Users, etc.)
driveLetter := strings.ToUpper(fs.Mountpoint[:2])
fs.Disk = driveLetter
log.Debug().
Str("node", node).
Int("vmid", vmid).
Str("mountpoint", fs.Mountpoint).
Str("synthesized_disk", driveLetter).
Msg("Synthesized Windows drive identifier from mountpoint")
} else {
// Use mountpoint as unique identifier for non-Windows paths
fs.Disk = fs.Mountpoint
}
}
}
}
return arrayResult.Data.Result, nil
}
// If that fails, try as an object (might be an error response or different format)
var objectResult struct {
Data struct {
Result interface{} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &objectResult); err == nil {
// If result is an object, it might be an error or empty response
// Check if it's null or an error
if objectResult.Data.Result == nil {
log.Debug().
Str("node", node).
Int("vmid", vmid).
Msg("GetVMFSInfo received null result - guest agent may not be providing disk info")
} else {
log.Debug().
Str("node", node).
Int("vmid", vmid).
Interface("result", objectResult.Data.Result).
Msg("GetVMFSInfo received object instead of array")
}
// Return empty array to indicate no filesystem info available
return []VMFileSystem{}, nil
}
// If both fail, return error
return nil, fmt.Errorf("unexpected response format from guest agent get-fsinfo")
}
// GetVMNetworkInterfaces returns network interfaces reported by the guest agent
func (c *Client) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]VMNetworkInterface, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/network-get-interfaces", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data struct {
Result []VMNetworkInterface `json:"result"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data.Result, nil
}
// GetVMStatus returns detailed VM status including balloon info
func (c *Client) GetVMStatus(ctx context.Context, node string, vmid int) (*VMStatus, error) {
// Note: Proxmox 9.x removed support for the "full" parameter
// The endpoint now returns all data by default
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/status/current", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data VMStatus `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
// GetContainerStatus returns detailed container status using real-time endpoint
func (c *Client) GetContainerStatus(ctx context.Context, node string, vmid int) (*Container, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/status/current", node, vmid))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data Container `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
// ClusterResource represents a resource from /cluster/resources
type ClusterResource struct {
ID string `json:"id"`
Type string `json:"type"`
Node string `json:"node"`
Status string `json:"status"`
Name string `json:"name,omitempty"`
VMID int `json:"vmid,omitempty"`
CPU float64 `json:"cpu,omitempty"`
MaxCPU int `json:"maxcpu,omitempty"`
Mem uint64 `json:"mem,omitempty"`
MaxMem uint64 `json:"maxmem,omitempty"`
Disk uint64 `json:"disk,omitempty"`
MaxDisk uint64 `json:"maxdisk,omitempty"`
NetIn uint64 `json:"netin,omitempty"`
NetOut uint64 `json:"netout,omitempty"`
DiskRead uint64 `json:"diskread,omitempty"`
DiskWrite uint64 `json:"diskwrite,omitempty"`
Uptime uint64 `json:"uptime,omitempty"`
Template int `json:"template,omitempty"`
Tags string `json:"tags,omitempty"`
}
// GetClusterResources returns all resources (VMs, containers) across the cluster
func (c *Client) GetClusterResources(ctx context.Context, resourceType string) ([]ClusterResource, error) {
path := "/cluster/resources"
if resourceType != "" {
path = fmt.Sprintf("%s?type=%s", path, resourceType)
}
resp, err := c.get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []ClusterResource `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// ZFSPoolStatus represents the status of a ZFS pool (list endpoint)
type ZFSPoolStatus struct {
Name string `json:"name"`
Health string `json:"health"` // ONLINE, DEGRADED, FAULTED, etc.
Size uint64 `json:"size"`
Alloc uint64 `json:"alloc"`
Free uint64 `json:"free"`
Frag int `json:"frag"`
Dedup float64 `json:"dedup"`
}
// ZFSPoolDetail represents detailed status of a ZFS pool
type ZFSPoolDetail struct {
Name string `json:"name"`
State string `json:"state"` // ONLINE, DEGRADED, FAULTED, etc.
Status string `json:"status"` // Detailed status message
Action string `json:"action"` // Recommended action
Scan string `json:"scan"` // Scan status
Errors string `json:"errors"` // Error summary
Children []ZFSPoolDevice `json:"children"` // Top-level vdevs
}
// ZFSPoolDevice represents a device in a ZFS pool
type ZFSPoolDevice struct {
Name string `json:"name"`
State string `json:"state"` // ONLINE, DEGRADED, FAULTED, etc.
Read int64 `json:"read"`
Write int64 `json:"write"`
Cksum int64 `json:"cksum"`
Msg string `json:"msg"`
Leaf int `json:"leaf"` // 1 for leaf devices, 0 for vdevs
Children []ZFSPoolDevice `json:"children,omitempty"`
}
// VMStatus represents detailed VM status
// VMMemInfo describes memory statistics reported by the guest agent.
// Proxmox surfaces guest /proc/meminfo values (in bytes). The available
// field is only present on newer agent versions, so we keep the raw
// components to reconstruct it when missing.
type VMMemInfo struct {
Total uint64 `json:"total,omitempty"`
Used uint64 `json:"used,omitempty"`
Free uint64 `json:"free,omitempty"`
Available uint64 `json:"available,omitempty"`
Buffers uint64 `json:"buffers,omitempty"`
Cached uint64 `json:"cached,omitempty"`
Shared uint64 `json:"shared,omitempty"`
}
// VMAgentField handles the polymorphic agent field that changed in Proxmox 8.3+.
// Older versions: integer (0 or 1)
// Proxmox 8.3+: object {"enabled":1,"available":1} or similar
type VMAgentField struct {
Value int
}
// UnmarshalJSON implements custom JSON unmarshaling to handle both int and object formats
func (a *VMAgentField) UnmarshalJSON(data []byte) error {
// Try parsing as int first (older Proxmox versions)
var intValue int
if err := json.Unmarshal(data, &intValue); err == nil {
a.Value = intValue
return nil
}
// Try parsing as object (Proxmox 8.3+)
var objValue struct {
Enabled int `json:"enabled"`
Available int `json:"available"`
}
if err := json.Unmarshal(data, &objValue); err == nil {
// Agent is considered enabled if either field is > 0
// Typically we want to check "available" for actual functionality
if objValue.Available > 0 {
a.Value = objValue.Available
} else if objValue.Enabled > 0 {
a.Value = objValue.Enabled
} else {
a.Value = 0
}
return nil
}
// If neither worked, default to 0 (agent disabled)
a.Value = 0
return nil
}
// VMStatus represents detailed VM status returned by Proxmox.
type VMStatus struct {
Status string `json:"status"`
CPU float64 `json:"cpu"`
CPUs int `json:"cpus"`
Mem uint64 `json:"mem"`
MaxMem uint64 `json:"maxmem"`
Balloon uint64 `json:"balloon"`
BalloonMin uint64 `json:"balloon_min"`
FreeMem uint64 `json:"freemem"`
MemInfo *VMMemInfo `json:"meminfo,omitempty"`
Disk uint64 `json:"disk"`
MaxDisk uint64 `json:"maxdisk"`
DiskRead uint64 `json:"diskread"`
DiskWrite uint64 `json:"diskwrite"`
NetIn uint64 `json:"netin"`
NetOut uint64 `json:"netout"`
Uptime uint64 `json:"uptime"`
Agent VMAgentField `json:"agent"`
}
// GetZFSPoolStatus gets the status of ZFS pools on a node
func (c *Client) GetZFSPoolStatus(ctx context.Context, node string) ([]ZFSPoolStatus, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/disks/zfs", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []ZFSPoolStatus `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// GetZFSPoolDetail gets detailed status of a specific ZFS pool
func (c *Client) GetZFSPoolDetail(ctx context.Context, node, pool string) (*ZFSPoolDetail, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/disks/zfs/%s", node, pool))
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Proxmox returns {"data": {...}}
var result struct {
Data ZFSPoolDetail `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
const wearoutUnknown = -1
// Disk represents a physical disk on a Proxmox node
type Disk struct {
DevPath string `json:"devpath"`
Model string `json:"model"`
Serial string `json:"serial"`
Type string `json:"type"` // nvme, sata, sas
Health string `json:"health"` // PASSED, FAILED, UNKNOWN
Wearout int `json:"-"` // SSD wear percentage (0-100, 100 is best, -1 when unavailable)
Size int64 `json:"size"` // Size in bytes
RPM int `json:"rpm"` // 0 for SSDs
Used string `json:"used"` // Filesystem or partition usage
Vendor string `json:"vendor"`
WWN string `json:"wwn"` // World Wide Name
}
// UnmarshalJSON custom unmarshaler for Disk to handle non-numeric wearout values
func (d *Disk) UnmarshalJSON(data []byte) error {
type Alias Disk
aux := &struct {
Wearout interface{} `json:"wearout"`
RPM interface{} `json:"rpm"`
*Alias
}{
Alias: (*Alias)(d),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// Handle wearout field which can be int, string ("N/A"), or null
switch v := aux.Wearout.(type) {
case float64:
d.Wearout = int(v)
case string:
// Proxmox returns "N/A" or empty string for HDDs/RAID controllers.
// Some controllers also return numeric wearout values as strings, so try to parse them.
d.Wearout = parseWearoutValue(v)
case nil:
d.Wearout = wearoutUnknown
default:
// Unexpected type, normalize to unknown
d.Wearout = wearoutUnknown
}
d.Wearout = clampWearoutConsumed(d.Wearout)
// Handle rpm field which can be number, string descriptor ("SSD"/"N/A"), or null
switch v := aux.RPM.(type) {
case float64:
d.RPM = int(v)
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" || strings.EqualFold(trimmed, "ssd") || strings.EqualFold(trimmed, "n/a") {
d.RPM = 0
break
}
if parsed, err := strconv.Atoi(trimmed); err == nil {
d.RPM = parsed
} else {
d.RPM = 0
}
case nil:
d.RPM = 0
default:
d.RPM = 0
}
return nil
}
// parseWearoutValue normalizes the wearout value returned by Proxmox into an integer percentage.
// The API occasionally wraps numeric values in escaped quotes (\"81\"), appends percent symbols,
// or reports descriptive strings like "N/A". We strip those variations so downstream code can work
// with a simple integer. Non-numeric results bubble up wearoutUnknown (-1) so callers can treat them
// as "not reported" instead of a critical wearout value.
func parseWearoutValue(raw string) int {
cleaned := strings.TrimSpace(raw)
if cleaned == "" {
return wearoutUnknown
}
// Remove escaped quotes and surrounding quotes the API sometimes includes.
cleaned = strings.ReplaceAll(cleaned, "\\\"", "")
cleaned = strings.Trim(cleaned, "\"'")
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
return wearoutUnknown
}
switch strings.ToLower(cleaned) {
case "n/a", "na", "none", "unknown":
return wearoutUnknown
}
if parsed, err := strconv.Atoi(cleaned); err == nil {
return parsed
}
if parsed, err := strconv.ParseFloat(cleaned, 64); err == nil {
if parsed <= 0 {
return int(parsed)
}
return int(parsed)
}
var digits strings.Builder
for _, r := range cleaned {
if unicode.IsDigit(r) {
digits.WriteRune(r)
}
}
if digits.Len() > 0 {
if parsed, err := strconv.Atoi(digits.String()); err == nil {
return parsed
}
}
return wearoutUnknown
}
func clampWearoutConsumed(val int) int {
if val == wearoutUnknown {
return wearoutUnknown
}
if val < 0 {
return 0
}
if val > 100 {
return 100
}
return val
}
// DiskSmart represents SMART data for a disk
type DiskSmart struct {
Health string `json:"health"` // PASSED, FAILED, UNKNOWN
Wearout int `json:"wearout"` // SSD wear percentage
Type string `json:"type"` // Type of response (text, attributes)
Text string `json:"text"` // Raw SMART output text
}
// GetDisks returns the list of physical disks on a node
func (c *Client) GetDisks(ctx context.Context, node string) ([]Disk, error) {
resp, err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/disks/list", node), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Disk `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// AptPackage represents a pending package update from apt
type AptPackage struct {
Package string `json:"Package"` // Package name
Title string `json:"Title"` // Human-readable title
Description string `json:"Description"` // Package description
OldVersion string `json:"OldVersion"` // Currently installed version
NewVersion string `json:"Version"` // Available version
Priority string `json:"Priority"` // Update priority (e.g., "important", "optional")
Section string `json:"Section"` // Package section
Origin string `json:"Origin"` // Repository origin
}
// GetNodePendingUpdates returns the list of pending apt updates for a node
// Requires Sys.Audit permission on /nodes/{node}
func (c *Client) GetNodePendingUpdates(ctx context.Context, node string) ([]AptPackage, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/apt/update", node))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []AptPackage `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}