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 }