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