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

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:
2026-09-16 16:55:55 +02:00
parent 383a30b3c0
commit 2f8ff2414c
16 changed files with 640 additions and 50 deletions
+83 -11
View File
@@ -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() {
@@ -0,0 +1,72 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// fileLegProvider declares a MANDATORY drive-side bind for one app and nothing for the others — the
// shape of a class-A app (calibre-web, immich, nextcloud, paperless-ngx) against the 45 whose data
// lives entirely in Docker volumes.
type fileLegProvider struct {
blockProvider
withLegs string
}
func (p *fileLegProvider) GetStackComposePath(string) (string, bool) { return "compose", true }
func (p *fileLegProvider) GetStackClassifiedBinds(n string) ([]backup.ClassifiedBind, bool) {
if n != p.withLegs {
return nil, false
}
return []backup.ClassifiedBind{{
ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: "appdata/" + n},
Class: appbackup.ClassMandatory,
}}, true
}
// R-537 — the backup page must describe what each TIER captured, not what the app is shaped like.
//
// The defect this pins, measured live on 2026-09-16 on a fresh box: the Tier-1 row read
// „DB + Konfig + Adatok" for Nextcloud, over a unit that held a database dump, three volume tars and
// no copy of the customer's files at all. The five photos in that folder were in no backup on the
// box, and the page said they were.
//
// Red-proof: put the old single label back (`if app.HasHDDData || app.HasVolumeData → "Adatok"`,
// rendered on all three tier rows) → the Tier-1 assertion fails.
func TestAppBackupRows_Tier1LabelDoesNotClaimFilesItCannotHold(t *testing.T) {
s, _, m := newOffboxWebServer(t)
drive := t.TempDir()
m.SetStackProvider(&fileLegProvider{blockProvider: blockProvider{hdd: drive}, withLegs: "nextcloud"})
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "nextcloud", DisplayName: "Nextcloud", HasHDDData: true, HasVolumeData: true},
{StackName: "privatebin", DisplayName: "PrivateBin", HasVolumeData: true},
}})
nc := findRow(rows, "nextcloud")
if nc == nil {
t.Fatal("no row for nextcloud")
}
if strings.Contains(nc.Tier1Contents, "Adatok") {
t.Fatalf("the Tier-1 label claims it holds the app's data: %q — the unit has no file leg and the customer's files are on the drive", nc.Tier1Contents)
}
if !strings.Contains(nc.Tier23Contents, "Adatok") {
t.Fatalf("Tier 2/3 DO carry the file legs; their label must say so: %q", nc.Tier23Contents)
}
if nc.DriveFilesNote == "" {
t.Fatal("an app whose files a local backup cannot hold must be told where they ARE protected")
}
// NEGATIVE CONTROL: an app whose data really is inside the volumes the unit captured keeps its
// „Adatok". Without this, "never say Adatok" would pass and would be a different lie.
pb := findRow(rows, "privatebin")
if pb == nil || !strings.Contains(pb.Tier1Contents, "Adatok") {
t.Fatalf("an app whose data IS in the captured volumes must keep its Adatok label: %+v", pb)
}
if pb.DriveFilesNote != "" {
t.Fatalf("an app with no drive-side files needs no note about them: %q", pb.DriveFilesNote)
}
}
@@ -202,7 +202,8 @@
{{else}}<span class="state-text-neutral" title="Erről a mentésről nincs eredményünk.">—</span>{{end}}
</span>
{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
<span class="tier-contents">{{.Tier1Contents}}</span>
{{if .DriveFilesNote}}<span class="state-text-neutral" style="font-size:.8rem">{{.DriveFilesNote}}</span>{{end}}
{{if and .HasDB (eq .Tier1DBStatus "error")}}
<span class="text-error" style="font-size:.8rem"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> DB dump hiba</span>
{{end}}
@@ -226,7 +227,7 @@
{{else}}
<span class="layer-last" style="opacity:.6">Még nincs sikeres másolat</span>
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<span class="tier-contents" style="opacity:.6">{{.Tier23Contents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
@@ -241,7 +242,7 @@
{{else}}
<span class="layer-last" style="opacity:.6">Még nincs sikeres másolat</span>
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<span class="tier-contents" style="opacity:.6">{{.Tier23Contents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
@@ -272,7 +273,7 @@
not render as a plain fresh copy. The status line above is about the RUN;
this one is about the PACKAGE, and after a preserved leg they differ. */}}
{{if .Tier2UnitStaleNotice}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2UnitStaleNotice}}</span>{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
<span class="tier-contents">{{.Tier23Contents}}</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}