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
|
## 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)
|
### 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
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
"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/settings"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
"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 {
|
if apps == nil {
|
||||||
apps = []string{}
|
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 —
|
// handleStorageWipe is the customer-confirmed wipe of a USER-DATA drive: it unmounts (eject —
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
"text/template"
|
"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) {
|
func TestMountWhere(t *testing.T) {
|
||||||
if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" {
|
if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" {
|
||||||
t.Errorf("mountWhere(hdd_1) = %q, %v", w, err)
|
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==='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==='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.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 '';
|
return '';
|
||||||
}
|
}
|
||||||
function capBar(d){
|
function capBar(d){
|
||||||
@@ -460,19 +460,24 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
|
|||||||
window.__closeConfirm=closeModal;
|
window.__closeConfirm=closeModal;
|
||||||
async function openConfirm(opts){
|
async function openConfirm(opts){
|
||||||
// opts: {title, mount, mountName, danger, onConfirm}
|
// opts: {title, mount, mountName, danger, onConfirm}
|
||||||
var apps=[];
|
var apps=[], copies=[];
|
||||||
try{
|
try{
|
||||||
var r=await fetch('/api/storage/impact?where='+encodeURIComponent(opts.mount));
|
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){}
|
}catch(e){}
|
||||||
var appsHtml = apps.length
|
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>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>';
|
: '<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');
|
var root=document.getElementById('confirm-root');
|
||||||
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)__closeConfirm()"><div class="confirm-box">'
|
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)__closeConfirm()"><div class="confirm-box">'
|
||||||
+'<h3>'+esc(opts.title)+'</h3>'
|
+'<h3>'+esc(opts.title)+'</h3>'
|
||||||
+'<div class="alert alert-warning">'+esc(opts.danger)+'</div>'
|
+'<div class="alert alert-warning">'+esc(opts.danger)+'</div>'
|
||||||
+appsHtml
|
+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>'
|
+'<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>'
|
+'<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>'
|
+'<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