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