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:
@@ -401,6 +401,11 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request)
|
||||
writeDiskJSON(w, http.StatusConflict, false, "customer confirmation required", resp)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agentapi.ErrNoDurableID) {
|
||||
s.logger.Printf("[INFO] [web] disk format refused by agent: %s has no durable id (a virtual disk without a serial, R-1069)", req.Device)
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.no_durable_id"), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agentapi.ErrFormatRefused) {
|
||||
s.logger.Printf("[WARN] [web] disk format refused by agent (system/backup-protected): %s", req.Device)
|
||||
writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp)
|
||||
|
||||
@@ -2,8 +2,15 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
@@ -115,3 +122,57 @@ func TestMissingStorageLabel(t *testing.T) {
|
||||
t.Errorf("unregistered path: missing=%v label=%q, want true/non-empty", missing, label)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1063: the agent is asked FIRST. When it refuses — 403 (a protected role) or 409 (the whole-guest backup target) —
|
||||
// nothing changes on the dashboard: the drive is not marked decommissioned, the default does not move, and the
|
||||
// household reads a plain reason in its language (a 409 to the browser, like the M1 refusal).
|
||||
// RED-PROOF: with SetDecommissioned + the default move back BEFORE agent.Decommission (the v0.328.0 order), the
|
||||
// drive reads decommissioned and the default moved, and this test fails.
|
||||
func TestFinalizeDecommission_AgentRefusalChangesNothing(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
status int
|
||||
want error
|
||||
huFrag, enFrag string
|
||||
}{
|
||||
{http.StatusForbidden, errDecommissionProtected, "rendszer v", "system protects this drive"},
|
||||
{http.StatusConflict, errDecommissionBackupTarget, "rendszermentés helyéül", "place for the system backup"},
|
||||
} {
|
||||
t.Run(strconv.Itoa(c.status), func(t *testing.T) {
|
||||
s := testServer(t)
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true})
|
||||
agent := &mockAgent{decommissionErr: &agentapi.DecommissionRefusedError{Status: c.status, Reason: "refused"}}
|
||||
|
||||
err := s.finalizeDecommissionWith(context.Background(), agent, "/mnt/old", "")
|
||||
if !errors.Is(err, c.want) {
|
||||
t.Fatalf("err = %v, want the plain refusal %v", err, c.want)
|
||||
}
|
||||
if len(agent.decommissionCalls) != 1 {
|
||||
t.Fatalf("agent asked %d times, want 1", len(agent.decommissionCalls))
|
||||
}
|
||||
if s.settings.IsDecommissioned("/mnt/old") {
|
||||
t.Error("the drive reads decommissioned although the agent refused")
|
||||
}
|
||||
if got := s.settings.GetDefaultStoragePath(); got != "/mnt/old" {
|
||||
t.Errorf("default moved to %q although the agent refused", got)
|
||||
}
|
||||
if !isDecommissionPolicyRefusal(err) {
|
||||
t.Error("the refusal is not mapped to 409 (the CF edge would swallow a 502's body)")
|
||||
}
|
||||
if hu := util.ErrText("hu", err); !strings.Contains(hu, c.huFrag) || !strings.Contains(hu, "Semmi nem v") {
|
||||
t.Errorf("HU text = %q", hu)
|
||||
}
|
||||
if en := util.ErrText("en", err); !strings.Contains(en, c.enFrag) || !strings.Contains(en, "Nothing was changed") {
|
||||
t.Errorf("EN text = %q", en)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Any other agent failure is still the old „failed" message, and changes nothing either.
|
||||
s := testServer(t)
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true})
|
||||
err := s.finalizeDecommissionWith(context.Background(), &mockAgent{decommissionErr: errors.New("agentapi: dial: refused")}, "/mnt/old", "")
|
||||
if err == nil || isDecommissionPolicyRefusal(err) || s.settings.IsDecommissioned("/mnt/old") {
|
||||
t.Fatalf("a transport failure: err=%v policy=%v marked=%v", err, isDecommissionPolicyRefusal(err), s.settings.IsDecommissioned("/mnt/old"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-1069: a disk the agent cannot bind a format to (a virtio disk without a serial) is refused with a plain
|
||||
// sentence in the household's language — what happened, that nothing changed, who can fix it — and the init stops
|
||||
// before any mount or registration. The agent's refusal itself is unchanged (a safety rule).
|
||||
// RED-PROOF: with the ErrNoDurableID branch removed from runStorageInit, the error is „formázás sikertelen:
|
||||
// agentapi: format refused — …" and the HU/EN assertions fail.
|
||||
func TestR1069_NoDurableIDIsAPlainRefusal(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &mockAgent{formatErr: agentapi.ErrNoDurableID}
|
||||
_, err := s.runStorageInit(context.Background(), agent, "/dev/vdb", "ext4", "/mnt/felhom-drives/adat", "Adat", false, false, "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("the init went on although the agent refused the format")
|
||||
}
|
||||
if len(agent.assignCalls) != 0 {
|
||||
t.Fatalf("a mount was attempted after the refusal: %v", agent.assignCalls)
|
||||
}
|
||||
hu, en := util.ErrText("hu", err), util.ErrText("en", err)
|
||||
for _, frag := range []string{"virtu", "sorozatsz", "nem v", "zemeltet"} {
|
||||
if !strings.Contains(hu, frag) {
|
||||
t.Errorf("HU text lacks %q: %q", frag, hu)
|
||||
}
|
||||
}
|
||||
for _, frag := range []string{"virtual machine", "serial number", "was not changed", "operator"} {
|
||||
if !strings.Contains(en, frag) {
|
||||
t.Errorf("EN text lacks %q: %q", frag, en)
|
||||
}
|
||||
}
|
||||
if strings.Contains(hu, "agentapi") || strings.Contains(en, "agentapi") {
|
||||
t.Errorf("the raw agent text reached the household: %q / %q", hu, en)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -57,6 +57,7 @@ type mockAgent struct {
|
||||
formatStatusCalls int
|
||||
guestAttachCalls []string
|
||||
decommissionCalls []string
|
||||
decommissionErr error
|
||||
guestRebootCalls int
|
||||
guestRebootErr error
|
||||
candidates agentapi.CandidatesResult
|
||||
@@ -99,6 +100,9 @@ func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectRe
|
||||
}
|
||||
func (m *mockAgent) Decommission(_ context.Context, where string) (agentapi.DecommissionResult, error) {
|
||||
m.decommissionCalls = append(m.decommissionCalls, where)
|
||||
if m.decommissionErr != nil {
|
||||
return agentapi.DecommissionResult{}, m.decommissionErr
|
||||
}
|
||||
return agentapi.DecommissionResult{Decommissioned: where}, nil
|
||||
}
|
||||
func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
|
||||
|
||||
Reference in New Issue
Block a user