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