v0.329.0: each app reaches only its own appdata folder; empty allow-list; drive retirement asks the agent first; plain no-durable-id refusal; :latest only for a release
gates / gates (push) Successful in 45s

R-1142: the HDD_PATH root is appdata/<own folder>, with a compiled-in folder map (paperless-ngx ->
paperless). R-981: the allow-list is empty (homepage's socket served no Felhom feature). R-1063:
finalizeDecommissionWith asks the agent before it marks the drive or moves the default; the 403/409
come back typed and reach the household as a plain 409. R-1069: the agent's no-durable-id format
refusal is ErrNoDurableID and a plain sentence. R-1143: build.sh carries :latest only for x.y.z.
Each pinned by a red-proofed test. MinAgent 0.158.1.
This commit is contained in:
2026-10-17 23:11:56 +02:00
parent 5d67041d6d
commit 2fe634141f
20 changed files with 504 additions and 78 deletions
+45 -9
View File
@@ -142,6 +142,9 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs
}
return res, nil // STOP — no bypass; the UI surfaces Opsign.
}
if errors.Is(err, agentapi.ErrNoDurableID) {
return storageInitResult{}, util.MsgError("disk.err.no_durable_id") // R-1069: plain words, nothing changed
}
if err != nil && !isAgentTimeout(err) {
return storageInitResult{}, util.MsgError("err.web.formazas_sikertelen", err)
}
@@ -562,7 +565,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
// The M1 policy refusal is a 409 (CONFLICT with current state), not a gateway
// failure — and 409 passes the CF edge with its JSON body intact.
status := http.StatusBadGateway
if errors.Is(err, errLastUsableDrive) {
if isDecommissionPolicyRefusal(err) {
status = http.StatusConflict
}
writeDiskJSON(w, status, false, s.errText(r, err), nil)
@@ -580,6 +583,19 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
// browser intact (a 502 would be swallowed by the Cloudflare edge error page).
var errLastUsableDrive = util.MsgError("err.web.ez_az_egyetlen_hasznalhato_tarhely_a")
// errDecommissionProtected / errDecommissionBackupTarget are the agent's two policy refusals (R-1063), in plain
// words. Both are 409s like the M1 refusal, and in both NOTHING changed: the agent is asked first.
var (
errDecommissionProtected = util.MsgError("err.web.decommission_protected")
errDecommissionBackupTarget = util.MsgError("err.web.decommission_backup_target")
)
// isDecommissionPolicyRefusal: a refusal the household can act on (409 with its JSON), not a gateway failure.
func isDecommissionPolicyRefusal(err error) bool {
return errors.Is(err, errLastUsableDrive) || errors.Is(err, errDecommissionProtected) ||
errors.Is(err, errDecommissionBackupTarget)
}
// finalizeDecommission resolves the agent client then soft-marks + decommissions (see *With).
func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo string) error {
agent, err := s.agentClient()
@@ -589,10 +605,15 @@ func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo str
return s.finalizeDecommissionWith(ctx, agent, where, migratedTo)
}
// finalizeDecommissionWith soft-marks the registry path (keeping the entry — blocks A1 resurrection)
// and tells the agent to decommission the drive (intent + unmount; never formats). migratedTo is the
// target path for a migrate-then-decommission, or "" for decommission-anyway. The agent is injected so
// the orchestration is testable without a live agent.
// finalizeDecommissionWith tells the agent to decommission the drive (intent + unmount; never formats) and, only
// once the agent said yes, soft-marks the registry path (keeping the entry — blocks A1 resurrection) and moves the
// default. migratedTo is the target path for a migrate-then-decommission, or "" for decommission-anyway. The agent
// is injected so the orchestration is testable without a live agent.
//
// R-1063 (2026-10-18): the AGENT IS ASKED FIRST. Until v0.328.0 the mark and the default moved before the agent was
// asked, and its 403 (protected role) or 409 (whole-guest backup target) left the dashboard showing a retired drive
// the agent kept serving. On a refusal nothing changes now, and the household reads which refusal it was.
// Pinned by TestFinalizeDecommission_AgentRefusalChangesNothing.
func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent, where, migratedTo string) error {
// M1: never leave zero default. If `where` is the default, promote another usable drive; if none
// exists, BLOCK before any side-effect (compute against the registry BEFORE SetDecommissioned, which
@@ -601,7 +622,22 @@ func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent,
if mustBlock {
return errLastUsableDrive
}
// Registered path is the STABLE /mnt/felhom-drives/<name>; the agent decommissions the raw mount.
if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil {
var refused *agentapi.DecommissionRefusedError
if errors.As(err, &refused) {
s.logger.Printf("[INFO] [web] decommission of %s refused by the agent (HTTP %d) — nothing changed", where, refused.Status)
if refused.Status == http.StatusConflict {
return errDecommissionBackupTarget
}
return errDecommissionProtected
}
return util.MsgError("err.web.a_meghajto_leszerelese_sikertelen", err)
}
if err := s.settings.SetDecommissioned(where, migratedTo); err != nil {
// The agent has let go of the drive; only the dashboard's record failed. Said loudly — the next
// decommission press is idempotent on the agent side and writes the mark.
s.logger.Printf("[ERROR] [web] decommission of %s: the agent decommissioned it but the registry mark failed: %v", where, err)
return util.MsgError("err.web.nyilvantartas_frissitese_sikertelen", err)
}
if promote != "" {
@@ -611,10 +647,6 @@ func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent,
s.logger.Printf("[INFO] [web] default drive reassigned %s → %s (M1)", where, promote)
}
}
// Registered path is the STABLE /mnt/felhom-drives/<name>; the agent decommissions the raw mount.
if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil {
return util.MsgError("err.web.a_meghajto_leszerelese_sikertelen", err)
}
if s.stackMgr != nil {
go s.SyncFileBrowserMounts()
}
@@ -818,6 +850,10 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.protected"), probe)
return
}
if errors.Is(perr, agentapi.ErrNoDurableID) {
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.no_durable_id"), nil)
return
}
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
if perr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", s.errText(r, perr)), nil)