Files
felhom-controller/controller/internal/agentapi/backup_tiers.go
T
Claude Code de96efc0c5 v0.174.0 — R-82 Slice B: one quiesce window, two backup tiers
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.
2026-07-26 14:40:44 +02:00

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
}