Files
pulse/pkg/proxmox/client.go
T
pulse-triage[bot] 9dcd5697cc Keep incomplete Proxmox guest inventories out of publication
Require whole bounded, unambiguous enumerations on the efficient and node fallback routes. Preserve scoped empty inventories, LXC integer compatibility and ordinary counter/receipt semantics. Validate retained readings, History, breaches and template policy through actual fallback and recovery.

Change-source: pulse-maintainer
2026-10-14 11:35:55 +01:00

3133 lines
92 KiB
Go

package proxmox
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
"github.com/rs/zerolog/log"
)
const maxResponseBodyBytes int64 = 8 << 20 // 8 MiB
type apiErrorLogLevel uint8
const (
apiErrorLogNone apiErrorLogLevel = iota
apiErrorLogDebug
apiErrorLogWarn
)
func isNodeScopedAPIPath(path string) bool {
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "nodes" && i+1 < len(parts) {
return true
}
}
return false
}
func classifyAPIErrorLog(status int, path string) (apiErrorLogLevel, string) {
if status == http.StatusForbidden && strings.Contains(path, "/apt/update") {
return apiErrorLogDebug, "Proxmox permission error (optional endpoint)"
}
if status == 595 && isNodeScopedAPIPath(path) {
return apiErrorLogDebug, "Proxmox node resource unavailable"
}
if status == 595 || status == http.StatusUnauthorized || status == http.StatusForbidden {
return apiErrorLogWarn, "Proxmox authentication error"
}
return apiErrorLogNone, ""
}
// Preserve an incomplete body through the HTTP error-response wrapper too.
// An error status alone is not a completed refusal if its body was truncated.
type responseBodyReadError struct{ cause error }
func (e *responseBodyReadError) Error() string { return e.cause.Error() }
func (e *responseBodyReadError) Unwrap() error { return e.cause }
func readResponseBodyLimited(r io.Reader) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(r, maxResponseBodyBytes+1))
if err != nil {
return nil, &responseBodyReadError{cause: err}
}
if int64(len(body)) > maxResponseBodyBytes {
return nil, &responseBodyReadError{cause: fmt.Errorf("response body exceeds %d bytes", maxResponseBodyBytes)}
}
return body, nil
}
// 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 {
parsed, err := floatToIntTrunc(fl)
if err != nil {
return err
}
*f = FlexInt(parsed)
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)
parsed, err := parseFlexibleIntString(s)
if err != nil {
return err
}
// Convert to int
*f = FlexInt(parsed)
return nil
}
func parseFlexibleIntString(raw string) (int, error) {
if strings.ContainsAny(raw, ".eE") {
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, err
}
return floatToIntTrunc(parsed)
}
parsed, err := strconv.Atoi(raw)
if err != nil {
return 0, err
}
return parsed, nil
}
func int64ToInt(v int64) (int, error) {
parsed, err := strconv.Atoi(strconv.FormatInt(v, 10))
if err != nil {
return 0, fmt.Errorf("integer %d exceeds int range", v)
}
return parsed, nil
}
func uint64ToInt(v uint64) (int, error) {
parsed, err := strconv.Atoi(strconv.FormatUint(v, 10))
if err != nil {
return 0, fmt.Errorf("integer %d exceeds int range", v)
}
return parsed, nil
}
func floatToIntTrunc(v float64) (int, error) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0, fmt.Errorf("non-finite float %v cannot be converted to int", v)
}
var maxAbs float64
if strconv.IntSize == 32 {
maxAbs = float64(math.MaxInt32) + 1
} else {
maxAbs = float64(math.MaxInt64) + 1
}
if v >= maxAbs || v < -maxAbs {
return 0, fmt.Errorf("float %v exceeds int range", v)
}
return int(v), nil
}
func floatToIntRounded(v float64) (int, error) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0, fmt.Errorf("non-finite float %v cannot be converted to int", v)
}
return floatToIntTrunc(math.Round(v))
}
func looksLikeNumericLiteral(raw string) bool {
hasDigit := false
var prev rune
for i, r := range raw {
switch {
case unicode.IsDigit(r):
hasDigit = true
case r == '.' || r == 'e' || r == 'E':
case r == '+' || r == '-':
if i != 0 && prev != 'e' && prev != 'E' {
return false
}
default:
return false
}
prev = r
}
return hasDigit
}
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
baseAPIURL *url.URL
httpClient *http.Client
auth auth
config ClientConfig
// A polling client is shared by concurrent node and guest reads. Publish
// each ticket/CSRF/expiry tuple atomically and share only session renewal,
// not the subsequent API requests or their caller-owned cancellation.
authMu sync.Mutex
authGeneration uint64
authInFlight *authRefresh
}
type authRefresh struct {
done chan struct{}
err error
}
func (c *Client) authSnapshot() (auth, uint64) {
c.authMu.Lock()
defer c.authMu.Unlock()
return c.auth, c.authGeneration
}
func (c *Client) apiBaseURL() (*url.URL, error) {
if c.baseAPIURL != nil {
return c.baseAPIURL, nil
}
baseURL := strings.TrimSpace(c.baseURL)
if baseURL == "" {
return nil, fmt.Errorf("base URL is required")
}
parsed, err := securityutil.NormalizeHTTPBaseURL(baseURL, "")
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
c.baseAPIURL = parsed
return parsed, nil
}
// 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) {
return NewClientWithContext(context.Background(), cfg)
}
// NewClientWithContext binds initial password authentication to its caller.
// The retained client's later reads keep their own contexts and full timeout.
func NewClientWithContext(ctx context.Context, cfg ClientConfig) (*Client, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var user, realm string
// 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)
baseHost := strings.TrimSpace(cfg.Host)
if baseHost == "" {
return nil, fmt.Errorf("host is required")
}
defaultedScheme := !strings.HasPrefix(baseHost, "http://") && !strings.HasPrefix(baseHost, "https://")
if defaultedScheme {
baseHost = "https://" + baseHost
}
baseHostURL, err := securityutil.NormalizeInfrastructureBaseURL(baseHost, "")
if err != nil {
return nil, fmt.Errorf("invalid Proxmox host: %w", err)
}
// Do not log raw configuration before URL validation.
log.Debug().
Str("host", baseHostURL.String()).
Bool("hasToken", cfg.TokenName != "").
Bool("hasPassword", cfg.Password != "").
Str("tokenName", cfg.TokenName).
Str("user", cfg.User).
Msg("Creating Proxmox client")
if defaultedScheme {
log.Debug().Str("host", baseHostURL.String()).Msg("No protocol specified in Proxmox host, defaulting to HTTPS")
}
if baseHostURL.Scheme == "http" {
log.Warn().Str("host", baseHostURL.String()).Msg("Using HTTP for Proxmox connection - consider enabling HTTPS")
}
baseAPIURL := securityutil.AppendURLPath(baseHostURL, "api2", "json")
// 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: baseAPIURL.String(),
baseAPIURL: baseAPIURL,
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(ctx); err != nil {
httpClient.CloseIdleConnections()
return nil, fmt.Errorf("authentication failed: %w", err)
}
}
if err := ctx.Err(); err != nil {
httpClient.CloseIdleConnections()
return nil, err
}
return client, nil
}
// authenticate performs password-based authentication
func (c *Client) authenticate(ctx context.Context) error {
return c.renewAuthentication(ctx, nil)
}
// expected binds expiry or a 401 to the session actually used by that read.
// A late rejection of an old ticket cannot trigger another renewal after a
// concurrent request has already replaced it. Explicit authentication (nil)
// still owns the existing JSON/form fallback and failed-attempt behaviour.
func (c *Client) renewAuthentication(ctx context.Context, expected *uint64) error {
if err := ctx.Err(); err != nil {
return err
}
c.authMu.Lock()
if expected != nil && c.authGeneration != *expected {
c.authMu.Unlock()
return nil
}
if pending := c.authInFlight; pending != nil {
c.authMu.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
case <-pending.done:
return pending.err
}
}
pending := &authRefresh{done: make(chan struct{})}
c.authInFlight = pending
username := c.auth.user + "@" + c.auth.realm
c.authMu.Unlock()
password := c.config.Password
err := c.authenticateJSON(ctx, username, password)
if shouldFallbackToForm(err) {
err = c.authenticateForm(ctx, username, password)
}
c.authMu.Lock()
pending.err = err
c.authInFlight = nil
close(pending.done)
c.authMu.Unlock()
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
}
baseAPIURL, err := c.apiBaseURL()
if err != nil {
return err
}
req, err := securityutil.NewRelativeRequestWithContext(ctx, "POST", baseAPIURL, "/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},
}
baseAPIURL, err := c.apiBaseURL()
if err != nil {
return err
}
req, err := securityutil.NewRelativeRequestWithContext(ctx, "POST", baseAPIURL, "/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, err := readResponseBodyLimited(resp.Body)
if err != nil {
return &authHTTPError{status: resp.StatusCode, body: err.Error()}
}
return &authHTTPError{status: resp.StatusCode, body: string(body)}
}
if err := securityutil.LimitResponseBody(resp, maxResponseBodyBytes); err != nil {
return err
}
body, err := readResponseBodyLimited(resp.Body)
if err != nil {
return err
}
ticket, csrf, err := securityutil.ParseProxmoxSession(body)
if err != nil {
return err
}
c.authMu.Lock()
c.auth.ticket = ticket
c.auth.csrfToken = csrf
c.auth.expiresAt = time.Now().Add(2 * time.Hour) // PVE tickets expire after 2 hours
c.authGeneration++
c.authMu.Unlock()
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) {
return c.requestWithRetry(ctx, method, path, data, false)
}
func (c *Client) requestWithRetry(ctx context.Context, method, path string, data url.Values, retriedAfter401 bool) (*http.Response, error) {
return c.requestWithRetryUsingClient(ctx, method, path, data, retriedAfter401, c.httpClient)
}
func (c *Client) requestWithRetryUsingClient(ctx context.Context, method, path string, data url.Values, retriedAfter401 bool, httpClient *http.Client) (*http.Response, error) {
// Re-authenticate if needed
auth, generation := c.authSnapshot()
if c.config.Password != "" && auth.tokenName == "" && time.Now().After(auth.expiresAt) {
if err := c.renewAuthentication(ctx, &generation); err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
auth, generation = c.authSnapshot()
}
var body io.Reader
if data != nil {
body = strings.NewReader(data.Encode())
}
baseAPIURL, err := c.apiBaseURL()
if err != nil {
return nil, err
}
req, err := securityutil.NewRelativeRequestWithContext(ctx, method, baseAPIURL, 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 auth.tokenName != "" && auth.tokenValue != "" {
// API token authentication
authHeader := fmt.Sprintf("PVEAPIToken=%s@%s!%s=%s",
auth.user, auth.realm, auth.tokenName, 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=***",
auth.user, auth.realm, auth.tokenName)
log.Debug().
Str("authHeader", maskedHeader).
Str("url", req.URL.String()).
Msg("Setting API token authentication")
} else if auth.ticket != "" {
// Ticket authentication
req.Header.Set("Cookie", "PVEAuthCookie="+auth.ticket)
if method != "GET" && auth.csrfToken != "" {
req.Header.Set("CSRFPreventionToken", auth.csrfToken)
}
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
// Check for errors
if resp.StatusCode >= 400 {
body, err := readResponseBodyLimited(resp.Body)
_ = resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("API error %d: %w", resp.StatusCode, err)
}
// Password sessions can be invalidated server-side before local expiry.
// Retry once after forcing re-authentication on 401.
if resp.StatusCode == http.StatusUnauthorized && !retriedAfter401 && c.config.Password != "" && auth.tokenName == "" {
if err := c.renewAuthentication(ctx, &generation); err != nil {
return nil, fmt.Errorf("re-authentication failed after 401: %w", err)
}
return c.requestWithRetryUsingClient(ctx, method, path, data, true, httpClient)
}
// Create base error with helpful guidance for common issues
var apiErr error
if resp.StatusCode == 403 && c.config.TokenName != "" {
// Special case for 403 with API token - this is usually a permission issue
apiErr = 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", auth.user, 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 isNodeScopedAPIPath(req.URL.Path) {
// This looks like a node-specific resource request
apiErr = fmt.Errorf("API error 595: Cannot access node resource - node may be offline or credentials may be invalid")
} else {
apiErr = fmt.Errorf("API error 595: Authentication failed - please check your credentials")
}
} else if resp.StatusCode == 401 {
apiErr = fmt.Errorf("API error 401 (Unauthorized): Invalid credentials or token")
} else {
apiErr = fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
// Classify the failed operation by its expected impact. A node-scoped 595
// is the normal pveproxy response when a cluster member is offline; the
// successful cluster request already proves the credential is usable.
if level, msg := classifyAPIErrorLog(resp.StatusCode, req.URL.Path); level != apiErrorLogNone {
event := log.Warn()
if level == apiErrorLogDebug {
event = log.Debug()
}
event.
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(msg)
}
// Preserve the established returned-error classification for callers;
// this distinction changes only the emitted diagnostic severity.
if resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode == 595 {
apiErr = fmt.Errorf("authentication error: %w", apiErr)
}
guestFailureReason := guestAgentTerminalFailureReason(path, body)
return nil, &apiResponseError{
statusCode: resp.StatusCode,
guestCommandRejected: resp.StatusCode == http.StatusInternalServerError && guestFailureReason != "",
guestFailureReason: guestFailureReason,
cause: apiErr,
}
}
if err := securityutil.LimitResponseBody(resp, maxResponseBodyBytes); err != nil {
return nil, err
}
return resp, nil
}
// get performs a GET request
func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
if node, vmid, ok := guestAgentPath(path); ok {
return c.getGuestAgent(ctx, path, node, vmid)
}
if strings.Contains(path, "/agent/") {
return nil, &guestAgentDeferredError{reason: "invalid-guest-key"}
}
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"`
NetIn *float64 `json:"netin,omitempty"`
NetOut *float64 `json:"netout,omitempty"`
}
// GuestRRDPoint represents a single RRD datapoint for a VM or LXC container.
// Guest RRD carries only cache-inclusive mem/maxmem columns; the cache-aware
// memused/memavailable columns exist only in node RRD (#1634).
type GuestRRDPoint struct {
Time int64 `json:"time"`
MaxMem *float64 `json:"maxmem,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 when reclaimable-cache evidence is present.
// MemFree by itself is not an availability estimate.
func (m *MemoryStatus) EffectiveAvailable() uint64 {
if m == nil {
return 0
}
if m.Available > 0 {
if m.Total > 0 && m.Available > m.Total {
return 0
}
return m.Available
}
if m.Avail > 0 {
if m.Total > 0 && m.Avail > m.Total {
return 0
}
return m.Avail
}
if m.Buffers == 0 && m.Cached == 0 {
return 0
}
derived := m.Free
for _, value := range []uint64{m.Buffers, m.Cached} {
if math.MaxUint64-derived < value {
derived = math.MaxUint64
break
}
derived += value
}
if derived == 0 {
return 0
}
// Conflicting components are not a usable availability estimate.
if m.Total > 0 && derived > m.Total {
return 0
}
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
}
// /nodes is also the cluster's startup/recovery probe. A decodable JSON
// prefix or absent inventory cannot establish usable polling health.
body, readErr := readResponseBodyLimited(resp.Body)
closeErr := resp.Body.Close()
if readErr != nil || closeErr != nil {
return nil, fmt.Errorf("read Proxmox node inventory: %w", errors.Join(readErr, closeErr))
}
return decodeNodeInventory(body)
}
// 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)
// Note: the "ds" parameter is not sent because older PVE versions
// (including 9.x) reject it as an unknown property. The API returns
// all available RRD fields regardless, so we simply filter client-side.
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
}
// getGuestRRDData retrieves RRD metrics for a guest. guestPath is the PVE API
// guest segment ("lxc", "qemu").
func (c *Client) getGuestRRDData(ctx context.Context, node, guestPath string, vmid int, timeframe, cf string) ([]GuestRRDPoint, error) {
if timeframe == "" {
timeframe = "hour"
}
if cf == "" {
cf = "AVERAGE"
}
params := url.Values{}
params.Set("timeframe", timeframe)
params.Set("cf", cf)
// Note: the "ds" parameter is not sent because older PVE versions
// (including 9.x) reject it as an unknown property.
path := fmt.Sprintf("/nodes/%s/%s/%d/rrddata", url.PathEscape(node), guestPath, 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
}
// 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) {
return c.getGuestRRDData(ctx, node, "lxc", vmid, timeframe, cf)
}
// GetVMRRDData retrieves RRD metrics for a QEMU VM.
func (c *Client) GetVMRRDData(ctx context.Context, node string, vmid int, timeframe, cf string, ds []string) ([]GuestRRDPoint, error) {
return c.getGuestRRDData(ctx, node, "qemu", vmid, timeframe, cf)
}
// VM represents a Proxmox VE virtual machine
type VM struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Node string `json:"node"`
Pool string `json:"pool,omitempty"`
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"`
IOCounters IOCounterPresence `json:"-"`
ObservedAt time.Time `json:"-"`
}
// 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"`
Pool string `json:"pool,omitempty"`
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"`
IOCounters IOCounterPresence `json:"-"`
ObservedAt time.Time `json:"-"`
}
// 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., "198.51.100.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, err := readResponseBodyLimited(resp.Body)
if err != nil {
return nil, err
}
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"`
Pool string `json:"pool,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
}
values, err := readGuestInventory(resp, func(vm VM) (string, bool) { return guestInventoryID(vm.VMID) })
if err != nil {
return nil, err
}
stampVMObservation(values, time.Now().UTC())
return values, 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
}
values, err := readGuestInventory(resp, func(container Container) (string, bool) { return guestInventoryID(int(container.VMID)) })
if err != nil {
return nil, err
}
stampContainerObservation(values, time.Now().UTC())
return values, 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
}
// backupTaskListLimit caps how many vzdump tasks are listed per node. The PVE
// default of 50 covers all task types, so vzdump entries get crowded out on
// busy nodes; filtering server-side keeps the window useful.
const backupTaskListLimit = 200
// GetBackupTasks gets all backup tasks across all nodes, including currently
// running ones (source=all) so in-progress backup jobs are visible.
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
}
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/tasks?typefilter=vzdump&source=all&limit=%d", node.Node, backupTaskListLimit))
if err != nil {
// Log error but continue with other nodes
continue
}
var result struct {
Data []Task `json:"data"`
}
decodeErr := json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
if decodeErr != nil {
continue
}
// Filter for backup tasks
for _, task := range result.Data {
if task.Type == "vzdump" {
allTasks = append(allTasks, task)
}
}
}
return allTasks, nil
}
// TaskLogLine is a single line of a task's log output.
type TaskLogLine struct {
LineNumber int64 `json:"n"`
Text string `json:"t"`
}
// taskLogLineLimit caps how many log lines are fetched per task. The PVE
// default is 50 lines, far too few for multi-guest vzdump job logs; 20k lines
// covers jobs with hundreds of guests while bounding response size.
const taskLogLineLimit = 20000
// GetTaskLog fetches the log output of a task.
func (c *Client) GetTaskLog(ctx context.Context, node, upid string) ([]TaskLogLine, error) {
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/tasks/%s/log?start=0&limit=%d", node, url.PathEscape(upid), taskLogLineLimit))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := readResponseBodyLimited(resp.Body)
if err != nil {
return nil, err
}
return nil, fmt.Errorf("failed to get task log (status %d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result struct {
Data []TaskLogLine `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, 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, err := readResponseBodyLimited(resp.Body)
if err != nil {
return nil, err
}
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()
// Guest-agent admission uses this response as authoritative lock evidence.
// Decoding only the first JSON value can accept an unlocked prefix before a
// truncated, stalled, oversized or malformed response has been received.
body, err := readResponseBodyLimited(resp.Body)
if err != nil {
return nil, fmt.Errorf("read VM configuration: %w", err)
}
var result struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(body, &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, fmt.Errorf("guest agent get-osinfo: %w", 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 "", fmt.Errorf("guest agent info: %w", 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"`
TotalBytesPrivileged uint64 `json:"total-bytes-privileged"`
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 {
fields, valid := guestAgentResponseFields(data)
if !valid {
return fmt.Errorf("guest filesystem record is ambiguous or malformed")
}
recognized := false
for name := range fields {
// encoding/json also matches case-insensitive struct fields. Identity
// and filter metadata must not overwrite an earlier canonical field or
// turn a rejected volume into a special mount that silently disappears.
for _, canonical := range []string{"name", "type", "mountpoint", "total-bytes", "total-bytes-privileged", "used-bytes", "disk"} {
if name == canonical {
recognized = true
} else if strings.EqualFold(name, canonical) {
return fmt.Errorf("guest filesystem record has a noncanonical field name")
}
}
}
if !recognized {
return fmt.Errorf("guest filesystem record has no recognized fields")
}
type rawVMFileSystem struct {
Name string `json:"name"`
Type string `json:"type"`
Mountpoint string `json:"mountpoint"`
TotalBytes json.RawMessage `json:"total-bytes"`
TotalBytesPrivileged json.RawMessage `json:"total-bytes-privileged"`
UsedBytes json.RawMessage `json:"used-bytes"`
DiskRaw interface{} `json:"disk"`
}
var raw rawVMFileSystem
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
total, err := parseVMFilesystemBytes(raw.TotalBytes, false)
if err != nil {
return err
}
totalPrivileged, err := parseVMFilesystemBytes(raw.TotalBytesPrivileged, false)
if err != nil {
return err
}
used, err := parseVMFilesystemBytes(raw.UsedBytes, total > 0 || totalPrivileged > 0)
if err != nil {
return err
}
if total == 0 && totalPrivileged > 0 {
total = totalPrivileged
}
if used > total {
return fmt.Errorf("guest filesystem used bytes exceed total bytes")
}
fs.Name = raw.Name
fs.Type = raw.Type
fs.Mountpoint = raw.Mountpoint
if normalized, ok := normalizeWindowsDriveMountpoint(raw.Name); ok {
if fs.Mountpoint == "" || isWindowsVolumeGUIDMountpoint(fs.Mountpoint) {
fs.Mountpoint = normalized
}
}
fs.TotalBytes = total
fs.TotalBytesPrivileged = totalPrivileged
fs.UsedBytes = used
fs.DiskRaw = normalizeVMFilesystemDiskRaw(raw.DiskRaw)
fs.Disk = ""
return nil
}
func normalizeVMFilesystemDiskRaw(value interface{}) []interface{} {
switch v := value.(type) {
case nil:
return nil
case []interface{}:
return v
case map[string]interface{}:
return []interface{}{v}
default:
return nil
}
}
func normalizeWindowsDriveMountpoint(value string) (string, bool) {
value = strings.TrimSpace(value)
if len(value) < 2 || value[1] != ':' {
return "", false
}
drive := strings.ToUpper(value[:2])
if len(value) == 2 {
return drive, true
}
switch value[2] {
case '\\', '/':
return drive + value[2:], true
default:
return "", false
}
}
func isWindowsVolumeGUIDMountpoint(value string) bool {
value = strings.TrimSpace(strings.ToLower(value))
return strings.HasPrefix(value, `\\?\volume{`) && strings.HasSuffix(value, `}\`)
}
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:
return floatToUint64Trunc(v)
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
}
return floatToUint64Trunc(f)
}
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)
}
}
func floatToUint64Trunc(v float64) (uint64, error) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0, fmt.Errorf("non-finite float %v cannot be converted to uint64", v)
}
if v < 0 {
return 0, nil
}
if v >= math.Exp2(64) {
return 0, fmt.Errorf("float %v exceeds uint64 range", v)
}
return uint64(v), nil
}
type VMIPAddress struct {
Address string `json:"ip-address"`
Prefix int `json:"prefix"`
}
func (a *VMIPAddress) UnmarshalJSON(data []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
a.Address = coerceString(raw["ip-address"])
prefix, err := coerceUint64("prefix", raw["prefix"])
if err != nil {
prefix = 0
}
if prefix > 128 {
prefix = 128
}
if prefixInt, err := uint64ToInt(prefix); err == nil {
a.Prefix = prefixInt
}
return nil
}
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"`
}
func (iface *VMNetworkInterface) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
iface.Name = unmarshalRawString(raw["name"])
iface.HardwareAddr = unmarshalRawString(raw["hardware-address"])
iface.HasIp4Gateway = unmarshalRawBool(raw["has-ipv4-synth-gateway"])
iface.HasIp6Gateway = unmarshalRawBool(raw["has-ipv6-synth-gateway"])
iface.Statistics = nil
if statsRaw, ok := raw["statistics"]; ok && len(statsRaw) > 0 && string(statsRaw) != "null" {
var stats interface{}
if err := json.Unmarshal(statsRaw, &stats); err == nil {
iface.Statistics = stats
}
}
iface.IPAddresses = nil
if addressesRaw, ok := raw["ip-addresses"]; ok && len(addressesRaw) > 0 && string(addressesRaw) != "null" {
var rawAddresses []json.RawMessage
if err := json.Unmarshal(addressesRaw, &rawAddresses); err == nil {
iface.IPAddresses = decodeVMIpAddresses(rawAddresses)
} else {
var rawAddress json.RawMessage
if err := json.Unmarshal(addressesRaw, &rawAddress); err == nil && len(rawAddress) > 0 {
iface.IPAddresses = decodeVMIpAddresses([]json.RawMessage{rawAddress})
}
}
}
return nil
}
func decodeVMIpAddresses(rawAddresses []json.RawMessage) []VMIPAddress {
if len(rawAddresses) == 0 {
return nil
}
addresses := make([]VMIPAddress, 0, len(rawAddresses))
for _, rawAddr := range rawAddresses {
var addr VMIPAddress
if err := json.Unmarshal(rawAddr, &addr); err != nil {
continue
}
if addr.Address == "" {
continue
}
addresses = append(addresses, addr)
}
if len(addresses) == 0 {
return nil
}
return addresses
}
func unmarshalRawString(data json.RawMessage) string {
if len(data) == 0 || string(data) == "null" {
return ""
}
var value interface{}
if err := json.Unmarshal(data, &value); err != nil {
return ""
}
return coerceString(value)
}
func unmarshalRawBool(data json.RawMessage) bool {
if len(data) == 0 || string(data) == "null" {
return false
}
var value bool
if err := json.Unmarshal(data, &value); err == nil {
return value
}
var generic interface{}
if err := json.Unmarshal(data, &generic); err != nil {
return false
}
switch v := generic.(type) {
case string:
lower := strings.ToLower(strings.TrimSpace(v))
return lower == "true" || lower == "1" || lower == "yes"
case float64:
return v != 0
case int:
return v != 0
case int64:
return v != 0
case uint64:
return v != 0
default:
return false
}
}
func coerceString(value interface{}) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case json.Number:
return strings.TrimSpace(v.String())
case float64:
return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64))
case float32:
return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32))
case int:
return strconv.Itoa(v)
case int32:
return strconv.FormatInt(int64(v), 10)
case int64:
return strconv.FormatInt(v, 10)
case uint32:
return strconv.FormatUint(uint64(v), 10)
case uint64:
return strconv.FormatUint(v, 10)
default:
return ""
}
}
// GetVMFSInfo returns filesystem information from QEMU guest agent. If any
// record is malformed, valid peers are returned with an error: they cannot
// establish complete guest capacity or removal of the rejected filesystem.
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, fmt.Errorf("guest agent get-fsinfo: %w", err)
}
defer resp.Body.Close()
// First, read the response body into bytes so we can try multiple unmarshal attempts
bodyBytes, err := readResponseBodyLimited(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")
// Decode entry-by-entry to retain valid peers, but never mistake that
// remainder for a complete inventory after dropping a malformed record.
var arrayResult struct {
Data struct {
Result []json.RawMessage `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &arrayResult); err == nil && arrayResult.Data.Result != nil {
filesystems := make([]VMFileSystem, 0, len(arrayResult.Data.Result))
invalidEntries := 0
for idx, rawFS := range arrayResult.Data.Result {
var fs VMFileSystem
if err := json.Unmarshal(rawFS, &fs); err != nil {
invalidEntries++
log.Warn().
Err(err).
Str("node", node).
Int("vmid", vmid).
Int("filesystem_index", idx).
Msg("Skipping malformed guest agent filesystem entry")
continue
}
filesystems = append(filesystems, fs)
}
postProcessVMFilesystems(node, vmid, filesystems)
if invalidEntries > 0 {
return filesystems, fmt.Errorf("guest filesystem inventory is incomplete: %d malformed entries", invalidEntries)
}
return filesystems, nil
}
// If that fails, try as an object (might be an error response or different format)
var objectResult struct {
Data struct {
Result json.RawMessage `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &objectResult); err == nil {
rawFS := bytes.TrimSpace(objectResult.Data.Result)
if len(rawFS) == 0 || bytes.Equal(rawFS, []byte("null")) {
log.Debug().
Str("node", node).
Int("vmid", vmid).
Msg("GetVMFSInfo received null result - guest agent may not be providing disk info")
return []VMFileSystem{}, nil
}
var fsMap map[string]interface{}
if json.Unmarshal(rawFS, &fsMap) == nil && looksLikeVMFilesystemResult(fsMap) {
var fs VMFileSystem
if unmarshalErr := json.Unmarshal(rawFS, &fs); unmarshalErr != nil {
return nil, fmt.Errorf("failed to parse object-style guest filesystem result: %w", unmarshalErr)
}
filesystems := []VMFileSystem{fs}
postProcessVMFilesystems(node, vmid, filesystems)
return filesystems, nil
}
log.Debug().
Str("node", node).
Int("vmid", vmid).
Interface("result", objectResult.Data.Result).
Msg("GetVMFSInfo received object instead of array")
return []VMFileSystem{}, nil
}
// If both fail, return error
return nil, fmt.Errorf("unexpected response format from guest agent get-fsinfo")
}
func looksLikeVMFilesystemResult(result map[string]interface{}) bool {
if len(result) == 0 {
return false
}
for _, key := range []string{"mountpoint", "name", "type", "total-bytes", "total-bytes-privileged", "used-bytes", "disk"} {
if _, ok := result[key]; ok {
return true
}
}
return false
}
func postProcessVMFilesystems(node string, vmid int, filesystems []VMFileSystem) {
for i := range filesystems {
fs := &filesystems[i]
if len(fs.DiskRaw) > 0 {
if diskMap, ok := fs.DiskRaw[0].(map[string]interface{}); ok {
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 fs.Disk != "" || fs.Mountpoint == "" {
continue
}
if fs.Mountpoint == "/" {
fs.Disk = "root-filesystem"
continue
}
isWindowsDrive := len(fs.Mountpoint) >= 2 && fs.Mountpoint[1] == ':' && strings.Contains(fs.Mountpoint, "\\")
if isWindowsDrive {
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")
continue
}
fs.Disk = fs.Mountpoint
}
}
// 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, fmt.Errorf("guest agent network-get-interfaces: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := readResponseBodyLimited(resp.Body)
if err != nil {
return nil, err
}
var arrayResult struct {
Data struct {
Result []json.RawMessage `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &arrayResult); err == nil && arrayResult.Data.Result != nil {
interfaces := make([]VMNetworkInterface, 0, len(arrayResult.Data.Result))
for idx, rawIface := range arrayResult.Data.Result {
var iface VMNetworkInterface
if err := json.Unmarshal(rawIface, &iface); err != nil {
log.Warn().
Err(err).
Str("node", node).
Int("vmid", vmid).
Int("interface_index", idx).
Msg("Skipping malformed guest agent network interface entry")
continue
}
if !vmNetworkInterfaceHasUsefulData(iface) {
continue
}
interfaces = append(interfaces, iface)
}
return interfaces, nil
}
var objectResult struct {
Data struct {
Result interface{} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &objectResult); err == nil {
if objectResult.Data.Result == nil {
return []VMNetworkInterface{}, nil
}
if ifaceMap, ok := objectResult.Data.Result.(map[string]interface{}); ok && looksLikeVMNetworkInterfaceResult(ifaceMap) {
rawIface, marshalErr := json.Marshal(ifaceMap)
if marshalErr != nil {
return nil, fmt.Errorf("failed to marshal object-style guest network interface result: %w", marshalErr)
}
var iface VMNetworkInterface
if unmarshalErr := json.Unmarshal(rawIface, &iface); unmarshalErr != nil {
return nil, fmt.Errorf("failed to parse object-style guest network interface result: %w", unmarshalErr)
}
if !vmNetworkInterfaceHasUsefulData(iface) {
return []VMNetworkInterface{}, nil
}
return []VMNetworkInterface{iface}, nil
}
return []VMNetworkInterface{}, nil
}
return nil, fmt.Errorf("unexpected response format from guest agent network-get-interfaces")
}
func looksLikeVMNetworkInterfaceResult(result map[string]interface{}) bool {
if len(result) == 0 {
return false
}
for _, key := range []string{"name", "hardware-address", "ip-addresses", "statistics"} {
if _, ok := result[key]; ok {
return true
}
}
return false
}
func vmNetworkInterfaceHasUsefulData(iface VMNetworkInterface) bool {
return iface.Name != "" ||
iface.HardwareAddr != "" ||
len(iface.IPAddresses) > 0 ||
iface.Statistics != nil ||
iface.HasIp4Gateway ||
iface.HasIp6Gateway
}
// LinuxMemoryAvailability records the cache-aware fields used to establish a
// Linux guest's available memory. Source is either meminfo-available or
// meminfo-derived.
type LinuxMemoryAvailability struct {
Total uint64
Free uint64
Available uint64
Buffers uint64
Cached uint64
SReclaimable uint64
Shmem uint64
SwapTotal uint64
SwapFree uint64
EffectiveAvailable uint64
Source string
}
// ParseLinuxMemoryAvailability parses /proc/meminfo. New kernels provide
// MemAvailable directly. Older kernels use the conservative cache-aware
// estimate Free + Buffers + Cached + SReclaimable - Shmem. A partial payload
// containing only MemTotal/MemFree is deliberately rejected. Impossible
// physical counters or a derived estimate above MemTotal are not measurements;
// clamping them would manufacture a trusted zero-usage sample.
func ParseLinuxMemoryAvailability(content string, truncated bool) (LinuxMemoryAvailability, error) {
var result LinuxMemoryAvailability
seen := make(map[string]struct{})
fieldsByName := map[string]*uint64{
"MemTotal": &result.Total,
"MemFree": &result.Free,
"MemAvailable": &result.Available,
"Buffers": &result.Buffers,
"Cached": &result.Cached,
"SReclaimable": &result.SReclaimable,
"Shmem": &result.Shmem,
"SwapTotal": &result.SwapTotal,
"SwapFree": &result.SwapFree,
}
for _, line := range strings.Split(content, "\n") {
nameValue := strings.SplitN(strings.TrimSpace(line), ":", 2)
if len(nameValue) != 2 {
continue
}
target, ok := fieldsByName[nameValue[0]]
if !ok {
continue
}
if _, duplicate := seen[nameValue[0]]; duplicate {
return LinuxMemoryAvailability{}, fmt.Errorf("duplicate %s field in /proc/meminfo", nameValue[0])
}
seen[nameValue[0]] = struct{}{}
fields := strings.Fields(nameValue[1])
if len(fields) < 2 || fields[1] != "kB" {
return LinuxMemoryAvailability{}, fmt.Errorf("invalid %s field in /proc/meminfo", nameValue[0])
}
kB, err := strconv.ParseUint(fields[0], 10, 64)
if err != nil {
return LinuxMemoryAvailability{}, fmt.Errorf("parse %s value %q: %w", nameValue[0], fields[0], err)
}
if kB > math.MaxUint64/1024 {
return LinuxMemoryAvailability{}, fmt.Errorf("%s value %d kB overflows uint64", nameValue[0], kB)
}
*target = kB * 1024
}
if result.Total > 0 {
for _, name := range []string{"MemFree", "Buffers", "Cached", "SReclaimable", "Shmem"} {
if *fieldsByName[name] > result.Total {
return LinuxMemoryAvailability{}, fmt.Errorf("%s exceeds MemTotal", name)
}
}
}
if _, present := seen["MemAvailable"]; present {
if _, ok := seen["MemTotal"]; !ok || result.Total == 0 {
return LinuxMemoryAvailability{}, fmt.Errorf("MemAvailable has no valid MemTotal")
}
if result.Available > result.Total {
return LinuxMemoryAvailability{}, fmt.Errorf("MemAvailable exceeds MemTotal")
}
result.EffectiveAvailable = result.Available
result.Source = "meminfo-available"
return result, nil
}
if truncated {
return LinuxMemoryAvailability{}, fmt.Errorf("truncated /proc/meminfo has no MemAvailable")
}
for _, required := range []string{"MemTotal", "MemFree", "Buffers", "Cached"} {
if _, ok := seen[required]; !ok {
return LinuxMemoryAvailability{}, fmt.Errorf("old-kernel /proc/meminfo missing %s", required)
}
}
if result.Total == 0 || result.Free == 0 && result.Buffers == 0 && result.Cached == 0 && result.SReclaimable == 0 {
return LinuxMemoryAvailability{}, fmt.Errorf("insufficient cache-aware fields in /proc/meminfo")
}
if result.Buffers == 0 && result.Cached == 0 && result.SReclaimable == 0 {
return LinuxMemoryAvailability{}, fmt.Errorf("MemAvailable and reclaimable cache fields not found in /proc/meminfo")
}
available := result.Free
for _, value := range []uint64{result.Buffers, result.Cached, result.SReclaimable} {
if math.MaxUint64-available < value {
return LinuxMemoryAvailability{}, fmt.Errorf("cache-aware /proc/meminfo estimate overflows uint64")
}
available += value
}
if result.Shmem >= available {
available = 0
} else {
available -= result.Shmem
}
if available > result.Total {
return LinuxMemoryAvailability{}, fmt.Errorf("derived MemAvailable exceeds MemTotal")
}
result.EffectiveAvailable = available
result.Source = "meminfo-derived"
return result, nil
}
// GetVMMemoryAvailabilityFromAgent reads /proc/meminfo via the QEMU guest
// agent and returns the raw evidence plus the selected cache-aware value.
func (c *Client) GetVMMemoryAvailabilityFromAgent(ctx context.Context, node string, vmid int) (LinuxMemoryAvailability, error) {
fileParam := url.QueryEscape("/proc/meminfo")
resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/agent/file-read?file=%s", node, vmid, fileParam))
if err != nil {
return LinuxMemoryAvailability{}, fmt.Errorf("guest agent file-read /proc/meminfo: %w", err)
}
defer resp.Body.Close()
var result struct {
Data struct {
Content string `json:"content"`
Truncated *bool `json:"truncated,omitempty"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return LinuxMemoryAvailability{}, fmt.Errorf("decode file-read response: %w", err)
}
truncated := result.Data.Truncated != nil && *result.Data.Truncated
availability, err := ParseLinuxMemoryAvailability(result.Data.Content, truncated)
if err != nil {
return LinuxMemoryAvailability{}, err
}
return availability, nil
}
// GetVMMemAvailableFromAgent is retained for callers that only need the
// selected availability value.
func (c *Client) GetVMMemAvailableFromAgent(ctx context.Context, node string, vmid int) (uint64, error) {
availability, err := c.GetVMMemoryAvailabilityFromAgent(ctx, node, vmid)
if err != nil {
return 0, err
}
return availability.EffectiveAvailable, 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
}
result.Data.ObservedAt = time.Now().UTC()
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
}
result.Data.ObservedAt = time.Now().UTC()
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"`
Pool string `json:"pool,omitempty"`
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"`
IOCounters IOCounterPresence `json:"-"`
ObservedAt time.Time `json:"-"`
Lock string `json:"lock,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
}
values, err := readGuestInventory(resp, func(resource ClusterResource) (string, bool) {
if resourceType == "vm" && resource.Type != "qemu" && resource.Type != "lxc" {
return "", false
}
return clusterInventoryID(resource)
})
if err != nil {
return nil, err
}
stampClusterResourceObservation(values, time.Now().UTC())
return values, nil
}
// ClusterOptions holds selected Proxmox datacenter configuration options.
type ClusterOptions struct {
TagStyle string `json:"tag-style,omitempty"`
}
// TagStyle captures the Proxmox tag presentation settings Pulse needs to
// reproduce Proxmox tag colors in other surfaces.
type TagStyle struct {
Colors map[string]string
CaseSensitive bool
}
// GetClusterOptions fetches datacenter-level options such as tag color config.
func (c *Client) GetClusterOptions(ctx context.Context) (*ClusterOptions, error) {
resp, err := c.get(ctx, "/cluster/options")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data ClusterOptions `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
// ParseTagStyle parses Proxmox's tag-style property string and returns the
// presentation settings needed for tag color rendering.
func ParseTagStyle(tagStyle string) TagStyle {
style := TagStyle{
Colors: make(map[string]string),
}
properties := make(map[string]string)
for _, part := range strings.Split(tagStyle, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
properties[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
style.CaseSensitive = properties["case-sensitive"] == "1"
parseTagColorMapInto(style.Colors, properties["color-map"], style.CaseSensitive)
return style
}
// ParseTagColorMap parses a Proxmox tag-style string and returns a map of tag
// name to "#rrggbb" hex color string. The keys are lowercase unless the
// Proxmox tag-style enables case-sensitive tags.
// Example input: "color-map=production:ff0000;staging:ffaa00,ordering=config"
func ParseTagColorMap(tagStyle string) map[string]string {
return ParseTagStyle(tagStyle).Colors
}
func parseTagColorMapInto(colors map[string]string, colorMap string, caseSensitive bool) {
if colors == nil {
return
}
for _, pair := range strings.Split(colorMap, ";") {
fields := strings.Split(pair, ":")
if len(fields) < 2 {
continue
}
tag := strings.TrimSpace(fields[0])
if !caseSensitive {
tag = strings.ToLower(tag)
}
hex := strings.TrimSpace(fields[1])
hex = strings.TrimPrefix(hex, "#")
if tag == "" || !isHexColorToken(hex) {
continue
}
colors[tag] = "#" + strings.ToLower(hex)
}
}
func isHexColorToken(value string) bool {
if len(value) != 6 {
return false
}
for _, r := range value {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
case r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
// 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"`
availablePresent bool // Measured zero must not become missing availability.
}
// HasAvailable distinguishes a guest's measured zero availability from older
// status payloads without that field. Nonzero programmatically built samples
// retain their existing meaning.
func (m VMMemInfo) HasAvailable() bool {
return m.availablePresent || m.Available > 0
}
func (m *VMMemInfo) UnmarshalJSON(data []byte) error {
type plain VMMemInfo
var decoded plain
wire := struct {
*plain
Available *uint64 `json:"available"`
}{plain: &decoded}
if err := json.Unmarshal(data, &wire); err != nil {
return err
}
if wire.Available != nil {
decoded.Available = *wire.Available
decoded.availablePresent = true
}
*m = VMMemInfo(decoded)
return nil
}
// Keep explicit zero through status copies/JSON round trips without inventing
// an available field for legacy payloads. Internal presence is not a wire field.
func (m VMMemInfo) MarshalJSON() ([]byte, error) {
type plain VMMemInfo
var available *uint64
if m.HasAvailable() {
available = &m.Available
}
return json.Marshal(struct {
plain
Available *uint64 `json:"available,omitempty"`
}{plain: plain(m), Available: available})
}
// VMBalloonInfo describes guest memory details returned by status/current when
// the virtio balloon driver can report guest-side memory state.
type VMBalloonInfo struct {
Actual uint64 `json:"actual,omitempty"`
FreeMem uint64 `json:"free_mem,omitempty"`
LastUpdate uint64 `json:"last_update,omitempty"`
MajorPageFaults uint64 `json:"major_page_faults,omitempty"`
MaxMem uint64 `json:"max_mem,omitempty"`
MemSwappedIn uint64 `json:"mem_swapped_in,omitempty"`
MemSwappedOut uint64 `json:"mem_swapped_out,omitempty"`
MinorPageFaults uint64 `json:"minor_page_faults,omitempty"`
TotalMem uint64 `json:"total_mem,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
Enabled int
Available int
AvailabilityKnown bool
}
// IsAvailable reports whether status/current says guest-agent commands are
// currently queryable. Legacy integer payloads only expose one bit, so they are
// treated as available when non-zero.
func (a VMAgentField) IsAvailable() bool {
return a.Value > 0
}
// IsEnabled reports whether the VM config has the guest agent enabled even if
// the current status says the in-guest service is unavailable.
func (a VMAgentField) IsEnabled() bool {
if a.Enabled > 0 {
return true
}
return !a.AvailabilityKnown && a.Value > 0
}
// 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
a.Enabled = intValue
a.Available = intValue
a.AvailabilityKnown = false
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 {
a.Value = 0
a.Enabled = 0
a.Available = 0
a.AvailabilityKnown = objValue.Available != nil
if objValue.Enabled != nil {
a.Enabled = *objValue.Enabled
}
if objValue.Available != nil {
a.Available = *objValue.Available
if a.Available > 0 {
a.Value = a.Available
}
} else if a.Enabled > 0 {
a.Value = a.Enabled
}
return nil
}
// If neither worked, default to 0 (agent disabled)
a.Value = 0
a.Enabled = 0
a.Available = 0
a.AvailabilityKnown = false
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"`
BalloonInfo *VMBalloonInfo `json:"ballooninfo,omitempty"`
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"`
IOCounters IOCounterPresence `json:"-"`
ObservedAt time.Time `json:"-"`
Lock string `json:"lock,omitempty"`
}
// 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:
parsed, err := floatToIntTrunc(v)
if err != nil {
d.Wearout = wearoutUnknown
break
}
d.Wearout = parsed
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:
parsed, err := floatToIntTrunc(v)
if err != nil {
d.RPM = 0
break
}
d.RPM = parsed
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 looksLikeNumericLiteral(cleaned) {
if parsed, err := strconv.ParseFloat(cleaned, 64); err == nil {
if value, convErr := floatToIntTrunc(parsed); convErr == nil {
return value
}
}
return wearoutUnknown
}
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
// Access is evaluated by Proxmox for the configured credential on /nodes/{node}.
// Do not infer access from general node-audit permission; endpoint requirements
// can differ from other monitoring reads.
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
}