v0.43.0: rebuilt storage management (guided init/attach/eject on agent disk model)

Controller-only UI/orchestration over the agent's disk endpoints + StoragePath
registry. New: storage overview (data_bearing badges), guided init (format ->
resolve fs UUID -> assign -> register; data-bearing REFUSAL surfaces the
felhom-opsign command, no force-format), guided attach, eject (+deregister,
dependent-guest warning). agentapi: DiskInfo.DurableID/FSUUID + FormatResult.
PendingOp (parsed from the 403). Honest buttons (migrate disabled, no 404s).
Phase 3: removed dead CrossDrive blocks in deploy.html/backups.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 19:47:58 +02:00
parent 8fcd49304d
commit 29a9dcdd8c
11 changed files with 819 additions and 212 deletions
@@ -0,0 +1,160 @@
package web
import (
"context"
"io"
"log"
"path/filepath"
"testing"
"text/template"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv
// cleanups) to parse — they are otherwise only parsed at server startup (template.Must).
func TestTemplatesParse(t *testing.T) {
s := &Server{}
if _, err := template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"); err != nil {
t.Fatalf("templates parse: %v", err)
}
}
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
type mockAgent struct {
disks agentapi.DisksResponse
formatRes agentapi.FormatResult
formatErr error
assignErr error
assignCalls []assignCall
disksCalls int
}
type assignCall struct{ uuid, where, fstype string }
func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) {
m.disksCalls++
return m.disks, nil
}
func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string) (agentapi.FormatResult, error) {
return m.formatRes, m.formatErr
}
func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error {
m.assignCalls = append(m.assignCalls, assignCall{uuid, where, fstype})
return m.assignErr
}
func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) {
return agentapi.EjectResult{Ejected: where}, nil
}
func testServer(t *testing.T) *Server {
t.Helper()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
return &Server{settings: sett, logger: lg, cfg: &config.Config{}}
}
// SECURITY: a data-bearing refusal must surface the opsign command and perform NO assign/register.
func TestRunStorageInit_DataBearingRefusal(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatErr: agentapi.ErrFormatRefused,
formatRes: agentapi.FormatResult{
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature",
PendingOp: &agentapi.PendingOp{Op: "storage_wipe", HostScope: "host-1", DurableID: "byuuid:1234", FSType: "ext4"},
},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Refused {
t.Fatal("expected Refused=true on a data-bearing device")
}
if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" {
t.Errorf("opsign command not surfaced: %q", res.Opsign)
}
if len(agent.assignCalls) != 0 {
t.Fatalf("REFUSAL MUST NOT mount: got %d assign call(s)", len(agent.assignCalls))
}
if len(s.settings.GetStoragePaths()) != 0 {
t.Fatal("REFUSAL MUST NOT register a StoragePath")
}
}
// Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register.
func TestRunStorageInit_Success(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, DataBearing: false},
disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"},
}},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Registered || res.Where != "/mnt/hdd1" {
t.Fatalf("expected registered at /mnt/hdd1, got %+v", res)
}
if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "NEW-9999" || agent.assignCalls[0].where != "/mnt/hdd1" {
t.Fatalf("assign must use the resolved fs UUID + mount path: %+v", agent.assignCalls)
}
paths := s.settings.GetStoragePaths()
if len(paths) != 1 || paths[0].Path != "/mnt/hdd1" || paths[0].Label != "Külső HDD" || !paths[0].IsDefault || !paths[0].Schedulable {
t.Fatalf("StoragePath not registered as expected: %+v", paths)
}
}
// Attach is non-destructive: resolve UUID → assign → register (no format).
func TestRunStorageAttach_Success(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:EXISTING-42"},
}},
}
res, err := s.runStorageAttach(context.Background(), agent, "/dev/sdb1", "", "/mnt/media", "Média", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Registered {
t.Fatal("expected registered")
}
if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "EXISTING-42" {
t.Fatalf("attach must assign by the existing fs UUID: %+v", agent.assignCalls)
}
}
func TestFSUUIDForDevice(t *testing.T) {
disks := agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{BackingDevice: "/dev/sda1", DurableID: "uuid:AAAA"},
{BackingDevice: "/dev/sdb1", DurableID: "store:lvm"}, // non-fs identity → no UUID
}}
if got := fsUUIDForDevice(disks, "/dev/sda1"); got != "AAAA" {
t.Errorf("fsUUIDForDevice(sda1) = %q, want AAAA", got)
}
if got := fsUUIDForDevice(disks, "/dev/sdb1"); got != "" {
t.Errorf("fsUUIDForDevice(non-fs) = %q, want empty", got)
}
if got := fsUUIDForDevice(disks, "/dev/sdc1"); got != "" {
t.Errorf("fsUUIDForDevice(absent) = %q, want empty", got)
}
}
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)
}
for _, bad := range []string{"", "../etc", "a/b", "x y", "/abs"} {
if _, err := mountWhere(bad); err == nil {
t.Errorf("mountWhere(%q) should be rejected", bad)
}
}
}