From 491313051406930873812cf2055a5eafa473cc33 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 12 Jun 2026 18:00:27 +0200 Subject: [PATCH] =?UTF-8?q?controller=20v0.50.0:=20slice=2010=20P4=20?= =?UTF-8?q?=E2=80=94=20dual-role=20drives=20+=20backup-aware=20wipe=20warn?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4A: user-data drives are backup-target-eligible (not role-locked) — surfaced in the drive purpose note. 4B: handleStorageImpact returns backup_copies (apps whose cross-drive backups live on the drive, via backupCopiesOnPath); the wipe/eject modal warns they'd be destroyed (stays customer-confirmable — copies redundant). Cross-drive backup engine remains out of scope. Test: TestBackupCopiesOnPath. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++++++++ controller/internal/web/storage_handlers.go | 40 ++++++++++++++++++- .../internal/web/storage_handlers_test.go | 26 ++++++++++++ .../internal/web/templates/settings.html | 11 +++-- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1f5e7..092333d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ ## Changelog +### v0.50.0 — slice 10 P4: dual-role drives + backup-aware wipe warning (2026-06-12) + +Pairs with felhom-agent P3 (self-heal). Establishes the dual-role MODEL + the backup-aware wipe +warning; the cross-drive backup ENGINE (restic USB1↔USB2) is a follow-on slice (needs a 2nd physical +drive to validate) and is deliberately NOT built here. + +- **4A dual-role eligibility:** a user-data drive is appdata AND backup-target-eligible (it may hold + cross-drive backup copies of *other* drives) — it is not locked to a single role. Surfaced in the + drive overview's per-card purpose note ("Más meghajtók biztonsági mentési céljaként is szolgálhat"). + `felhom-pbs` stays the dedicated whole-guest backup datastore (operator-signature); system/backup + roles unchanged. +- **4B backup-aware wipe/eject warning:** `handleStorageImpact` now also returns `backup_copies` — the + apps whose cross-drive (secondary) backups are stored on the drive (`backupCopiesOnPath` scans + `felhom-data/backups/secondary/`, skipping the shared restic repo / `_infra`). The type-to- + confirm modal names them ("Ez a meghajtó más alkalmazások biztonsági másolatait is tárolja — a + törlés ezeket is eltávolítja"). The wipe stays **customer-confirmable** (the copies are redundant — + originals live on the source drive), not operator-signature. Forward-compatible: empty until the + cross-drive engine writes there. Test: `TestBackupCopiesOnPath`. + ### v0.49.0 — slice 10 P2 activation: pending-drive detection + "Újraindítás most" (2026-06-12) A drive enrolled into a running guest activates only at the next guest boot (the host-side live inject diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 9c708ba..ab75e3b 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -6,12 +6,15 @@ import ( "errors" "fmt" "net/http" + "os" "path" + "path/filepath" "regexp" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) @@ -307,7 +310,42 @@ func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) { if apps == nil { apps = []string{} } - writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "apps": apps}) + // P4 (4B): a user-data drive is ALSO backup-target-eligible — it may hold cross-drive backup copies + // of OTHER drives' app data. A wipe destroys those copies too, so name them in the confirmation. + // (The copies are redundant — the originals live on the source drive — so the wipe stays customer- + // confirmable, NOT operator-signature; the warning just makes the loss explicit.) + backupCopies := backupCopiesOnPath(where) + if backupCopies == nil { + backupCopies = []string{} + } + writeDiskJSON(w, http.StatusOK, true, "", map[string]any{ + "where": where, "apps": apps, "backup_copies": backupCopies, + }) +} + +// backupCopiesOnPath lists the apps whose CROSS-DRIVE (secondary) backup copies are stored on the +// drive mounted at `where` (slice 10 P4) — the felhom-data/backups/secondary/ dirs. A wipe of +// this drive removes these copies. Best-effort filesystem scan; empty until the cross-drive backup +// ENGINE (a follow-on slice) actually writes here. Shared/aggregate dirs (restic repo, _infra) are +// not apps and are skipped. +func backupCopiesOnPath(where string) []string { + secondary := filepath.Join(where, appbackup.FelhomDataDir, "backups", "secondary") + entries, err := os.ReadDir(secondary) + if err != nil { + return nil // no secondary backups here (or the path isn't readable) — nothing to warn about + } + var apps []string + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if name == "restic" || name == "_infra" { // shared repo / infra, not a per-app copy + continue + } + apps = append(apps, name) + } + return apps } // handleStorageWipe is the customer-confirmed wipe of a USER-DATA drive: it unmounts (eject — diff --git a/controller/internal/web/storage_handlers_test.go b/controller/internal/web/storage_handlers_test.go index 51f02cd..b11a7e4 100644 --- a/controller/internal/web/storage_handlers_test.go +++ b/controller/internal/web/storage_handlers_test.go @@ -4,7 +4,9 @@ import ( "context" "io" "log" + "os" "path/filepath" + "sort" "testing" "text/template" @@ -274,6 +276,30 @@ func TestSortDisksForView(t *testing.T) { } } +// P4 (4B): a drive's cross-drive backup copies (felhom-data/backups/secondary/) are listed so the +// wipe confirmation can warn they'd be destroyed. Shared repo / infra dirs and files are skipped. +func TestBackupCopiesOnPath(t *testing.T) { + root := t.TempDir() + sec := filepath.Join(root, "felhom-data", "backups", "secondary") + for _, d := range []string{"immich", "nextcloud", "restic", "_infra"} { + if err := os.MkdirAll(filepath.Join(sec, d), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(sec, "stray-file"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + got := backupCopiesOnPath(root) + sort.Strings(got) + if len(got) != 2 || got[0] != "immich" || got[1] != "nextcloud" { + t.Fatalf("backup copies: got %v, want [immich nextcloud] (restic/_infra/files skipped)", got) + } + // A drive with no secondary backups → nil (no warning). + if c := backupCopiesOnPath(t.TempDir()); c != nil { + t.Fatalf("a drive with no cross-drive backups should report none, got %v", c) + } +} + func TestMountWhere(t *testing.T) { if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" { t.Errorf("mountWhere(hdd_1) = %q, %v", w, err) diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index 2086cd0..96314b3 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -403,7 +403,7 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}} if(d.type==='lvmthin') return 'Belső SSD — a szerver rendszere, a Docker és a telepített alkalmazások adatbázisai itt találhatók.'; if(d.type==='local'||d.type==='dir') return 'Host tárhely — rendszer-sablonok, ISO-k, host szintű mentések. Nem tárol alkalmazásadatot.'; if(d.type==='pbs'||d.role==='backup') return 'A biztonsági mentések tárhelye.'; - if(d.role==='user-data') return 'Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek.'; + if(d.role==='user-data') return 'Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek. Más meghajtók biztonsági mentési céljaként is szolgálhat.'; return ''; } function capBar(d){ @@ -460,19 +460,24 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}} window.__closeConfirm=closeModal; async function openConfirm(opts){ // opts: {title, mount, mountName, danger, onConfirm} - var apps=[]; + var apps=[], copies=[]; try{ var r=await fetch('/api/storage/impact?where='+encodeURIComponent(opts.mount)); - var j=await r.json(); if(j.ok && j.data && j.data.apps) apps=j.data.apps; + var j=await r.json(); if(j.ok && j.data){ apps=j.data.apps||[]; copies=j.data.backup_copies||[]; } }catch(e){} var appsHtml = apps.length ? '

A művelet után a következő alkalmazások nem fognak működni:

    '+apps.map(function(a){return '
  • '+esc(a)+'
  • ';}).join('')+'
' : '

Ehhez a meghajtóhoz jelenleg nincs telepített alkalmazás rendelve.

'; + // P4 (4B): this drive may also hold cross-drive backup COPIES of other apps — a wipe removes them. + var copiesHtml = copies.length + ? '
Ez a meghajtó más alkalmazások biztonsági másolatait is tárolja — a törlés ezeket is eltávolítja:
    '+copies.map(function(a){return '
  • '+esc(a)+'
  • ';}).join('')+'
(A másolatok redundánsak — az eredetik a forrás-meghajtón maradnak.)
' + : ''; var root=document.getElementById('confirm-root'); root.innerHTML='
' +'

'+esc(opts.title)+'

' +'
'+esc(opts.danger)+'
' +appsHtml + +copiesHtml +'
' +'
' +'
'