v0.141.0: F6 initialize-to-usable (detached crash-safe init job + status poll) + F7 Vissza back-routes

- F6: POST /api/storage/init runs format→mount→register as a DETACHED single-flight job (context.Background, netAddState shape) the wizard polls via GET /api/storage/init/status; 3-step progress; register-last marker-last crash-safety. Fixes the client-disconnect-aborts-mount bug. No agent change (chain reaches FileBrowser sync = controller-only). Red-proof TestStorageInit_DetachedSurvivesClientDisconnect.
- F7: storage_init/attach Vissza → /storage (was /settings). Test TestStorageWizardBackAnchors_PointToStorage.
This commit is contained in:
2026-07-17 09:20:59 +02:00
parent 5be2449267
commit 0025a3b090
7 changed files with 365 additions and 38 deletions
@@ -11,8 +11,10 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
"text/template"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
@@ -63,7 +65,13 @@ func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string, confirm
m.formatCalls = append(m.formatCalls, formatCall{device, fstype, durableID, confirmed})
return m.formatRes, m.formatErr
}
func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error {
func (m *mockAgent) AssignDisk(ctx context.Context, uuid, where, fstype, _ string) error {
// Respect cancellation — a mount over a dead client/request context fails (this is the F6 bug's
// mechanism: a disconnect after format aborts the mount+register leg). Background ctx never
// cancels, so existing happy-path tests are unaffected.
if err := ctx.Err(); err != nil {
return err
}
m.assignCalls = append(m.assignCalls, assignCall{uuid, where, fstype})
return m.assignErr
}
@@ -104,7 +112,7 @@ func TestRunStorageInit_SystemBackupRefusal(t *testing.T) {
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, false, "")
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -133,7 +141,7 @@ func TestRunStorageInit_UserDataNeedsConfirmation(t *testing.T) {
Role: "user-data", DurableID: "byid:wwn-abc",
},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "")
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -158,7 +166,7 @@ func TestRunStorageInit_UserDataConfirmedProceeds(t *testing.T) {
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-1", Role: "user-data"},
}},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, true, "byid:wwn-abc")
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, true, "byid:wwn-abc", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -179,7 +187,7 @@ func TestRunStorageInit_Success(t *testing.T) {
{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, false, "")
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true, false, "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -204,6 +212,86 @@ func TestRunStorageInit_Success(t *testing.T) {
}
}
// F6 (VALIDATION-n100) — initialize must end in a USABLE (mounted+registered) drive even when the
// client disconnects mid-format. Two halves:
// RED-PROOF: runStorageInit on a CANCELLED context (the disconnect) aborts at the mount step →
// the device is formatted but NOT registered (the N100-observed state). This is exactly what
// the pre-fix handler did (it ran the chain on r.Context()).
// FIX: startStorageInit runs the chain on a DETACHED context → it registers regardless of the
// client, and leaves EXACTLY ONE registry entry (marker-last, Scenario B).
func TestStorageInit_DetachedSurvivesClientDisconnect(t *testing.T) {
newMock := func() *mockAgent {
return &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"},
}},
}
}
t.Run("cancelled_ctx_does_not_register", func(t *testing.T) {
s := testServer(t)
agent := newMock()
ctx, cancel := context.WithCancel(context.Background())
cancel() // the client disconnected right after confirm
_, err := s.runStorageInit(ctx, agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "", nil)
if err == nil {
t.Fatal("expected the disconnected (cancelled-ctx) chain to fail at mount")
}
if got := s.settings.GetStoragePaths(); len(got) != 0 {
t.Fatalf("a disconnected init must NOT register (F6 bug): got %+v", got)
}
})
t.Run("detached_job_registers_exactly_once", func(t *testing.T) {
s := testServer(t)
agent := newMock()
if !s.startStorageInit(agent, storageInitParams{
device: "/dev/sdb1", fstype: "ext4", where: "/mnt/hdd1", label: "HDD", setDefault: true,
}) {
t.Fatal("startStorageInit refused (slot unexpectedly busy)")
}
var job *storageInitJob
for i := 0; i < 300; i++ {
if job = s.storageInit.snapshot(); job != nil && (job.Phase == storageInitPhaseDone || job.Phase == storageInitPhaseFailed) {
break
}
time.Sleep(10 * time.Millisecond)
}
if job == nil || job.Phase != storageInitPhaseDone {
t.Fatalf("detached job did not reach done: %+v", job)
}
if job.Where != "/mnt/felhom-drives/hdd1" {
t.Fatalf("done job registered path = %q, want the stable /mnt/felhom-drives/hdd1", job.Where)
}
paths := s.settings.GetStoragePaths()
if len(paths) != 1 || paths[0].Path != "/mnt/felhom-drives/hdd1" {
t.Fatalf("detached init must register EXACTLY ONE stable path (marker-last): %+v", paths)
}
if len(agent.assignCalls) != 1 {
t.Fatalf("expected exactly one mount (assign): %+v", agent.assignCalls)
}
})
}
// F7 (VALIDATION-n100) — the "Vissza" (Back) anchor on the init/attach wizards must route to
// /storage, not /settings. One assertion per template.
func TestStorageWizardBackAnchors_PointToStorage(t *testing.T) {
for _, file := range []string{"templates/storage_init.html", "templates/storage_attach.html"} {
b, err := templateFS.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
src := string(b)
if !strings.Contains(src, `<a href="/storage" class="btn btn-sm btn-outline">← Vissza</a>`) {
t.Errorf("%s: the Vissza Back anchor must point to /storage (F7)", file)
}
if strings.Contains(src, `<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>`) {
t.Errorf("%s: the Vissza Back anchor still points to /settings (F7 regression)", file)
}
}
}
// Attach is non-destructive: resolve UUID → assign → register (no format).
func TestRunStorageAttach_Success(t *testing.T) {
s := testServer(t)