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:
@@ -57,6 +57,14 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Hidden re-exec mode (NAS verify-before-commit): the web server re-execs THIS binary as
|
||||||
|
// `felhom-controller --netprobe <dir>` with uid/gid-1000 credentials to prove a media app can
|
||||||
|
// write through a freshly-verified network share (SPIKE-nas-verify Q2). Handled before flag
|
||||||
|
// parsing; the exit code is the probe verdict (see web/netprobe.go).
|
||||||
|
if len(os.Args) == 3 && os.Args[1] == "--netprobe" {
|
||||||
|
os.Exit(web.NetProbeChild(os.Args[2]))
|
||||||
|
}
|
||||||
|
|
||||||
configPath := flag.String("config", "/opt/docker/felhom-controller/controller.yaml", "Path to configuration file")
|
configPath := flag.String("config", "/opt/docker/felhom-controller/controller.yaml", "Path to configuration file")
|
||||||
showVersion := flag.Bool("version", false, "Show version and exit")
|
showVersion := flag.Bool("version", false, "Show version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|||||||
@@ -652,7 +652,9 @@ type AddNetStorageRequest struct {
|
|||||||
Password string `json:"password,omitempty"` // SMB secret — pass-through, never persisted
|
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 {
|
type NetStorageAddResult struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
@@ -660,18 +662,67 @@ type NetStorageAddResult struct {
|
|||||||
GuestPath string `json:"guest_path"`
|
GuestPath string `json:"guest_path"`
|
||||||
HostUID int `json:"host_uid"`
|
HostUID int `json:"host_uid"`
|
||||||
HostGID int `json:"host_gid"`
|
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
|
// NetAddRefusedError is the agent's CATEGORIZED sync refusal of a netstorage add (the 2 s TCP
|
||||||
// the shared bind). Returns the in-guest path the media app's data dir is pointed at.
|
// 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) {
|
func (c *Client) AddNetStorage(ctx context.Context, req AddNetStorageRequest) (NetStorageAddResult, error) {
|
||||||
var out NetStorageAddResult
|
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 {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &out); err != nil {
|
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
|
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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ import (
|
|||||||
const defaultMediaUID = 1000
|
const defaultMediaUID = 1000
|
||||||
|
|
||||||
// networkStorageItem is the UI row: the registered descriptor + live per-share health from the agent.
|
// 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 {
|
type networkStorageItem struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
@@ -33,10 +36,12 @@ type networkStorageItem struct {
|
|||||||
Reachable bool `json:"reachable"`
|
Reachable bool `json:"reachable"`
|
||||||
Mounted bool `json:"mounted"`
|
Mounted bool `json:"mounted"`
|
||||||
Configured bool `json:"configured"`
|
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
|
// handleNetStorageAdd starts the verify-before-commit orchestration: sync input validation (a bad
|
||||||
// Kind=network StoragePath (no password persisted).
|
// 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) {
|
func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -81,46 +86,42 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
gid = defaultMediaUID
|
gid = defaultMediaUID
|
||||||
}
|
}
|
||||||
|
|
||||||
agent, err := s.agentClient()
|
agent, err := s.netAgentForAdd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||||
return
|
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)
|
label := strings.TrimSpace(req.Label)
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = "Hálózati tárhely: " + name
|
label = "Hálózati tárhely: " + name
|
||||||
}
|
}
|
||||||
// Register the Kind=network path. NO password is persisted — only the non-secret descriptors.
|
// The password rides INSIDE the request straight to the agent (0600 creds file) — it is never
|
||||||
sp := settings.StoragePath{
|
// persisted controller-side and never appears in the job status.
|
||||||
Path: res.GuestPath,
|
started := s.startNetAdd(agent, agentapi.AddNetStorageRequest{
|
||||||
Label: label,
|
Name: name, Protocol: proto, Server: server, Export: export,
|
||||||
Schedulable: true,
|
MappedUID: uid, MappedGID: gid, IdleTimeoutSec: req.IdleTimeoutSec,
|
||||||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
Username: req.Username, Password: req.Password,
|
||||||
Kind: settings.StorageKindNetwork,
|
}, label)
|
||||||
Protocol: proto,
|
if !started {
|
||||||
Server: server,
|
writeDiskJSON(w, http.StatusConflict, false, "már folyamatban van egy csatlakoztatás", nil)
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.logger.Printf("[INFO] [web] network storage added: %s (%s %s:%s) → %s", name, proto, server, export, 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{"registered": true, "name": name, "path": res.GuestPath})
|
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
|
// 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.
|
// Shared by the JSON list handler and the settings page render.
|
||||||
func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
|
func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
|
||||||
live := map[string]agentapi.NetworkMountStatus{}
|
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
|
||||||
lctx, cancel := context.WithTimeout(ctx, 5*time.Second) // never let a slow agent hang the page
|
defer cancel()
|
||||||
defer cancel()
|
if mounts, lerr := s.listNetStorage(lctx); lerr == nil {
|
||||||
if mounts, lerr := agent.ListNetStorage(lctx); lerr == nil {
|
for _, m := range mounts {
|
||||||
for _, m := range mounts {
|
live[m.Name] = m
|
||||||
live[m.Name] = m
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
s.logger.Printf("[WARN] [web] netstorage live health unavailable: %v", lerr)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
s.logger.Printf("[WARN] [web] netstorage live health unavailable: %v", lerr)
|
||||||
}
|
}
|
||||||
items := make([]networkStorageItem, 0)
|
items := make([]networkStorageItem, 0)
|
||||||
|
registered := map[string]bool{}
|
||||||
for _, sp := range s.settings.GetStoragePaths() {
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
if !sp.IsNetwork() {
|
if !sp.IsNetwork() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := pathBase(sp.Path)
|
name := pathBase(sp.Path)
|
||||||
|
registered[name] = true
|
||||||
it := networkStorageItem{
|
it := networkStorageItem{
|
||||||
Name: name, Label: sp.Label, Protocol: sp.Protocol,
|
Name: name, Label: sp.Label, Protocol: sp.Protocol,
|
||||||
Server: sp.Server, Export: sp.Export, Path: sp.Path, Health: "unknown",
|
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)
|
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
|
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})
|
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.
|
// pathBase returns the last path segment (the share name) of a /mnt/felhom-drives/<name> path.
|
||||||
func pathBase(p string) string {
|
func pathBase(p string) string {
|
||||||
p = strings.TrimRight(p, "/")
|
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.
|
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
|
||||||
wipeStagedEscrowFn func(ctx context.Context) error
|
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)
|
// Asset syncer for Hub-managed assets (optional)
|
||||||
assetsSyncer *assets.Syncer
|
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:
|
case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
|
||||||
s.handleStorageDecommission(w, r)
|
s.handleStorageDecommission(w, r)
|
||||||
// NAS network storage (Part A2) — distinct from the drive lifecycle above (proxy to agent /netstorage/*).
|
// 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:
|
case r.URL.Path == "/api/storage/netstorage/add" && r.Method == http.MethodPost:
|
||||||
s.handleNetStorageAdd(w, r)
|
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:
|
case r.URL.Path == "/api/storage/netstorage" && r.Method == http.MethodGet:
|
||||||
s.handleNetStorageList(w, r)
|
s.handleNetStorageList(w, r)
|
||||||
case r.URL.Path == "/api/storage/netstorage/remove" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/netstorage/remove" && r.Method == http.MethodPost:
|
||||||
|
|||||||
Reference in New Issue
Block a user