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
@@ -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)
}
}