Files
pulse/pkg/pbs/client.go
T
pulse-triage[bot] 3096fb2748 Require complete Proxmox password sessions before publication
Reject incomplete or ambiguous 200 login bodies before replacing ticket, CSRF, expiry and PVE generation. Preserve retained sessions, joined renewal failures, normal encoding fallback and later ordinary recovery; cover real pinned startup/poll/broadcast consumption.

Contract-Neutral: Restore existing complete-session and failed-renewal preservation guarantees; no API/wire schema, TLS trust, credential authority, guest lifecycle, polling cadence or History contract delta.
Change-source: pulse-maintainer
2026-10-14 04:25:26 +01:00

2173 lines
64 KiB
Go

package pbs
import (
"bytes"
"context"
"encoding/json"
"errors"
"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
// Polling and datastore reads share this client. Publish a whole session
// tuple and share its renewal, not the subsequent requests or cancellation.
authMu sync.Mutex
authInFlight *authRefresh
// GetNodeName caching. The node hostname is stable for the lifetime of a
// connection. PBS exposes /nodes as a superuser-only compatibility endpoint,
// so GetNodeName rejects API-token and non-superuser sessions before I/O.
nodeNameMu sync.Mutex
cachedNodeName string
nodeNameAttempt *nodeNameAttempt
}
type nodeNameAttempt struct {
done chan struct{}
joined int
name string
err error
}
type authRefresh struct {
done chan struct{}
err error
}
func (c *Client) authSnapshot() auth {
c.authMu.Lock()
defer c.authMu.Unlock()
return c.auth
}
// ErrNodeNameUnavailableForAuth means the current PBS authentication mode
// cannot access the superuser-only /nodes compatibility endpoint. Callers
// should treat this as absent optional identity evidence, not as a connection
// or permission failure that the operator can repair by widening an API token.
var ErrNodeNameUnavailableForAuth = errors.New("PBS node name is unavailable for this authentication mode")
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) {
return NewClientWithContext(context.Background(), cfg)
}
// NewClientWithContext binds initial password authentication to its caller.
// Later polling remains independent of this construction context.
func NewClientWithContext(ctx context.Context, cfg ClientConfig) (*Client, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
// Normalize host URL - ensure it has a protocol
defaultedScheme := !strings.HasPrefix(cfg.Host, "http://") && !strings.HasPrefix(cfg.Host, "https://")
if defaultedScheme {
// Default to HTTPS if no protocol specified
cfg.Host = "https://" + cfg.Host
}
baseHostURL, err := securityutil.NormalizeInfrastructureBaseURL(cfg.Host, "")
if err != nil {
return nil, fmt.Errorf("invalid PBS host: %w", err)
}
// Logs must use validated input: a refused host can contain credentials.
if defaultedScheme {
log.Debug().Str("host", baseHostURL.String()).Msg("No protocol specified in PBS host, defaulting to HTTPS")
}
if baseHostURL.Scheme == "http" {
log.Warn().Str("host", baseHostURL.String()).Msg("Using HTTP for PBS connection. PBS typically requires HTTPS. If connection fails, try using https:// instead")
}
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(ctx); err != nil {
httpClient.CloseIdleConnections()
return nil, fmt.Errorf("authentication failed: %w", err)
}
}
if err := ctx.Err(); err != nil {
httpClient.CloseIdleConnections()
return nil, err
}
return client, nil
}
// authenticate performs password-based authentication
func (c *Client) authenticate(ctx context.Context) error {
return c.refreshAuthentication(ctx, true)
}
// Ordinary expiry is checked under the same lock as session publication, so a
// reader that observed an old ticket cannot start a second renewal. Explicit
// authentication keeps its existing forced-refresh semantics. A joining caller
// can stop waiting without cancelling the owner or replaying a failed attempt.
func (c *Client) refreshAuthentication(ctx context.Context, force bool) error {
if err := ctx.Err(); err != nil {
return err
}
c.authMu.Lock()
if !force && (c.config.Password == "" || c.auth.tokenName != "" || !time.Now().After(c.auth.expiresAt)) {
c.authMu.Unlock()
return nil
}
if pending := c.authInFlight; pending != nil {
c.authMu.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
case <-pending.done:
return pending.err
}
}
pending := &authRefresh{done: make(chan struct{})}
c.authInFlight = pending
username := c.auth.user + "@" + c.auth.realm
c.authMu.Unlock()
password := c.config.Password
err := c.authenticateJSON(ctx, username, password)
if shouldFallbackToForm(err) {
err = c.authenticateForm(ctx, username, password)
}
c.authMu.Lock()
pending.err = err
c.authInFlight = nil
close(pending.done)
c.authMu.Unlock()
return err
}
func (c *Client) authenticateJSON(ctx context.Context, username, password string) error {
payload := map[string]string{
"username": username,
"password": password,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
baseAPIURL, err := c.apiBaseURL()
if err != nil {
return err
}
req, err := securityutil.NewRelativeRequestWithContext(ctx, "POST", baseAPIURL, "/access/ticket", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return c.handleAuthResponse(resp)
}
func (c *Client) authenticateForm(ctx context.Context, username, password string) error {
data := url.Values{
"username": {username},
"password": {password},
}
baseAPIURL, err := c.apiBaseURL()
if err != nil {
return err
}
req, err := securityutil.NewRelativeRequestWithContext(ctx, "POST", baseAPIURL, "/access/ticket", strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return c.handleAuthResponse(resp)
}
func (c *Client) handleAuthResponse(resp *http.Response) error {
if resp.StatusCode != http.StatusOK {
body, err := readResponseBodyLimited(resp.Body)
if err != nil {
return &authHTTPError{status: resp.StatusCode, body: err.Error()}
}
return &authHTTPError{status: resp.StatusCode, body: string(body)}
}
if err := securityutil.LimitResponseBody(resp, maxResponseBodyBytes); err != nil {
return err
}
body, err := readResponseBodyLimited(resp.Body)
if err != nil {
return err
}
ticket, csrf, err := securityutil.ParseProxmoxSession(body)
if err != nil {
return err
}
c.authMu.Lock()
c.auth.ticket = ticket
c.auth.csrfToken = csrf
c.auth.expiresAt = time.Now().Add(2 * time.Hour) // PBS tickets expire after 2 hours
c.authMu.Unlock()
return nil
}
type authHTTPError struct {
status int
body string
}
func (e *authHTTPError) Error() string {
if e.status == http.StatusUnauthorized || e.status == http.StatusForbidden {
return fmt.Sprintf("authentication failed (status %d): %s", e.status, e.body)
}
return fmt.Sprintf("authentication failed: %s", e.body)
}
func shouldFallbackToForm(err error) bool {
if authErr, ok := err.(*authHTTPError); ok {
switch authErr.status {
case http.StatusBadRequest, http.StatusUnsupportedMediaType:
return true
}
}
return false
}
// apiHTTPError preserves the response status separately from the server body
// so callers can make retry and permission decisions without parsing text.
type apiHTTPError struct {
status int
body string
}
func (e *apiHTTPError) Error() string {
message := fmt.Sprintf("API error %d: %s", e.status, e.body)
if e.status == http.StatusUnauthorized || e.status == http.StatusForbidden {
return "authentication error: " + message
}
return message
}
// HTTPStatus returns the response status from a PBS API or authentication error,
// including wrapped errors. The response body is never used for classification.
func HTTPStatus(err error) (int, bool) {
var apiErr *apiHTTPError
if errors.As(err, &apiErr) {
return apiErr.status, true
}
var authErr *authHTTPError
if errors.As(err, &authErr) {
return authErr.status, true
}
return 0, false
}
func isPBSPermissionError(err error) bool {
status, ok := HTTPStatus(err)
return ok && (status == http.StatusUnauthorized || status == http.StatusForbidden)
}
func isPBSNotFoundError(err error) bool {
status, ok := HTTPStatus(err)
return ok && status == http.StatusNotFound
}
// 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 err := c.refreshAuthentication(ctx, false); err != nil {
// An ended caller or cancelled renewal is not a credential refusal.
// Preserve its context error class, including token reads with no login.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
auth := c.authSnapshot()
var body io.Reader
if data != nil {
body = strings.NewReader(data.Encode())
}
baseAPIURL, err := c.apiBaseURL()
if err != nil {
return nil, err
}
req, err := securityutil.NewRelativeRequestWithContext(ctx, method, baseAPIURL, path, body)
if err != nil {
return nil, err
}
// Set headers
if data != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
// Set authentication
if auth.tokenName != "" && auth.tokenValue != "" {
// API token authentication
// 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",
auth.user, auth.realm, auth.tokenName, auth.tokenValue)
req.Header.Set("Authorization", authHeader)
// NEVER log the actual token value - only log that we're using token auth
// Log the auth header format (without the secret)
maskedHeader := fmt.Sprintf("PBSAPIToken=%s@%s!%s:***",
auth.user, auth.realm, auth.tokenName)
log.Debug().
Str("user", auth.user).
Str("realm", auth.realm).
Str("tokenName", auth.tokenName).
Str("authHeaderFormat", maskedHeader).
Str("url", req.URL.String()).
Msg("Setting PBS API token authentication")
} else if auth.ticket != "" {
// Ticket authentication
req.Header.Set("Cookie", "PBSAuthCookie="+auth.ticket)
if method != "GET" && auth.csrfToken != "" {
req.Header.Set("CSRFPreventionToken", 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, &apiHTTPError{status: resp.StatusCode, body: fmt.Sprintf("read response body: %v", err)}
}
return nil, &apiHTTPError{status: resp.StatusCode, body: string(body)}
}
if err := securityutil.LimitResponseBody(resp, maxResponseBodyBytes); err != nil {
return nil, err
}
return resp, nil
}
// get performs a GET request
func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
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 isPBSNotFoundError(err) {
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. PBS exposes /nodes as a
// superuser-only compatibility endpoint, so only a direct root@pam session may
// perform the request. API tokens and non-superuser sessions return
// ErrNodeNameUnavailableForAuth without network I/O (refs #1691). Successful
// results are cached for the client lifetime, transient failures retry on the
// next call, and concurrent callers share one in-flight request.
func (c *Client) GetNodeName(ctx context.Context) (string, error) {
if !c.canReadNodeName() {
return "", ErrNodeNameUnavailableForAuth
}
c.nodeNameMu.Lock()
if c.cachedNodeName != "" {
name := c.cachedNodeName
c.nodeNameMu.Unlock()
return name, nil
}
if attempt := c.nodeNameAttempt; attempt != nil {
attempt.joined++
c.nodeNameMu.Unlock()
select {
case <-ctx.Done():
return "", ctx.Err()
case <-attempt.done:
return attempt.name, attempt.err
}
}
attempt := &nodeNameAttempt{done: make(chan struct{})}
c.nodeNameAttempt = attempt
c.nodeNameMu.Unlock()
name, err := c.fetchNodeName(ctx)
c.nodeNameMu.Lock()
attempt.name = name
attempt.err = err
if err == nil {
c.cachedNodeName = name
}
c.nodeNameAttempt = nil
close(attempt.done)
c.nodeNameMu.Unlock()
return name, err
}
func (c *Client) canReadNodeName() bool {
return c.auth.tokenName == "" && c.auth.user == "root" && c.auth.realm == "pam"
}
func (c *Client) fetchNodeName(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 {
if isPBSPermissionError(err) {
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)
}
// A successful HTTP response is not evidence of available metrics.
// Missing/null data must not become zero usage and resolve active alerts.
if statusResult.Data == nil {
return nil, fmt.Errorf("node status response contains no data")
}
// The poller has one availability flag for CPU and memory. Require both
// measurements before marking the snapshot available; omitted JSON numbers
// otherwise decode to zero and can fabricate recovery. Do not require
// unrelated fields (load, swap, root, etc.) or reject measured zero usage.
var measurements struct {
Data struct {
CPU *float64 `json:"cpu"`
Memory struct {
Used *int64 `json:"used"`
Total *int64 `json:"total"`
} `json:"memory"`
} `json:"data"`
}
if err := json.Unmarshal(body, &measurements); err != nil {
return nil, fmt.Errorf("failed to decode node measurements: %w", err)
}
m := measurements.Data
if m.CPU == nil || m.Memory.Used == nil || m.Memory.Total == nil {
return nil, fmt.Errorf("node status response contains incomplete CPU or memory measurements")
}
if *m.CPU < 0 || *m.Memory.Used < 0 || *m.Memory.Total <= 0 {
return nil, fmt.Errorf("node status response contains invalid CPU or memory measurements")
}
return statusResult.Data, nil
}
// GetDatastores returns all datastores with their status.
func (c *Client) GetDatastores(ctx context.Context) ([]Datastore, error) {
return c.GetDatastoresFiltered(ctx, nil)
}
// GetDatastoresFiltered returns datastores with their status, applying include
// to the cheap datastore-name listing before any per-datastore RRD, status, or
// GC requests are made. A nil include function keeps every datastore.
func (c *Client) GetDatastoresFiltered(
ctx context.Context,
include func(datastoreName string) bool,
) ([]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 {
if include != nil && !include(ds.Store) {
continue
}
// 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
}
// RunningDataTask describes a PBS task which may currently be writing a
// backup snapshot. Backup tasks identify one backup group through WorkerID;
// sync jobs can write several groups during one task.
type RunningDataTask struct {
WorkerType string
WorkerID string
StartTime int64
}
var (
pbsRunningTaskPageLimit = 200
pbsRunningTaskMaxPages = 3
)
// ListRunningDataTasks returns the currently running backup and sync tasks.
// Querying each writer type separately prevents unrelated running tasks from
// consuming the bounded result window. Reaching the page cap returns an error
// rather than a partial set so callers retain conservative running state.
func (c *Client) ListRunningDataTasks(ctx context.Context) ([]RunningDataTask, error) {
limit := pbsRunningTaskPageLimit
if limit <= 0 {
limit = 200
}
maxPages := pbsRunningTaskMaxPages
if maxPages <= 0 {
maxPages = 1
}
tasks := make([]RunningDataTask, 0)
for _, requestedType := range []string{pbsTaskTypeBackup, pbsTaskTypeSyncJob} {
for page := 0; page < maxPages; page++ {
params := url.Values{}
params.Set("limit", fmt.Sprintf("%d", limit))
params.Set("running", "true")
params.Set("typefilter", requestedType)
if page > 0 {
params.Set("start", fmt.Sprintf("%d", page*limit))
}
resp, err := c.get(ctx, "/nodes/localhost/tasks?"+params.Encode())
if err != nil {
return nil, err
}
var result struct {
Data []map[string]interface{} `json:"data"`
}
decodeErr := json.NewDecoder(resp.Body).Decode(&result)
closeErr := resp.Body.Close()
if decodeErr != nil {
return nil, fmt.Errorf("failed to decode running PBS %s tasks: %w", requestedType, decodeErr)
}
if closeErr != nil {
return nil, fmt.Errorf("failed to close running PBS %s task response: %w", requestedType, closeErr)
}
for _, raw := range result.Data {
workerType := strings.ToLower(stringJobField(raw, "worker-type", "worker_type", "type"))
if workerType != requestedType {
continue
}
tasks = append(tasks, RunningDataTask{
WorkerType: workerType,
WorkerID: stringJobField(raw, "worker-id", "worker_id", "id"),
StartTime: int64JobField(raw, "starttime", "start-time", "start_time"),
})
}
if len(result.Data) < limit {
break
}
if page == maxPages-1 {
return nil, fmt.Errorf("running PBS %s task listing reached bounded cap (%d pages x %d limit)", requestedType, maxPages, limit)
}
}
}
return tasks, nil
}
// 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 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
}