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 @@
+
+
+