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:
@@ -1,5 +1,29 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.141.0 — N100 polish: initialize-to-usable (F6) + Vissza back-routes (F7) (2026-07-17)
|
||||||
|
|
||||||
|
Closes two `VALIDATION-n100-baremetal-2026-07-16.md` findings. Green:
|
||||||
|
`go build ./... && go vet ./... && go test ./...` all pass; template/emoji/native-confirm gates pass.
|
||||||
|
|
||||||
|
- **F6 (MEDIUM) — drive "initialize" now ends in a USABLE (mounted+registered) drive, disconnect-safe.**
|
||||||
|
Root cause (fork verdict, source-grounded): the format→mount→register orchestration
|
||||||
|
(`internal/web/storage_handlers.go` `runStorageInit`) ran on the REQUEST context; a closed tab /
|
||||||
|
lost connection cancelled it after `FormatDisk` (the agent's mkfs continues detached, returns
|
||||||
|
`errFormatClientGone`), so the mount+register leg was aborted — device formatted but
|
||||||
|
unmounted/unregistered (the N100-observed state). The chain must reach `SyncFileBrowserMounts`
|
||||||
|
(controller-only), so it stays controller-side — **no agent change**. Fix: `POST /api/storage/init`
|
||||||
|
starts a DETACHED single-flight job (`internal/web/storage_init_job.go`, the `netAddState` shape) on
|
||||||
|
`context.Background()`; `runStorageInit` gains a nil-safe phase callback (formatting → mounting →
|
||||||
|
registering). The wizard polls the new `GET /api/storage/init/status` and renders the 3-step
|
||||||
|
progress (`storage_init.html`); the confirm/refuse verdicts surface through the same poll. Register
|
||||||
|
is the LAST step (marker-last, Scenario B) and every prior step is idempotent (`AddStoragePath`
|
||||||
|
dedups) → a crash leaves at most an unregistered orphan, never a broken/duplicate registration.
|
||||||
|
Red-proof `TestStorageInit_DetachedSurvivesClientDisconnect` (pre-fix: cancelled-ctx chain fails at
|
||||||
|
mount, NOT registered → FAIL; fixed: detached job registers exactly once).
|
||||||
|
- **F7 (LOW) — the "Vissza" (Back) anchor on `/storage/init` and `/storage/attach` now routes to
|
||||||
|
`/storage`** (was `/settings`). The init success link also points to `/storage` (where the new
|
||||||
|
drive appears). Test `TestStorageWizardBackAnchors_PointToStorage`.
|
||||||
|
|
||||||
### v0.140.0 — Direction-2 immediate-sync: hub→box wait channel client (2026-07-16)
|
### v0.140.0 — Direction-2 immediate-sync: hub→box wait channel client (2026-07-16)
|
||||||
|
|
||||||
The other half of the immediacy arc (Direction 1 = v0.139.0 box→hub trigger). An operator action on
|
The other half of the immediacy arc (Direction 1 = v0.139.0 box→hub trigger). An operator action on
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ type Server struct {
|
|||||||
|
|
||||||
// NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams.
|
// NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams.
|
||||||
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
|
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
|
||||||
netAdd netAddState
|
netAdd netAddState
|
||||||
|
storageInit storageInitState
|
||||||
netAgentFn func() (netAgent, error)
|
netAgentFn func() (netAgent, error)
|
||||||
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
||||||
fabUpload uploadState
|
fabUpload uploadState
|
||||||
|
|||||||
@@ -108,13 +108,19 @@ type storageInitResult struct {
|
|||||||
// UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation
|
// UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation
|
||||||
// (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In
|
// (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In
|
||||||
// either refusal it performs NO further (destructive or mount) action.
|
// either refusal it performs NO further (destructive or mount) action.
|
||||||
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string) (storageInitResult, error) {
|
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string, progress func(string)) (storageInitResult, error) {
|
||||||
|
setP := func(phase string) {
|
||||||
|
if progress != nil {
|
||||||
|
progress(phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
if !validFSTypes[fstype] {
|
if !validFSTypes[fstype] {
|
||||||
return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype)
|
return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype)
|
||||||
}
|
}
|
||||||
// 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
|
// 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
|
||||||
// is allowed only with the customer's confirmation bound to its durable id; system/backup needs
|
// is allowed only with the customer's confirmation bound to its durable id; system/backup needs
|
||||||
// an operator signature.
|
// an operator signature.
|
||||||
|
setP(storageInitPhaseFormatting)
|
||||||
fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID)
|
fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID)
|
||||||
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
|
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
|
||||||
// USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against.
|
// USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against.
|
||||||
@@ -135,6 +141,7 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs
|
|||||||
}
|
}
|
||||||
// 2. Resolve the NEW fs UUID. A freshly-formatted RAW device isn't in /disks (not enrolled yet), so
|
// 2. Resolve the NEW fs UUID. A freshly-formatted RAW device isn't in /disks (not enrolled yet), so
|
||||||
// resolve via the raw-device scan too (Impl-2b) — the device now appears there with its new durable_id.
|
// resolve via the raw-device scan too (Impl-2b) — the device now appears there with its new durable_id.
|
||||||
|
setP(storageInitPhaseMounting)
|
||||||
uuid := resolveEnrollUUID(ctx, agent, device)
|
uuid := resolveEnrollUUID(ctx, agent, device)
|
||||||
if uuid == "" {
|
if uuid == "" {
|
||||||
return storageInitResult{}, fmt.Errorf("formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót")
|
return storageInitResult{}, fmt.Errorf("formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót")
|
||||||
@@ -147,6 +154,10 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs
|
|||||||
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
|
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
|
||||||
}
|
}
|
||||||
s.attachIntoGuest(ctx, agent, where)
|
s.attachIntoGuest(ctx, agent, where)
|
||||||
|
// 5. Register the stable path — THE LAST step (marker-last, F6/Scenario B): a crash before this
|
||||||
|
// leaves at most a mounted-but-unregistered drive (surfaced by the list), never a broken
|
||||||
|
// registration, and AddStoragePath dedups a re-run so there is never a duplicate entry.
|
||||||
|
setP(storageInitPhaseRegistering)
|
||||||
stable := stablePathForName(path.Base(where))
|
stable := stablePathForName(path.Base(where))
|
||||||
if err := s.registerStoragePath(stable, label, setDefault); err != nil {
|
if err := s.registerStoragePath(stable, label, setDefault); err != nil {
|
||||||
return storageInitResult{}, err
|
return storageInitResult{}, err
|
||||||
@@ -273,6 +284,8 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
switch {
|
switch {
|
||||||
case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost:
|
||||||
s.handleStorageInit(w, r)
|
s.handleStorageInit(w, r)
|
||||||
|
case r.URL.Path == "/api/storage/init/status" && r.Method == http.MethodGet:
|
||||||
|
s.handleStorageInitStatus(w, r)
|
||||||
case r.URL.Path == "/api/storage/attach" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/attach" && r.Method == http.MethodPost:
|
||||||
s.handleStorageAttach(w, r)
|
s.handleStorageAttach(w, r)
|
||||||
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
|
||||||
@@ -527,20 +540,33 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault, req.Confirmed, req.DurableID)
|
// F6: run format→mount→register as a DETACHED job (off the request context) the wizard polls via
|
||||||
if err != nil {
|
// /api/storage/init/status. A closed tab / lost connection can no longer abort the post-mkfs
|
||||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
// mount+register leg. The confirm/refuse verdicts (fast, pre-mkfs) surface through the same poll.
|
||||||
|
started := s.startStorageInit(agent, storageInitParams{
|
||||||
|
device: req.Device, fstype: req.FSType, where: where, label: req.Label,
|
||||||
|
setDefault: req.SetDefault, confirmed: req.Confirmed, durableID: req.DurableID,
|
||||||
|
})
|
||||||
|
if !started {
|
||||||
|
writeDiskJSON(w, http.StatusConflict, false, "már folyamatban van egy meghajtó-inicializálás", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if res.NeedsConfirmation {
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "phase": storageInitPhaseFormatting})
|
||||||
writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res)
|
}
|
||||||
|
|
||||||
|
// handleStorageInitStatus reports the last / in-flight drive-init job (deep copy; "idle" when never
|
||||||
|
// ran). The wizard polls this for the 3-step progress + the confirm/refuse verdicts (F6).
|
||||||
|
func (s *Server) handleStorageInitStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
job := s.storageInit.snapshot()
|
||||||
|
if job == nil {
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"phase": "idle"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if res.Refused {
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
|
||||||
writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res)
|
"phase": job.Phase, "device": job.Device, "where": job.Where, "error": job.Error,
|
||||||
return
|
"reason": job.Reason, "durable_id": job.DurableID, "opsign": job.Opsign,
|
||||||
}
|
"started_at": job.StartedAt, "updated_at": job.UpdatedAt,
|
||||||
writeDiskJSON(w, http.StatusOK, true, "", res)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount —
|
// storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount —
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"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})
|
m.formatCalls = append(m.formatCalls, formatCall{device, fstype, durableID, confirmed})
|
||||||
return m.formatRes, m.formatErr
|
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})
|
m.assignCalls = append(m.assignCalls, assignCall{uuid, where, fstype})
|
||||||
return m.assignErr
|
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"},
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,7 +141,7 @@ func TestRunStorageInit_UserDataNeedsConfirmation(t *testing.T) {
|
|||||||
Role: "user-data", DurableID: "byid:wwn-abc",
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
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"},
|
{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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
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"},
|
{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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
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).
|
// Attach is non-destructive: resolve UUID → assign → register (no format).
|
||||||
func TestRunStorageAttach_Success(t *testing.T) {
|
func TestRunStorageAttach_Success(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Drive-initialize orchestration (F6, VALIDATION-n100). POST /api/storage/init no longer runs the
|
||||||
|
// format→mount→register chain ON THE REQUEST CONTEXT — a closed tab or a lost connection cancelled
|
||||||
|
// that context after mkfs (the agent's mkfs continues detached, but the controller's mount+register
|
||||||
|
// leg was aborted, leaving a formatted-but-unusable drive). It now starts a DETACHED job (the
|
||||||
|
// netstorage-add shape: single-flight, deep-copied status, register-LAST) the wizard polls via
|
||||||
|
// GET /api/storage/init/status. The chain reuses runStorageInit unchanged (format via the agent's
|
||||||
|
// crash-safe detached mkfs → resolve UUID → AssignDisk → attachIntoGuest → registerStoragePath →
|
||||||
|
// SyncFileBrowserMounts); this file only moves it off the request context and makes it pollable.
|
||||||
|
//
|
||||||
|
// Crash-safety (Scenario B): registerStoragePath is the LAST step (marker-last), and every prior
|
||||||
|
// step is idempotent (mkfs idempotent; AssignDisk idempotent; AddStoragePath dedups a re-register).
|
||||||
|
// So the worst outcome of a controller crash mid-chain is a formatted+mounted-but-unregistered
|
||||||
|
// drive surfaced by the drive list — never a registered-but-broken path, and never a duplicate
|
||||||
|
// registry entry: a re-run of the wizard self-heals to the terminal state.
|
||||||
|
|
||||||
|
// storageInitJob is the poll-visible orchestration state (GET /api/storage/init/status).
|
||||||
|
type storageInitJob struct {
|
||||||
|
Device string `json:"device"`
|
||||||
|
Where string `json:"where,omitempty"` // the registered stable path (done only)
|
||||||
|
Phase string `json:"phase"` // formatting | mounting | registering | done | failed | needs_confirmation | refused
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"` // refusal/confirm reason (Hungarian, from the agent)
|
||||||
|
DurableID string `json:"durable_id,omitempty"` // needs_confirmation: the durable id to confirm against
|
||||||
|
Opsign string `json:"opsign,omitempty"` // refused: the operator opsign command
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
storageInitPhaseFormatting = "formatting"
|
||||||
|
storageInitPhaseMounting = "mounting"
|
||||||
|
storageInitPhaseRegistering = "registering"
|
||||||
|
storageInitPhaseDone = "done"
|
||||||
|
storageInitPhaseFailed = "failed"
|
||||||
|
storageInitPhaseNeedsConfirm = "needs_confirmation"
|
||||||
|
storageInitPhaseRefused = "refused"
|
||||||
|
)
|
||||||
|
|
||||||
|
// storageInitDeadline bounds the WHOLE chain on the detached context. The dominant term is the
|
||||||
|
// agent's mkfs (bounded to 60 min agent-side); a wall-clock ceiling well above any real format
|
||||||
|
// keeps a wedged agent from pinning the single-flight slot forever.
|
||||||
|
const storageInitDeadline = 65 * time.Minute
|
||||||
|
|
||||||
|
// storageInitParams carries the validated init request into the detached job.
|
||||||
|
type storageInitParams struct {
|
||||||
|
device, fstype, where, label, durableID string
|
||||||
|
setDefault, confirmed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageInitState is the single-flight slot (netAddState shape: acquire/release/set/snapshot,
|
||||||
|
// deep-copied status). One guest initializes one drive at a time.
|
||||||
|
type storageInitState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
cur *storageInitJob
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storageInitState) acquire(job *storageInitJob) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.running {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.running = true
|
||||||
|
cp := *job
|
||||||
|
s.cur = &cp
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storageInitState) release() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storageInitState) set(job *storageInitJob) {
|
||||||
|
s.mu.Lock()
|
||||||
|
cp := *job
|
||||||
|
s.cur = &cp
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot returns a copy of the last / in-flight job (nil = never ran).
|
||||||
|
func (s *storageInitState) snapshot() *storageInitJob {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.cur == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *s.cur
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// startStorageInit claims the single-flight slot and launches the detached init chain. false = an
|
||||||
|
// init is already in flight (Scenario/edge: never two chains on one device).
|
||||||
|
func (s *Server) startStorageInit(agent diskAgent, p storageInitParams) bool {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
job := &storageInitJob{
|
||||||
|
Device: p.device, Where: p.where, Phase: storageInitPhaseFormatting,
|
||||||
|
StartedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if !s.storageInit.acquire(job) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
go s.runStorageInitJob(agent, p, job)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStorageInitJob drives runStorageInit on a DETACHED context (a closed tab can no longer abort
|
||||||
|
// the mount+register leg — F6) and records the phase/outcome in the poll slot.
|
||||||
|
func (s *Server) runStorageInitJob(agent diskAgent, p storageInitParams, job *storageInitJob) {
|
||||||
|
defer s.storageInit.release()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), storageInitDeadline)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
logx.Infof(s.logger, "[web] storage init %q started (device %s, fs %s)", p.where, p.device, p.fstype)
|
||||||
|
|
||||||
|
progress := func(phase string) {
|
||||||
|
logx.Debugf(s.logger, "[web] storage init %q phase %s -> %s (%dms elapsed)",
|
||||||
|
p.where, job.Phase, phase, time.Since(start).Milliseconds())
|
||||||
|
job.Phase = phase
|
||||||
|
job.UpdatedAt = time.Now().UTC()
|
||||||
|
s.storageInit.set(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.runStorageInit(ctx, agent, p.device, p.fstype, p.where, p.label, p.setDefault, p.confirmed, p.durableID, progress)
|
||||||
|
job.UpdatedAt = time.Now().UTC()
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
job.Phase = storageInitPhaseFailed
|
||||||
|
job.Error = err.Error()
|
||||||
|
logx.Warnf(s.logger, "[web] storage init %q failed: %v (%dms)", p.where, err, time.Since(start).Milliseconds())
|
||||||
|
case res.NeedsConfirmation:
|
||||||
|
job.Phase = storageInitPhaseNeedsConfirm
|
||||||
|
job.DurableID = res.DurableID
|
||||||
|
job.Reason = res.Reason
|
||||||
|
case res.Refused:
|
||||||
|
job.Phase = storageInitPhaseRefused
|
||||||
|
job.Reason = res.Reason
|
||||||
|
job.Opsign = res.Opsign
|
||||||
|
default:
|
||||||
|
job.Phase = storageInitPhaseDone
|
||||||
|
job.Where = res.Where
|
||||||
|
logx.Infof(s.logger, "[web] storage init done: %s registered (%dms)", res.Where, time.Since(start).Milliseconds())
|
||||||
|
}
|
||||||
|
s.storageInit.set(job)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div style="display:flex;align-items:center;gap:.5rem">
|
<div style="display:flex;align-items:center;gap:.5rem">
|
||||||
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
|
<a href="/storage" class="btn btn-sm btn-outline">← Vissza</a>
|
||||||
<h2>Meglévő meghajtó csatolása</h2>
|
<h2>Meglévő meghajtó csatolása</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div style="display:flex;align-items:center;gap:.5rem">
|
<div style="display:flex;align-items:center;gap:.5rem">
|
||||||
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
|
<a href="/storage" class="btn btn-sm btn-outline">← Vissza</a>
|
||||||
<h2>Új meghajtó inicializálása</h2>
|
<h2>Új meghajtó inicializálása</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +111,9 @@ function pickDisk(radio){
|
|||||||
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
|
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// postInit runs the init POST. confirmed/durableId carry the customer's wipe confirmation (user-data).
|
// postInit STARTS the detached init job (F6). confirmed/durableId carry the customer's wipe
|
||||||
|
// confirmation (user-data). The chain (format → mount → register) now runs server-side off the
|
||||||
|
// request context and is followed via pollInitStatus — a closed tab no longer aborts it.
|
||||||
async function postInit(confirmed, durableId){
|
async function postInit(confirmed, durableId){
|
||||||
var body={device:selDevice, fstype:document.getElementById('fstype').value,
|
var body={device:selDevice, fstype:document.getElementById('fstype').value,
|
||||||
mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value,
|
mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value,
|
||||||
@@ -120,24 +122,52 @@ async function postInit(confirmed, durableId){
|
|||||||
return {status:r.status, j:await r.json()};
|
return {status:r.status, j:await r.json()};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stepper renders the 3-step progress (Formázás → Csatolás → Regisztrálás) for the running phase.
|
||||||
|
function stepper(phase){
|
||||||
|
var steps=['Formázás','Csatolás','Regisztrálás'];
|
||||||
|
var order={formatting:0,mounting:1,registering:2};
|
||||||
|
var cur=order[phase]!=null?order[phase]:0;
|
||||||
|
var rows=steps.map(function(lbl,i){
|
||||||
|
var state=i<cur?'kész':(i===cur?'folyamatban…':'vár');
|
||||||
|
var style=i<cur?'color:var(--accent-ok,#3a9a4a)':(i===cur?'font-weight:600':'opacity:.5');
|
||||||
|
return '<div style="'+style+'">'+(i+1)+'. '+esc(lbl)+' — '+state+'</div>';
|
||||||
|
});
|
||||||
|
return '<div class="init-steps" style="display:flex;flex-direction:column;gap:.25rem">'+rows.join('')+'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms){ return new Promise(function(r){ setTimeout(r,ms); }); }
|
||||||
|
|
||||||
|
// pollInitStatus follows the detached job to a terminal state (F6): 3-step progress while running,
|
||||||
|
// the confirm/refuse verdicts, or success/failure.
|
||||||
|
async function pollInitStatus(out, btn){
|
||||||
|
for(;;){
|
||||||
|
await sleep(1000);
|
||||||
|
var r; try{ r=await fetch('/api/storage/init/status'); }catch(e){ continue; }
|
||||||
|
var j; try{ j=await r.json(); }catch(e){ continue; }
|
||||||
|
if(!j.ok){ out.innerHTML='<div class="alert alert-error">Az állapot lekérdezése sikertelen.</div>'; btn.disabled=false; return; }
|
||||||
|
var d=j.data||{}; var ph=d.phase;
|
||||||
|
if(ph==='formatting'||ph==='mounting'||ph==='registering'){ out.innerHTML=stepper(ph); continue; }
|
||||||
|
if(ph==='idle'){ continue; } // slot not yet populated
|
||||||
|
if(ph==='needs_confirmation'){ renderConfirm(d.durable_id, out, btn); return; }
|
||||||
|
if(ph==='refused'){
|
||||||
|
out.innerHTML='<div class="alert alert-warning"><strong>Operátori aláírás szükséges.</strong> Ez a meghajtó védett (rendszer/biztonsági mentés).'
|
||||||
|
+(d.opsign?('<pre class="mono" style="white-space:pre-wrap;background:var(--bg-0);padding:.75rem;border-radius:6px;margin-top:.5rem">'+esc(d.opsign)+'</pre>'):'')+'</div>';
|
||||||
|
btn.disabled=false; return;
|
||||||
|
}
|
||||||
|
if(ph==='done'){ finishInit(d.where, out); return; }
|
||||||
|
if(ph==='failed'){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(d.error||'')+'</div>'; btn.disabled=false; return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitInit(ev){
|
async function submitInit(ev){
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
var btn=document.getElementById('init-btn'); var out=document.getElementById('init-result');
|
var btn=document.getElementById('init-btn'); var out=document.getElementById('init-result');
|
||||||
btn.disabled=true; out.innerHTML='<p class="form-hint">Formázás és csatlakoztatás folyamatban…</p>';
|
btn.disabled=true; out.innerHTML=stepper('formatting');
|
||||||
try{
|
try{
|
||||||
var res=await postInit(false, "");
|
var res=await postInit(false, "");
|
||||||
// USER-DATA data-bearing → the customer must confirm the wipe (type-to-confirm), then re-submit.
|
if(res.status===409){ out.innerHTML='<div class="alert alert-warning">'+esc((res.j&&res.j.error)||'Már folyamatban van egy inicializálás.')+'</div>'; btn.disabled=false; return false; }
|
||||||
if(res.status===409 && res.j.data && res.j.data.needs_confirmation){
|
if(!res.j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(res.j.error||'')+'</div>'; btn.disabled=false; return false; }
|
||||||
renderConfirm(res.j.data.durable_id, out, btn);
|
await pollInitStatus(out, btn);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// SYSTEM/BACKUP (shouldn't reach here — filtered out — but surface the opsign if it does).
|
|
||||||
if(res.status===409 && res.j.data && res.j.data.refused){
|
|
||||||
out.innerHTML='<div class="alert alert-warning"><strong>Operátori aláírás szükséges.</strong> Ez a meghajtó védett (rendszer/biztonsági mentés).'
|
|
||||||
+(res.j.data.opsign?('<pre class="mono" style="white-space:pre-wrap;background:var(--bg-0);padding:.75rem;border-radius:6px;margin-top:.5rem">'+esc(res.j.data.opsign)+'</pre>'):'')+'</div>';
|
|
||||||
btn.disabled=false; return false;
|
|
||||||
}
|
|
||||||
finishInit(res.j, out);
|
|
||||||
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; btn.disabled=false; }
|
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; btn.disabled=false; }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -150,18 +180,19 @@ function renderConfirm(durableId, out, btn){
|
|||||||
+'<div class="form-actions" style="gap:.6rem;margin-top:.75rem"><button id="init-confirm-go" class="btn btn-danger-outline" disabled>Törlés és inicializálás megerősítése</button></div>'
|
+'<div class="form-actions" style="gap:.6rem;margin-top:.75rem"><button id="init-confirm-go" class="btn btn-danger-outline" disabled>Törlés és inicializálás megerősítése</button></div>'
|
||||||
+'<div id="init-confirm-result" style="margin-top:.6rem"></div>';
|
+'<div id="init-confirm-result" style="margin-top:.6rem"></div>';
|
||||||
document.getElementById('init-confirm-go').onclick=async function(){
|
document.getElementById('init-confirm-go').onclick=async function(){
|
||||||
var cr=document.getElementById('init-confirm-result'); cr.innerHTML='<p class="form-hint">Törlés és inicializálás folyamatban…</p>';
|
var cr=document.getElementById('init-confirm-result'); cr.innerHTML='<p class="form-hint">Törlés és inicializálás indítása…</p>';
|
||||||
try{
|
try{
|
||||||
var res2=await postInit(true, durableId);
|
var res2=await postInit(true, durableId);
|
||||||
|
if(res2.status===409){ cr.innerHTML='<div class="alert alert-warning">'+esc((res2.j&&res2.j.error)||'')+'</div>'; return; }
|
||||||
if(!res2.j.ok){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(res2.j.error||'')+'</div>'; return; }
|
if(!res2.j.ok){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(res2.j.error||'')+'</div>'; return; }
|
||||||
finishInit(res2.j, out);
|
out.innerHTML=stepper('formatting');
|
||||||
|
await pollInitStatus(out, btn);
|
||||||
}catch(e){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; }
|
}catch(e){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishInit(j, out){
|
function finishInit(where, out){
|
||||||
if(!j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(j.error||'')+'</div>'; document.getElementById('init-btn').disabled=false; return; }
|
out.innerHTML='<div class="alert alert-success">A meghajtó sikeresen inicializálva és regisztrálva: <strong class="mono">'+esc(where)+'</strong>. <a href="/storage">Vissza a tárhelyhez →</a></div>';
|
||||||
out.innerHTML='<div class="alert alert-success">A meghajtó sikeresen inicializálva és regisztrálva: <strong class="mono">'+esc(j.data&&j.data.where)+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
|
|
||||||
}
|
}
|
||||||
loadDisks();
|
loadDisks();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user