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) }