Files
felhom-controller/controller/internal/web/storage_init_job.go
T
admin 2487681396 style: gofmt normalization — no logic changes
gofmt -w across the controller tree (46 files) so gofmt -l is empty — disarms the
formatting landmine where a targeted edit + accidental gofmt -w swept ~46 unrelated
files. Pure formatting: whitespace + gofmt's optional-semicolon removal in reflowed
inline closures. One doc comment reworded ('' -> 'the empty string') to avoid gofmt's
Go-1.19 doc-comment typographic substitition ('' -> curly quote) muddying its meaning.
No build/vet/test behavior change.
2026-07-25 07:37:02 +02:00

158 lines
5.9 KiB
Go

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