3f7cf2a965
The half that makes the rest work: a degraded backup target recorded only in
config is the silent-degradation pattern this arc has spent a week removing.
Part 3 -- POST /api/backup-target/assign moves the target via the agent's
POST /backup/target. It is the ONLY writer of the role: registration does not set
it, the drive-gate does not, no scheduler does. Declining is not calling it. The
agent returns restart_required rather than restarting itself, because restarting
with a backup in flight records a spurious tier failure for a backup that
actually succeeded (E-1 did exactly that).
Part 4 -- GET /api/backup-target returns the state and, when degraded, Hungarian
copy in FACT -> CONSEQUENCE -> REMEDY order, pinned by a test: a customer told
only the fact cannot act on it.
Healthy renders NOTHING -- no badge, no reassurance, no tonal change.
degradedMessageFor is the single decision point, so exactly one place could start
decorating a working box. Red-proofed: reassuring on the healthy branch fails
Scenario E.
UNKNOWN is not degraded: an unreachable or pre-R-82 agent means we could not ask,
which is not evidence of degradation (R-88 Part 2's class).
A HOLLOW TEST caught by its own red-proof: TestUnknownStateRendersNothing used
{Known:false} with Degraded left false, so it passed even with the !Known guard
deleted -- the second condition covered for it. Now {Known:false, Degraded:true},
which fails properly. Without the red-proof the test would have been decoration.
State is derived from the AGENT, never from our intent flag: on the two boxes
migrated by hand in E-1 the intent was never recorded while the drive really is
the target.
MinAgent: 0.113.0
Green gate: build + vet + test rc=0 (27 packages), run separately from this commit.
131 lines
5.0 KiB
Go
131 lines
5.0 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
|
|
}
|
|
|
|
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
|
|
type SetBackupTargetResponse struct {
|
|
Target string `json:"target"`
|
|
Where string `json:"where"`
|
|
// RestartRequired is always true on success: the agent builds its tiers once at daemon start, so
|
|
// the move needs a restart. The agent deliberately does NOT restart itself — restarting with a
|
|
// backup in flight cancels the wait and records a spurious tier failure for a backup that actually
|
|
// succeeded. The RESTART IS THE OPERATOR'S, behind an immediate in-flight check.
|
|
RestartRequired bool `json:"restart_required"`
|
|
}
|
|
|
|
// SetBackupTarget moves the primary whole-guest backup tier onto the drive at raw host mount `where`.
|
|
// Creates the storage and grants the agent access as one ordered operation.
|
|
func (c *Client) SetBackupTarget(ctx context.Context, where string) (SetBackupTargetResponse, error) {
|
|
var out SetBackupTargetResponse
|
|
// vmid is deliberately omitted: the agent derives the guest from the token and scopedFromBody
|
|
// treats an absent vmid as "use the token's" — the same shape as AssignDisk/GuestAttach.
|
|
body, err := c.post(ctx, "/backup/target", map[string]string{"where": where})
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return out, fmt.Errorf("agentapi: decode /backup/target: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|