Pulse/pkg/pbs/client.go
rcourtman d88cce2cfc fix: use AVERAGE instead of average for PBS RRD API cf parameter
Fixes #483 - PBS syslog was being flooded with 400 Bad Request errors
because the cf parameter value 'average' is not in the valid enumeration.
Changed to 'AVERAGE' (all caps) per PBS API specification.
2025-10-01 11:07:36 +00:00

875 lines
25 KiB
Go

package pbs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
"github.com/rs/zerolog/log"
)
// Client represents a Proxmox Backup Server API client
type Client struct {
baseURL string
httpClient *http.Client
auth auth
config ClientConfig
}
// ClientConfig holds configuration for the PBS 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 PBS API client
func NewClient(cfg ClientConfig) (*Client, error) {
// Normalize host URL - ensure it has a protocol
if !strings.HasPrefix(cfg.Host, "http://") && !strings.HasPrefix(cfg.Host, "https://") {
// Default to HTTPS if no protocol specified
cfg.Host = "https://" + cfg.Host
// Log that we're defaulting to HTTPS
log.Debug().Str("host", cfg.Host).Msg("No protocol specified in PBS host, defaulting to HTTPS")
}
// Warn if using HTTP
if strings.HasPrefix(cfg.Host, "http://") {
log.Warn().Str("host", cfg.Host).Msg("Using HTTP for PBS connection. PBS typically requires HTTPS. If connection fails, try using https:// instead")
}
var user, realm string
// For token auth, user might be empty or in a different format
if cfg.TokenName != "" && cfg.TokenValue != "" {
// Token authentication - parse the token name to extract user info if needed
if strings.Contains(cfg.TokenName, "!") {
// Token name contains full format: user@realm!tokenname
parts := strings.Split(cfg.TokenName, "!")
if len(parts) == 2 && strings.Contains(parts[0], "@") {
userParts := strings.Split(parts[0], "@")
if len(userParts) == 2 {
user = userParts[0]
realm = userParts[1]
// Update token name to just the token part
cfg.TokenName = parts[1]
}
}
} else if cfg.User != "" {
// User provided separately
parts := strings.Split(cfg.User, "@")
if len(parts) == 2 {
user = parts[0]
realm = parts[1]
} else {
// If no realm specified, default to pbs
user = cfg.User
realm = "pbs"
}
} else {
return nil, fmt.Errorf("token authentication requires user information either in token name (user@realm!tokenname) or user field")
}
} else {
// Password authentication - user@realm format is required
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)
client := &Client{
baseURL: strings.TrimSuffix(cfg.Host, "/") + "/api2/json",
httpClient: httpClient,
config: cfg,
auth: auth{
user: user,
realm: realm,
tokenName: cfg.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) // PBS 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
// Note: tokenName already contains just the token part (e.g., "pulse-token")
// after parsing in NewClient, so we reconstruct the full format
authHeader := fmt.Sprintf("PBSAPIToken=%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
// Log the auth header format (without the secret)
maskedHeader := fmt.Sprintf("PBSAPIToken=%s@%s!%s:***",
c.auth.user, c.auth.realm, c.auth.tokenName)
log.Debug().
Str("user", c.auth.user).
Str("realm", c.auth.realm).
Str("tokenName", c.auth.tokenName).
Str("authHeaderFormat", maskedHeader).
Str("url", req.URL.String()).
Msg("Setting PBS API token authentication")
} else if c.auth.ticket != "" {
// Ticket authentication
req.Header.Set("Cookie", "PBSAuthCookie="+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
err := fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
// Wrap with appropriate error type
if resp.StatusCode == 401 || resp.StatusCode == 403 {
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)
}
// Version represents PBS version information
type Version struct {
Version string `json:"version"`
Release string `json:"release"`
Repoid string `json:"repoid"`
}
// Datastore represents a PBS datastore
type Datastore struct {
Store string `json:"store"`
Total int64 `json:"total,omitempty"`
Used int64 `json:"used,omitempty"`
Avail int64 `json:"avail,omitempty"`
// Alternative field names PBS might use
TotalSpace int64 `json:"total-space,omitempty"`
UsedSpace int64 `json:"used-space,omitempty"`
AvailSpace int64 `json:"avail-space,omitempty"`
// Status fields
GCStatus string `json:"gc-status,omitempty"`
DeduplicationFactor float64 `json:"deduplication_factor,omitempty"`
Error string `json:"error,omitempty"`
}
// GetVersion returns PBS version information
func (c *Client) GetVersion(ctx context.Context) (*Version, error) {
log.Debug().Msg("PBS GetVersion: starting request")
resp, err := c.get(ctx, "/version")
if err != nil {
log.Debug().Err(err).Msg("PBS GetVersion: request failed")
return nil, err
}
defer resp.Body.Close()
log.Debug().Msg("PBS GetVersion: request succeeded")
var result struct {
Data Version `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
// GetNodeName returns the PBS node's hostname
func (c *Client) GetNodeName(ctx context.Context) (string, error) {
log.Debug().Msg("PBS GetNodeName: fetching node name")
resp, err := c.get(ctx, "/nodes")
if err != nil {
return "", fmt.Errorf("failed to get nodes: %w", err)
}
defer resp.Body.Close()
var result struct {
Data []struct {
Node string `json:"node"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode nodes response: %w", err)
}
if len(result.Data) == 0 {
return "", fmt.Errorf("no nodes found")
}
// Return the first (usually only) node name
nodeName := result.Data[0].Node
log.Debug().Str("nodeName", nodeName).Msg("PBS GetNodeName: found node name")
return nodeName, nil
}
// GetNodeStatus returns the status of the PBS node (CPU, memory, etc.)
func (c *Client) GetNodeStatus(ctx context.Context) (*NodeStatus, error) {
log.Debug().Msg("PBS GetNodeStatus: starting")
// The /nodes/localhost/status endpoint requires special permissions that API tokens often don't have
// This is a known PBS limitation - the endpoint is primarily for internal use
// We'll gracefully handle the permission error and return nil
statusResp, err := c.get(ctx, "/nodes/localhost/status")
if err != nil {
// Check if this is a permission error (403)
if strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "permission") {
log.Debug().Msg("PBS GetNodeStatus: permission denied (expected with API tokens) - returning nil")
return nil, nil // Return nil without error for permission issues
}
return nil, fmt.Errorf("failed to get node status: %w", err)
}
defer statusResp.Body.Close()
// Read the response body to log it
body, err := io.ReadAll(statusResp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read status response: %w", err)
}
log.Debug().Str("response", string(body)).Msg("PBS node status response")
var statusResult struct {
Data NodeStatus `json:"data"`
}
if err := json.Unmarshal(body, &statusResult); err != nil {
return nil, fmt.Errorf("failed to decode status response: %w", err)
}
return &statusResult.Data, nil
}
// GetDatastores returns all datastores with their status
func (c *Client) GetDatastores(ctx context.Context) ([]Datastore, error) {
// First get the list of datastores
resp, err := c.get(ctx, "/admin/datastore")
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Check if response is HTML (error page) instead of JSON
if len(body) > 0 && body[0] == '<' {
// If using HTTP, suggest HTTPS
if strings.HasPrefix(c.config.Host, "http://") {
return nil, fmt.Errorf("PBS returned HTML instead of JSON. PBS typically requires HTTPS, not HTTP. Try changing your URL from %s to %s", c.config.Host, strings.Replace(c.config.Host, "http://", "https://", 1))
}
return nil, fmt.Errorf("PBS returned HTML instead of JSON (likely an error page). Please check your PBS URL and port (default is 8007)")
}
var datastoreList struct {
Data []struct {
Store string `json:"store"`
Comment string `json:"comment,omitempty"`
} `json:"data"`
}
if err := json.Unmarshal(body, &datastoreList); err != nil {
log.Error().
Str("response", string(body)).
Err(err).
Msg("Failed to parse PBS datastore list response")
return nil, fmt.Errorf("failed to parse datastore list: %w", err)
}
// Now get status for each datastore
var datastores []Datastore
for _, ds := range datastoreList.Data {
// Try to get RRD data first which has more statistics
// RRD requires cf (consolidation function) and timeframe parameters
// Valid cf values: AVERAGE, MAXIMUM, MINIMUM (all caps per PBS API spec)
rrdPath := fmt.Sprintf("/admin/datastore/%s/rrd?cf=AVERAGE&timeframe=hour", ds.Store)
rrdResp, err := c.get(ctx, rrdPath)
var dedupFactor float64
if err == nil {
defer rrdResp.Body.Close()
rrdBody, _ := io.ReadAll(rrdResp.Body)
var rrdResult struct {
Data []struct {
Time float64 `json:"time"`
DedupFactor float64 `json:"dedup_factor"`
} `json:"data"`
}
if json.Unmarshal(rrdBody, &rrdResult) == nil && len(rrdResult.Data) > 0 {
// Get the most recent deduplication factor
dedupFactor = rrdResult.Data[len(rrdResult.Data)-1].DedupFactor
log.Info().Float64("dedup_from_rrd", dedupFactor).Str("store", ds.Store).Msg("Got dedup factor from RRD")
}
}
// Get individual datastore status
statusResp, err := c.get(ctx, fmt.Sprintf("/admin/datastore/%s/status", ds.Store))
if err != nil {
log.Error().Str("store", ds.Store).Err(err).Msg("Failed to get datastore status")
// Create entry with no size info if status fails
datastores = append(datastores, Datastore{
Store: ds.Store,
Error: fmt.Sprintf("Failed to get status: %v", err),
})
continue
}
defer statusResp.Body.Close()
statusBody, err := io.ReadAll(statusResp.Body)
if err != nil {
log.Error().Str("store", ds.Store).Err(err).Msg("Failed to read datastore status response")
datastores = append(datastores, Datastore{
Store: ds.Store,
Error: fmt.Sprintf("Failed to read status: %v", err),
})
continue
}
var statusResult struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(statusBody, &statusResult); err != nil {
log.Error().
Str("store", ds.Store).
Str("response", string(statusBody)).
Err(err).
Msg("Failed to parse datastore status")
datastores = append(datastores, Datastore{
Store: ds.Store,
Error: fmt.Sprintf("Failed to parse status: %v", err),
})
continue
}
// Extract fields from the map
total, _ := statusResult.Data["total"].(float64)
used, _ := statusResult.Data["used"].(float64)
avail, _ := statusResult.Data["avail"].(float64)
// Check for deduplication_factor in status response
if df, ok := statusResult.Data["deduplication-factor"].(float64); ok {
dedupFactor = df
} else if df, ok := statusResult.Data["deduplication_factor"].(float64); ok {
dedupFactor = df
}
// If still no dedup factor, try gc-status endpoint
if dedupFactor == 0 {
gcResp, err := c.get(ctx, fmt.Sprintf("/admin/datastore/%s/gc", ds.Store))
if err == nil {
defer gcResp.Body.Close()
gcBody, _ := io.ReadAll(gcResp.Body)
var gcResult struct {
Data struct {
IndexDataBytes float64 `json:"index-data-bytes"`
DiskBytes float64 `json:"disk-bytes"`
} `json:"data"`
}
if json.Unmarshal(gcBody, &gcResult) == nil {
// Calculate deduplication factor from index-data-bytes / disk-bytes
if gcResult.Data.DiskBytes > 0 && gcResult.Data.IndexDataBytes > 0 {
dedupFactor = gcResult.Data.IndexDataBytes / gcResult.Data.DiskBytes
log.Info().
Float64("index_bytes", gcResult.Data.IndexDataBytes).
Float64("disk_bytes", gcResult.Data.DiskBytes).
Float64("dedup_factor", dedupFactor).
Str("store", ds.Store).
Msg("Calculated dedup factor from gc endpoint")
}
}
}
}
// Create datastore with status info
datastore := Datastore{
Store: ds.Store,
Total: int64(total),
Used: int64(used),
Avail: int64(avail),
DeduplicationFactor: dedupFactor,
}
// Log all fields to see what's available
log.Info().
Str("store", datastore.Store).
Int64("total", datastore.Total).
Int64("used", datastore.Used).
Int64("avail", datastore.Avail).
Float64("dedup_factor", datastore.DeduplicationFactor).
Interface("all_fields", statusResult.Data).
Msg("PBS datastore status - ALL FIELDS")
datastores = append(datastores, datastore)
}
return datastores, nil
}
// NodeStatus represents PBS node status information
type NodeStatus struct {
CPU float64 `json:"cpu"` // CPU usage percentage
Memory Memory `json:"memory"` // Memory information
Uptime int64 `json:"uptime"` // Uptime in seconds
LoadAverage []float64 `json:"loadavg"` // Load average [1min, 5min, 15min]
KSM KSMInfo `json:"ksm"` // Kernel Same-page Merging info
Swap Memory `json:"swap"` // Swap information
RootFS FSInfo `json:"root"` // Root filesystem info
}
// Memory represents memory information
type Memory struct {
Total int64 `json:"total"` // Total memory in bytes
Used int64 `json:"used"` // Used memory in bytes
Free int64 `json:"free"` // Free memory in bytes
}
// KSMInfo represents Kernel Same-page Merging information
type KSMInfo struct {
Shared int64 `json:"shared"` // Shared memory in bytes
}
// FSInfo represents filesystem information
type FSInfo struct {
Total int64 `json:"total"` // Total space in bytes
Used int64 `json:"used"` // Used space in bytes
Free int64 `json:"free"` // Free space in bytes
}
// Namespace represents a PBS namespace
type Namespace struct {
NS string `json:"ns"`
Path string `json:"path"`
Name string `json:"name"`
Parent string `json:"parent,omitempty"`
}
// BackupGroup represents a group of backups for a specific VM/CT
type BackupGroup struct {
BackupType string `json:"backup-type"` // "vm" or "ct"
BackupID string `json:"backup-id"` // VMID
LastBackup int64 `json:"last-backup"` // Unix timestamp
BackupCount int `json:"backup-count"`
Files []string `json:"files,omitempty"`
Owner string `json:"owner,omitempty"`
}
// BackupSnapshot represents a single backup snapshot
type BackupSnapshot struct {
BackupType string `json:"backup-type"` // "vm" or "ct"
BackupID string `json:"backup-id"` // VMID
BackupTime int64 `json:"backup-time"` // Unix timestamp
Files []interface{} `json:"files,omitempty"` // Can be strings or objects
Size int64 `json:"size"`
Protected bool `json:"protected"`
Comment string `json:"comment,omitempty"`
Owner string `json:"owner,omitempty"`
Verification interface{} `json:"verification,omitempty"` // Can be string or object
}
// ListNamespaces lists namespaces for a datastore
func (c *Client) ListNamespaces(ctx context.Context, datastore string, parentNamespace string, maxDepth int) ([]Namespace, error) {
path := fmt.Sprintf("/admin/datastore/%s/namespace", datastore)
// Build query parameters
params := url.Values{}
if parentNamespace != "" {
params.Set("ns", parentNamespace)
}
if maxDepth > 0 {
params.Set("max-depth", fmt.Sprintf("%d", maxDepth))
}
if len(params) > 0 {
path += "?" + params.Encode()
}
resp, err := c.get(ctx, path)
if err != nil {
// If namespace endpoint doesn't exist (older PBS versions), return empty list
if strings.Contains(err.Error(), "404") {
return []Namespace{}, nil
}
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []Namespace `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// ListBackupGroups lists all backup groups in a datastore/namespace
func (c *Client) ListBackupGroups(ctx context.Context, datastore string, namespace string) ([]BackupGroup, error) {
path := fmt.Sprintf("/admin/datastore/%s/groups", datastore)
// Add namespace parameter if provided
params := url.Values{}
if namespace != "" {
params.Set("ns", namespace)
}
if len(params) > 0 {
path = path + "?" + params.Encode()
}
// Log the API call
log.Debug().Str("url", c.baseURL+path).Msg("PBS API: ListBackupGroups")
resp, err := c.get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
var result struct {
Data []BackupGroup `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode backup groups: %w", err)
}
log.Debug().
Str("namespace", namespace).
Int("count", len(result.Data)).
Msg("PBS API: Backup groups found")
return result.Data, nil
}
// ListBackupSnapshots lists all snapshots for a specific backup group
func (c *Client) ListBackupSnapshots(ctx context.Context, datastore string, namespace string, backupType string, backupID string) ([]BackupSnapshot, error) {
path := fmt.Sprintf("/admin/datastore/%s/snapshots", datastore)
// Build parameters
params := url.Values{}
if namespace != "" {
params.Set("ns", namespace)
}
params.Set("backup-type", backupType)
params.Set("backup-id", backupID)
path = path + "?" + params.Encode()
resp, err := c.get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
var result struct {
Data []BackupSnapshot `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode snapshots: %w", err)
}
return result.Data, nil
}
// ListAllBackups fetches all backups from all namespaces concurrently
func (c *Client) ListAllBackups(ctx context.Context, datastore string, namespaces []string) (map[string][]BackupSnapshot, error) {
type namespaceResult struct {
namespace string
snapshots []BackupSnapshot
err error
}
// Channel for results
resultCh := make(chan namespaceResult, len(namespaces))
// WaitGroup to track goroutines
var wg sync.WaitGroup
// Semaphore to limit concurrent requests
sem := make(chan struct{}, 3) // Max 3 concurrent requests
// Fetch backups from each namespace concurrently
for _, ns := range namespaces {
wg.Add(1)
go func(namespace string) {
defer wg.Done()
// Acquire semaphore
sem <- struct{}{}
defer func() { <-sem }()
// Get groups first
groups, err := c.ListBackupGroups(ctx, datastore, namespace)
if err != nil {
log.Error().
Str("datastore", datastore).
Str("namespace", namespace).
Err(err).
Msg("Failed to list backup groups")
resultCh <- namespaceResult{namespace: namespace, err: err}
return
}
log.Info().
Str("datastore", datastore).
Str("namespace", namespace).
Int("groups", len(groups)).
Msg("Found backup groups")
var allSnapshots []BackupSnapshot
// For each group, get snapshots
for _, group := range groups {
snapshots, err := c.ListBackupSnapshots(ctx, datastore, namespace, group.BackupType, group.BackupID)
if err != nil {
log.Error().
Str("datastore", datastore).
Str("namespace", namespace).
Str("type", group.BackupType).
Str("id", group.BackupID).
Err(err).
Msg("Failed to list snapshots")
continue
}
allSnapshots = append(allSnapshots, snapshots...)
}
resultCh <- namespaceResult{
namespace: namespace,
snapshots: allSnapshots,
err: nil,
}
}(ns)
}
// Close channel when all goroutines complete
go func() {
wg.Wait()
close(resultCh)
}()
// Collect results
results := make(map[string][]BackupSnapshot)
var errors []error
for result := range resultCh {
if result.err != nil {
errors = append(errors, fmt.Errorf("namespace %s: %w", result.namespace, result.err))
} else {
results[result.namespace] = result.snapshots
}
}
// Return combined error if any occurred
if len(errors) > 0 {
return results, fmt.Errorf("errors fetching backups: %v", errors)
}
return results, nil
}