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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user