843b319f35
gates / gates (push) Successful in 14s
MinAgent: 0.131.0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
329 lines
13 KiB
Go
329 lines
13 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"net/url"
|
||
"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, "", "Érvénytelen időpont. Használja a ÓÓ:PP formátumot (például 02:30).")
|
||
return
|
||
}
|
||
if err := s.settings.SetBackupWindowStart(start); err != nil {
|
||
s.logger.Printf("[ERROR] [web] backup window save failed: %v", err)
|
||
s.backupWindowRedirect(w, r, "", "A mentési időablak mentése nem sikerült.")
|
||
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, "Mentési időablak frissítve.", "")
|
||
}
|
||
|
||
// 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 += "?flash_error=" + url.QueryEscape(flashErr)
|
||
} else if flash != "" {
|
||
dest += "?flash=" + url.QueryEscape(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
|
||
|
||
// 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
|
||
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, err.Error(), 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, err.Error(), nil)
|
||
return
|
||
}
|
||
st, err := client.BackupStatus(r.Context())
|
||
if err != nil {
|
||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||
return
|
||
}
|
||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
|
||
"phase": st.Phase, "job_id": st.JobID, "error": st.Error, "backup": st.Backup,
|
||
})
|
||
}
|