package pbs import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil" "github.com/rs/zerolog/log" ) const maxResponseBodyBytes int64 = 8 << 20 // 8 MiB func readResponseBodyLimited(r io.Reader) ([]byte, error) { body, err := io.ReadAll(io.LimitReader(r, maxResponseBodyBytes+1)) if err != nil { return nil, err } if int64(len(body)) > maxResponseBodyBytes { return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBodyBytes) } return body, nil } // Client represents a Proxmox Backup Server API client type Client struct { baseURL string baseAPIURL *url.URL httpClient *http.Client auth auth config ClientConfig } func (c *Client) apiBaseURL() (*url.URL, error) { if c.baseAPIURL != nil { return c.baseAPIURL, nil } baseURL := strings.TrimSpace(c.baseURL) if baseURL == "" { return nil, fmt.Errorf("base URL is required") } parsed, err := securityutil.NormalizeHTTPBaseURL(baseURL, "") if err != nil { return nil, fmt.Errorf("invalid base URL: %w", err) } c.baseAPIURL = parsed return parsed, nil } // ClientConfig holds configuration for the 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") } baseHostURL, err := securityutil.NormalizeHTTPBaseURL(cfg.Host, "") if err != nil { return nil, fmt.Errorf("invalid PBS host: %w", err) } baseAPIURL := securityutil.AppendURLPath(baseHostURL, "api2", "json") 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") } if user == "" { return nil, fmt.Errorf("could not parse user information from token name") } } 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: baseAPIURL.String(), baseAPIURL: baseAPIURL, 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 } baseAPIURL, err := c.apiBaseURL() if err != nil { return err } req, err := securityutil.NewRelativeRequestWithContext(ctx, "POST", baseAPIURL, "/access/ticket", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() return c.handleAuthResponse(resp) } func (c *Client) authenticateForm(ctx context.Context, username, password string) error { data := url.Values{ "username": {username}, "password": {password}, } baseAPIURL, err := c.apiBaseURL() if err != nil { return err } req, err := securityutil.NewRelativeRequestWithContext(ctx, "POST", baseAPIURL, "/access/ticket", strings.NewReader(data.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() return c.handleAuthResponse(resp) } func (c *Client) handleAuthResponse(resp *http.Response) error { if resp.StatusCode != http.StatusOK { body, err := readResponseBodyLimited(resp.Body) if err != nil { return &authHTTPError{status: resp.StatusCode, body: err.Error()} } return &authHTTPError{status: resp.StatusCode, body: string(body)} } 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()) } baseAPIURL, err := c.apiBaseURL() if err != nil { return nil, err } req, err := securityutil.NewRelativeRequestWithContext(ctx, method, baseAPIURL, path, body) if err != nil { return nil, err } // Set headers if data != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } // Set authentication if 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, err := readResponseBodyLimited(resp.Body) if err != nil { return nil, err } // Create base error apiErr := 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", apiErr) } return nil, apiErr } return resp, nil } // get performs a GET request func (c *Client) get(ctx context.Context, path string) (*http.Response, error) { return c.request(ctx, "GET", path, nil) } // post performs a POST request func (c *Client) post(ctx context.Context, path string, data url.Values) (*http.Response, error) { return c.request(ctx, "POST", path, data) } // delete performs a DELETE request func (c *Client) delete(ctx context.Context, path string) (*http.Response, error) { return c.request(ctx, "DELETE", path, nil) } // TokenResponse represents the response from token creation type TokenResponse struct { TokenID string `json:"tokenid"` Value string `json:"value"` } // CreateUser creates a new user on the PBS server // This requires admin privileges (typically root@pam) func (c *Client) CreateUser(ctx context.Context, userID, comment string) error { log.Debug().Str("userID", userID).Msg("PBS CreateUser: creating user") data := url.Values{} data.Set("userid", userID) if comment != "" { data.Set("comment", comment) } resp, err := c.post(ctx, "/access/users", data) if err != nil { // User might already exist, which is okay if isAlreadyExistsError(err) { log.Debug().Str("userID", userID).Msg("PBS CreateUser: user already exists") return nil } return fmt.Errorf("create user: %w", err) } defer resp.Body.Close() log.Info().Str("userID", userID).Msg("PBS CreateUser: user created successfully") return nil } func isAlreadyExistsError(err error) bool { return strings.Contains(strings.ToLower(err.Error()), "already exists") } // SetUserACL sets ACL permissions for a user func (c *Client) SetUserACL(ctx context.Context, authID, path, role string) error { log.Debug().Str("authID", authID).Str("path", path).Str("role", role).Msg("PBS SetUserACL: setting ACL") data := url.Values{} data.Set("auth-id", authID) data.Set("path", path) data.Set("role", role) resp, err := c.post(ctx, "/access/acl", data) if err != nil { return fmt.Errorf("set ACL: %w", err) } defer resp.Body.Close() log.Info().Str("authID", authID).Str("role", role).Msg("PBS SetUserACL: ACL set successfully") return nil } // CreateUserToken creates an API token for a user // Returns the full token ID and the secret value func (c *Client) CreateUserToken(ctx context.Context, userID, tokenName string) (*TokenResponse, error) { log.Debug().Str("userID", userID).Str("tokenName", tokenName).Msg("PBS CreateUserToken: creating token") // PBS API: POST /access/users/{userid}/token/{tokenname} path := fmt.Sprintf("/access/users/%s/token/%s", url.PathEscape(userID), url.PathEscape(tokenName)) // Token with no expiry data := url.Values{} data.Set("expire", "0") resp, err := c.post(ctx, path, data) if err != nil { return nil, fmt.Errorf("create token: %w", err) } defer resp.Body.Close() body, err := readResponseBodyLimited(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } // Parse response - PBS returns {"data": {"tokenid": "...", "value": "..."}} var result struct { Data TokenResponse `json:"data"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w (body: %s)", err, string(body)) } if result.Data.Value == "" { return nil, fmt.Errorf("empty token value in response: %s", string(body)) } log.Info(). Str("userID", userID). Str("tokenName", tokenName). Str("tokenID", result.Data.TokenID). Msg("PBS CreateUserToken: token created successfully") return &result.Data, nil } // DeleteUserToken deletes an existing API token for a user. func (c *Client) DeleteUserToken(ctx context.Context, userID, tokenName string) error { log.Debug().Str("userID", userID).Str("tokenName", tokenName).Msg("PBS DeleteUserToken: deleting token") path := fmt.Sprintf("/access/users/%s/token/%s", url.PathEscape(userID), url.PathEscape(tokenName)) resp, err := c.delete(ctx, path) if err != nil { // Deleting a missing token should be treated as already converged. if strings.Contains(err.Error(), "API error 404") { return nil } return fmt.Errorf("delete token: %w", err) } defer resp.Body.Close() log.Info(). Str("userID", userID). Str("tokenName", tokenName). Msg("PBS DeleteUserToken: token deleted successfully") return nil } // SetupMonitoringAccess creates a monitoring user with Audit role and returns API token // This is the turnkey method for setting up PBS monitoring access func (c *Client) SetupMonitoringAccess(ctx context.Context, tokenName string) (tokenID, tokenValue string, err error) { const ( monitorUser = "pulse-monitor@pbs" monitorComment = "Pulse monitoring service" auditRole = "Audit" ) log.Info().Str("tokenName", tokenName).Msg("PBS SetupMonitoringAccess: starting turnkey setup") // Step 1: Create monitoring user (ignore if exists) if err := c.CreateUser(ctx, monitorUser, monitorComment); err != nil { log.Warn().Err(err).Msg("PBS SetupMonitoringAccess: failed to create user (may already exist)") // Continue - user might already exist } // Step 2: Grant Audit role on / (root path) if err := c.SetUserACL(ctx, monitorUser, "/", auditRole); err != nil { return "", "", fmt.Errorf("set user ACL: %w", err) } // Step 3: Create API token token, err := c.CreateUserToken(ctx, monitorUser, tokenName) if err != nil { if isAlreadyExistsError(err) { log.Warn().Str("tokenName", tokenName).Msg("PBS SetupMonitoringAccess: token already exists; rotating in place") if deleteErr := c.DeleteUserToken(ctx, monitorUser, tokenName); deleteErr != nil { return "", "", fmt.Errorf("delete existing token: %w", deleteErr) } token, err = c.CreateUserToken(ctx, monitorUser, tokenName) } if err != nil { return "", "", fmt.Errorf("create token: %w", err) } } // Step 4: Grant Audit role to the token as well if err := c.SetUserACL(ctx, token.TokenID, "/", auditRole); err != nil { log.Warn().Err(err).Msg("PBS SetupMonitoringAccess: failed to set ACL on token (may not be required)") // Continue - might not be strictly necessary } log.Info(). Str("tokenID", token.TokenID). Msg("PBS SetupMonitoringAccess: turnkey setup complete") return token.TokenID, token.Value, 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 Status string `json:"status,omitempty"` 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 := readResponseBodyLimited(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 := readResponseBodyLimited(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, err := readResponseBodyLimited(rrdResp.Body) if err != nil { log.Warn().Err(err).Str("store", ds.Store).Msg("Skipping datastore RRD response that exceeds size limit") rrdBody = nil } 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, Status: "unavailable", Error: fmt.Sprintf("Failed to get status: %v", err), }) continue } defer statusResp.Body.Close() statusBody, err := readResponseBodyLimited(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, Status: "unavailable", 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, Status: "unavailable", 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) totalSpace, _ := statusResult.Data["total-space"].(float64) usedSpace, _ := statusResult.Data["used-space"].(float64) availSpace, _ := statusResult.Data["avail-space"].(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 } status := "available" if rawStatus, ok := statusResult.Data["status"].(string); ok && strings.TrimSpace(rawStatus) != "" { status = strings.TrimSpace(rawStatus) } gcStatus, _ := statusResult.Data["gc-status"].(string) // 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, err := readResponseBodyLimited(gcResp.Body) if err != nil { log.Warn().Err(err).Str("store", ds.Store).Msg("Skipping datastore GC response that exceeds size limit") gcBody = nil } 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), TotalSpace: int64(totalSpace), UsedSpace: int64(usedSpace), AvailSpace: int64(availSpace), Status: status, GCStatus: strings.TrimSpace(gcStatus), 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 } // JobHealthOptions selects which PBS job families should be collected. type JobHealthOptions struct { MonitorBackups bool MonitorSyncJobs bool MonitorVerifyJobs bool MonitorPruneJobs bool MonitorGarbageJobs bool } const ( JobEvidenceSourcePBSJobConfig = "pbs-job-config" JobEvidenceSourcePBSTaskHistory = "pbs-task-history" JobEvidenceSourcePBSPartialRead = "pbs-partial-read" JobEvidenceScopeConfiguredJob = "configured-job" JobEvidenceScopeObservedTask = "observed-task" JobEvidenceScopePartialRead = "partial-read" JobEvidenceConfidenceConfigOnly = "config-only" JobEvidenceConfidenceConfigLastRun = "direct-config-last-run" JobEvidenceConfidenceDirectTaskMatch = "direct-task-match" JobEvidenceConfidenceTaskHistoryOnly = "task-history-only" JobEvidenceConfidenceObservedBackupTask = "observed-backup-task" JobEvidenceConfidencePartialPermission = "partial-permission" JobEvidenceConfidencePartialError = "partial-error" JobEvidenceConfidenceHistoryTruncated = "bounded-task-history-truncated" ) var ( pbsTaskHistoryLookback = 35 * 24 * time.Hour pbsTaskHistoryPageLimit = 200 pbsTaskHistoryMaxPages = 3 ) // JobHealthEvidence is the source-level PBS job ledger fact collected from // config and task-history endpoints. PBS does not expose canonical scheduled // backup-job configuration; backup-family entries from this collector are // observed task evidence only. Scheduled backup compliance must come from a // future PVE /cluster/backup source, not from PBS task history. type JobHealthEvidence struct { ID string `json:"id"` Family string `json:"family"` Store string `json:"store,omitempty"` Remote string `json:"remote,omitempty"` Namespace string `json:"namespace,omitempty"` Schedule string `json:"schedule,omitempty"` Comment string `json:"comment,omitempty"` Enabled bool `json:"enabled"` LastRunState string `json:"last-run-state,omitempty"` LastRunUPID string `json:"last-run-upid,omitempty"` LastRunEndtime int64 `json:"last-run-endtime,omitempty"` NextRun int64 `json:"next-run,omitempty"` UPID string `json:"upid,omitempty"` WorkerType string `json:"worker-type,omitempty"` WorkerID string `json:"worker-id,omitempty"` TaskStatus string `json:"task-status,omitempty"` TaskStartTime int64 `json:"task-starttime,omitempty"` TaskEndTime int64 `json:"task-endtime,omitempty"` Confidence string `json:"confidence"` EvidenceSource string `json:"evidenceSource,omitempty"` EvidenceScope string `json:"evidenceScope,omitempty"` Error string `json:"error,omitempty"` } // GetJobHealthEvidence collects PBS job health facts from job configuration // and bounded task history. Permission failures are represented as // partial-permission evidence instead of failing the whole ledger. func (c *Client) GetJobHealthEvidence(ctx context.Context, datastores []string, opts JobHealthOptions) ([]JobHealthEvidence, error) { taskHistory, taskPartials, tasksErr := c.listTaskHistory(ctx, datastores, opts) taskByUPID := make(map[string]JobHealthEvidence, len(taskHistory)) for _, task := range taskHistory { if task.UPID != "" { taskByUPID[task.UPID] = task } } evidence := make([]JobHealthEvidence, 0) matchedTasks := make(map[string]struct{}) collectConfigs := func(family string, enabled bool, path string) { if !enabled { return } configs, err := c.listJobConfigMaps(ctx, path) if err != nil { if isPBSNotFoundError(err) { return } evidence = append(evidence, partialPermissionJobEvidence(family, path, err)) return } for _, cfg := range configs { fact := jobEvidenceFromConfigMap(family, cfg) if fact.ID == "" { fact.ID = fallbackJobEvidenceID(fact) } fact.Enabled = !boolJobField(cfg, "disable", "disabled") fact.EvidenceSource = JobEvidenceSourcePBSJobConfig fact.EvidenceScope = JobEvidenceScopeConfiguredJob fact.Confidence = JobEvidenceConfidenceConfigOnly if fact.LastRunState != "" || fact.LastRunUPID != "" || fact.LastRunEndtime > 0 { fact.Confidence = JobEvidenceConfidenceConfigLastRun } if fact.LastRunUPID != "" { if task, ok := taskByUPID[fact.LastRunUPID]; ok { mergeTaskEvidence(&fact, task) fact.Confidence = JobEvidenceConfidenceDirectTaskMatch matchedTasks[task.UPID] = struct{}{} } } evidence = append(evidence, fact) } } collectConfigs("sync", opts.MonitorSyncJobs, "/config/sync") collectConfigs("verify", opts.MonitorVerifyJobs, "/config/verify") collectConfigs("prune", opts.MonitorPruneJobs, "/config/prune") if opts.MonitorGarbageJobs { for _, datastore := range datastores { datastore = strings.TrimSpace(datastore) if datastore == "" { continue } path := fmt.Sprintf("/admin/datastore/%s/gc", url.PathEscape(datastore)) cfg, err := c.getJobConfigMap(ctx, path) if err != nil { if isPBSNotFoundError(err) { continue } evidence = append(evidence, partialPermissionJobEvidence("garbage", path, err)) continue } fact := jobEvidenceFromConfigMap("garbage", cfg) fact.Store = firstNonEmptyString(fact.Store, datastore) if fact.ID == "" { fact.ID = "garbage:" + datastore } fact.Enabled = !boolJobField(cfg, "disable", "disabled") fact.EvidenceSource = JobEvidenceSourcePBSJobConfig fact.EvidenceScope = JobEvidenceScopeConfiguredJob fact.Confidence = JobEvidenceConfidenceConfigOnly if fact.LastRunState != "" || fact.LastRunUPID != "" || fact.LastRunEndtime > 0 { fact.Confidence = JobEvidenceConfidenceConfigLastRun } if fact.LastRunUPID != "" { if task, ok := taskByUPID[fact.LastRunUPID]; ok { mergeTaskEvidence(&fact, task) fact.Confidence = JobEvidenceConfidenceDirectTaskMatch matchedTasks[task.UPID] = struct{}{} } } evidence = append(evidence, fact) } } if tasksErr != nil { for _, family := range []string{"backup", "sync", "verify", "prune", "garbage"} { if !jobFamilyEnabled(family, opts) { continue } partial := partialPermissionJobEvidence(family, "/nodes/localhost/tasks", tasksErr) partial.ID = family + ":task-history-partial" evidence = append(evidence, partial) } } evidence = append(evidence, taskPartials...) for _, task := range taskHistory { if task.UPID == "" { continue } if _, ok := matchedTasks[task.UPID]; ok { continue } family := familyFromPBSTask(task.WorkerType, task.WorkerID) if !jobFamilyEnabled(family, opts) { continue } task.Family = family task.ID = fallbackJobEvidenceID(task) task.Enabled = true task.EvidenceSource = JobEvidenceSourcePBSTaskHistory task.EvidenceScope = JobEvidenceScopeObservedTask task.Confidence = JobEvidenceConfidenceTaskHistoryOnly if family == "backup" { task.Confidence = JobEvidenceConfidenceObservedBackupTask } evidence = append(evidence, task) } return evidence, nil } func (c *Client) listJobConfigMaps(ctx context.Context, path string) ([]map[string]interface{}, error) { resp, err := c.get(ctx, path) 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 } func (c *Client) getJobConfigMap(ctx context.Context, path string) (map[string]interface{}, error) { resp, err := c.get(ctx, path) 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 = map[string]interface{}{} } return result.Data, nil } type taskHistoryQuery struct { Family string TypeFilter string Store string StatusFilters []string Since int64 Until int64 } const ( pbsTaskTypeBackup = "backup" pbsTaskTypeSyncJob = "syncjob" pbsTaskTypeVerificationJob = "verificationjob" pbsTaskTypePruneJob = "prunejob" pbsTaskTypeGarbageCollection = "garbage_collection" ) func (c *Client) listTaskHistory(ctx context.Context, datastores []string, opts JobHealthOptions) ([]JobHealthEvidence, []JobHealthEvidence, error) { now := time.Now() until := now.Unix() since := now.Add(-pbsTaskHistoryLookback).Unix() queries := buildTaskHistoryQueries(datastores, opts, since, until) if len(queries) == 0 { return nil, nil, nil } tasks := make([]JobHealthEvidence, 0) partials := make([]JobHealthEvidence, 0) seenTasks := make(map[string]struct{}) for _, query := range queries { queryTasks, truncated, err := c.listTaskHistoryQuery(ctx, query) if err != nil { if isPBSNotFoundError(err) { continue } partials = append(partials, partialTaskHistoryEvidence(query.Family, query, err)) continue } for _, task := range queryTasks { key := taskHistoryDedupKey(task) if _, ok := seenTasks[key]; ok { continue } seenTasks[key] = struct{}{} tasks = append(tasks, task) } if truncated { partials = append(partials, truncatedTaskHistoryEvidence(query.Family, query)) for _, statusQuery := range truncatedStatusTaskHistoryQueries(query) { statusTasks, statusTruncated, err := c.listTaskHistoryQuery(ctx, statusQuery) if err != nil { partials = append(partials, partialTaskHistoryEvidence(statusQuery.Family, statusQuery, err)) continue } for _, task := range statusTasks { key := taskHistoryDedupKey(task) if _, ok := seenTasks[key]; ok { continue } seenTasks[key] = struct{}{} tasks = append(tasks, task) } if statusTruncated { partials = append(partials, truncatedTaskHistoryEvidence(statusQuery.Family, statusQuery)) } } } } return tasks, partials, nil } func buildTaskHistoryQueries(datastores []string, opts JobHealthOptions, since, until int64) []taskHistoryQuery { queries := make([]taskHistoryQuery, 0) add := func(family, typeFilter, store string) { queries = append(queries, taskHistoryQuery{ Family: family, TypeFilter: typeFilter, Store: store, Since: since, Until: until, }) } if opts.MonitorBackups { stores := uniqueNonEmptyStrings(datastores) if len(stores) == 0 { add("backup", pbsTaskTypeBackup, "") } else { for _, store := range stores { add("backup", pbsTaskTypeBackup, store) } } } if opts.MonitorSyncJobs { add("sync", pbsTaskTypeSyncJob, "") } if opts.MonitorVerifyJobs { add("verify", pbsTaskTypeVerificationJob, "") } if opts.MonitorPruneJobs { add("prune", pbsTaskTypePruneJob, "") } if opts.MonitorGarbageJobs { add("garbage", pbsTaskTypeGarbageCollection, "") } return queries } func truncatedStatusTaskHistoryQueries(query taskHistoryQuery) []taskHistoryQuery { if len(query.StatusFilters) > 0 { return nil } out := make([]taskHistoryQuery, 0, 2) for _, status := range []string{"error", "warning"} { next := query next.StatusFilters = []string{status} out = append(out, next) } return out } func (c *Client) listTaskHistoryQuery(ctx context.Context, query taskHistoryQuery) ([]JobHealthEvidence, bool, error) { limit := pbsTaskHistoryPageLimit if limit <= 0 { limit = 200 } maxPages := pbsTaskHistoryMaxPages if maxPages <= 0 { maxPages = 1 } all := make([]JobHealthEvidence, 0, limit) for page := 0; page < maxPages; page++ { pageTasks, err := c.listTaskHistoryPage(ctx, query, page*limit, limit) if err != nil { return all, false, err } all = append(all, pageTasks...) if len(pageTasks) < limit { return all, false, nil } if page == maxPages-1 { return all, true, nil } } return all, false, nil } func (c *Client) listTaskHistoryPage(ctx context.Context, query taskHistoryQuery, start, limit int) ([]JobHealthEvidence, error) { params := url.Values{} if limit > 0 { params.Set("limit", fmt.Sprintf("%d", limit)) } if start > 0 { params.Set("start", fmt.Sprintf("%d", start)) } if query.Since > 0 { params.Set("since", fmt.Sprintf("%d", query.Since)) } if query.Until > 0 { params.Set("until", fmt.Sprintf("%d", query.Until)) } if strings.TrimSpace(query.TypeFilter) != "" { params.Set("typefilter", strings.TrimSpace(query.TypeFilter)) } if strings.TrimSpace(query.Store) != "" { params.Set("store", strings.TrimSpace(query.Store)) } for _, status := range query.StatusFilters { status = strings.TrimSpace(status) if status == "" { continue } params.Add("statusfilter", status) } path := "/nodes/localhost/tasks" if len(params) > 0 { path += "?" + params.Encode() } resp, err := c.get(ctx, path) if err != nil { if isPBSNotFoundError(err) { return nil, 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 } tasks := make([]JobHealthEvidence, 0, len(result.Data)) for _, raw := range result.Data { task := JobHealthEvidence{ UPID: stringJobField(raw, "upid"), WorkerType: stringJobField(raw, "worker-type", "worker_type", "type"), WorkerID: stringJobField(raw, "worker-id", "worker_id", "id"), TaskStatus: stringJobField(raw, "status"), TaskStartTime: int64JobField(raw, "starttime", "start-time", "start_time"), TaskEndTime: int64JobField(raw, "endtime", "end-time", "end_time"), Store: stringJobField(raw, "store", "datastore"), EvidenceSource: JobEvidenceSourcePBSTaskHistory, EvidenceScope: JobEvidenceScopeObservedTask, } if task.UPID == "" && task.WorkerType == "" && task.WorkerID == "" { continue } tasks = append(tasks, task) } return tasks, nil } func taskHistoryDedupKey(task JobHealthEvidence) string { if strings.TrimSpace(task.UPID) != "" { return strings.TrimSpace(task.UPID) } return strings.Join([]string{ task.WorkerType, task.WorkerID, fmt.Sprintf("%d", task.TaskStartTime), fmt.Sprintf("%d", task.TaskEndTime), }, "\x00") } func jobEvidenceFromConfigMap(family string, raw map[string]interface{}) JobHealthEvidence { return JobHealthEvidence{ ID: stringJobField(raw, "id", "job-id", "job_id", "name"), Family: family, Store: stringJobField(raw, "store", "datastore", "remote-store", "remote_store"), Remote: stringJobField(raw, "remote", "remote-store", "remote_store"), Namespace: stringJobField(raw, "ns", "namespace"), Schedule: stringJobField(raw, "schedule"), Comment: stringJobField(raw, "comment"), LastRunState: stringJobField(raw, "last-run-state", "last_run_state"), LastRunUPID: stringJobField(raw, "last-run-upid", "last_run_upid"), LastRunEndtime: int64JobField(raw, "last-run-endtime", "last_run_endtime"), NextRun: int64JobField(raw, "next-run", "next_run"), } } func mergeTaskEvidence(fact *JobHealthEvidence, task JobHealthEvidence) { fact.UPID = firstNonEmptyString(fact.UPID, task.UPID) fact.WorkerType = firstNonEmptyString(fact.WorkerType, task.WorkerType) fact.WorkerID = firstNonEmptyString(fact.WorkerID, task.WorkerID) fact.TaskStatus = firstNonEmptyString(fact.TaskStatus, task.TaskStatus) if fact.TaskStartTime == 0 { fact.TaskStartTime = task.TaskStartTime } if fact.TaskEndTime == 0 { fact.TaskEndTime = task.TaskEndTime } } func partialPermissionJobEvidence(family, path string, err error) JobHealthEvidence { confidence := JobEvidenceConfidencePartialPermission if !isPBSPermissionError(err) { confidence = JobEvidenceConfidencePartialError } return JobHealthEvidence{ ID: family + ":partial", Family: family, Enabled: true, Confidence: confidence, EvidenceSource: JobEvidenceSourcePBSPartialRead, EvidenceScope: JobEvidenceScopePartialRead, Error: fmt.Sprintf("%s: %v", path, err), } } func partialTaskHistoryEvidence(family string, query taskHistoryQuery, err error) JobHealthEvidence { partial := partialPermissionJobEvidence(family, taskHistoryQueryPath(query), err) partial.ID = family + ":task-history-partial" partial.EvidenceSource = JobEvidenceSourcePBSTaskHistory return partial } func truncatedTaskHistoryEvidence(family string, query taskHistoryQuery) JobHealthEvidence { return JobHealthEvidence{ ID: taskHistoryPartialID(family, query, "truncated"), Family: family, Store: query.Store, Enabled: true, Confidence: JobEvidenceConfidenceHistoryTruncated, EvidenceSource: JobEvidenceSourcePBSTaskHistory, EvidenceScope: JobEvidenceScopePartialRead, Error: fmt.Sprintf("%s reached bounded page cap (%d pages x %d limit)", taskHistoryQueryPath(query), pbsTaskHistoryMaxPages, pbsTaskHistoryPageLimit), } } func taskHistoryPartialID(family string, query taskHistoryQuery, suffix string) string { parts := []string{family, "task-history"} if strings.TrimSpace(query.Store) != "" { parts = append(parts, strings.TrimSpace(query.Store)) } if len(query.StatusFilters) > 0 { parts = append(parts, strings.Join(query.StatusFilters, "-")) } parts = append(parts, suffix) return strings.Join(parts, ":") } func taskHistoryQueryPath(query taskHistoryQuery) string { params := url.Values{} if query.Since > 0 { params.Set("since", fmt.Sprintf("%d", query.Since)) } if query.Until > 0 { params.Set("until", fmt.Sprintf("%d", query.Until)) } if strings.TrimSpace(query.TypeFilter) != "" { params.Set("typefilter", strings.TrimSpace(query.TypeFilter)) } if strings.TrimSpace(query.Store) != "" { params.Set("store", strings.TrimSpace(query.Store)) } for _, status := range query.StatusFilters { status = strings.TrimSpace(status) if status != "" { params.Add("statusfilter", status) } } if len(params) == 0 { return "/nodes/localhost/tasks" } return "/nodes/localhost/tasks?" + params.Encode() } func fallbackJobEvidenceID(e JobHealthEvidence) string { for _, value := range []string{e.ID, e.WorkerID, e.LastRunUPID, e.UPID, e.Store} { if strings.TrimSpace(value) != "" { return e.Family + ":" + strings.TrimSpace(value) } } return e.Family + ":unknown" } func jobFamilyEnabled(family string, opts JobHealthOptions) bool { switch family { case "backup": return opts.MonitorBackups case "sync": return opts.MonitorSyncJobs case "verify": return opts.MonitorVerifyJobs case "prune": return opts.MonitorPruneJobs case "garbage": return opts.MonitorGarbageJobs default: return false } } func familyFromPBSTask(workerType, workerID string) string { combined := strings.ToLower(strings.TrimSpace(workerType + " " + workerID)) combined = strings.ReplaceAll(combined, "_", "-") switch { case strings.Contains(combined, "sync"): return "sync" case strings.Contains(combined, "verif"): return "verify" case strings.Contains(combined, "prune"): return "prune" case strings.Contains(combined, "garbage") || strings.Contains(combined, "gc"): return "garbage" case strings.Contains(combined, "backup"): return "backup" default: return "" } } func stringJobField(raw map[string]interface{}, keys ...string) string { for _, key := range keys { value, ok := raw[key] if !ok || value == nil { continue } switch typed := value.(type) { case string: if strings.TrimSpace(typed) != "" { return strings.TrimSpace(typed) } case fmt.Stringer: if strings.TrimSpace(typed.String()) != "" { return strings.TrimSpace(typed.String()) } } } return "" } func int64JobField(raw map[string]interface{}, keys ...string) int64 { for _, key := range keys { value, ok := raw[key] if !ok || value == nil { continue } switch typed := value.(type) { case float64: return int64(typed) case int64: return typed case int: return int64(typed) case json.Number: n, _ := typed.Int64() return n } } return 0 } func boolJobField(raw map[string]interface{}, keys ...string) bool { for _, key := range keys { value, ok := raw[key] if !ok || value == nil { continue } if typed, ok := value.(bool); ok { return typed } } return false } func isPBSPermissionError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "403") || strings.Contains(msg, "401") || strings.Contains(msg, "permission") || strings.Contains(msg, "authentication") } func isPBSNotFoundError(err error) bool { if err == nil { return false } return strings.Contains(strings.ToLower(err.Error()), "404") } func firstNonEmptyString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func uniqueNonEmptyStrings(values []string) []string { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } key := strings.ToLower(value) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, value) } return out } // 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, err := readResponseBodyLimited(resp.Body) if err != nil { return nil, err } 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, err := readResponseBodyLimited(resp.Body) if err != nil { return nil, err } 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 snapshotsMu sync.Mutex ) groupSem := make(chan struct{}, 5) var groupWG sync.WaitGroup // For each group, get snapshots concurrently with a small worker limit to avoid hammering PBS for _, group := range groups { if ctx.Err() != nil { log.Debug(). Str("datastore", datastore). Str("namespace", namespace). Msg("Context cancelled before completing snapshot fetch") break } group := group groupWG.Add(1) go func() { defer groupWG.Done() select { case groupSem <- struct{}{}: case <-ctx.Done(): return } defer func() { <-groupSem }() 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") return } if len(snapshots) == 0 { return } snapshotsMu.Lock() allSnapshots = append(allSnapshots, snapshots...) snapshotsMu.Unlock() }() } groupWG.Wait() if ctx.Err() != nil { resultCh <- namespaceResult{namespace: namespace, err: ctx.Err()} return } 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 }