Files
felhom-controller/controller/internal/agentapi/backup_tiers.go
T

134 lines
5.2 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"`
// Storage (agent >= v0.131.0, R-518): "present" | "absent" | "unknown"; "" on an older agent.
// Only "absent" licenses skipping a tier — unknown and legacy are treated as present.
Storage string `json:"storage,omitempty"`
}
// 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
}