controller v0.269.0: whole restore from the second drive; crash loops stopped; exact image digests; steps judged by their own .felhom.yml (decisions 26-28, R-661 R-666 R-667 R-668 R-664 R-665 R-662, 09 6.4 part 6)
gates / gates (push) Successful in 27s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-24 12:18:39 +02:00
parent 7c3b3a9694
commit 3c6b49b31c
141 changed files with 3401 additions and 237 deletions
+34 -11
View File
@@ -1685,17 +1685,15 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
// customer gets the route that CAN return their files instead of a success message over an app
// listing photos it cannot open.
//
// `accept_missing_files=1` is the explicit, separately-worded second step („csak az adatbázist és
// a beállításokat"). It is deliberately not a sibling of the main button: two controls whose
// difference is "your data comes back" are never siblings (R-48).
acceptMissingFiles := r.FormValue("accept_missing_files") == "1"
if !acceptMissingFiles {
if legs := s.backupMgr.DeclaredDriveFileLegs(stackName); len(legs) > 0 {
msg := s.missingFileLegsRefusal(r.Context(), stackName)
s.logger.Printf("[WARN] [web] restore refused for %s: unit carries no file leg (%d drive path(s))", stackName, len(legs))
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
// R-662 (v0.269.0): the „csak az adatbázist és a beállításokat" second step that used to be read here
// (`accept_missing_files=1`) is REMOVED. No page ever sent it, the backup layer refused it anyway, and
// it is the database-only restore under existing files that `09` §3 decision 25 ruled out (option B).
// The way back for such an app is the second drive's whole restore (decision 26) or the off-site one.
if legs := s.backupMgr.DeclaredDriveFileLegs(stackName); len(legs) > 0 {
msg := s.missingFileLegsRefusal(r.Context(), stackName)
s.logger.Printf("[WARN] [web] restore refused for %s: unit carries no file leg (%d drive path(s))", stackName, len(legs))
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
@@ -2058,6 +2056,30 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
return
}
// v0.269.0 (`09` §3 decision 26, R-661): for an app whose files live on the drive, this button is the
// WHOLE restore — files by the four rules, then the unit. The unit-only restore below would refuse it
// (R-538), which is what left such an app with no way back from the second drive.
if s.backupMgr.HasDriveFileLegs(stackName) {
s.logger.Printf("[WARN] [web] Tier-2 WHOLE restore requested (async, files + database): stack=%s from %s", stackName, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("tier2-whole-restore", stackName)
go func() {
start := time.Now()
res, err := s.backupMgr.RestoreTier2Whole(stackName)
files := s.note("note.restore.whole_files", res.Files.Restored, res.Files.Replaced, res.Files.KeptNewer, res.Files.Unchanged)
if err != nil {
s.logger.Printf("[ERROR] [web] Tier-2 whole restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_unit_failed", s.noteErr(err))+" "+files)
return
}
s.logger.Printf("[INFO] [web] Tier-2 whole restore completed (async): stack=%s in %s (files restored %d, replaced %d, kept-newer %d, unchanged %d; volumes %d/%d, dbs %d/%d)",
stackName, time.Since(start), res.Files.Restored, res.Files.Replaced, res.Files.KeptNewer, res.Files.Unchanged,
res.Unit.VolumesReplayed, res.Unit.ManifestVolumes, res.Unit.DBsReplayed, res.Unit.ManifestDBs)
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res.Unit)+" "+files)
}()
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash", "flash.restore.full_started"), http.StatusFound)
return
}
// Pre-flight, before any op is begun and before the app is stopped: does this copy hold a unit
// this restore can actually open? Only the no-unit case is pre-flighted; every other refusal keeps
// its existing async path, so this cannot alter behaviour anywhere else.
@@ -2659,6 +2681,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
// v0.264.0: the update outcomes (default ON). A type the page cannot render is a type every
// Save drops — so they are listed here AND carry a checkbox.
"app_update_undone", "app_update_held",
"app_stopped_unhealthy", // v0.269.0, decision 28
"storage_reconnected", "health_recovered",
} {
if r.FormValue("event_"+evt) == "on" {