controller v0.50.0: slice 10 P4 — dual-role drives + backup-aware wipe warning

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:00:27 +02:00
parent 2a353572f7
commit 4913130514
4 changed files with 92 additions and 4 deletions
+19
View File
@@ -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/<app>`, 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
+39 -1
View File
@@ -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/<app> 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 —
@@ -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/<app>) 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)
@@ -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
? '<p>A művelet után a következő alkalmazások <strong>nem fognak működni</strong>:</p><ul class="confirm-apps">'+apps.map(function(a){return '<li>'+esc(a)+'</li>';}).join('')+'</ul>'
: '<p class="form-hint">Ehhez a meghajtóhoz jelenleg nincs telepített alkalmazás rendelve.</p>';
// P4 (4B): this drive may also hold cross-drive backup COPIES of other apps — a wipe removes them.
var copiesHtml = copies.length
? '<div class="alert alert-warning" style="margin-top:.5rem">Ez a meghajtó más alkalmazások <strong>biztonsági másolatait</strong> is tárolja — a törlés ezeket is eltávolítja:<ul class="confirm-apps">'+copies.map(function(a){return '<li>'+esc(a)+'</li>';}).join('')+'</ul><span class="form-hint">(A másolatok redundánsak — az eredetik a forrás-meghajtón maradnak.)</span></div>'
: '';
var root=document.getElementById('confirm-root');
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)__closeConfirm()"><div class="confirm-box">'
+'<h3>'+esc(opts.title)+'</h3>'
+'<div class="alert alert-warning">'+esc(opts.danger)+'</div>'
+appsHtml
+copiesHtml
+'<div class="confirm-input"><label>Megerősítéshez írja be a csatlakoztatási nevet: <strong class="mono">'+esc(opts.mountName)+'</strong></label>'
+'<input type="text" id="confirm-type" class="form-control" autocomplete="off" placeholder="'+esc(opts.mountName)+'" oninput="document.getElementById(\'confirm-go\').disabled=(this.value!==\''+esc(opts.mountName)+'\')"></div>'
+'<div class="form-actions"><button id="confirm-go" class="btn btn-danger-outline" disabled>Megerősítés</button>'