7c05b59708
gates / gates (push) Successful in 24s
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
333 lines
13 KiB
Go
333 lines
13 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/quiesce"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||
)
|
||
|
||
// effectiveBackupWindow resolves the active backup-window start (settings > controller.yaml >
|
||
// "02:30") for this server. Every nightly-leg display and the DB-dump next-run derive from it.
|
||
func (s *Server) effectiveBackupWindow() string {
|
||
return backupwindow.EffectiveWindow(s.settings.GetBackupWindowStart(), s.cfg.Backup.DBDumpSchedule)
|
||
}
|
||
|
||
// backupWindowData injects the customer-configurable-window view onto the Áttekintés page: the
|
||
// effective start, the three derived leg times (DB / helyi másolat / távoli mentés), and the
|
||
// whole-guest gate span [W+2h, W+6h). The offsets are DERIVED here, never stored.
|
||
func (s *Server) backupWindowData(data map[string]interface{}) {
|
||
win := s.effectiveBackupWindow()
|
||
db, tier2, offbox := backupwindow.LegTimes(win)
|
||
from, to := backupwindow.GateWindow(win)
|
||
data["BackupWindow"] = win
|
||
data["BackupLegDB"] = db
|
||
data["BackupLegTier2"] = tier2
|
||
data["BackupLegOffbox"] = offbox
|
||
data["BackupGateFrom"] = from
|
||
data["BackupGateTo"] = to
|
||
}
|
||
|
||
// backupWindowSaveHandler persists a new backup-window start and fans it out to the three daily legs
|
||
// live (no restart) via UpdateDaily. POST /backups/window (behind RequireAuth + CsrfProtect). On an
|
||
// invalid time nothing is stored and the jobs are untouched.
|
||
func (s *Server) backupWindowSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
start := strings.TrimSpace(r.FormValue("window_start"))
|
||
if backupwindow.Valid(start) != nil {
|
||
s.backupWindowRedirect(w, r, "", "flash.backup.window_invalid_time")
|
||
return
|
||
}
|
||
if err := s.settings.SetBackupWindowStart(start); err != nil {
|
||
s.logger.Printf("[ERROR] [web] backup window save failed: %v", err)
|
||
s.backupWindowRedirect(w, r, "", "flash.backup.window_save_failed")
|
||
return
|
||
}
|
||
// Fan out to the three daily legs at their fixed offsets — takes effect at the next scheduling
|
||
// pass (no restart). The scheduler wakes each job via its reschedule signal.
|
||
db, tier2, offbox := backupwindow.LegTimes(start)
|
||
if s.scheduler != nil {
|
||
s.scheduler.UpdateDaily("db-dump", db)
|
||
s.scheduler.UpdateDaily("tier2-backup", tier2)
|
||
s.scheduler.UpdateDaily("offbox-backup", offbox)
|
||
}
|
||
// Refresh the cached "next DB dump" so the display updates immediately, not at the next 5m tick.
|
||
if s.backupMgr != nil {
|
||
s.backupMgr.RefreshCache(scheduler.NextDailyRun(db))
|
||
}
|
||
s.logger.Printf("[INFO] [web] backup window set to %s (legs %s/%s/%s)", start, db, tier2, offbox)
|
||
s.backupWindowRedirect(w, r, "flash.backup.window_updated", "")
|
||
}
|
||
|
||
// backupWindowRedirect PRG-redirects back to the Áttekintés page with a success or error flash.
|
||
func (s *Server) backupWindowRedirect(w http.ResponseWriter, r *http.Request, flash, flashErr string) {
|
||
dest := "/backups"
|
||
if flashErr != "" {
|
||
dest += "?" + flashQuery("flash_error", flashErr)
|
||
} else if flash != "" {
|
||
dest += "?" + flashQuery("flash", flash)
|
||
}
|
||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||
}
|
||
|
||
// Whole-guest backup visibility + manual trigger (spec Part 2). The agent owns whole-guest
|
||
// vzdump/PBS backup; the controller is a read-only window onto it (GET /backup/{status,due},
|
||
// /restore-test/status) plus a "Mentés most" trigger that goes through the quiesce loop (the
|
||
// CONTROLLER owns quiescing — stop stacks → POST /backup → resume — so the captured state is
|
||
// app-consistent, not the agent's crash-consistent default). Cadence/retention CONFIG is NOT here
|
||
// (hub-served policy, slice 10).
|
||
|
||
// guestBackupView is the template payload for the "Rendszermentés" section. Times are time.Time so
|
||
// the existing fmtTime/timeAgo funcmap helpers format them; size is int64 for fmtBytes.
|
||
type guestBackupView struct {
|
||
Available bool // agent reachable + a status read succeeded
|
||
Note string // shown when not Available (unprovisioned / unreachable)
|
||
|
||
Phase string // idle | running | snapshotted | done | failed
|
||
Running bool // a backup job is in progress now
|
||
|
||
HasBackup bool
|
||
Success bool
|
||
StartedAt time.Time
|
||
SizeBytes int64
|
||
Target string // human label: "Biztonsági szerver – külön hardver (PBS)" / "Helyi tároló (local)"
|
||
Offsite bool // the whole-guest backup landed on the PBS offsite tier (separate hardware)
|
||
Archive string
|
||
Mode string // snapshot | stop
|
||
StopMode bool // mode == stop → full app downtime during the backup (warn)
|
||
|
||
Due bool
|
||
DueReason string
|
||
AgeHours int64 // age of the newest successful backup, hours (for "X órája")
|
||
|
||
HasRestoreTest bool
|
||
RestorePass bool
|
||
RestoreVerified string
|
||
RestoreTestedAt time.Time
|
||
|
||
CanTrigger bool // a backup trigger (quiesce loop) is wired
|
||
|
||
// SizeUnknown (R-517): the shown success was read back from storage after an agent restart, so
|
||
// its size is not known. The tile prints „–", never „0 B" (measured live on 9201, 2026-09-15).
|
||
SizeUnknown bool
|
||
|
||
// Tiers (R-517, agent >= v0.131.0) is the per-tier truth. Empty on an older agent — the tile then
|
||
// renders the single latest record as before.
|
||
Tiers []guestTierView
|
||
}
|
||
|
||
// guestTierView is one whole-guest tier as the customer sees it. A failed attempt is shown UNDER the
|
||
// newest success, never instead of it ("presence is not success").
|
||
type guestTierView struct {
|
||
Target string
|
||
Label string // Hungarian tier name
|
||
IsPBS bool
|
||
NotSetUp bool // the tier's storage does not exist on the host → „nincs beállítva"
|
||
HasSuccess bool
|
||
SuccessAt time.Time
|
||
SizeKnown bool // false when the success was read back from storage after a restart
|
||
SizeBytes int64
|
||
Current bool // newest success is inside the tier's window
|
||
FailedAfter bool // the last attempt failed and is newer than the newest success
|
||
FailedAt time.Time
|
||
}
|
||
|
||
// tierWindow is how old a tier's newest success may be and still count as current: its cadence plus
|
||
// half again (a scheduled run lands inside the nightly window, not on the exact second). A tier with
|
||
// no advertised cadence uses a day.
|
||
func tierWindow(cadenceSecs int64) time.Duration {
|
||
if cadenceSecs <= 0 {
|
||
cadenceSecs = 24 * 3600
|
||
}
|
||
return time.Duration(cadenceSecs) * time.Second * 3 / 2
|
||
}
|
||
|
||
// buildTierViews turns the agent's per-tier state into page rows and derives the three legacy tile
|
||
// fields from SUCCESSES only: HasBackup/Success/Size/Target/StartedAt from the primary tier's newest
|
||
// success; Due when any set-up tier has no current success; Offsite only on a current PBS success.
|
||
func buildTierViews(v *guestBackupView, tiers []agentapi.TierBackupState, cadence map[string]int64, now time.Time) {
|
||
v.Tiers = nil
|
||
v.Due, v.Offsite, v.HasBackup = false, false, false
|
||
var newestOK time.Time
|
||
for _, t := range tiers {
|
||
tv := guestTierView{Target: t.Target, IsPBS: strings.Contains(strings.ToLower(t.Target), "pbs")}
|
||
if tv.IsPBS {
|
||
tv.Label = "Biztonsági szerver – külön hardver (PBS)"
|
||
} else {
|
||
tv.Label = "Helyi tároló (" + t.Target + ")"
|
||
}
|
||
tv.NotSetUp = t.Storage == "absent"
|
||
if t.LastSuccess != nil {
|
||
if ts, err := time.Parse(time.RFC3339, t.LastSuccess.StartedAt); err == nil {
|
||
tv.HasSuccess, tv.SuccessAt = true, ts
|
||
tv.SizeKnown = t.LastSuccessSource != "storage"
|
||
tv.SizeBytes = t.LastSuccess.SizeBytes
|
||
tv.Current = now.Sub(ts) <= tierWindow(cadence[t.Target])
|
||
}
|
||
}
|
||
if a := t.LastAttempt; a != nil && !a.Success {
|
||
if ts, err := time.Parse(time.RFC3339, a.StartedAt); err == nil && (!tv.HasSuccess || ts.After(tv.SuccessAt)) {
|
||
tv.FailedAfter, tv.FailedAt = true, ts
|
||
}
|
||
}
|
||
if !tv.NotSetUp && !tv.Current {
|
||
v.Due = true
|
||
}
|
||
if tv.IsPBS && tv.Current && !tv.NotSetUp {
|
||
v.Offsite = true
|
||
}
|
||
if tv.HasSuccess && (t.Primary || !v.HasBackup) && (t.Primary || tv.SuccessAt.After(newestOK)) {
|
||
v.HasBackup, v.Success = true, true
|
||
v.StartedAt, v.SizeBytes, v.Target = tv.SuccessAt, tv.SizeBytes, tv.Label
|
||
v.SizeUnknown = !tv.SizeKnown
|
||
newestOK = tv.SuccessAt
|
||
}
|
||
v.Tiers = append(v.Tiers, tv)
|
||
}
|
||
if v.Due {
|
||
v.DueReason = "tier_not_current"
|
||
}
|
||
if v.HasBackup {
|
||
v.AgeHours = int64(now.Sub(v.StartedAt).Hours())
|
||
}
|
||
}
|
||
|
||
// loadGuestBackup fetches the agent's whole-guest backup view (best-effort). Returns a view with
|
||
// Available=false (+ a note) when the agent isn't configured/reachable — the page still renders.
|
||
func (s *Server) loadGuestBackup(ctx context.Context) *guestBackupView {
|
||
v := &guestBackupView{CanTrigger: s.backupTrigger != nil}
|
||
client, err := s.agentClient()
|
||
if err != nil {
|
||
v.Note = "A host-ügynök nincs konfigurálva ezen a gépen."
|
||
return v
|
||
}
|
||
st, err := client.BackupStatus(ctx)
|
||
if err != nil {
|
||
v.Note = "A host-ügynök jelenleg nem elérhető."
|
||
return v
|
||
}
|
||
v.Available = true
|
||
v.Phase = st.Phase
|
||
v.Running = st.Phase == agentapi.PhaseRunning || st.Phase == "snapshotted"
|
||
if st.Backup != nil {
|
||
v.HasBackup = true
|
||
v.Success = st.Backup.Success
|
||
v.SizeBytes = st.Backup.SizeBytes
|
||
v.Archive = st.Backup.Archive
|
||
v.Mode = st.Backup.Mode
|
||
v.StopMode = st.Backup.Mode == "stop"
|
||
v.Target = backupTargetLabel(st.Backup)
|
||
v.Offsite = backupIsPBS(st.Backup)
|
||
if t, perr := time.Parse(time.RFC3339, st.Backup.StartedAt); perr == nil {
|
||
v.StartedAt = t
|
||
}
|
||
}
|
||
// Due window (best-effort; a failure just leaves the due fields zero).
|
||
if due, derr := client.BackupDue(ctx); derr == nil {
|
||
v.Due = due.Due
|
||
v.DueReason = due.Reason
|
||
if due.AgeSecs != nil {
|
||
v.AgeHours = *due.AgeSecs / 3600
|
||
}
|
||
}
|
||
// R-517: an agent that speaks per tier replaces the single-record fields with per-tier truth.
|
||
// Done AFTER the legacy fill so an older agent keeps exactly the old rendering.
|
||
if len(st.Tiers) > 0 {
|
||
cadence := map[string]int64{}
|
||
if tr, terr := client.BackupTiers(ctx); terr == nil {
|
||
for _, t := range tr.Tiers {
|
||
cadence[t.Target] = t.CadenceSeconds
|
||
}
|
||
}
|
||
buildTierViews(v, st.Tiers, cadence, time.Now())
|
||
}
|
||
// Restore-test (the "verified restorable" trust signal; nil until one runs).
|
||
if rt, rerr := client.RestoreTestStatus(ctx); rerr == nil && rt != nil {
|
||
v.HasRestoreTest = true
|
||
v.RestorePass = rt.Pass
|
||
v.RestoreVerified = rt.Verified
|
||
if t, perr := time.Parse(time.RFC3339, rt.TestedAt); perr == nil {
|
||
v.RestoreTestedAt = t
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
|
||
// backupIsPBS reports whether a whole-guest backup landed on the PBS offsite tier (separate
|
||
// hardware), inferred from the target id / archive volid ("felhom-pbs"/"pbs:" ⇒ PBS).
|
||
func backupIsPBS(b *agentapi.BackupRecord) bool {
|
||
id := strings.ToLower(b.TargetID)
|
||
arc := strings.ToLower(b.Archive)
|
||
return strings.Contains(id, "pbs") || strings.HasPrefix(arc, "felhom-pbs") || strings.Contains(arc, "pbs:")
|
||
}
|
||
|
||
// backupTargetLabel maps the agent's backup target to a customer-facing Hungarian label. The PBS
|
||
// case calls out that the backup is on SEPARATE HARDWARE (real disaster recovery — survives a host
|
||
// disk/hardware failure), which is the whole point of re-pointing the backup offsite.
|
||
func backupTargetLabel(b *agentapi.BackupRecord) string {
|
||
if backupIsPBS(b) {
|
||
return "Biztonsági szerver – külön hardver (PBS)"
|
||
}
|
||
if b.TargetID != "" {
|
||
return "Helyi tároló (" + b.TargetID + ")"
|
||
}
|
||
return "Helyi tároló"
|
||
}
|
||
|
||
// ServeBackupAPI dispatches /api/guest-backup/* (whole-guest manual trigger + status poll). A
|
||
// distinct prefix from apiRouter's app-data /api/backup/{run,status}. Wired behind RequireAuth +
|
||
// CsrfProtect in main.go.
|
||
func (s *Server) ServeBackupAPI(w http.ResponseWriter, r *http.Request) {
|
||
switch {
|
||
case r.URL.Path == "/api/guest-backup/trigger" && r.Method == http.MethodPost:
|
||
s.handleBackupTriggerAPI(w, r)
|
||
case r.URL.Path == "/api/guest-backup/status" && r.Method == http.MethodGet:
|
||
s.handleBackupStatusAPI(w, r)
|
||
default:
|
||
http.NotFound(w, r)
|
||
}
|
||
}
|
||
|
||
// handleBackupTriggerAPI starts an app-consistent whole-guest backup NOW via the quiesce loop. It
|
||
// returns immediately (the backup runs async, minutes); the page polls /api/backup/status.
|
||
func (s *Server) handleBackupTriggerAPI(w http.ResponseWriter, r *http.Request) {
|
||
if s.backupTrigger == nil {
|
||
writeDiskJSON(w, http.StatusServiceUnavailable, false, "a rendszermentés nem érhető el ezen a gépen", nil)
|
||
return
|
||
}
|
||
if err := s.backupTrigger.TriggerNow(); err != nil {
|
||
if errors.Is(err, quiesce.ErrBackupInProgress) {
|
||
writeDiskJSON(w, http.StatusConflict, false, "mentés már folyamatban van", nil)
|
||
return
|
||
}
|
||
s.logger.Printf("[ERROR] [web] backup trigger failed: %v", err)
|
||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||
return
|
||
}
|
||
s.logger.Printf("[INFO] [web] manual whole-guest backup triggered (quiesce loop)")
|
||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true})
|
||
}
|
||
|
||
// handleBackupStatusAPI proxies the agent's GET /backup/status for the page's progress poll.
|
||
func (s *Server) handleBackupStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||
client, err := s.agentClient()
|
||
if err != nil {
|
||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||
return
|
||
}
|
||
st, err := client.BackupStatus(r.Context())
|
||
if err != nil {
|
||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||
return
|
||
}
|
||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
|
||
"phase": st.Phase, "job_id": st.JobID, "error": st.Error, "backup": st.Backup,
|
||
})
|
||
}
|