0fe04bb6d5
gates / gates (push) Successful in 28s
The 2026-09-20 English drill ended one screen short: the claim page was English and its answers were Hungarian, so a household who mistyped the code from their e-mail could not tell a typo from a dead code. Fourteen call sites carrying nine messages now go through s.msg; the backup page's two protection warnings — which are promises about whether the customer's files are safe — follow the same route. Hungarian is byte-identical, proved structurally by the go-parity gate against the frozen base capture and red-proofed on a single added full stop. data["Title"] was DEAD (claim.html is standalone; .Title is layout.html's) and is deleted rather than translated — a translated dead field is a permanent false signal about where the page's title comes from. Six existing copy-contract tests were kept, not weakened: each now resolves its key through the real bundle, so it still convicts on a reworded Hungarian sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
337 lines
16 KiB
Go
337 lines
16 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).
|
|
//
|
|
// R-598 — THESE ARE PROMISES ABOUT WHETHER THE CUSTOMER'S FILES ARE SAFE, and until v0.259.0 they
|
|
// were composed in Go as finished Hungarian sentences and handed to the page as DATA. That is the
|
|
// defect class R-573 and R-590 already cost: no template-parity fixture and no English-page test can
|
|
// see a Hungarian sentence arriving as a struct field. So the constants below are now KEYS, and
|
|
// degradedMessageFor returns the key; the one place that has a language resolves it.
|
|
//
|
|
// The Hungarian text moved to the bundle byte-for-byte (scripts/i18n_go_parity.py measures that
|
|
// against the frozen base capture), so a Hungarian household reads exactly the same words.
|
|
const (
|
|
backupTargetDegradedKey = "backup.target.degraded"
|
|
backupTargetOfferKey = "backup.target.offer"
|
|
// 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. (v0.259.0: the HUNGARIAN side of the drift risk is
|
|
// unchanged — hu.json carries the same bytes the const carried. The ENGLISH side is new: the hub's
|
|
// English `mail.event.backup_target_absent` and this key must say the same thing too, and nothing
|
|
// binds those either. Same row, now twice as wide.)
|
|
backupTargetAbsentKey = "backup.target.absent"
|
|
)
|
|
|
|
// 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).
|
|
// It returns a bundle KEY, not a sentence (R-598). The decision — "does the customer see anything,
|
|
// and which fact is it?" — is language-free and stays here; the words are chosen by the caller that
|
|
// knows who is reading. Returning a key also means an untranslated state is a visible key on the
|
|
// page rather than a Hungarian sentence on an English one, which is the failure mode worth having.
|
|
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 backupTargetAbsentKey
|
|
}
|
|
return backupTargetDegradedKey
|
|
}
|
|
|
|
// ---- 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.
|
|
//
|
|
// `lang` is the reader's, resolved once by the handler (s.langFor(r)) — the same shape
|
|
// buildDataPathCards and buildStorageBars already use. The KEY comes from degradedMessageFor; the
|
|
// WORDS are chosen here, and nowhere else.
|
|
func (s *Server) backupTargetView(ctx context.Context, lang string) *BackupTargetView {
|
|
st := s.resolveBackupTargetState(ctx)
|
|
key := degradedMessageFor(st)
|
|
if key == "" {
|
|
return nil // healthy or unknown — a working configuration must look normal
|
|
}
|
|
v := &BackupTargetView{Message: s.msgLang(lang, key)}
|
|
if st.OfferPath != "" {
|
|
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, s.msgLang(lang, backupTargetOfferKey)
|
|
}
|
|
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"] = s.msg(r, degradedMessageFor(st))
|
|
if st.OfferPath != "" {
|
|
out["offer_path"] = st.OfferPath
|
|
out["offer_label"] = st.OfferLabel
|
|
out["offer_message"] = s.msg(r, backupTargetOfferKey)
|
|
}
|
|
}
|
|
}
|
|
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, s.msg(r, "backup.target.err.bad_request"), nil)
|
|
return
|
|
}
|
|
stable := strings.TrimSpace(req.Path)
|
|
if stable == "" {
|
|
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "backup.target.err.missing_drive"), nil)
|
|
return
|
|
}
|
|
agent, err := s.agentClient()
|
|
if err != nil {
|
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.msg(r, "backup.target.err.agent_unreachable"), 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, s.msg(r, "backup.target.err.assign_failed")+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
|
|
}
|