de96efc0c5
MinAgent UNCHANGED — degrades gracefully against ANY older agent.
The agent gained per-target tiers in v0.97.0. The controller owns quiescing,
so the multi-tier schedule is reconciled here: every due tier is collected up
front and run inside ONE quiesce window (one stop, N sequential backups, one
resume). Two cycles on the weekly night would mean two app outages for one
night's work.
Dedup rule: local-only -> one quiesce; PBS-only -> one quiesce; BOTH due ->
ONE window with both backups inside; neither -> no quiesce.
- quiesce.TieredBackend + BackupTier + ErrTiersUnsupported (optional extension)
- agentapi: BackupTiers/BackupDueFor/StartBackupFor/BackupStatusFor;
targetQuery("") yields an EMPTY suffix so untargeted hits the pre-R-82 route
byte-for-byte
- Loop.resolveDueTiers = the dedup rule in one place, agent order preserved
- quiesceAndPollTiers + pollTier: app stays quiesced until the LAST tier
snapshots (resuming earlier loses app-consistency on the DR tier). Consequence
stated in the docs: both-due-night downtime = first tier's full backup + last
tier's snapshot, which is why tiers run fast-first.
- Manual 'Mentes most' covers EVERY tier, due-ness ignored.
- Window-gate safety valve now uses the OLDEST due tier, so a stale DR tier
cannot be starved by a fresher local one.
Capability detection: /backup/tiers 404 = pre-R-82 agent (the documented
route-probe mechanism). Not a featureProbes row on purpose — the loop needs the
tier LIST, not a yes/no. Degrade logged exactly once per process.
Tests +11, full suite green. Red-proofs #2 and #3 observed and restored.
104 lines
3.6 KiB
Go
104 lines
3.6 KiB
Go
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
|
|
}
|