v0.185.0 — E-2 Parts 3+4: the offer, and the honest degraded state

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.
This commit is contained in:
2026-07-29 09:10:20 +02:00
parent 4d6c8a6056
commit 3f7cf2a965
5 changed files with 353 additions and 0 deletions
@@ -101,3 +101,30 @@ func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResp
}
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
}