package web import ( "context" "errors" "fmt" "net" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/logx" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // NAS add orchestration (verify-before-commit). POST /api/storage/netstorage/add no longer // registers blind: it validates sync, then a DETACHED job (migrate.go shape — sync-validate-then- // goroutine, single-flight, deep-copied status) drives: // // agent_add → the agent installs units + starts ITS detached verify (auto-rollback on fail) // verifying → poll the agent's verify slot every 2 s; "none" after install = agent restarted // mid-verify ⇒ WE roll back (Scenario F) // probing → in-guest uid-1000 write probe (the squash trap — a mountable-but-unwritable // export must never register) // registering→ AddStoragePath — the LAST step, so the worst crash outcome is an agent-side // orphan (surfaced by the list), never a registered-but-broken path // done | failed // // The job runs on context.Background() with an overall deadline — a closed browser tab can never // abort a half-done add mid-rollback. Rollback (agent RemoveNetStorage) is idempotent; a // double-remove after the agent's own auto-rollback is harmless. // netAgent is the agent surface the orchestrator needs (seam — tests inject a fake; production is // the shared *agentapi.Client). type netAgent interface { AddNetStorage(ctx context.Context, req agentapi.AddNetStorageRequest) (agentapi.NetStorageAddResult, error) NetVerifyStatus(ctx context.Context) (agentapi.NetVerifyStatus, error) RemoveNetStorage(ctx context.Context, name string) error } // netAddJob is the poll-visible orchestration state (GET /api/storage/netstorage/add/status). type netAddJob struct { Name string `json:"name"` Phase string `json:"phase"` // agent_add | verifying | probing | registering | done | failed Category string `json:"category,omitempty"` // failure category (agent classifier vocabulary + not_writable/probe_io) Message string `json:"message,omitempty"` // the category's Hungarian customer message (§3.2) Detail string `json:"detail,omitempty"` // raw English/journal detail (the collapsible) Warn string `json:"warn,omitempty"` // non-fatal note on a successful add (e.g. probe cleanup) Path string `json:"path,omitempty"` // the registered in-guest path (done only) StartedAt time.Time `json:"started_at"` UpdatedAt time.Time `json:"updated_at"` } const ( netAddPhaseAgentAdd = "agent_add" netAddPhaseVerifying = "verifying" netAddPhaseProbing = "probing" netAddPhaseRegistering = "registering" netAddPhaseDone = "done" netAddPhaseFailed = "failed" ) const ( // netAddDeadline bounds the WHOLE orchestration (§8: detached ctx + ~150 s). The dominant term // is the agent verify's own 95 s worst case (black-holed-but-routed server). netAddDeadline = 150 * time.Second // netVerifyPollEvery is the agent verify-slot poll cadence. netVerifyPollEvery = 2 * time.Second // netRollbackBudget bounds a rollback call on a possibly-expired job context. netRollbackBudget = 30 * time.Second ) // netAddState is the single-flight slot (migrate.go's acquire/release + deep-copy status shape). type netAddState struct { mu sync.Mutex running bool cur *netAddJob } func (s *netAddState) acquire(job *netAddJob) 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 *netAddState) release() { s.mu.Lock() s.running = false s.mu.Unlock() } func (s *netAddState) set(job *netAddJob) { 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 *netAddState) snapshot() *netAddJob { s.mu.Lock() defer s.mu.Unlock() if s.cur == nil { return nil } cp := *s.cur return &cp } // netAgentForAdd resolves the net-storage agent surface (test seam first, then the shared // client). Despite the name it serves the whole share lifecycle — the remove handler resolves // through it too (v0.130.0). func (s *Server) netAgentForAdd() (netAgent, error) { if s.netAgentFn != nil { return s.netAgentFn() } c, err := s.agentClient() if err != nil { return nil, err } return c, nil } // netProbe runs the in-guest uid-1000 write probe (test seam first, then the re-exec runner). func (s *Server) netProbe(ctx context.Context, dir string) probeOutcome { if s.netProbeFn != nil { return s.netProbeFn(ctx, dir) } logx.Debugf(s.logger, "[web] netprobe exec start (uid-1000 re-exec) dir=%s", dir) o := runNetProbe(ctx, dir) logx.Debugf(s.logger, "[web] netprobe result: ok=%v category=%s detail=%s", o.OK, o.Category, firstLine(o.Detail)) return o } // firstLine bounds a raw detail to its first line for a log field. func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s } // startNetAdd claims the single-flight slot and launches the detached orchestration. false = an add // is already in flight (Scenario G). func (s *Server) startNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, label string) bool { job := &netAddJob{ Name: req.Name, Phase: netAddPhaseAgentAdd, StartedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } if !s.netAdd.acquire(job) { return false } go s.runNetAdd(agent, req, label, job) return true } // runNetAdd drives the phase machine on a DETACHED context (a closed tab can't abort a rollback). func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, label string, job *netAddJob) { defer s.netAdd.release() ctx, cancel := context.WithTimeout(context.Background(), netAddDeadline) defer cancel() start := time.Now() logx.Infof(s.logger, "[web] netstorage add %q started (%s %s:%s, mapped_uid=%d)", req.Name, req.Protocol, req.Server, req.Export, req.MappedUID) setPhase := func(p string) { logx.Debugf(s.logger, "[web] netstorage add %q phase %s -> %s (%dms elapsed)", req.Name, job.Phase, p, time.Since(start).Milliseconds()) job.Phase = p job.UpdatedAt = time.Now().UTC() s.netAdd.set(job) } fail := func(category, detail string) { job.Phase = netAddPhaseFailed job.Category = category job.Message = netAddMessage(category, req.Server, req.MappedUID) job.Detail = detail job.UpdatedAt = time.Now().UTC() s.netAdd.set(job) logx.Warnf(s.logger, "[web] netstorage add %q failed: category=%s detail=%s (%dms)", req.Name, category, detail, time.Since(start).Milliseconds()) } rollback := func(why string) { logx.Debugf(s.logger, "[web] netstorage add %q rollback started (%s)", req.Name, why) rctx, rcancel := context.WithTimeout(context.Background(), netRollbackBudget) defer rcancel() if err := agent.RemoveNetStorage(rctx, req.Name); err != nil { // Best-effort by design: the agent may have auto-rolled-back already (double-remove is // harmless) — but log it, a REAL leftover shows up as an orphan row in the list. logx.Warnf(s.logger, "[web] netstorage add %q rollback (%s): remove: %v", req.Name, why, err) } else { logx.Infof(s.logger, "[web] netstorage add %q rolled back (%s)", req.Name, why) } } // Phase 1 — agent add (install units + start the agent-side verify). res, err := agent.AddNetStorage(ctx, req) if err != nil { var refused *agentapi.NetAddRefusedError if errors.As(err, &refused) { fail(refused.Code, refused.Msg) // categorized sync refusal — NOTHING was installed return } fail("agent_error", err.Error()) return } // Phase 2 — poll the agent's verify slot. On a pre-verify agent (no job started) skip straight // to the probe: the mount-trigger check then happens implicitly through the probe's write. logx.Debugf(s.logger, "[web] netstorage add %q agent add accepted (verify=%s job_id=%s guest_path=%s)", req.Name, res.Verify, res.JobID, res.GuestPath) if res.Verify == "started" { setPhase(netAddPhaseVerifying) verdict, verr := s.pollAgentVerify(ctx, agent, res.JobID) logx.Debugf(s.logger, "[web] netstorage add %q agent verify verdict: phase=%s code=%s (err=%v)", req.Name, verdict.Phase, verdict.Code, verr) switch { case verr != nil: rollback("verify poll failed") fail("agent_error", "verify status unavailable: "+verr.Error()) return case verdict.Phase == "failed": // The agent has ALREADY auto-rolled-back (units + creds) — no controller rollback needed. fail(verdict.Code, verdict.Detail) return case verdict.Phase == "done": // verified — proceed default: // "none" (agent restarted mid-verify — Scenario F) or an alien job id: the verify is // LOST, the units may linger — roll back and fail loud. rollback("verify lost (agent restart?)") fail("mount_failed", fmt.Sprintf("agent verify job lost after install (phase=%q) — rolled back", verdict.Phase)) return } } // Phase 3 — the in-guest uid-1000 write probe (the squash trap). setPhase(netAddPhaseProbing) outcome := s.netProbe(ctx, res.GuestPath) logx.Debugf(s.logger, "[web] netstorage add %q probe verdict: ok=%v category=%s warn=%q", req.Name, outcome.OK, outcome.Category, outcome.Warn) if !outcome.OK { rollback("probe failed") fail(outcome.Category, outcome.Detail) return } // Phase 4 — register. THE LAST STEP (worst crash outcome = an agent-side orphan, never a // registered-but-broken path). setPhase(netAddPhaseRegistering) sp := settings.StoragePath{ Path: res.GuestPath, Label: label, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), Kind: settings.StorageKindNetwork, Protocol: req.Protocol, Server: req.Server, Export: req.Export, MappedUID: req.MappedUID, MappedGID: req.MappedGID, } if err := s.settings.AddStoragePath(sp); err != nil { rollback("register failed") fail("register_failed", err.Error()) return } job.Phase = netAddPhaseDone job.Path = res.GuestPath job.Warn = outcome.Warn job.UpdatedAt = time.Now().UTC() s.netAdd.set(job) logx.Infof(s.logger, "[web] network storage added + verified: %s (%s %s:%s) → %s (warn=%q) in %dms", req.Name, req.Protocol, req.Server, req.Export, res.GuestPath, outcome.Warn, time.Since(start).Milliseconds()) // R-67: the fresh share becomes browsable — same debounced/mutexed path the drive flows use. go s.SyncFileBrowserMounts() } // pollAgentVerify polls the agent's verify slot until it leaves `running` (or ctx expires). // Transient poll errors are tolerated up to a small budget — the agent may be busy mounting. func (s *Server) pollAgentVerify(ctx context.Context, agent netAgent, jobID string) (agentapi.NetVerifyStatus, error) { var last agentapi.NetVerifyStatus consecutiveErrs := 0 t := time.NewTicker(netVerifyPollEvery) defer t.Stop() for { st, err := agent.NetVerifyStatus(ctx) if err != nil { consecutiveErrs++ if consecutiveErrs >= 5 { return last, err } } else { consecutiveErrs = 0 last = st if st.Phase != "running" { // A different job id in the slot means OUR job is gone (restart + a newer add) — // report it as the lost-verify shape, not a false done/failed. if st.Phase != "none" && jobID != "" && st.JobID != jobID { last.Phase = "none" } return last, nil } } select { case <-ctx.Done(): return last, ctx.Err() case <-t.C: } } } // netAddMessage maps a failure category to the EXACT Hungarian customer message (§3.2). server and // mappedUID parameterize the unreachable/not_writable texts (host id = mapped uid + 100000). func netAddMessage(category, server string, mappedUID int) string { switch category { case "unreachable": msg := "A szerver nem érhető el (" + server + "). Ellenőrizze az IP-címet, és hogy a NAS be van-e kapcsolva." // R-66 Leg C: a single-label non-IP server (»FELHOM«) is almost always a Windows/NetBIOS // network name, which this box generally cannot resolve — name the trap instead of letting // the generic text teach nothing. Purely lexical on the submitted value: NO NetBIOS/mDNS // resolution is ever attempted, and an IP or dotted DNS name never gets nagged about this. if looksLikeFlatNetworkName(server) { msg += " Tipp: a(z) »" + server + "« Windows-hálózati névnek tűnik — használja az eszköz IP-címét." } return msg case "nfs_export": return "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizze az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét." case "smb_auth": return "Hibás SMB felhasználónév vagy jelszó." case "smb_share": return "A megadott SMB-megosztás nem található a szerveren. Ellenőrizze a megosztás nevét." case "timeout": return "Időtúllépés a csatolás közben — a szerver elérhető a hálózaton, de a megosztás nem csatolható. Ellenőrizze a NAS NFS/SMB szolgáltatását." case "not_writable": return "A megosztás csatolható, de az alkalmazások nem tudnak rá írni. NFS esetén kapcsolja be a NAS-on a „minden felhasználó leképezése” (map all users / all squash) beállítást a megosztáson — vagy állítsa a fájlok tulajdonosát a(z) " + fmt.Sprint(mappedUID+100000) + " azonosítóra. SMB esetén ellenőrizze, hogy a felhasználónak írási joga van a megosztáson." case "not_network_fs": return "A hálózati tárhely csatolása a rendszeren belül nem jött létre megfelelően. Próbálja újra a csatlakoztatást; ha a hiba ismétlődik, jelezze az üzemeltetőnek." case "probe_io": return "Írási hiba a megosztáson (az adat nem olvasható vissza hibátlanul). Ellenőrizze a megosztást és a hálózatot." case "busy": return "Már folyamatban van egy csatlakoztatás. Várja meg, amíg befejeződik." default: // mount_failed + agent_error + register_failed + any future agent code return "A csatolás sikertelen. Részletek alább." } } // looksLikeFlatNetworkName reports whether a submitted server value is a single-label non-IP name // (no dot, not an IP literal) — the NetBIOS-name shape. `nas.local` (dotted) and any parseable IP // (v4 or v6 — colons carry the v6 case through ParseIP) are NOT flagged: the hint must never nag // someone who typed a resolvable form (R-66 C2/C3). func looksLikeFlatNetworkName(server string) bool { s := strings.TrimSpace(server) return s != "" && !strings.Contains(s, ".") && net.ParseIP(s) == nil } // trimNetDetail bounds the raw detail shown in the UI collapsible. func trimNetDetail(d string) string { d = strings.TrimSpace(d) if len(d) > 800 { return d[:800] + "…" } return d }