v0.231.0: the off-site proof gets a by-hand trigger, like its integrity sibling (R-87)
gates / gates (push) Successful in 12s

Without it the only way to see the job work is to wait for 05:30, which makes live
validation and any future diagnosis a next-day exercise. Same function as the scheduled
job - no second code path.

ONE deliberate difference from the integrity button: due-ness is NOT bypassed. 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 second way to choose an app, exactly what having one
function prevents. When nothing is due the button says so, honestly.

Every other guard intact, including the single-writer flag: a hand-run during a backup
SKIPS exactly as the scheduled one would.

POST /api/debug/backup/offsite-proof, button beside "Restic integritas" on the debug page.
debug_route_gate pairs the two, so a button with no dispatch (R-400's shape) cannot ship.
This commit is contained in:
2026-08-31 21:09:09 +02:00
parent e43b5ec07d
commit 303129e3af
3 changed files with 69 additions and 1 deletions
+60 -1
View File
@@ -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
@@ -82,6 +82,9 @@
<button class="btn btn-secondary btn-sm" id="btn-integrity" data-label="Restic integritás" onclick="triggerAction('btn-integrity','/api/debug/backup/integrity','POST')">Restic integritás</button>
<span class="debug-result" id="btn-integrity-result"></span>
<button class="btn btn-secondary btn-sm" id="btn-offsite-proof" data-label="Távoli mentés tartalma" onclick="triggerAction('btn-offsite-proof','/api/debug/backup/offsite-proof','POST')">Távoli mentés tartalma</button>
<span class="debug-result" id="btn-offsite-proof-result"></span>
</div>
</div>
</div>