Files
felhom-controller/controller/internal/web/backup_target_offer.go
T
admin b331f18424 v0.186.0 — R-114 + R-112: tell the truth about the backup target, then show it
Two defects E-2d found on a real box, fixed in this order deliberately: the
message is corrected BEFORE it is put on screen, because switching on a banner
that lies is worse than a silent one.

R-114 — the third state. resolveBackupTargetState had two outcomes: a disk
claims the target (healthy), or nothing does (degraded, "the backup is on the
system disk"). The state "configured, and its drive is gone" had no branch, so
it fell into the second and inherited its message AND its offer. Observed live
with the target detached: degraded:true, target:"felhom-backup" plus the
system-disk copy (false -- the backup was on a drive that had vanished) plus
offer_path naming that same vanished drive as the remedy.

New BackupTargetState.TargetAbsent discriminates. Degraded keeps its meaning
("is there a problem") so the wire contract is unchanged for every consumer;
TargetAbsent answers "which problem", because the two have opposite remedies --
attach any second drive, versus reconnect THAT one. Copy routed through
degradedMessageFor so one place still decides what a customer reads. The offer
is suppressed on the branch itself, NOT left to firstOfferableDrive's
Disconnected skip: that flag is set by the agent-side gate in another repo
(R-113), and this state must be correct independently of it.

R-112 — the state finally has a consumer. The endpoint was byte-correct and
nothing in the product ever asked for it: templates fetch 18 distinct
/api/storage/* endpoints and backup-target[/assign] were the only two with zero
references. Server-rendered on /backups now, following the existing
SingleCopyWarning banner pattern -- not a 19th JS fetch, because a banner that
needs JavaScript to appear is one more thing that can silently not happen.
backupTargetView returns nil for healthy and unknown so those render nothing at
all. The offer control POSTs to the existing assign endpoint behind the standard
inline confirm, never auto-submits, and surfaces restart_required honestly
instead of adding a self-restart.

Scenario E (the seam test) drives backupsHandler over httptest and asserts the
RENDERED HTML -- handler -> view -> resolver -> template. It deliberately does
not call the resolver and assert a string, which would prove the resolver that
was never broken. Deleting the one line that sets data["BackupTarget"]
reproduces the R-112 state and fails every render assertion.

Tests 326 -> 338 (+12) in internal/web; suite green (27 packages); both template
gates pass. Three red-proofs run and reverted, files byte-identical after.

MinAgent unchanged at 0.113.0: R-114 reads BackupTarget/MountPath/GuestPath/Role,
none of which R-113 altered (it changed BoundUnderParent, which this code does
not read). demo-hp on agent 0.113.0 is not held.

The absent copy is verbatim the hub's customerMessages["backup_target_absent"]
so the banner and the email tell one story -- filed as a two-repo drift risk,
not solved.

NOT LIVE-VALIDATED. Scenario C cannot occur on a healthy box; Session C proves it.
2026-07-29 19:21:32 +02:00

320 lines
14 KiB
Go

package web
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
var (
errAgentUnreadable = errors.New("a meghajtók listája nem olvasható")
errDriveNotFound = errors.New("a meghajtó nem található")
)
// BackupTargetState is the customer-facing answer to "does the whole-system backup survive a disk
// failure?". Derived from the AGENT (the authority), never from our own intent flag alone.
type BackupTargetState struct {
// Known is false when the agent could not be asked. Everything below is then meaningless, and the
// UI must render NOTHING rather than guess — an unreachable agent is not evidence of degradation.
Known bool
// Degraded is true when the whole-guest backup is NOT protected against drive loss. It answers
// "is there a problem", not "which problem" — TargetAbsent below is the discriminator. Both
// problem states set it, so the wire's `degraded` flag keeps its meaning for every consumer.
Degraded bool
// TargetAbsent (R-114) separates the two problem states, which have OPPOSITE remedies:
//
// Degraded && !TargetAbsent — never configured. The backup is on the system drive. Remedy:
// attach a second drive and assign it. An offer belongs here.
// Degraded && TargetAbsent — configured, and its drive is GONE. The backup is not on the
// system drive at all. Remedy: reconnect THAT drive. No offer —
// suggesting a different drive is the wrong instruction.
//
// Before R-114 the second case fell into the first, so a customer whose backup drive had vanished
// was told the backup was on the system disk (false) and offered the drive that just disappeared
// (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.3).
TargetAbsent bool
// TargetID is the agent's primary tier storage id (e.g. "felhom-backup" / "local").
TargetID string
// Label is the customer-facing drive name when the target is a real drive.
Label string
// OfferPath, when non-empty, is a registered drive that COULD become the target — the offer.
OfferPath string
OfferLabel string
}
// resolveBackupTargetState asks the agent what the primary tier actually writes to and classifies it.
//
// DEGRADED means "on the system drive", and it is decided from the agent's own storage view: a tier
// whose target has NO mount path of its own (the builtin `local`, i.e. /var/lib/vz on the root fs)
// cannot survive the root device dying. A tier on a drive with its own mountpoint can.
//
// PBS is deliberately NOT counted as the local target here: the offsite tier is separate hardware and
// a different question. This state answers only "is there a LOCAL copy that survives a disk failure",
// which is matrix row 4.
func (s *Server) resolveBackupTargetState(ctx context.Context) BackupTargetState {
tiers, err := s.fetchBackupTiers(ctx)
if err != nil {
// Includes ErrTiersUnsupported (a pre-R-82 agent). Unknown, never "degraded": claiming
// degradation because we could not ask would put a permanent warning on a healthy box.
return BackupTargetState{}
}
var primary string
for _, t := range tiers.Tiers {
if t.Primary {
primary = t.Target
break
}
}
st := BackupTargetState{Known: true, TargetID: primary}
disks, derr := s.fetchDisks(ctx)
if derr != nil {
return BackupTargetState{} // could not classify → say nothing
}
for _, d := range disks.Disks {
if d.BackupTarget && d.MountPath != "" {
// A real drive with its own mountpoint — healthy.
st.Label = s.storageLabelFor(stablePathForName(baseName(d.MountPath)))
return st
}
}
// No disk claims the target. Two DIFFERENT states land here and R-114 separates them, because
// before it they shared one message and one remedy — and for the second the message was false.
st.Degraded = true
if targetIsConfiguredDrive(primary) {
// A real storage id is configured, yet no disk claims it ⇒ its drive is GONE. Saying "the
// backup is on the system disk" here is simply untrue, and offering another drive answers a
// question the customer did not ask. The remedy is to reconnect THAT drive.
st.TargetAbsent = true
// Offer suppressed unconditionally — NOT left to firstOfferableDrive's Disconnected skip.
// That skip only works once the agent's drive-gate has marked the path (R-113, another repo);
// this state must be correct on its own. Belt here, braces there.
return st
}
st.OfferPath, st.OfferLabel = s.firstOfferableDrive(disks.Disks)
return st
}
// fetchBackupTiers reads the agent's tier view through the tiersFn test seam (nil → the real client),
// mirroring fetchDisks so both halves of this state come from seams a test can drive.
func (s *Server) fetchBackupTiers(ctx context.Context) (agentapi.TiersResponse, error) {
if s.tiersFn != nil {
return s.tiersFn(ctx)
}
client, err := s.agentClient()
if err != nil {
return agentapi.TiersResponse{}, err
}
return client.BackupTiers(ctx)
}
// builtinLocalTarget is the PVE builtin storage on the root filesystem — the "no separate drive"
// target. Anything else is a deliberately configured storage.
const builtinLocalTarget = "local"
// targetIsConfiguredDrive reports whether the primary tier names a real storage rather than the
// builtin root-fs one. Empty means the tier is unset (never configured), which is the same customer
// situation as `local`: nothing has been chosen yet.
func targetIsConfiguredDrive(targetID string) bool {
t := strings.TrimSpace(targetID)
return t != "" && t != builtinLocalTarget
}
// firstOfferableDrive picks a registered, connected, non-network drive that could hold the backup.
// It is a SUGGESTION for the offer — the customer still has to choose (E-2 §3). Nothing here assigns
// anything.
func (s *Server) firstOfferableDrive(disks []agentapi.DiskInfo) (path, label string) {
for _, sp := range s.settings.GetStoragePaths() {
if sp.IsNetwork() || sp.Decommissioned || sp.Disconnected {
continue
}
for _, d := range disks {
// Only a drive the agent classifies as user-data with its OWN mountpoint can be a target;
// the agent refuses anything else anyway, so offering it would be a dead end.
if d.GuestPath == sp.Path && d.MountPath != "" && d.Role == "user-data" {
return sp.Path, s.storageLabelFor(sp.Path)
}
}
}
return "", ""
}
// baseName is path.Base without importing path into this file's surface.
func baseName(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[i+1:]
}
return p
}
// ---- Hungarian customer copy -------------------------------------------------------------------
//
// Adult tone, no alarm decoration. It states the FACT, the CONSEQUENCE and the REMEDY, in that order,
// because a customer who is told only the fact cannot act on it.
//
// HEALTHY RENDERS NOTHING. There is deliberately no "your backup is safe" banner: a working
// configuration must look normal, or every customer's dashboard grows a permanent notice and the
// warning stops meaning anything (E-2 Scenario E).
const (
backupTargetDegradedText = "A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — " +
"így hibás fájlok ellen véd, lemezhiba ellen nem. Csatlakoztass egy második meghajtót a teljes védelemhez."
backupTargetOfferText = "Ezt a meghajtót kijelölheted a rendszermentés helyéül — így egy lemezhiba " +
"után is vissza tudod állítani a rendszert."
// backupTargetAbsentText (R-114) is the CONFIGURED-BUT-GONE state. It is VERBATIM the hub's
// customerMessages["backup_target_absent"] (felhom.eu hub/internal/notify/templates.go:93) so the
// banner a customer reads on the page and the email they receive say exactly the same thing — a
// customer who is told two different stories about one drive trusts neither.
//
// DRIFT RISK, filed not fixed: this string now lives in two repos with nothing binding them. If
// one is reworded the other silently disagrees.
backupTargetAbsentText = "A rendszermentés meghajtója nem érhető el — amíg vissza nem " +
"csatlakoztatod, a teljes rendszermentés nem készül el."
)
// degradedMessageFor is the single decision point for "does the customer see anything?" — extracted
// so the render rule is testable without a live agent, and so there is exactly ONE place that can
// accidentally start decorating a healthy box.
//
// Returns "" for BOTH healthy and unknown. They are different states with the same rendering, and
// collapsing them here is deliberate: unknown means we could not ask, which is not evidence of
// degradation (the absence-read-as-a-value mistake R-88 Part 2 closed).
func degradedMessageFor(st BackupTargetState) string {
if !st.Known || !st.Degraded {
return ""
}
if st.TargetAbsent {
// R-114: configured, drive gone. A different fact with a different remedy, so a different
// sentence — routed through here so there is still exactly one place that decides copy.
return backupTargetAbsentText
}
return backupTargetDegradedText
}
// ---- the render (R-112) ------------------------------------------------------------------------
// BackupTargetView is the template-facing shape of this state. It exists so the template stays a
// dumb renderer: every "does the customer see anything?" decision is already made by the time it
// arrives, in degradedMessageFor, which remains the single decision point.
//
// R-112: until v0.186.0 this state had NO consumer at all. The endpoint was byte-correct and no
// template, handler or script ever asked for it — the controller's templates fetch 18 distinct
// /api/storage/* endpoints and backup-target was one of the only two with zero references, so a
// customer whose backup was unprotected was never told (felhom.eu
// audits/E2D-fresh-vm-2026-07-29.md §5.1). Server-rendered here rather than a 19th fetch: the state
// is already resolved when the page is built, and a banner that needs JavaScript to appear is one
// more thing that can silently not happen.
type BackupTargetView struct {
// Message is the customer copy. Empty is impossible here — a nil *BackupTargetView means
// "render nothing", so the template never has to decide.
Message string
// OfferPath is empty in every state except never-configured-with-an-eligible-drive.
OfferPath string
OfferLabel string
OfferText string
}
// backupTargetView resolves the state and reduces it to what the page renders, or nil for the two
// states that render NOTHING — healthy and unknown. Returning nil rather than an empty struct means
// a template typo cannot accidentally decorate a working box.
func (s *Server) backupTargetView(ctx context.Context) *BackupTargetView {
st := s.resolveBackupTargetState(ctx)
msg := degradedMessageFor(st)
if msg == "" {
return nil // healthy or unknown — a working configuration must look normal
}
v := &BackupTargetView{Message: msg}
if st.OfferPath != "" {
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, backupTargetOfferText
}
return v
}
// handleBackupTargetState serves GET /api/storage/backup-target — the JSON view of the same state the
// backups page renders server-side (see backupTargetView). Kept because `assign` needs a POST partner
// and the payload is a stable contract.
func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request) {
st := s.resolveBackupTargetState(r.Context())
out := map[string]any{"known": st.Known}
if st.Known {
out["degraded"] = st.Degraded
out["target"] = st.TargetID
out["label"] = st.Label
if st.Degraded {
out["message"] = degradedMessageFor(st)
if st.OfferPath != "" {
out["offer_path"] = st.OfferPath
out["offer_label"] = st.OfferLabel
out["offer_message"] = backupTargetOfferText
}
}
}
writeDiskJSON(w, http.StatusOK, true, "", out)
}
// handleBackupTargetAssign is the ACCEPTANCE of the offer — POST /api/storage/backup-target/assign.
//
// NOTHING calls this except an explicit customer action. Registration does not, the drive-gate does
// not, and no scheduler does (E-2 §3: a drive never acquires a role by appearing). Declining is
// simply not calling it, and the degraded state stays visible on the next visit rather than going
// quiet (Scenario D).
func (s *Server) handleBackupTargetAssign(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
stable := strings.TrimSpace(req.Path)
if stable == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "hiányzó meghajtó", nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, "a host-ügynök nem elérhető", nil)
return
}
// The agent operates on the RAW host mount path, not our stable guest path — the same asymmetry
// that register/attach already navigate.
raw, err := s.rawMountForStable(r.Context(), agent, stable)
if err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
return
}
res, err := agent.SetBackupTarget(r.Context(), raw)
if err != nil {
s.logger.Printf("[WARN] [web] backup-target assign %s (raw %s): %v", stable, raw, err)
writeDiskJSON(w, http.StatusBadGateway, false, "a mentési cél beállítása nem sikerült: "+err.Error(), nil)
return
}
// Record the INTENT only after the agent accepted, so our flag can never claim a target the agent
// does not have.
if err := s.settings.SetBackupTarget(stable); err != nil {
s.logger.Printf("[WARN] [web] backup-target intent record %s: %v", stable, err)
}
s.logger.Printf("[INFO] [web] backup target assigned to %s (raw %s); agent restart_required=%v",
stable, raw, res.RestartRequired)
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
"assigned": stable, "restart_required": res.RestartRequired,
})
}
// rawMountForStable maps our registered stable guest path back to the agent's raw host mount.
func (s *Server) rawMountForStable(ctx context.Context, agent *agentapi.Client, stable string) (string, error) {
disks, err := agent.Disks(ctx)
if err != nil {
return "", errAgentUnreadable
}
for _, d := range disks.Disks {
if d.GuestPath == stable && d.MountPath != "" {
return d.MountPath, nil
}
}
return "", errDriveNotFound
}