C6B-F2: refuse network-share removal while a deployed app binds it

handleNetStorageRemove now refuses (409, Hungarian, names the apps) when any
DEPLOYED stack's HDD_PATH is the share root or a subpath of it — the C6B live
event removed campaign6 under a running sonarr, and the agent's tolerated
best-effort stop steps then deleted the unit files under the busy mount,
leaving an unreapable orphaned autofs mount until host reboot. The guard cuts
that chain off at the product flow. The remove handler resolves the agent via
the netAgent seam (netAgentForAdd), making the negative control testable.
NOTE: the agent-side residual (tolerate-and-continue stop in felhom-agent
netmount.go RemoveNetworkMount) is out of this controller-only task's scope —
flagged in REPORT for a follow-up agent task. Red-proof recorded: disabling
the guard returns the live pre-fix removed:true.
This commit is contained in:
2026-07-14 15:28:24 +02:00
parent a829cdc91f
commit b49076db4b
3 changed files with 175 additions and 3 deletions
+44 -2
View File
@@ -3,6 +3,7 @@ package web
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
@@ -298,7 +299,19 @@ func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request)
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen név", nil)
return
}
agent, err := s.agentClient()
where := settings.NetworkMountRoot + "/" + name
// C6B-F2 guard (v0.130.0): refuse the removal while a DEPLOYED app's HDD_PATH lives on the
// share. Removing the share under a bound app strands the app's storage AND orphans the host
// automount (the agent's stop steps are tolerated best-effort, so a busy mount gets its unit
// files deleted anyway → an unreapable autofs mount until host reboot — the C6B-F2 orphan).
if apps := s.deployedAppsOnPath(where); len(apps) > 0 {
s.logger.Printf("[WARN] [web] netstorage remove %q refused: deployed app(s) on the share: %s", name, strings.Join(apps, ", "))
writeDiskJSON(w, http.StatusConflict, false, fmt.Sprintf(
"A tároló nem távolítható el, amíg alkalmazás használja: %s. Előbb távolítsa el vagy költöztesse át az alkalmazást.",
strings.Join(apps, ", ")), nil)
return
}
agent, err := s.netAgentForAdd()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
@@ -308,7 +321,6 @@ func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request)
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
where := settings.NetworkMountRoot + "/" + name
if err := s.settings.RemoveStoragePath(where); err != nil {
s.logger.Printf("[WARN] [web] netstorage deregister %q: %v", where, err)
}
@@ -316,6 +328,36 @@ func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request)
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"removed": true, "name": name})
}
// deployedAppsOnPath returns the display names of DEPLOYED stacks whose HDD_PATH is base itself
// or a subpath of it (apps on a share store <share-root>/<app>). Nil-safe on stackMgr.
func (s *Server) deployedAppsOnPath(base string) []string {
if s.stackMgr == nil || base == "" {
return nil
}
var apps []string
for _, st := range s.stackMgr.GetStacks() {
if !st.Deployed {
continue
}
cfg := s.stackMgr.LoadAppConfigByName(st.Name)
if cfg == nil {
continue
}
hdd := cfg.Env["HDD_PATH"]
if hdd == "" {
continue
}
if hdd == base || strings.HasPrefix(hdd, base+"/") {
name := st.Meta.DisplayName
if name == "" {
name = st.Name
}
apps = append(apps, name)
}
}
return apps
}
// 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 {