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:
@@ -652,7 +652,9 @@ type AddNetStorageRequest struct {
|
||||
Password string `json:"password,omitempty"` // SMB secret — pass-through, never persisted
|
||||
}
|
||||
|
||||
// NetStorageAddResult mirrors the agent's add response (the in-guest path the media app's data dir points at).
|
||||
// NetStorageAddResult mirrors the agent's add response (the in-guest path the media app's data dir
|
||||
// points at). Since agent v0.81.0 (verify-before-commit) a successful add means "units installed,
|
||||
// verify STARTED" — Verify/JobID carry the detached verify job the caller polls via NetVerifyStatus.
|
||||
type NetStorageAddResult struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
@@ -660,18 +662,67 @@ type NetStorageAddResult struct {
|
||||
GuestPath string `json:"guest_path"`
|
||||
HostUID int `json:"host_uid"`
|
||||
HostGID int `json:"host_gid"`
|
||||
Verify string `json:"verify"` // "started" on a verify-before-commit agent (v0.81.0+)
|
||||
JobID string `json:"job_id"`
|
||||
Code string `json:"code"` // set on a categorized SYNC refusal (e.g. "unreachable", "busy")
|
||||
}
|
||||
|
||||
// AddNetStorage mounts a NAS share host-side (the agent automounts it; it propagates into this guest via
|
||||
// the shared bind). Returns the in-guest path the media app's data dir is pointed at.
|
||||
// NetAddRefusedError is the agent's CATEGORIZED sync refusal of a netstorage add (the 2 s TCP
|
||||
// pre-probe "unreachable", or "busy" single-flight). Code is the verify-category vocabulary the UI
|
||||
// maps to Hungarian; nothing was installed agent-side.
|
||||
type NetAddRefusedError struct {
|
||||
Code string
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *NetAddRefusedError) Error() string {
|
||||
return "agentapi: netstorage add refused (" + e.Code + "): " + e.Msg
|
||||
}
|
||||
|
||||
// NetVerifyStatus mirrors the agent's GET /netstorage/verify-status: the single verify slot.
|
||||
// Phase "none" is a REAL signal — after an agent restart the in-memory job is gone; the caller
|
||||
// treats none-after-install as verify-lost and rolls the add back (Scenario F).
|
||||
type NetVerifyStatus struct {
|
||||
Phase string `json:"phase"` // none | running | done | failed
|
||||
Name string `json:"name"`
|
||||
Where string `json:"where"`
|
||||
Protocol string `json:"protocol"`
|
||||
Code string `json:"code"` // failure category (the agent's classifier vocabulary)
|
||||
Detail string `json:"detail"` // operator hint + raw journal fragment
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
|
||||
// AddNetStorage installs a NAS share host-side and starts the agent's detached verify job (agent
|
||||
// v0.81.0 verify-before-commit). A categorized sync refusal returns *NetAddRefusedError carrying
|
||||
// the category code (the result body also carries it); other failures are plain errors.
|
||||
func (c *Client) AddNetStorage(ctx context.Context, req AddNetStorageRequest) (NetStorageAddResult, error) {
|
||||
var out NetStorageAddResult
|
||||
body, err := c.post(ctx, "/netstorage/add", req)
|
||||
env, status, err := c.postWithStatus(ctx, "/netstorage/add", req)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if len(env.Data) > 0 {
|
||||
_ = json.Unmarshal(env.Data, &out) // best-effort; the refusal body carries {code}
|
||||
}
|
||||
if rerr := refusalError("/netstorage/add", status, env); rerr != nil {
|
||||
if out.Code != "" {
|
||||
return out, &NetAddRefusedError{Code: out.Code, Msg: truncateErr(env.Error, 300)}
|
||||
}
|
||||
return out, rerr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NetVerifyStatus polls the agent's verify slot (short GET — fits the client's global 15 s timeout;
|
||||
// the LONG wait lives in the caller's poll loop, never in one HTTP call).
|
||||
func (c *Client) NetVerifyStatus(ctx context.Context) (NetVerifyStatus, error) {
|
||||
var out NetVerifyStatus
|
||||
body, err := c.get(ctx, "/netstorage/verify-status")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /netstorage/add: %w", err)
|
||||
return out, fmt.Errorf("agentapi: decode /netstorage/verify-status: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// In-guest uid-1000 write probe (NAS verify-before-commit, SPIKE-nas-verify Q2/Q3). The agent's
|
||||
// verify proves the MOUNT is real; this probe proves a uid/gid-1000 app can actually WRITE through
|
||||
// it — the squash trap (Q3 row c: an export that mounts fine but denies every write). The probe is
|
||||
// a RE-EXEC of this binary (`felhom-controller --netprobe <dir>`) dropped to uid/gid 1000 via
|
||||
// SysProcAttr.Credential (spike Q2: SETUID/SETGID caps present in the container) — no shell, no
|
||||
// in-process setuid (which would poison the whole Go runtime's credentials).
|
||||
|
||||
// Probe child exit codes (the parent maps them to verify categories).
|
||||
const (
|
||||
netProbeExitOK = 0 // write + readback + delete all fine
|
||||
netProbeExitNoWrite = 2 // create/write failed → not_writable (the squash trap)
|
||||
netProbeExitMismatch = 3 // readback failed or differed → probe_io
|
||||
netProbeExitCleanup = 4 // wrote fine but delete failed → OK + warn (never a failure)
|
||||
)
|
||||
|
||||
// netProbeReadBack is the child's readback seam (package var — the child is a re-exec'd process in
|
||||
// production, so a struct seam can't reach it; tests override in-process).
|
||||
var netProbeReadBack = os.ReadFile
|
||||
|
||||
// NetProbeChild is the --netprobe body, run AS uid/gid 1000 by the re-exec parent: create a
|
||||
// dot-file with a random name + nonce in dir, read it back, compare, remove. Pure file logic —
|
||||
// unit-tested directly in t.TempDir(). Exposed for cmd/controller's hidden mode.
|
||||
func NetProbeChild(dir string) int {
|
||||
name := filepath.Join(dir, ".felhom-proba-"+randHexToken(8))
|
||||
nonce := randHexToken(32)
|
||||
if err := os.WriteFile(name, []byte(nonce), 0o644); err != nil {
|
||||
return netProbeExitNoWrite
|
||||
}
|
||||
back, err := netProbeReadBack(name)
|
||||
if err != nil || string(back) != nonce {
|
||||
_ = os.Remove(name) // best-effort — the verdict is already mismatch
|
||||
return netProbeExitMismatch
|
||||
}
|
||||
if err := os.Remove(name); err != nil {
|
||||
return netProbeExitCleanup
|
||||
}
|
||||
return netProbeExitOK
|
||||
}
|
||||
|
||||
// probeOutcome is the parent-side verdict of one probe run.
|
||||
type probeOutcome struct {
|
||||
OK bool
|
||||
Category string // failure category (not_writable | probe_io) when !OK
|
||||
Detail string
|
||||
Warn string // set on OK when cleanup failed (§8: a failed delete is a WARN, not a failure)
|
||||
}
|
||||
|
||||
// netProbeVerdict maps the child's exit code to the outcome (pure — unit-tested).
|
||||
func netProbeVerdict(exitCode int, output string) probeOutcome {
|
||||
switch exitCode {
|
||||
case netProbeExitOK:
|
||||
return probeOutcome{OK: true}
|
||||
case netProbeExitCleanup:
|
||||
return probeOutcome{OK: true, Warn: "a próbafájl törlése nem sikerült a megosztáson"}
|
||||
case netProbeExitNoWrite:
|
||||
return probeOutcome{OK: false, Category: "not_writable", Detail: "uid-1000 write probe: create/write refused | " + output}
|
||||
case netProbeExitMismatch:
|
||||
return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 write probe: readback failed or differed | " + output}
|
||||
default:
|
||||
return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 write probe: unexpected exit | " + output}
|
||||
}
|
||||
}
|
||||
|
||||
// randHexToken returns n random bytes hex-encoded (2n chars); crypto/rand, panics never — a rand
|
||||
// failure degrades to a constant (the probe still functions, names just stop being random).
|
||||
func randHexToken(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "felhom-static"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//go:build linux
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// netProbeTimeout bounds one probe run (LAN write+readback is sub-second; a wedged share must not
|
||||
// hold the orchestrator — the NFS soft/retry=0 options error out well inside this).
|
||||
const netProbeTimeout = 30 * time.Second
|
||||
|
||||
// runNetProbe re-execs this binary as `felhom-controller --netprobe <dir>` with uid/gid 1000
|
||||
// credentials (supplementary groups CLEARED — the probe must see exactly what a media app sees)
|
||||
// and maps the exit code to a verdict. This is the production netProbeFn seam value.
|
||||
func runNetProbe(ctx context.Context, dir string) probeOutcome {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return probeOutcome{OK: false, Category: "probe_io", Detail: "probe re-exec: executable path: " + err.Error()}
|
||||
}
|
||||
pctx, cancel := context.WithTimeout(ctx, netProbeTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(pctx, exe, "--netprobe", dir)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{Uid: 1000, Gid: 1000, Groups: []uint32{}},
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
output := strings.TrimSpace(string(out))
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
return netProbeVerdict(ee.ExitCode(), output)
|
||||
}
|
||||
// Spawn-level failure (EPERM on setuid = capability dropped somewhere — spike Q2 proved the
|
||||
// default container HAS the caps, so this is a real config regression worth the raw detail).
|
||||
return probeOutcome{OK: false, Category: "probe_io", Detail: "probe spawn failed: " + err.Error() + " | " + output}
|
||||
}
|
||||
return netProbeVerdict(0, output)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !linux
|
||||
|
||||
package web
|
||||
|
||||
import "context"
|
||||
|
||||
// runNetProbe is Linux-only (SysProcAttr.Credential). Off-linux (dev/test hosts) the seam must be
|
||||
// injected; reaching this stub is a wiring error, reported as a failed probe — never a false PASS.
|
||||
func runNetProbe(_ context.Context, _ string) probeOutcome {
|
||||
return probeOutcome{OK: false, Category: "probe_io", Detail: "uid-1000 probe unavailable on this platform"}
|
||||
}
|
||||
@@ -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, "/")
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"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 orchestrator's agent surface (test seam first, then the shared client).
|
||||
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)
|
||||
}
|
||||
return runNetProbe(ctx, dir)
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
setPhase := func(p string) {
|
||||
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)
|
||||
s.logger.Printf("[WARN] [web] netstorage add %q failed: category=%s detail=%s", req.Name, category, detail)
|
||||
}
|
||||
rollback := func(why string) {
|
||||
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.
|
||||
s.logger.Printf("[WARN] [web] netstorage add %q rollback (%s): remove: %v", req.Name, why, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
if res.Verify == "started" {
|
||||
setPhase(netAddPhaseVerifying)
|
||||
verdict, verr := s.pollAgentVerify(ctx, agent, res.JobID)
|
||||
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)
|
||||
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)
|
||||
s.logger.Printf("[INFO] [web] network storage added + verified: %s (%s %s:%s) → %s (warn=%q)",
|
||||
req.Name, req.Protocol, req.Server, req.Export, res.GuestPath, outcome.Warn)
|
||||
}
|
||||
|
||||
// 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":
|
||||
return "A szerver nem érhető el (" + server + "). Ellenőrizze az IP-címet, és hogy a NAS be van-e kapcsolva."
|
||||
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 "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."
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Orchestration tests (verify-before-commit): everything runs through the netAgent / netProbeFn /
|
||||
// netListFn seams — no docker, no TLS, no re-exec.
|
||||
|
||||
// fakeNetAgent is the netAgent seam fake: scripted add/verify results + call recording.
|
||||
type fakeNetAgent struct {
|
||||
mu sync.Mutex
|
||||
addRes agentapi.NetStorageAddResult
|
||||
addErr error
|
||||
verify agentapi.NetVerifyStatus
|
||||
verifyErr error
|
||||
addCalls int
|
||||
removes []string
|
||||
verifyPoll int
|
||||
}
|
||||
|
||||
func (f *fakeNetAgent) AddNetStorage(_ context.Context, req agentapi.AddNetStorageRequest) (agentapi.NetStorageAddResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.addCalls++
|
||||
return f.addRes, f.addErr
|
||||
}
|
||||
func (f *fakeNetAgent) NetVerifyStatus(_ context.Context) (agentapi.NetVerifyStatus, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.verifyPoll++
|
||||
return f.verify, f.verifyErr
|
||||
}
|
||||
func (f *fakeNetAgent) RemoveNetStorage(_ context.Context, name string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.removes = append(f.removes, name)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeNetAgent) removed() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.removes...)
|
||||
}
|
||||
|
||||
// okAddRes is the standard successful agent add result (units installed, verify started).
|
||||
func okAddRes(name string) agentapi.NetStorageAddResult {
|
||||
return agentapi.NetStorageAddResult{
|
||||
Name: name, Protocol: "nfs", Where: settings.NetworkMountRoot + "/" + name,
|
||||
GuestPath: settings.NetworkMountRoot + "/" + name, HostUID: 101000, HostGID: 101000,
|
||||
Verify: "started", JobID: "job-1",
|
||||
}
|
||||
}
|
||||
|
||||
// netAddReq builds the standard orchestrator input.
|
||||
func netAddReq(name string) agentapi.AddNetStorageRequest {
|
||||
return agentapi.AddNetStorageRequest{
|
||||
Name: name, Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/" + name,
|
||||
MappedUID: 1000, MappedGID: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
// waitNetAdd polls the job slot until it reaches a terminal phase.
|
||||
func waitNetAdd(t *testing.T, s *Server) *netAddJob {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if j := s.netAdd.snapshot(); j != nil && (j.Phase == netAddPhaseDone || j.Phase == netAddPhaseFailed) {
|
||||
return j
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("net add job did not finish (last: %+v)", s.netAdd.snapshot())
|
||||
return nil
|
||||
}
|
||||
|
||||
// networkPathCount counts registered Kind=network paths.
|
||||
func networkPathCount(s *Server) int {
|
||||
n := 0
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.IsNetwork() {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- C1: happy path — register ONLY after probe-ok, exactly once ------------------------------------
|
||||
// Companion red-proof: move AddStoragePath before the probe → registeredAtProbe becomes 1 → FAIL.
|
||||
func TestNetAdd_HappyPath_RegisterOnlyAfterProbe(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &fakeNetAgent{addRes: okAddRes("media"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
|
||||
registeredAtProbe := -1
|
||||
s.netProbeFn = func(_ context.Context, dir string) probeOutcome {
|
||||
registeredAtProbe = networkPathCount(s) // MUST be 0 — registration is the LAST step
|
||||
if dir != settings.NetworkMountRoot+"/media" {
|
||||
t.Errorf("probe dir = %q, want the guest path", dir)
|
||||
}
|
||||
return probeOutcome{OK: true}
|
||||
}
|
||||
|
||||
if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
|
||||
t.Fatal("startNetAdd refused with a free slot")
|
||||
}
|
||||
job := waitNetAdd(t, s)
|
||||
if job.Phase != netAddPhaseDone {
|
||||
t.Fatalf("phase = %s (category=%s detail=%s), want done", job.Phase, job.Category, job.Detail)
|
||||
}
|
||||
if registeredAtProbe != 0 {
|
||||
t.Errorf("AddStoragePath ran BEFORE the probe (count at probe = %d, want 0)", registeredAtProbe)
|
||||
}
|
||||
if got := networkPathCount(s); got != 1 {
|
||||
t.Errorf("registered network paths = %d, want exactly 1", got)
|
||||
}
|
||||
if len(agent.removed()) != 0 {
|
||||
t.Errorf("happy path must not roll back: removes=%v", agent.removed())
|
||||
}
|
||||
}
|
||||
|
||||
// --- C2: probe-fail ⇒ rollback + NOT registered + failed{not_writable} -------------------------------
|
||||
// Companion red-proof: drop the rollback call in the probe-fail branch → the removes assertion fails.
|
||||
func TestNetAdd_ProbeFail_RollsBackNotRegistered(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &fakeNetAgent{addRes: okAddRes("media"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
|
||||
s.netProbeFn = func(context.Context, string) probeOutcome {
|
||||
return probeOutcome{OK: false, Category: "not_writable", Detail: "uid-1000 write probe: create/write refused"}
|
||||
}
|
||||
|
||||
if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
|
||||
t.Fatal("startNetAdd refused")
|
||||
}
|
||||
job := waitNetAdd(t, s)
|
||||
if job.Phase != netAddPhaseFailed || job.Category != "not_writable" {
|
||||
t.Fatalf("phase/category = %s/%s, want failed/not_writable", job.Phase, job.Category)
|
||||
}
|
||||
// The §3.2 Route-A message, with the computed host id (1000 + 100000).
|
||||
if !strings.Contains(job.Message, "minden felhasználó leképezése") || !strings.Contains(job.Message, "101000") {
|
||||
t.Errorf("not_writable message must carry the map-all-users guidance + host id 101000: %q", job.Message)
|
||||
}
|
||||
if got := agent.removed(); len(got) != 1 || got[0] != "media" {
|
||||
t.Errorf("probe-fail must roll the agent install back: removes=%v", got)
|
||||
}
|
||||
if got := networkPathCount(s); got != 0 {
|
||||
t.Errorf("a probe-failed share must NOT be registered (got %d paths)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- C3: agent verify failed{code} ⇒ mapped Hungarian message, NOT registered ------------------------
|
||||
func TestNetAdd_AgentVerifyFailed_MappedMessage(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &fakeNetAgent{
|
||||
addRes: okAddRes("media"),
|
||||
verify: agentapi.NetVerifyStatus{Phase: "failed", Code: "nfs_export", Detail: "reason given by server: No such file or directory", JobID: "job-1"},
|
||||
}
|
||||
probeRan := false
|
||||
s.netProbeFn = func(context.Context, string) probeOutcome { probeRan = true; return probeOutcome{OK: true} }
|
||||
|
||||
if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
|
||||
t.Fatal("startNetAdd refused")
|
||||
}
|
||||
job := waitNetAdd(t, s)
|
||||
if job.Phase != netAddPhaseFailed || job.Category != "nfs_export" {
|
||||
t.Fatalf("phase/category = %s/%s, want failed/nfs_export", job.Phase, job.Category)
|
||||
}
|
||||
// The EXACT §3.2 merged message (NFSv4 cannot distinguish not-found from not-permitted).
|
||||
want := "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."
|
||||
if job.Message != want {
|
||||
t.Errorf("nfs_export message:\n got %q\nwant %q", job.Message, want)
|
||||
}
|
||||
if probeRan {
|
||||
t.Error("the probe must not run after a failed agent verify")
|
||||
}
|
||||
if got := networkPathCount(s); got != 0 {
|
||||
t.Errorf("a verify-failed share must NOT be registered (got %d)", got)
|
||||
}
|
||||
// The AGENT already auto-rolled-back — the controller must not double-remove on this branch.
|
||||
if got := agent.removed(); len(got) != 0 {
|
||||
t.Errorf("verify-failed is agent-rolled-back; controller removes=%v want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- C4: agent "no job" after install ⇒ rollback + failed (Scenario F) --------------------------------
|
||||
// Companion red-proof: treat "none" as success (or skip the rollback) → assertions fail.
|
||||
func TestNetAdd_VerifyLost_RollsBack(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &fakeNetAgent{
|
||||
addRes: okAddRes("media"),
|
||||
verify: agentapi.NetVerifyStatus{Phase: "none"}, // the agent restarted mid-verify
|
||||
}
|
||||
s.netProbeFn = func(context.Context, string) probeOutcome {
|
||||
t.Error("probe must not run when the verify was lost")
|
||||
return probeOutcome{OK: true}
|
||||
}
|
||||
|
||||
if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
|
||||
t.Fatal("startNetAdd refused")
|
||||
}
|
||||
job := waitNetAdd(t, s)
|
||||
if job.Phase != netAddPhaseFailed {
|
||||
t.Fatalf("phase = %s, want failed", job.Phase)
|
||||
}
|
||||
if got := agent.removed(); len(got) != 1 || got[0] != "media" {
|
||||
t.Errorf("verify-lost must roll back the install: removes=%v", got)
|
||||
}
|
||||
if got := networkPathCount(s); got != 0 {
|
||||
t.Errorf("a verify-lost share must NOT be registered (got %d)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- C5: the --netprobe child body (pure file logic, t.TempDir) ---------------------------------------
|
||||
func TestNetProbeChild(t *testing.T) {
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if got := NetProbeChild(dir); got != netProbeExitOK {
|
||||
t.Fatalf("exit = %d, want 0", got)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("probe must clean up its file, left: %v", entries)
|
||||
}
|
||||
})
|
||||
t.Run("unwritable dir → exit 2 → not_writable", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nope") // nonexistent → create fails everywhere
|
||||
if runtime.GOOS != "windows" {
|
||||
dir = t.TempDir()
|
||||
if err := os.Chmod(dir, 0o555); err != nil { // read-only — the squash-trap shape
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
|
||||
}
|
||||
got := NetProbeChild(dir)
|
||||
if got != netProbeExitNoWrite {
|
||||
t.Fatalf("exit = %d, want %d", got, netProbeExitNoWrite)
|
||||
}
|
||||
if v := netProbeVerdict(got, ""); v.OK || v.Category != "not_writable" {
|
||||
t.Errorf("verdict = %+v, want not_writable", v)
|
||||
}
|
||||
})
|
||||
t.Run("nonce tamper → exit 3 → probe_io", func(t *testing.T) {
|
||||
orig := netProbeReadBack
|
||||
netProbeReadBack = func(path string) ([]byte, error) { return []byte("tampered"), nil }
|
||||
t.Cleanup(func() { netProbeReadBack = orig })
|
||||
got := NetProbeChild(t.TempDir())
|
||||
if got != netProbeExitMismatch {
|
||||
t.Fatalf("exit = %d, want %d", got, netProbeExitMismatch)
|
||||
}
|
||||
if v := netProbeVerdict(got, ""); v.OK || v.Category != "probe_io" {
|
||||
t.Errorf("verdict = %+v, want probe_io", v)
|
||||
}
|
||||
})
|
||||
t.Run("cleanup fail → exit 4 → OK with warn", func(t *testing.T) {
|
||||
orig := netProbeReadBack
|
||||
// Read back correctly but DELETE the file first — the child's own Remove then fails.
|
||||
netProbeReadBack = func(path string) ([]byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
return data, nil
|
||||
}
|
||||
t.Cleanup(func() { netProbeReadBack = orig })
|
||||
got := NetProbeChild(t.TempDir())
|
||||
if got != netProbeExitCleanup {
|
||||
t.Fatalf("exit = %d, want %d", got, netProbeExitCleanup)
|
||||
}
|
||||
v := netProbeVerdict(got, "")
|
||||
if !v.OK || v.Warn == "" {
|
||||
t.Errorf("cleanup-fail must be OK-with-warn (§8), got %+v", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- C6: single-flight (Scenario G) -------------------------------------------------------------------
|
||||
// Companion red-proof: drop the acquire() running-check → the second add is accepted → FAIL.
|
||||
func TestNetAdd_SingleFlight(t *testing.T) {
|
||||
s := testServer(t)
|
||||
release := make(chan struct{})
|
||||
agent := &fakeNetAgent{addRes: okAddRes("m1"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
|
||||
s.netAgentFn = func() (netAgent, error) { return agent, nil }
|
||||
s.netProbeFn = func(context.Context, string) probeOutcome { <-release; return probeOutcome{OK: true} }
|
||||
|
||||
if !s.startNetAdd(agent, netAddReq("m1"), "NAS m1") {
|
||||
t.Fatal("first add refused")
|
||||
}
|
||||
// Second add through the HTTP handler while the first is blocked in probing.
|
||||
body := `{"name":"m2","protocol":"nfs","server":"10.0.0.6","export":"/srv/m2"}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/storage/netstorage/add", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
s.handleNetStorageAdd(w, r)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("second add: got %d want 409 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "már folyamatban van egy csatlakoztatás") {
|
||||
t.Errorf("409 must carry the Hungarian busy message: %s", w.Body.String())
|
||||
}
|
||||
close(release)
|
||||
job := waitNetAdd(t, s)
|
||||
if job.Phase != netAddPhaseDone || job.Name != "m1" {
|
||||
t.Errorf("first job must finish unaffected: %+v", job)
|
||||
}
|
||||
}
|
||||
|
||||
// --- C7: orphan surfacing — live-but-unregistered share renders as a remove-only row ------------------
|
||||
// Companion red-proof: a registry-only filter (drop the live-map sweep) loses the ghost row → FAIL.
|
||||
func TestNetStorage_OrphanRow(t *testing.T) {
|
||||
s := testServer(t)
|
||||
addNetworkPath(t, s, "media") // registered + live
|
||||
s.netListFn = func(context.Context) ([]agentapi.NetworkMountStatus, error) {
|
||||
return []agentapi.NetworkMountStatus{
|
||||
{Name: "media", Protocol: "nfs", Server: "10.0.0.5", Where: settings.NetworkMountRoot + "/media", Configured: true, Mounted: true, Reachable: true, Health: "ok"},
|
||||
{Name: "ghost", Protocol: "nfs", Server: "10.0.0.5", Where: settings.NetworkMountRoot + "/ghost", Configured: true, Mounted: false, Reachable: true, Health: "idle"},
|
||||
}, nil
|
||||
}
|
||||
items := s.networkStorageItems(context.Background())
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2 (registered + orphan): %+v", len(items), items)
|
||||
}
|
||||
var ghost *networkStorageItem
|
||||
for i := range items {
|
||||
if items[i].Name == "ghost" {
|
||||
ghost = &items[i]
|
||||
} else if items[i].Orphan {
|
||||
t.Errorf("registered share %q must not be an orphan", items[i].Name)
|
||||
}
|
||||
}
|
||||
if ghost == nil {
|
||||
t.Fatalf("live-but-unregistered share missing from the list: %+v", items)
|
||||
}
|
||||
if !ghost.Orphan || ghost.Label != "Árva megosztás: ghost" {
|
||||
t.Errorf("ghost row must be Orphan with the árva label, got %+v", *ghost)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNetAdd_StatusEndpoint_NoJob: the poll endpoint's empty shape.
|
||||
func TestNetAdd_StatusEndpoint_NoJob(t *testing.T) {
|
||||
s := testServer(t)
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/storage/netstorage/add/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleNetStorageAddStatus(w, r)
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"phase":"none"`) {
|
||||
t.Fatalf("empty status: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,13 @@ type Server struct {
|
||||
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
|
||||
wipeStagedEscrowFn func(ctx context.Context) error
|
||||
|
||||
// NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams.
|
||||
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
|
||||
netAdd netAddState
|
||||
netAgentFn func() (netAgent, error)
|
||||
netProbeFn func(ctx context.Context, dir string) probeOutcome
|
||||
netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error)
|
||||
|
||||
// Asset syncer for Hub-managed assets (optional)
|
||||
assetsSyncer *assets.Syncer
|
||||
|
||||
|
||||
@@ -288,8 +288,11 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
||||
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
||||
s.handleStorageDecommission(w, r)
|
||||
// NAS network storage (Part A2) — distinct from the drive lifecycle above (proxy to agent /netstorage/*).
|
||||
// Add is verify-before-commit: it starts a detached orchestration job the UI polls on add/status.
|
||||
case r.URL.Path == "/api/storage/netstorage/add" && r.Method == http.MethodPost:
|
||||
s.handleNetStorageAdd(w, r)
|
||||
case r.URL.Path == "/api/storage/netstorage/add/status" && r.Method == http.MethodGet:
|
||||
s.handleNetStorageAddStatus(w, r)
|
||||
case r.URL.Path == "/api/storage/netstorage" && r.Method == http.MethodGet:
|
||||
s.handleNetStorageList(w, r)
|
||||
case r.URL.Path == "/api/storage/netstorage/remove" && r.Method == http.MethodPost:
|
||||
|
||||
Reference in New Issue
Block a user