Files
pulse/pkg/proxmox/client.go
T
Pulse Monitor 082383a4dc fix: increase storage API timeout from 15s to 30s (addresses #448)
The 15-second timeout introduced to handle unavailable NFS storage was too aggressive and caused legitimate storage queries to timeout on nodes with many storage backends or higher latency. This was causing storage to not be displayed for affected nodes.

Increased timeout to 30 seconds as a better balance between responsiveness and reliability.
2025-09-11 14:19:21 +00:00

1238 lines
36 KiB
Go

package proxmox
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"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
}
// 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 {
data := url.Values{
"username": {c.auth.user + "@" + c.auth.realm},
"password": {c.config.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()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return fmt.Errorf("authentication failed (status %d): %s", resp.StatusCode, string(body))
}
return fmt.Errorf("authentication failed: %s", 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
}
// 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)
}
// post performs a POST request
func (c *Client) post(ctx context.Context, path string, data url.Values) (*http.Response, error) {
return c.request(ctx, "POST", path, data)
}
// 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"`
}
// 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)
}
// 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
}
// 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"`
}
// 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"`
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"`
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
}
// 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
// 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/%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
}
// GetClusterNodes returns all nodes in the cluster with their connection info
func (c *Client) GetClusterNodes(ctx context.Context) ([]ClusterStatus, error) {
status, err := c.GetClusterStatus(ctx)
if err != nil {
return nil, err
}
var nodes []ClusterStatus
for _, s := range status {
if s.Type == "node" {
nodes = append(nodes, s)
}
}
return nodes, 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
}
// 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
}
// 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 {
// Use mountpoint as unique identifier
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")
}
// GetVMStatus returns detailed VM status including balloon info
func (c *Client) GetVMStatus(ctx context.Context, node string, vmid int) (*VMStatus, error) {
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
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 *struct {
Used uint64 `json:"used"`
Free uint64 `json:"free"`
Total uint64 `json:"total"`
} `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 int `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
}
// 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:"wearout"` // SSD wear percentage (0-100, 100 is best)
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
}
// 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
}
// GetDiskSmart returns SMART data for a specific disk
func (c *Client) GetDiskSmart(ctx context.Context, node, disk string) (*DiskSmart, error) {
params := url.Values{
"disk": {disk},
}
resp, err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/disks/smart?%s", node, params.Encode()), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data DiskSmart `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}