netstorage: verify-before-commit orchestration — agentapi verify fields, uid-1000 re-exec probe, detached add job, orphan rows
Controller half of the verify pipeline (SPIKE-nas-verify b57f6c1): AddNetStorage gains verify/job fields + typed NetAddRefusedError; NetVerifyStatus polls the agent slot; --netprobe hidden re-exec mode (SysProcAttr.Credential uid/gid 1000, no shell) proves in-guest writability; the add handler starts a detached single-flight job (agent_add → verifying → probing → registering LAST) with full rollback on any failure incl. verify-lost-after-restart (Scenario F); §3.2 Hungarian error map server-side; live-but-unregistered shares surface as remove- only 'Árva megosztás' rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +23,8 @@ import (
|
||||
const defaultMediaUID = 1000
|
||||
|
||||
// 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"`
|
||||
@@ -33,10 +36,12 @@ type networkStorageItem struct {
|
||||
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 proxies POST /api/storage/netstorage/add → agent /netstorage/add, then registers a
|
||||
// Kind=network StoragePath (no password persisted).
|
||||
// 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"`
|
||||
@@ -81,46 +86,42 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
||||
gid = defaultMediaUID
|
||||
}
|
||||
|
||||
agent, err := s.agentClient()
|
||||
agent, err := s.netAgentForAdd()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
res, err := agent.AddNetStorage(r.Context(), agentapi.AddNetStorageRequest{
|
||||
Name: name, Protocol: proto, Server: server, Export: export,
|
||||
MappedUID: uid, MappedGID: gid, IdleTimeoutSec: req.IdleTimeoutSec,
|
||||
Username: req.Username, Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] netstorage add %q via agent failed: %v", name, err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
label := strings.TrimSpace(req.Label)
|
||||
if label == "" {
|
||||
label = "Hálózati tárhely: " + name
|
||||
}
|
||||
// Register the Kind=network path. NO password is persisted — only the non-secret descriptors.
|
||||
sp := settings.StoragePath{
|
||||
Path: res.GuestPath,
|
||||
Label: label,
|
||||
Schedulable: true,
|
||||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Kind: settings.StorageKindNetwork,
|
||||
Protocol: proto,
|
||||
Server: server,
|
||||
Export: export,
|
||||
MappedUID: uid,
|
||||
MappedGID: gid,
|
||||
}
|
||||
if err := s.settings.AddStoragePath(sp); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] netstorage register %q failed: %v", name, err)
|
||||
writeDiskJSON(w, http.StatusInternalServerError, false, "regisztráció sikertelen", nil)
|
||||
// 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 added: %s (%s %s:%s) → %s", name, proto, server, export, res.GuestPath)
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "name": name, "path": res.GuestPath})
|
||||
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
|
||||
@@ -128,23 +129,23 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
||||
// Shared by the JSON list handler and the settings page render.
|
||||
func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
|
||||
live := map[string]agentapi.NetworkMountStatus{}
|
||||
if agent, err := s.agentClient(); err == nil {
|
||||
lctx, cancel := context.WithTimeout(ctx, 5*time.Second) // never let a slow agent hang the page
|
||||
defer cancel()
|
||||
if mounts, lerr := agent.ListNetStorage(lctx); lerr == nil {
|
||||
for _, m := range mounts {
|
||||
live[m.Name] = m
|
||||
}
|
||||
} else {
|
||||
s.logger.Printf("[WARN] [web] netstorage live health unavailable: %v", lerr)
|
||||
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",
|
||||
@@ -157,6 +158,26 @@ func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -198,6 +219,18 @@ func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request)
|
||||
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, "/")
|
||||
|
||||
Reference in New Issue
Block a user