Files
felhom-controller/controller/internal/web/netstorage_handlers.go
T
admin d347dc48d2 feat: agent-capability gate for coupled features — typed StatusError + Supports probe/cache + netstorage add gate (option-1)
- agentapi: non-2xx GETs now surface as typed *StatusError (same text); features.go
  adds Feature/SupportState/SupportCache (route probe, TTL 5m, Yes/No cached,
  Unknown never cached or refused) + Client.Supports
- web: handleNetStorageAdd refuses up front (412, code agent_outdated, Hungarian
  message) when the agent predates /netstorage/verify-status (= pre-0.81 add
  semantics); gate runs BEFORE the single-flight claim; SupportUnknown passes
  through to the existing agent-error paths
- netAddSupport page-render helper lands here; its template consumer follows
- tests: T1 gate refusal (job never starts, slot free), T2 unchanged happy path +
  warm-cache negative assertion, T3 indeterminate never 'too old', T4
  classification incl. the string-match trap, T6 TTL, wire-level 404-typing;
  red-proofs RP1-RP4 run and reverted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-11 13:45:49 +02:00

277 lines
11 KiB
Go

package web
import (
"context"
"encoding/json"
"net/http"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// NAS network storage (Part A2). The controller is a thin proxy over the agent's /netstorage/* (A1) +
// the local StoragePath registry (Kind=network). It holds NO mount authority and NEVER persists the SMB
// password (it passes the credential straight to the agent's add request, which writes the 0600 file).
// A network share is a DISTINCT class: NO eject/decommission/migrate/wipe/SMART — remove is the only
// lifecycle action (refuseNetworkLifecycle blocks the drive ops server-side).
// defaultMediaUID/GID is the container uid/gid most media apps run as (jellyfin, *arr, immich). The
// agent applies the +100000 host offset; this is the in-guest id the share is mapped to.
const defaultMediaUID = 1000
// netAddOutdatedMsg is the sync add-time refusal (machine code "agent_outdated") when the agent
// predates the coupled verify-before-commit add semantics (pre-v0.81.0).
const netAddOutdatedMsg = "Az ügynök frissítése szükséges ehhez a funkcióhoz — a frissítés megérkezése után próbáld újra."
// netAddSupport evaluates the coupled-feature probe for the settings-page render with a SHORT
// budget — a down agent must not stall the page (the cache usually answers instantly). Returns the
// template vocabulary: "yes" | "no" | "unknown"; only "no" swaps the add form for the banner —
// flaky states belong to the add-time handling.
func (s *Server) netAddSupport() string {
agent, err := s.netAgentForAdd()
if err != nil {
return "unknown"
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
switch s.netFeatures.Supports(ctx, agent, agentapi.FeatureNetstorageVerify) {
case agentapi.SupportYes:
return "yes"
case agentapi.SupportNo:
return "no"
default:
return "unknown"
}
}
// networkStorageItem is the UI row: the registered descriptor + live per-share health from the agent.
// Orphan marks an agent-configured share with NO registry entry (a crash-window leftover — Scenario
// F's visible closure): the row renders with ONLY the remove action.
type networkStorageItem struct {
Name string `json:"name"`
Label string `json:"label"`
Protocol string `json:"protocol"`
Server string `json:"server"`
Export string `json:"export"`
Path string `json:"path"` // the in-guest path apps point HDD_PATH at
Health string `json:"health"` // ok | idle | unreachable | unknown
Reachable bool `json:"reachable"`
Mounted bool `json:"mounted"`
Configured bool `json:"configured"`
Orphan bool `json:"orphan"` // agent-configured but NOT registered — remove is the only action
}
// handleNetStorageAdd starts the verify-before-commit orchestration: sync input validation (a bad
// request changes nothing), then the detached job (agent add → verify poll → uid-1000 probe →
// register LAST). The response is {started:true}; the UI polls /api/storage/netstorage/add/status.
func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Protocol string `json:"protocol"`
Server string `json:"server"`
Export string `json:"export"`
MappedUID int `json:"mapped_uid"`
MappedGID int `json:"mapped_gid"`
IdleTimeoutSec int `json:"idle_timeout_sec"`
Username string `json:"username"`
Password string `json:"password"`
Label string `json:"label"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
name := strings.TrimSpace(req.Name)
if !mountNameRe.MatchString(name) {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen név (csak betűk, számok, _ és -)", nil)
return
}
proto := strings.ToLower(strings.TrimSpace(req.Protocol))
if proto != "nfs" && proto != "smb" {
writeDiskJSON(w, http.StatusBadRequest, false, "protokoll: nfs vagy smb", nil)
return
}
server, export := strings.TrimSpace(req.Server), strings.TrimSpace(req.Export)
if server == "" || export == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "a szerver és a megosztás kötelező", nil)
return
}
if proto == "smb" && (req.Username == "" || req.Password == "") {
writeDiskJSON(w, http.StatusBadRequest, false, "SMB-hez felhasználónév és jelszó szükséges", nil)
return
}
uid, gid := req.MappedUID, req.MappedGID
if uid <= 0 {
uid = defaultMediaUID
}
if gid <= 0 {
gid = defaultMediaUID
}
agent, err := s.netAgentForAdd()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
// Capability gate (the publish-train backstop): the coupled add semantics shipped with agent
// v0.81.0 together with GET /netstorage/verify-status — on an older agent, refuse up front
// instead of failing mid-pipeline in `verifying` with a misleading rollback. Runs BEFORE the
// single-flight claim (a refused add must not consume the slot). SupportUnknown passes: a down
// agent speaks through the existing agent-error paths, never as "too old".
if s.netFeatures.Supports(r.Context(), agent, agentapi.FeatureNetstorageVerify) == agentapi.SupportNo {
s.logger.Printf("[WARN] [web] netstorage add %q refused: agent predates %s (probe 404)", name, agentapi.FeatureNetstorageVerify)
writeDiskJSON(w, http.StatusPreconditionFailed, false, netAddOutdatedMsg, map[string]any{"code": "agent_outdated"})
return
}
label := strings.TrimSpace(req.Label)
if label == "" {
label = "Hálózati tárhely: " + name
}
// The password rides INSIDE the request straight to the agent (0600 creds file) — it is never
// persisted controller-side and never appears in the job status.
started := s.startNetAdd(agent, agentapi.AddNetStorageRequest{
Name: name, Protocol: proto, Server: server, Export: export,
MappedUID: uid, MappedGID: gid, IdleTimeoutSec: req.IdleTimeoutSec,
Username: req.Username, Password: req.Password,
}, label)
if !started {
writeDiskJSON(w, http.StatusConflict, false, "már folyamatban van egy csatlakoztatás", nil)
return
}
s.logger.Printf("[INFO] [web] network storage add started: %s (%s %s:%s)", name, proto, server, export)
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "name": name})
}
// handleNetStorageAddStatus reports the last / in-flight add job (deep copy; "none" when never ran).
func (s *Server) handleNetStorageAddStatus(w http.ResponseWriter, r *http.Request) {
job := s.netAdd.snapshot()
if job == nil {
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"phase": "none"})
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
"phase": job.Phase, "name": job.Name, "category": job.Category,
"message": job.Message, "detail": trimNetDetail(job.Detail), "warn": job.Warn,
"path": job.Path, "started_at": job.StartedAt, "updated_at": job.UpdatedAt,
})
}
// networkStorageItems returns the registered network shares merged with the agent's live per-share
// health. A share the agent can't currently report (agent down, or not in the live list) is "unknown".
// Shared by the JSON list handler and the settings page render.
func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
live := map[string]agentapi.NetworkMountStatus{}
lctx, cancel := context.WithTimeout(ctx, 5*time.Second) // never let a slow agent hang the page
defer cancel()
if mounts, lerr := s.listNetStorage(lctx); lerr == nil {
for _, m := range mounts {
live[m.Name] = m
}
} else {
s.logger.Printf("[WARN] [web] netstorage live health unavailable: %v", lerr)
}
items := make([]networkStorageItem, 0)
registered := map[string]bool{}
for _, sp := range s.settings.GetStoragePaths() {
if !sp.IsNetwork() {
continue
}
name := pathBase(sp.Path)
registered[name] = true
it := networkStorageItem{
Name: name, Label: sp.Label, Protocol: sp.Protocol,
Server: sp.Server, Export: sp.Export, Path: sp.Path, Health: "unknown",
}
if m, ok := live[name]; ok {
it.Health = m.Health
it.Reachable = m.Reachable
it.Mounted = m.Mounted
it.Configured = m.Configured
}
items = append(items, it)
}
// Orphans (Scenario F's visible closure): an agent-configured share with NO registry entry is a
// crash-window leftover (e.g. controller died between agent-add and register). Surface it with
// ONLY the remove action — re-adding the same name is the repair path (EnsureNetworkMount is
// idempotent, verify runs again).
var orphans []string
for name := range live {
if !registered[name] {
orphans = append(orphans, name)
}
}
sort.Strings(orphans)
for _, name := range orphans {
m := live[name]
items = append(items, networkStorageItem{
Name: name, Label: "Árva megosztás: " + name, Protocol: m.Protocol,
Server: m.Server, Export: m.Export, Path: m.Where,
Health: m.Health, Reachable: m.Reachable, Mounted: m.Mounted,
Configured: m.Configured, Orphan: true,
})
}
return items
}
// handleNetStorageList returns the registered network shares merged with the agent's live per-share health.
func (s *Server) handleNetStorageList(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"network_storage": s.networkStorageItems(r.Context())})
}
// handleNetStorageRemove proxies POST /api/storage/netstorage/remove → agent /netstorage/remove, then
// deregisters the path. No decommission/migrate semantics (drive-only).
func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
name := strings.TrimSpace(req.Name)
if !mountNameRe.MatchString(name) {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen név", nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
if err := agent.RemoveNetStorage(r.Context(), name); err != nil {
s.logger.Printf("[ERROR] [web] netstorage remove %q via agent failed: %v", name, err)
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
where := settings.NetworkMountRoot + "/" + name
if err := s.settings.RemoveStoragePath(where); err != nil {
s.logger.Printf("[WARN] [web] netstorage deregister %q: %v", where, err)
}
s.logger.Printf("[INFO] [web] network storage removed: %s", name)
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"removed": true, "name": name})
}
// listNetStorage resolves the agent's live share list (test seam first, then the shared client).
func (s *Server) listNetStorage(ctx context.Context) ([]agentapi.NetworkMountStatus, error) {
if s.netListFn != nil {
return s.netListFn(ctx)
}
agent, err := s.agentClient()
if err != nil {
return nil, err
}
return agent.ListNetStorage(ctx)
}
// pathBase returns the last path segment (the share name) of a /mnt/felhom-drives/<name> path.
func pathBase(p string) string {
p = strings.TrimRight(p, "/")
if i := strings.LastIndexByte(p, '/'); i >= 0 {
return p[i+1:]
}
return p
}