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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user