package agentapi import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" ) // R-82 Slice B — the per-tier backup surface (agent >= v0.97.0). // // Every method here is ADDITIVE. The untargeted BackupDue/StartBackup/BackupStatus keep their exact // pre-R-82 meaning and are still the single-tier path used against an older agent. // ErrTiersUnsupported reports that this agent does not serve GET /backup/tiers — it predates R-82. // It is the DESIGNED capability probe (the route 404s), not a fault. The caller MUST degrade to the // untargeted single-tier path and still take a backup; concluding "nothing to do" from it would // silently stop backups during a fleet rollout. var ErrTiersUnsupported = errors.New("agentapi: agent does not serve /backup/tiers (pre-R-82)") // BackupTierInfo is one advertised tier. type BackupTierInfo struct { Target string `json:"target"` CadenceSeconds int64 `json:"cadence_seconds"` Primary bool `json:"primary"` } // TiersResponse mirrors the agent's GET /backup/tiers payload. type TiersResponse struct { VMID int `json:"vmid"` Tiers []BackupTierInfo `json:"tiers"` } // BackupTiers lists the agent's backup tiers, primary first. // Returns ErrTiersUnsupported (wrapped) on a pre-R-82 agent — key on it with errors.Is. func (c *Client) BackupTiers(ctx context.Context) (TiersResponse, error) { var out TiersResponse body, err := c.get(ctx, "/backup/tiers") if err != nil { var se *StatusError if errors.As(err, &se) && se.Code == http.StatusNotFound { return out, ErrTiersUnsupported } return out, err } if err := json.Unmarshal(body, &out); err != nil { return out, fmt.Errorf("agentapi: decode /backup/tiers: %w", err) } return out, nil } // targetQuery renders the ?target= suffix. An EMPTY target yields an empty string, so the caller // hits the untargeted route byte-for-byte — that is what keeps the pre-R-82 contract intact when // this client talks to an older agent. func targetQuery(target string) string { if target == "" { return "" } return "?target=" + url.QueryEscape(target) } // BackupDueFor reports whether THIS TIER is due. A fresh backup on another tier must not satisfy it // — that filtering happens agent-side (latestSuccessfulBackupForTarget); this just asks per tier. func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse, error) { var out DueResponse body, err := c.get(ctx, "/backup/due"+targetQuery(target)) if err != nil { return out, err } if err := json.Unmarshal(body, &out); err != nil { return out, fmt.Errorf("agentapi: decode /backup/due (target %q): %w", target, err) } return out, nil } // StartBackupFor enqueues a backup of this guest ON THE GIVEN TIER. func (c *Client) StartBackupFor(ctx context.Context, target string) (BackupResponse, error) { var out BackupResponse body, err := c.post(ctx, "/backup"+targetQuery(target), struct{}{}) if err != nil { return out, err } if err := json.Unmarshal(body, &out); err != nil { return out, fmt.Errorf("agentapi: decode POST /backup (target %q): %w", target, err) } return out, nil } // BackupStatusFor reports THIS TIER's current/last job phase. Jobs are keyed per tier agent-side, // so polling the wrong target would report a different tier's progress. func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResponse, error) { var out StatusResponse body, err := c.get(ctx, "/backup/status"+targetQuery(target)) if err != nil { return out, err } if err := json.Unmarshal(body, &out); err != nil { return out, fmt.Errorf("agentapi: decode /backup/status (target %q): %w", target, err) } return out, nil }