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:
@@ -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
|
||||
|
||||
@@ -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>'
|
||||
|
||||
Reference in New Issue
Block a user