v0.244.0: the backup page stops promising what it does not hold (R-537/R-538/R-536)
gates / gates (push) Successful in 17s
gates / gates (push) Successful in 17s
R-537 — the contents label is now PER TIER. One string computed from the app's shape was rendered on all three tier rows; a Tier-1 unit has no file-copy step, so for the four class-A apps it was claiming „Adatok" for files it does not hold. R-538 — a unit restore REFUSES before anything is touched when the unit cannot return the app's drive-side files, and names the route that can. It runs before the stack is stopped because the measured harm included the app's own wastebasket going unreachable, which still held every byte. R-536 — „Alkalmazás telepítve" moved from the deploy's acceptance to its completion, with app_deploy_started and app_deploy_failed as the honest pair. Each fix red-proofed: seen failing with its own sentence, passing when restored. Requires hub v0.116.0 for the two new event types. MinAgent unchanged (0.131.0). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -1174,9 +1174,15 @@ type AppBackupRow struct {
|
||||
StorageLabel string
|
||||
HDDSizeHuman string
|
||||
|
||||
// What this app's backup contains (for display)
|
||||
// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
|
||||
BackupContents string
|
||||
// What this app's backup contains, PER TIER (R-537). One string for all three tiers was a claim
|
||||
// no single string can support: a Tier-1 unit holds no copy of the files a class-A app keeps on
|
||||
// the data drive, while Tier 2 and Tier 3 do. e.g. Tier1Contents "DB + Konfig",
|
||||
// Tier23Contents "DB + Konfig + Adatok".
|
||||
Tier1Contents string
|
||||
Tier23Contents string
|
||||
// DriveFilesNote is non-empty exactly when this app keeps files on the data drive that a Tier-1
|
||||
// unit cannot hold — the one sentence that tells the household where those files ARE protected.
|
||||
DriveFilesNote string
|
||||
|
||||
// RestoreHeld (R-379) — this app is deliberately stopped because a database restore failed AND
|
||||
// the rollback failed. Distinct from any backup status: it is about the app's LIVE data, not its
|
||||
@@ -1342,16 +1348,41 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
|
||||
}
|
||||
}
|
||||
|
||||
// Build backup contents label
|
||||
var parts []string
|
||||
// Build the backup contents labels — ONE PER TIER (R-537).
|
||||
//
|
||||
// This used to be a single string rendered on all three tier rows, computed from the APP's
|
||||
// shape (`HasHDDData || HasVolumeData → "Adatok"`) rather than from what each tier actually
|
||||
// captures. On a fresh one-drive box that made the Tier-1 row read „DB + Konfig + Adatok"
|
||||
// over a unit holding a database dump, three volume tars and no copy of the customer's files
|
||||
// at all — measured 2026-09-16 with five photos that were in no backup while the page said
|
||||
// they were.
|
||||
//
|
||||
// The fact each tier captures is settled in 07-backup-architecture §6.1/§6.2 and is not
|
||||
// changed here: a Tier-1 unit carries compose + app.yaml + DB dumps + volume tars and has no
|
||||
// file-copy step; the drive-side file legs of a class-A app are carried by Tier 2 and Tier 3.
|
||||
// So the label differs by tier, and one string cannot be true for all three.
|
||||
hasDriveFileLegs := s.backupMgr != nil && s.backupMgr.HasDriveFileLegs(app.StackName)
|
||||
base := []string{}
|
||||
if hasDB {
|
||||
parts = append(parts, "DB")
|
||||
base = append(base, "DB")
|
||||
}
|
||||
parts = append(parts, "Konfig")
|
||||
if app.HasHDDData || app.HasVolumeData {
|
||||
parts = append(parts, "Adatok")
|
||||
base = append(base, "Konfig")
|
||||
withData := func(add bool) string {
|
||||
p := append([]string{}, base...)
|
||||
if add {
|
||||
p = append(p, "Adatok")
|
||||
}
|
||||
return strings.Join(p, " + ")
|
||||
}
|
||||
// Tier 1: „Adatok" only when the app's data really is inside the volumes this unit captured.
|
||||
// For an app that keeps its files on the drive it is a claim the unit cannot support.
|
||||
tier1Contents := withData(app.HasVolumeData && !hasDriveFileLegs)
|
||||
// Tier 2 / Tier 3 carry the file legs, so for them „Adatok" is true either way.
|
||||
tier23Contents := withData(app.HasVolumeData || hasDriveFileLegs)
|
||||
driveFilesNote := ""
|
||||
if hasDriveFileLegs {
|
||||
driveFilesNote = "Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi — ez a helyi mentés a beállításokat és az adatbázist tartalmazza."
|
||||
}
|
||||
contents := strings.Join(parts, " + ")
|
||||
|
||||
slug := ""
|
||||
if s.stackMgr != nil {
|
||||
@@ -1370,7 +1401,9 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
|
||||
DriveDisconnected: driveDisconnected,
|
||||
StorageLabel: app.StorageLabel,
|
||||
HDDSizeHuman: app.HDDSizeHuman,
|
||||
BackupContents: contents,
|
||||
Tier1Contents: tier1Contents,
|
||||
Tier23Contents: tier23Contents,
|
||||
DriveFilesNote: driveFilesNote,
|
||||
Tier1DBStatus: tier1DBStatus,
|
||||
}
|
||||
|
||||
@@ -1513,6 +1546,27 @@ func tier2DestLabel(destPath, systemDataPath string) string {
|
||||
return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
|
||||
}
|
||||
|
||||
// missingFileLegsRefusal builds the Hungarian sentence shown when a unit restore is refused because
|
||||
// the unit carries no copy of the app's files (R-538). It names the route that CAN return them, and
|
||||
// when there is none it says so rather than implying one exists.
|
||||
//
|
||||
// The three branches are the three real states, in the order a customer can act on them: the off-site
|
||||
// copy (a full restore brings files AND database), the second drive (its file half is its own
|
||||
// action), and nothing.
|
||||
func (s *Server) missingFileLegsRefusal(ctx context.Context, stackName string) string {
|
||||
const head = "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. "
|
||||
if s.backupMgr != nil {
|
||||
rows, _ := s.offsiteRestoreRows(ctx)
|
||||
if row := resolveOffsiteRestoreApp(rows, stackName); row != nil {
|
||||
return head + "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”."
|
||||
}
|
||||
if cov, err := s.backupMgr.Tier2RestoreCoverage(stackName); err == nil && cov.CanRestore() {
|
||||
return head + "A fájlok a második meghajtó másolatából állíthatók vissza: „Fájlok visszaállítása”."
|
||||
}
|
||||
}
|
||||
return head + "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót."
|
||||
}
|
||||
|
||||
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
@@ -1546,6 +1600,24 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
|
||||
return
|
||||
}
|
||||
// R-538: refuse a unit restore that would replay a database over files the unit does not hold —
|
||||
// BEFORE the op begins, so nothing is stopped and the app's own wastebasket is left intact. The
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
||||
s.backupMgr.BeginRestoreOp("restore", stackName)
|
||||
go func() {
|
||||
|
||||
Reference in New Issue
Block a user