Offsite tier policy engine: mandatory userdata, raw-data quota, restore rework (Task 3a, v0.134.0)

Each toggled app's offsite push = one multi-path restic snapshot (recovery unit + TierOffsite
mandatory userdata via ComputeCaptureSet); legacy/undeployed stay unit-only. Loud capture gaps
(SP-3.4: restic 0.14.0 silently skips missing paths). Quota = stats --mode raw-data (SP-1;
displayed size drops once). Pre-push enlargement gate blocks the userdata enlargement over-quota
(unit-only push continues; EnlargedBlocked; edge-triggered notify). forget --group-by host,tags
on both sites (SP-2). Restore reworked: scratch off the rootfs + headroom gate (F-A1), unit-only
default via --include, size-first full, place-to-live missing-only merge (never --delete).
UI: unit/full-two-step/place actions + per-app blocked note; route POST /backup/offbox/place.
HUB FLAG: offbox_enlarge_blocked event needs hub allowlist for push delivery.
+13 tests; all 10 §10 red-proofs verified. No tier-2/.fab/hub/agent changes.
This commit is contained in:
2026-07-14 22:51:54 +02:00
parent 0c6e151c1c
commit 2d20859858
17 changed files with 1413 additions and 109 deletions
+26
View File
@@ -661,6 +661,14 @@ func (s *Server) backupsOffboxData(data map[string]interface{}) {
}
// SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model).
data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt)
// 3a: per-app "config+DB only" note set — apps whose enlarged push the quota gate blocked last run.
blocked := map[string]bool{}
if offboxTgt != nil {
for _, a := range offboxTgt.EnlargedBlocked {
blocked[a] = true
}
}
data["OffboxBlockedSet"] = blocked
}
// offboxStaleWarningMarker is the substring the zero-toggled offbox run writes into
@@ -757,6 +765,24 @@ func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) {
func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-restore", "Biztonsági mentés — Visszaállítás", r)
s.backupsOffboxData(data) // restore-to-verify lists the offbox-toggled apps
// Full-restore two-step reveal (§7.2): after the size+headroom prepare step, offboxRestoreHandler
// redirects here with the app + human size so the confirm section can show the size BEFORE starting.
if fp := strings.TrimSpace(r.URL.Query().Get("full_prep")); fp != "" {
data["FullPrepApp"] = fp
data["FullPrepSize"] = r.URL.Query().Get("full_size")
}
// Per-app place-to-live availability (a completed full scratch exists → offer the merge action).
ready := map[string]bool{}
if s.backupMgr != nil {
if apps, ok := data["OffboxApps"].([]OffboxAppRow); ok {
for _, a := range apps {
if a.Enabled && s.backupMgr.OffboxFullScratchReady(a.Name) {
ready[a.Name] = true
}
}
}
}
data["OffboxScratchReady"] = ready
s.executeTemplate(w, r, "backups_restore", data)
}
+62 -11
View File
@@ -4,7 +4,6 @@ import (
"context"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
@@ -88,6 +87,8 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
tgt.LastDuration, tgt.RepoSizeHuman, tgt.SnapshotCount = prev.LastDuration, prev.RepoSizeHuman, prev.SnapshotCount
tgt.LastWarning = prev.LastWarning
tgt.EscrowState = prev.EscrowState
tgt.RepoSizeBytes = prev.RepoSizeBytes
tgt.EnlargedBlocked = prev.EnlargedBlocked
}
// fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks
// it PENDING — no offsite RUN proceeds until escrow is confirmed (atomicity). Re-editing an already
@@ -213,8 +214,10 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirect(w, r, "A távoli mentés elindult (a futás után az állapot frissül).", false)
}
// offboxRestoreHandler restores an app's off-box data to a scratch dir (non-destructive — does NOT
// overwrite live data; the operator inspects the restored files).
// offboxRestoreHandler restores an app's off-box data to an on-data-drive scratch dir (§7, F-A1;
// non-destructive — does NOT overwrite live data). mode=unit (default) restores the recovery unit
// only; mode=full is size-gated and two-step (first POST computes the size + headroom and redirects
// with a reveal cue; the revealed confirm POSTs mode=full&confirm=1, re-checked at execution).
func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
@@ -226,25 +229,73 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
return
}
// Part B: fast-path refuse a concurrent op, then run async on a BACKGROUND context. The old code
// bounded on r.Context()+30m — a proxy read-timeout then CANCELED the SFTP restore mid-flight
// (worse than F4: not just an error page, an aborted restore). Background ctx fixes that.
mode := strings.TrimSpace(r.FormValue("mode"))
if mode == "" {
mode = "unit"
}
// Step 1 of the full two-step: compute size + headroom BEFORE any restic restore; on a refusal
// flash the Hungarian reason, else redirect with the reveal params (size shown before starting).
if mode == "full" && r.FormValue("confirm") != "1" {
pctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
sizeHuman, err := s.backupMgr.OffboxRestorePrepareFull(pctx, app)
if err != nil {
offboxRedirectTo(w, r, "/backups/restore", err.Error(), true)
return
}
http.Redirect(w, r, "/backups/restore?full_prep="+url.QueryEscape(app)+"&full_size="+url.QueryEscape(sizeHuman), http.StatusFound)
return
}
// Fast-path refuse a concurrent op, then run async on a BACKGROUND context (a proxy read-timeout on
// r.Context() would CANCEL the SFTP restore mid-flight — the F4 lesson).
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
return
}
dest := filepath.Join(s.cfg.Paths.DataDir, "offbox-restore", app)
full := mode == "full"
s.backupMgr.BeginRestoreOp("offbox-restore", app)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if err := s.backupMgr.RestoreOffbox(ctx, app, dest); err != nil {
s.logger.Printf("[ERROR] [web] off-box restore %s (async): %v", app, err)
if err := s.backupMgr.RestoreOffboxScratch(ctx, app, full); err != nil {
s.logger.Printf("[ERROR] [web] off-box restore %s (full=%v, async): %v", app, full, err)
s.backupMgr.EndRestoreOp(false, "A visszaállítás sikertelen: "+err.Error())
return
}
s.logger.Printf("[INFO] [web] off-box restore %s completed (async) → %s", app, dest)
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" visszaállítva ide (ellenőrzésre): "+dest)
s.logger.Printf("[INFO] [web] off-box restore %s completed (full=%v, async)", app, full)
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" visszaállítva ellenőrző mappába a meghajtón (a meglévő adatok változatlanok).")
}()
offboxRedirectTo(w, r, "/backups/restore", "A távoli visszaállítás elindult — az állapot itt frissül.", false)
}
// offboxPlaceHandler places a COMPLETED full-restore scratch into the app's live locations via a
// missing-only merge (§7.3). Never overwrites existing files. Async on a background context.
func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
return
}
_ = r.ParseForm()
app := strings.TrimSpace(r.FormValue("app"))
if app == "" {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
return
}
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
return
}
s.backupMgr.BeginRestoreOp("offbox-place", app)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if err := s.backupMgr.PlaceOffsiteRestore(ctx, app); err != nil {
s.logger.Printf("[ERROR] [web] off-box place %s (async): %v", app, err)
s.backupMgr.EndRestoreOp(false, "A helyreállítás sikertelen: "+err.Error())
return
}
s.logger.Printf("[INFO] [web] off-box place %s completed (async)", app)
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" hiányzó fájljai helyreállítva az élő adatok közé.")
}()
offboxRedirectTo(w, r, "/backups/restore", "A helyreállítás elindult — az állapot itt frissül.", false)
}
+2
View File
@@ -370,6 +370,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.offboxRunHandler(w, r)
case path == "/backup/offbox/restore" && r.Method == http.MethodPost:
s.offboxRestoreHandler(w, r)
case path == "/backup/offbox/place" && r.Method == http.MethodPost:
s.offboxPlaceHandler(w, r)
// Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow.
case path == "/backup/escrow" && r.Method == http.MethodGet:
s.escrowWizardPageHandler(w, r)
@@ -103,6 +103,9 @@
<input type="hidden" name="enabled" value="{{if .Enabled}}false{{else}}true{{end}}">
<button type="submit" class="btn btn-xs {{if .Enabled}}btn-outline{{else}}btn-primary{{end}}">{{if .Enabled}}Távoli mentés kikapcsolása{{else}}Távoli mentés bekapcsolása{{end}}</button>
</form>
{{if $.OffboxBlockedSet}}{{if index $.OffboxBlockedSet .Name}}
<span class="form-hint" style="display:block;margin-top:.25rem">A teljes mentés túllépné a tárhelykeretet — csak a konfiguráció és az adatbázis kerül mentésre.</span>
{{end}}{{end}}
{{template "app_list_row_end"}}
{{end}}
</div>
@@ -72,8 +72,29 @@
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a(z) {{.DisplayName}} adatait a távoli tárolóról egy ellenőrző mappába? A meglévő adatok NEM íródnak felül.">Visszaállítás (ellenőrzéshez)</button>
<input type="hidden" name="mode" value="unit">
<button type="submit" class="btn btn-xs btn-outline">Visszaállítás ellenőrzéshez (konfiguráció + adatbázis)</button>
</form>
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<input type="hidden" name="mode" value="full">
<button type="submit" class="btn btn-xs btn-outline">Teljes visszaállítás előkészítése</button>
</form>
{{if $.FullPrepApp}}{{if eq $.FullPrepApp .Name}}
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<input type="hidden" name="mode" value="full">
<input type="hidden" name="confirm" value="1">
<button type="submit" class="btn btn-xs btn-primary">Teljes visszaállítás indítása (~{{$.FullPrepSize}})</button>
</form>
{{end}}{{end}}
{{if $.OffboxScratchReady}}{{if index $.OffboxScratchReady .Name}}
<form method="POST" action="/backup/offbox/place" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<button type="submit" class="btn btn-xs btn-outline">Helyreállítás az élő adatok közé (csak a hiányzó fájlok)</button>
</form>
<span class="form-hint" style="display:block;margin-top:.25rem">A meglévő fájlokat nem írja felül.</span>
{{end}}{{end}}
{{template "app_list_row_end"}}
{{end}}
{{end}}