diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index c965c6b..c3f9f83 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1634,6 +1634,12 @@ func main() { defer cancel() return runOffsiteIntegrityCheck(ctx, backupMgr, notifier, logger, force) } + // R-87: the proof button's caller. Same function as the scheduled job — no second path. + dc.RunOffsiteProof = func() backup.ProofResult { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + return runOffsiteProof(ctx, backupMgr, notifier, logger) + } dc.HubConnectivityTest = func() (int, int64, error) { start := time.Now() client := &http.Client{Timeout: 10 * time.Second} diff --git a/controller/internal/web/handler_debug.go b/controller/internal/web/handler_debug.go index a14530c..15e7480 100644 --- a/controller/internal/web/handler_debug.go +++ b/controller/internal/web/handler_debug.go @@ -16,8 +16,8 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" - "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "gitea.dooplex.hu/admin/felhom-controller/internal/report" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" @@ -39,6 +39,11 @@ type DebugCallbacks struct { // `force` is the ONLY difference between the two callers. It skips the due-ness question and // nothing else — every guard, above all the single-writer flag, applies identically. RunIntegrityCheck func(force bool) backup.IntegrityResult + // RunOffsiteProof (R-87) runs the nightly off-site content proof SYNCHRONOUSLY and returns what it + // found. Same function as the scheduled job — there is no second code path, for the reason + // runOffsiteProof's own comment gives: a hand-run that could drift from the scheduled one is how + // the button ends up proving something the nightly job does not. + RunOffsiteProof func() backup.ProofResult } // debugPageHandler renders the debug dashboard page. @@ -76,6 +81,11 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) { // own right rather than disappearing inside this change. case subpath == "backup/integrity" && r.Method == http.MethodPost: s.debugRunIntegrityCheck(w, r) + // R-87 — the off-site content proof, by hand. It exists for the same reason the integrity button + // does: without it the only way to see this job work is to wait for 05:30, which makes both live + // validation and any future diagnosis a next-day exercise. + case subpath == "backup/offsite-proof" && r.Method == http.MethodPost: + s.debugRunOffsiteProof(w, r) // Section 5: Hub & connectivity case subpath == "hub/push" && r.Method == http.MethodPost: @@ -469,6 +479,55 @@ func crossDriveTargets(all []stacks.Stack, isHDDApp func(name string) bool) []st return names } +// debugRunOffsiteProof runs the nightly off-site content proof by hand (R-87). +// +// SYNCHRONOUS, like the integrity button beside it and for the same reason: the operator pressed this +// to learn an ANSWER. One app's unit measured 2.3-4.0 s on demo-hp, so there is nothing to wait for. +// +// DUE-NESS IS NOT BYPASSED, and that is the ONE place this differs from the integrity button. There, +// forcing means "check the store again", which is always answerable. Here, due-ness IS the target +// selection — an app is due when its newest snapshot has not been proved — so ignoring it would mean +// inventing a different way to choose an app, i.e. a second code path, which is exactly what having +// one function is meant to prevent. When nothing is due the button says so, honestly. +// +// Every other guard is intact, including the single-writer flag: a hand-run during a backup SKIPS +// exactly as the scheduled one would, because "the operator asked for it" is precisely the reasoning +// that would reintroduce the hazard. +func (s *Server) debugRunOffsiteProof(w http.ResponseWriter, r *http.Request) { + if s.debugCallbacks == nil || s.debugCallbacks.RunOffsiteProof == nil { + writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) + return + } + res := s.debugCallbacks.RunOffsiteProof() + data := map[string]interface{}{ + "stack": res.Stack, + "snapshot": res.SnapshotID, + "verdict": res.Verdict(), + "reason": string(res.Judgement.Reason), + "missing": res.Judgement.Missing, + "duration_ms": res.Duration.Milliseconds(), + "skipped": res.Skipped, + "skip_reason": res.SkipReason, + "no_snapshot": res.NoSnapshot, + } + switch { + case res.Skipped: + writeDebugJSON(w, http.StatusOK, true, "Kihagyva: "+res.SkipReason, data) + case res.NoSnapshot: + writeDebugJSON(w, http.StatusOK, true, "Nincs esedékes alkalmazás — minden legújabb mentés már ellenőrizve", data) + case res.Err != nil: + // NOT a verdict about the backup: the restore did not finish, so nothing was concluded. + data["error"] = res.Err.Error() + writeDebugJSON(w, http.StatusOK, true, "A visszaállítás nem futott le — a mentésről semmi nem derült ki", data) + case res.Judgement.Verdict == backup.UnitProofPass: + writeDebugJSON(w, http.StatusOK, true, "A mentés tartalmazza az alkalmazás adatait", data) + case res.Judgement.Verdict == backup.UnitProofCannotJudge: + writeDebugJSON(w, http.StatusOK, true, "Nem megítélhető — a mentés nem hordoz elég információt", data) + default: + writeDebugJSON(w, http.StatusOK, false, "A mentés olvasható, de nem tartalmazza az alkalmazás adatait", data) + } +} + // debugRunIntegrityCheck runs the off-site integrity check by hand (R-359/R-397). // // SYNCHRONOUS on purpose, unlike the DB-dump button beside it: the operator pressed this to learn an diff --git a/controller/internal/web/templates/debug.html b/controller/internal/web/templates/debug.html index e4610e6..44a151e 100644 --- a/controller/internal/web/templates/debug.html +++ b/controller/internal/web/templates/debug.html @@ -82,6 +82,9 @@ + + +