Files
felhom-controller/controller/internal/web/tier2_config_handler.go
T
admin 80e6ad8c47
gates / gates (push) Successful in 26s
controller v0.267.0: tests off DooPlex's Docker, cut-off copies refused, two pages true
R-650: internal/dockerexec — every docker exec routed through it; under
go test a real docker is refused (opt-in FELHOM_TEST_REAL_DOCKER=1; a stub
under the temp dir is allowed). api/stacks/web tests run under a silent
stub (TestMain). TestR650_NoBareDockerExec pins it repo-wide.
R-640: a dump without its engine's completion marker is refused before
the first mutation (unit + off-site restore) and again before any load.
R-499: the Tier-2 page's system-disk sentence has four true branches.
R-518: the backup button states the measured ~8 min stop.
R-626: measured on 9202, not reproduced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 20:25:28 +02:00

165 lines
6.0 KiB
Go

package web
import (
"net/http"
"net/url"
)
// Per-app Tier-2 (off-drive copy) config panel — item 4.
//
// The "2. mentés" row on the backup page used to link its "Beállítás" button at the app's deploy
// page, which has no backup-location setting (a dead end). This is the real surface: it shows the
// current/auto off-drive target + last-run status, and lets the customer pin a different registered
// drive or turn Tier 2 off. It is ALWAYS shown — even when only the internal SSD qualifies, or the
// app's data lives on the rootfs (already in PBS) — with honest context rather than a hidden control.
//
// Routes (wired in server.go, behind RequireAuth + CsrfProtect):
// GET /stacks/{name}/backup → tier2ConfigPageHandler
// POST /stacks/{name}/backup → tier2ConfigSaveHandler
func (s *Server) tier2ConfigPageHandler(w http.ResponseWriter, r *http.Request, name string) {
stack, ok := s.stackMgr.GetStack(name)
if !ok {
http.NotFound(w, r)
return
}
if s.backupMgr == nil {
http.Error(w, "A mentés nincs beállítva ezen a szerveren.", http.StatusServiceUnavailable)
return
}
info := s.backupMgr.Tier2Info(name)
data := s.baseData("backups", "2. mentés beállítása — "+stack.Meta.DisplayName)
data["StackName"] = name
data["DisplayName"] = stack.Meta.DisplayName
data["Tier2"] = info
if !info.IsHDDApp {
// R-499: the sentence about a system-disk app must say where THIS box's whole-system backup
// goes. It used to promise „már szerepelnek a teljes rendszermentésben (PBS)" on every box.
data["SystemBackup"] = systemBackupFact(s.resolveBackupTargetState(r.Context()))
}
if flash := s.flashFrom(r, "flash"); flash != "" {
data["Flash"] = flash
}
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
s.executeTemplate(w, r, "tier2_config", data)
}
func (s *Server) tier2ConfigSaveHandler(w http.ResponseWriter, r *http.Request, name string) {
if _, ok := s.stackMgr.GetStack(name); !ok {
http.NotFound(w, r)
return
}
if s.backupMgr == nil {
http.Error(w, "A mentés nincs beállítva ezen a szerveren.", http.StatusServiceUnavailable)
return
}
_ = r.ParseForm()
// "enabled" checkbox: present → Tier 2 on; absent → off (UserDisabled = !enabled).
enabled := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
target := r.FormValue("target") // "" = automatic; otherwise a registered drive path
// Validate the chosen target against the eligible alternatives (defence-in-depth: the runner
// also re-validates off-disk at run time, but reject a bogus path here for a clean message).
if target != "" {
valid := false
for _, opt := range s.backupMgr.Tier2Info(name).Alternatives {
if opt.Path == target {
valid = true
break
}
}
if !valid {
s.redirectTier2(w, r, name, "", "flash.tier2.target_invalid")
return
}
}
if err := s.settings.SetTier2Preference(name, !enabled, target); err != nil {
s.logger.Printf("[ERROR] [web] save Tier 2 preference for %s: %v", name, err)
s.redirectTier2(w, r, name, "", "flash.tier2.save_failed")
return
}
s.logger.Printf("[INFO] [web] Tier 2 preference saved for %s: enabled=%v target=%q", name, enabled, target)
// Apply immediately when enabled for an HDD app so the customer sees the result on return.
if enabled && s.backupMgr.Tier2Info(name).IsHDDApp {
go func() {
if err := s.backupMgr.RunTier2(name); err != nil {
s.logger.Printf("[WARN] [web] immediate Tier 2 run for %s failed: %v", name, err)
}
}()
}
s.redirectTier2(w, r, name, "flash.tier2.saved", "")
}
// appEmailToggleHandler flips the per-app email toggle (only for apps with an smtp_mapping)
// and recreates the stack so the SMTP env injection takes effect. Redirects back to the
// app's config page with a flash.
func (s *Server) appEmailToggleHandler(w http.ResponseWriter, r *http.Request, name string) {
if _, ok := s.stackMgr.GetStack(name); !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true"
dest := "/stacks/" + url.PathEscape(name) + "/deploy"
if err := s.stackMgr.SetAppEmailEnabled(name, enabled); err != nil {
s.logger.Printf("[ERROR] [web] app-email toggle for %s: %v", name, err)
http.Redirect(w, r, dest+"?flash_error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
s.logger.Printf("[INFO] [web] App-email for %s set to %v", name, enabled)
msg := "flash.tier2.app_email_off"
if enabled {
msg = "flash.tier2.app_email_on"
}
http.Redirect(w, r, dest+"?"+flashQuery("flash", msg), http.StatusSeeOther)
}
// redirectTier2 sends the customer back to the panel with a flash message.
func (s *Server) redirectTier2(w http.ResponseWriter, r *http.Request, name, flash, flashErr string) {
dest := "/stacks/" + url.PathEscape(name) + "/backup"
q := url.Values{}
if flash != "" {
q.Set("flash", flash)
}
if flashErr != "" {
q.Set("flash_error", flashErr)
}
if e := q.Encode(); e != "" {
dest += "?" + e
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// systemBackupFact reduces the whole-system backup's target state to the one fact the Tier-2 page
// states about an app on the system disk (R-499). The page used to tell every such app that its data
// was „already in the full system backup (PBS)" and there was nothing to do — measured false on a box
// whose only whole-system copy sat on the same disk (2026-09-14). Four states, four sentences:
//
// protected — the whole-system backup goes to a drive of its own
// same_disk — it goes to the system disk itself: protects against bad files, not a dead disk
// absent — its drive is configured and gone: no fresh whole-system backup is being made
// unknown — the agent could not be asked: say so, promise nothing
//
// Pinned by TestR499_* (the mapping and one render per branch).
func systemBackupFact(st BackupTargetState) string {
switch {
case !st.Known:
return "unknown"
case st.TargetAbsent:
return "absent"
case st.Degraded:
return "same_disk"
default:
return "protected"
}
}