Files
felhom-controller/controller/internal/web/backup_target_offer.go
T
admin 7c05b59708
gates / gates (push) Successful in 24s
v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by
whoever caught them: too late to translate where they are shown, too early where they are
made. Every one now carries its key across that gap. ZERO Hungarian error literals remain.

util.MsgError does three things at once, each earned:
  - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged;
  - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause);
  - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole.
A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours.

76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do
not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the
household's language come from one value; UpdateRefusal gained a Cause to carry it.

Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a
per-call-site flag — the producer somebody forgot would read "3 app is not running". The
guard caught a real key collision (alert.deadapp.one) the day the rule landed.

TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter
silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could
not see it, because every surviving fragment WAS a real base literal while the CALL had lost
text; two behaviour tests caught it. And the counting script was case-sensitive, so it said
"0 left" while five remained.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 11:44:30 +02:00

320 lines
14 KiB
Go

package web
import (
"context"
"encoding/json"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"net/http"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
var (
errAgentUnreadable = util.MsgError("err.web.a_meghajtok_listaja_nem_olvashato")
errDriveNotFound = util.MsgError("err.web.a_meghajto_nem_talalhato")
)
// 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, s.errText(r, err), 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: "+s.errText(r, err), 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
}