v0.330.0: „Végleges leszerelés — mégis" asks the agent's disk list first and starts again the apps it stopped when the agent refuses (R-1144)
gates / gates (push) Successful in 52s

Red-proofed twice. MinAgent 0.158.1.
This commit is contained in:
2026-10-18 13:06:09 +02:00
parent 03ab121b25
commit 6c6bd87664
9 changed files with 253 additions and 37 deletions
+21
View File
@@ -1,3 +1,24 @@
## v0.330.0 — „Végleges leszerelés — mégis" no longer leaves a drive's apps stopped when the host refuses (R-1144) (2026-10-18)
**MinAgent: 0.158.1** — the floor's, carried (not above the vouched agent 0.159.1). Nothing here calls a new agent route.
No golden (`09` §3 decision 250). Task: the day brief of 2026-10-18 (felhom.eu `REPORT-day-2026-10-18.md`).
- **Ask first, where the agent already says it:** mode `anyway` reads the agent's own disk list (`GET /disks`) before it
stops anything; a drive whose role is not user-data (the agent's 403) or that holds the whole-guest backup target
(its 409) is refused with the plain sentence of v0.329.0 and NOTHING is stopped. No agent route was added; the agent
stays the authority (finalize asks it again), and a list it cannot give is no verdict.
- **Start again what was running:** when the finalize leaves the drive untouched after the stops (a policy refusal, or a
failed agent call — `driveUntouched`), exactly the apps that were running before the press are started again, and the
household reads that the drive was not retired and its apps run again (`disk.decommission.apps_restarted`, or
`…apps_not_all_restarted` when one would not start; HU + EN). A drive the agent let go keeps its apps stopped. The
stack manager is reached through a seam (`decomStacks`), so the tests never touch docker.
- Tests: `TestR1144_RefusalAfterStopStartsTheRunningAppsAgain` (403 and 409: starts = exactly immich + jellyfin, not the
stopped komga, not another drive's app), `TestR1144_AgentVerdictBeforeAnythingIsStopped` (role backup; backup target →
0 stops, 0 agent calls), `TestR1144_AcceptedRetirementKeepsTheAppsStopped`. Red-proofed twice (felhom.eu
`audits/day-2026-10-18/P3/redproof-r1144.txt`).
- The independent review of v0.328.0→v0.329.0 (2026-10-18) found nothing else in the controller (its F3 is this row).
- i18n: two keys born as keys; no page changed, every fixture byte-identical.
## v0.329.0 — each app reaches only its own data folder; homepage loses the Docker socket; a refused drive retirement changes nothing; a plain message for a virtual disk without a serial; `:latest` only for a release (R-1142, R-981, R-1063, R-1069, R-1143) (2026-10-18)
**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls a new agent route. No golden (`09` §3 decision 250).
+2
View File
@@ -1,6 +1,8 @@
# CONTEXT.md — Project Memory
> **2026-10-18 (day) — v0.330.0: „anyway" asks the agent's disk list first and starts again the apps it stopped when the
> agent refuses (R-1144).** MinAgent 0.158.1.
> **2026-10-18 (night) — v0.329.0: each app reaches only its own `appdata/<folder>` (R-1142, compiled-in folder map:
> paperless-ngx → paperless); the allow-list is EMPTY (homepage's socket removed, R-981); a drive retirement asks the agent
> first and a refusal changes nothing (R-1063); a plain message for a virtio disk without a serial (R-1069); `:latest`
+6 -15
View File
@@ -1,19 +1,10 @@
# REPORT — controller v0.329.0 (night 2026-10-17 → 18)
# REPORT — controller v0.330.0 (2026-10-18, day)
**In plain words.** Each app can now reach only its own data folder on the drive. Homepage no longer gets control of
Docker, so no app has a special right any more. When the host says „no" to retiring a drive, nothing changes on the
dashboard and the household reads why. A virtual disk that cannot be formatted gets a plain sentence instead of an
English error. Test builds no longer move the `:latest` tag. The full night report: felhom.eu
`REPORT-night-2026-10-18.md`.
**In plain words.** When a household presses „retire anyway" and the host says no, the drive's apps start again, and
the household is told so. Where the host's own disk list already says no, nothing is stopped at all.
| Row | What changed | Pinned by | Red-proof |
| Row | Change | Pinned by | Red-proof |
|---|---|---|---|
| R-1142 | `${HDD_PATH}` root = `appdata/<own folder>`; compiled-in map `paperless-ngx → paperless` | `TestAdmit_AnotherAppsAppdataFolderIsRefused`, `TestAllowList_AppdataFolderMustBeOneName` | root back at `appdata/` → 6 cases admitted |
| R-981 | allow-list EMPTY; homepage template has no socket (catalog) | `TestAllowList_HasNoExceptionAndRefusesHomepagesSocket` | old one-entry list → „1 entries" |
| R-1063 | agent asked first; 403/409 → plain 409, nothing changed | `TestFinalizeDecommission_AgentRefusalChangesNothing`, `TestDecommission_PolicyRefusalsAreTyped` | old order → drive marked, default moved |
| R-1069 | agent's no-durable-id refusal → `disk.err.no_durable_id` | `TestR1069_NoDurableIDIsAPlainRefusal`, `TestFormatDisk_NoDurableIDIsTyped` | branch removed → raw text reaches the household |
| R-1143 | `:latest` only for x.y.z | `scripts/test_build_push_once.sh` (16 cases) | every build adds `:latest` → 4 cases fail |
| R-1144 | precheck from the agent's disk list; restart exactly the apps that were running after a refusal | `TestR1144_*` (3) | no restart → starts = []; no precheck → 3 stops before the refusal |
Red-proof records: felhom.eu `documentation/audits/night-2026-10-18/P1/redproof-*.txt`. Green gate rc=0;
`controller_gates.py --fast` rc=0 (golden-notice advisory). i18n: three keys born as keys; every page fixture
byte-identical. MinAgent 0.158.1. Release path and box walk: the night report.
Gates: green gate rc=0; `controller_gates.py --fast` OK. Release path and box walk: felhom.eu `REPORT-day-2026-10-18.md`.
+5 -3
View File
@@ -2552,9 +2552,11 @@ that folder is never a dead end, and an install never runs into it silently (R-6
> retire a drive, non-destructive (never formats). Two choices (no partial): migrate-all-then-decommission
> (runs `MigrateAll`; the migration done-hook soft-marks the source + calls the agent on success), or
> decommission-anyway (type-to-confirm; stops the apps, keeps their `HDD_PATH` so they show "Hiányzó
> tárhely"). Both end at `settings.SetDecommissioned` (soft marker retained — blocks A1 resurrection) +
> `agentapi.Decommission` (agent v0.32.0: `POST /disks/decommission`, role-gated user-data, intent +
> bind-prune + unmount). Re-enrolling a decommissioned drive (`registerStoragePath`) clears the marker +
> tárhely"). Both end at `agentapi.Decommission` (agent v0.32.0: `POST /disks/decommission`, role-gated user-data,
> intent + bind-prune + unmount) and, only after its yes, `settings.SetDecommissioned` (soft marker retained — blocks
> A1 resurrection; the agent is asked FIRST since v0.329.0, R-1063). Since v0.330.0 (R-1144) „anyway" reads the
> agent's disk list before it stops anything (a protected role or the backup target is refused with nothing
> stopped), and when the agent refuses after the stops, the apps that were running start again. Re-enrolling a decommissioned drive (`registerStoragePath`) clears the marker +
> restores `Schedulable`. A deployed app whose drive is decommissioned/disconnected/absent shows the
> **"Hiányzó tárhely"** warning badge on the dashboard/stacks/app card.
>
+2
View File
@@ -1234,6 +1234,8 @@
"disk.err.bad_request": "that request is not valid",
"disk.err.device_required": "a device is required",
"disk.err.init_in_progress": "a drive is already being initialised",
"disk.decommission.apps_not_all_restarted": "The drive was not retired. Some of its apps did not start again — start them on the Apps page.",
"disk.decommission.apps_restarted": "The drive was not retired, and its apps are running again.",
"disk.err.name_mismatch": "the name you typed does not match the mount name",
"disk.err.no_durable_id": "This disk cannot be formatted from the dashboard: the virtual machine gives it no fixed name (serial number), so Felhom cannot identify it for certain. The disk was not changed. The operator, or the person who set up the virtual machine, can give the disk a serial number (or attach it on a SATA bus); then try again.",
"disk.err.protected": "this drive is protected (system / backup) — erasing it needs an operator signature",
+2
View File
@@ -1229,6 +1229,8 @@
"disk.err.bad_request": "érvénytelen kérés",
"disk.err.device_required": "eszköz kötelező",
"disk.err.init_in_progress": "már folyamatban van egy meghajtó-inicializálás",
"disk.decommission.apps_not_all_restarted": "A meghajtót nem szereltük le. Néhány rajta lévő alkalmazás nem indult el újra — indítsd el őket az Alkalmazások oldalon.",
"disk.decommission.apps_restarted": "A meghajtót nem szereltük le, és a rajta lévő alkalmazások újra futnak.",
"disk.err.name_mismatch": "a beírt név nem egyezik a csatlakoztatási névvel",
"disk.err.no_durable_id": "Ezt a lemezt a vezérlőpultról nem lehet formázni: a virtuális gép nem ad neki állandó nevet (sorozatszámot), ezért a Felhom nem tudja biztosan azonosítani. A lemez nem változott. Az üzemeltető vagy az, aki a virtuális gépet beállította, adhat a lemeznek sorozatszámot (vagy SATA-buszon csatolhatja), utána próbáld újra.",
"disk.err.protected": "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással",
@@ -0,0 +1,101 @@
package web
import (
"context"
"net/http"
"reflect"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// fakeDecomStacks is the stack-manager seam of „Végleges leszerelés — mégis": it records stops and starts and never
// reaches docker.
type fakeDecomStacks struct {
list []stacks.Stack
hdd map[string]string
stops, starts []string
}
func (f *fakeDecomStacks) GetStacks() []stacks.Stack { return f.list }
func (f *fakeDecomStacks) LoadAppConfigByName(n string) *stacks.AppConfig {
return &stacks.AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": f.hdd[n]}}
}
func (f *fakeDecomStacks) StopStack(n string) error { f.stops = append(f.stops, n); return nil }
func (f *fakeDecomStacks) StartStack(n string) error { f.starts = append(f.starts, n); return nil }
func r1144Setup(t *testing.T) (*Server, *fakeDecomStacks) {
t.Helper()
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})
st := func(n string, state stacks.ContainerState) stacks.Stack {
return stacks.Stack{Name: n, Deployed: true, State: state, Meta: stacks.Metadata{DisplayName: strings.ToUpper(n)}}
}
f := &fakeDecomStacks{
list: []stacks.Stack{st("immich", stacks.StateRunning), st("jellyfin", stacks.StateUnhealthy), st("komga", stacks.StateStopped), st("other", stacks.StateRunning)},
hdd: map[string]string{"immich": "/mnt/old", "jellyfin": "/mnt/old", "komga": "/mnt/old", "other": "/mnt/new"},
}
return s, f
}
// R-1144: the agent refuses AFTER the apps were stopped (403 here; its disk list gave no verdict) → exactly the apps
// that were running before the press run again (immich, jellyfin — not komga, which was stopped, and not other, on
// another drive), the drive is not marked, and the household reads both sentences.
// RED-PROOF: without the restart leg in decommissionAnyway, starts = [] and this test fails.
func TestR1144_RefusalAfterStopStartsTheRunningAppsAgain(t *testing.T) {
for _, code := range []int{http.StatusForbidden, http.StatusConflict} {
s, f := r1144Setup(t)
agent := &mockAgent{decommissionErr: &agentapi.DecommissionRefusedError{Status: code, Reason: "no"}}
stopped, all, err := s.decommissionAnyway(context.Background(), agent, f, "/mnt/old")
if err == nil || !driveUntouched(err) {
t.Fatalf("HTTP %d: err = %v, want a policy refusal", code, err)
}
if !reflect.DeepEqual(f.stops, []string{"immich", "jellyfin", "komga"}) {
t.Fatalf("stops = %v", f.stops)
}
if !reflect.DeepEqual(f.starts, []string{"immich", "jellyfin"}) || !all {
t.Fatalf("HTTP %d: starts = %v (all=%v), want exactly the apps that were running", code, f.starts, all)
}
if len(stopped) != 3 || s.settings.IsDecommissioned("/mnt/old") {
t.Fatalf("stopped=%v marked=%v", stopped, s.settings.IsDecommissioned("/mnt/old"))
}
for lang, frag := range map[string]string{"hu": "újra futnak", "en": "running again"} {
if !strings.Contains(util.Text(lang, "disk.decommission.apps_restarted"), frag) {
t.Errorf("[%s] the restart sentence lacks %q", lang, frag)
}
}
}
}
// When the agent's own disk list already says no (a protected role, or the whole-guest backup target), NOTHING is
// stopped: the refusal comes before the first StopStack.
func TestR1144_AgentVerdictBeforeAnythingIsStopped(t *testing.T) {
for name, d := range map[string]agentapi.DiskInfo{
"protected role": {MountPath: "/mnt/old", Role: "backup"},
"backup target": {MountPath: "/mnt/old", Role: "user-data", BackupTarget: true},
} {
s, f := r1144Setup(t)
agent := &mockAgent{disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{d}}}
_, _, err := s.decommissionAnyway(context.Background(), agent, f, "/mnt/old")
if !isDecommissionPolicyRefusal(err) || len(f.stops) != 0 || len(agent.decommissionCalls) != 0 {
t.Fatalf("%s: err=%v stops=%v agent calls=%v — want a refusal before anything is stopped", name, err, f.stops, agent.decommissionCalls)
}
}
}
// The agent says yes (200) → retired as before: marked, apps stay stopped, nothing restarted.
func TestR1144_AcceptedRetirementKeepsTheAppsStopped(t *testing.T) {
s, f := r1144Setup(t)
agent := &mockAgent{disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{{MountPath: "/mnt/old", Role: "user-data"}}}}
if _, all, err := s.decommissionAnyway(context.Background(), agent, f, "/mnt/old"); err != nil || !all {
t.Fatalf("err=%v all=%v", err, all)
}
if len(f.starts) != 0 || !s.settings.IsDecommissioned("/mnt/old") {
t.Fatalf("starts=%v marked=%v — a retired drive's apps must stay stopped", f.starts, s.settings.IsDecommissioned("/mnt/old"))
}
}
+111 -18
View File
@@ -547,28 +547,28 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "disk.err.name_mismatch"), nil)
return
}
// Stop the apps that live on this drive — but KEEP their HDD_PATH so the dashboard can name the
// drive in the "missing storage" indicator until the customer re-enrolls or migrates.
var stopped []string
for _, st := range s.stackMgr.GetStacks() {
if !st.Deployed {
continue
}
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil && cfg.Env["HDD_PATH"] == req.Where {
if err := s.stackMgr.StopStack(st.Name); err != nil {
s.logger.Printf("[WARN] [web] decommission: stop %s failed: %v", st.Name, err)
}
stopped = append(stopped, st.Meta.DisplayName)
}
agent, aerr := s.agentClient()
if aerr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, aerr), nil)
return
}
if err := s.finalizeDecommission(r.Context(), req.Where, ""); err != nil {
// 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.
stopped, restartedAll, err := s.decommissionAnyway(r.Context(), agent, s.stackMgr, req.Where)
if err != nil {
// The policy refusals (M1, the agent's 403/409) are 409s (CONFLICT with current state), not gateway
// failures — and a 409 passes the CF edge with its JSON body intact.
status := http.StatusBadGateway
if isDecommissionPolicyRefusal(err) {
status = http.StatusConflict
}
writeDiskJSON(w, status, false, s.errText(r, err), nil)
msg := s.errText(r, err)
if len(stopped) > 0 && driveUntouched(err) {
key := "disk.decommission.apps_restarted"
if !restartedAll {
key = "disk.decommission.apps_not_all_restarted"
}
msg += " " + s.msg(r, key)
}
writeDiskJSON(w, status, false, msg, map[string]any{"stopped_apps": stopped, "restarted": restartedAll})
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"decommissioned": true, "where": req.Where, "stopped_apps": stopped})
@@ -590,6 +590,99 @@ var (
errDecommissionBackupTarget = util.MsgError("err.web.decommission_backup_target")
)
// errDecommissionNotDone marks a finalize failure in which the agent did NOT decommission the drive (its call failed):
// like a policy refusal, the drive and the registry are as they were.
var errDecommissionNotDone = errors.New("the drive was not decommissioned")
// driveUntouched: the finalize failed BEFORE the drive was let go — a policy refusal or a failed agent call — so the
// drive still serves and the apps stopped for it can run again. NOT true when the agent let go and only the registry
// mark failed: then the drive is gone and its apps must stay stopped.
func driveUntouched(err error) bool {
return isDecommissionPolicyRefusal(err) || errors.Is(err, errDecommissionNotDone)
}
// decomStacks is the slice of the stack manager „Végleges leszerelés — mégis" uses (a seam: tests never reach docker).
type decomStacks interface {
GetStacks() []stacks.Stack
LoadAppConfigByName(name string) *stacks.AppConfig
StopStack(name string) error
StartStack(name string) error
}
// wasUp: the app was running (in any of its running shapes) before the press.
func wasUp(st stacks.ContainerState) bool {
switch st {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateDegraded, stacks.StateRestarting:
return true
}
return false
}
// decommissionAnyway is mode „anyway": ask the agent's verdict BEFORE stopping anything where the agent already says it
// (its own disk list: a role other than user-data → 403, the whole-guest backup target → 409), then stop the drive's
// apps, then finalize. R-1144 (2026-10-18): when the finalize leaves the drive untouched (a policy refusal, a failed
// agent call), EXACTLY the apps that were running before the press are started again. Returns the stopped apps'
// display names and whether every app that was running is running again. Pinned by TestR1144_*.
func (s *Server) decommissionAnyway(ctx context.Context, agent diskAgent, sm decomStacks, where string) (stopped []string, restartedAll bool, err error) {
if perr := s.decommissionPrecheck(ctx, agent, where); perr != nil {
return nil, true, perr
}
// Stop the apps that live on this drive — but KEEP their HDD_PATH so the dashboard can name the drive in the
// "missing storage" indicator until the customer re-enrolls or migrates.
var wereUp []string
for _, st := range sm.GetStacks() {
if !st.Deployed {
continue
}
if cfg := sm.LoadAppConfigByName(st.Name); cfg != nil && cfg.Env["HDD_PATH"] == where {
up := wasUp(st.State)
if serr := sm.StopStack(st.Name); serr != nil {
s.logger.Printf("[WARN] [web] decommission: stop %s failed: %v", st.Name, serr)
}
if up {
wereUp = append(wereUp, st.Name)
}
stopped = append(stopped, st.Meta.DisplayName)
}
}
err = s.finalizeDecommissionWith(ctx, agent, where, "")
if err == nil || !driveUntouched(err) {
return stopped, err == nil, err
}
restartedAll = true
for _, name := range wereUp {
if serr := sm.StartStack(name); serr != nil {
restartedAll = false
s.logger.Printf("[ERROR] [web] decommission of %s refused, and %s did not start again: %v", where, name, serr)
}
}
s.logger.Printf("[INFO] [web] decommission of %s did not happen — %d app(s) that were running started again (all=%v)", where, len(wereUp), restartedAll)
return stopped, restartedAll, err
}
// decommissionPrecheck answers the agent's two policy refusals from its own disk list, before anything is stopped.
// The agent stays the authority (finalize asks it again); a list it cannot give, or a drive not in it, is no verdict.
func (s *Server) decommissionPrecheck(ctx context.Context, agent diskAgent, where string) error {
resp, err := agent.Disks(ctx)
if err != nil {
return nil
}
raw := agentWhere(where)
for _, d := range resp.Disks {
if d.MountPath != raw && d.GuestPath != where {
continue
}
if d.BackupTarget {
return errDecommissionBackupTarget
}
if d.Role != "" && d.Role != "user-data" {
return errDecommissionProtected
}
return nil
}
return nil
}
// 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) ||
@@ -632,7 +725,7 @@ func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent,
}
return errDecommissionProtected
}
return util.MsgError("err.web.a_meghajto_leszerelese_sikertelen", err)
return util.MsgErrorf(errDecommissionNotDone, "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
+3 -1
View File
@@ -277,7 +277,9 @@
"note.tier2.reason_no_second": "REWORDED (v0.321.0, 2026-10-16) -- plain words on the app page's Data tab: no 'off-drive', no 'DB/konfiguracio'. Pinned by web TestAppData_OneBackupSection.",
"disk.err.no_durable_id": "BORN AS A KEY (R-1069, 2026-10-18) -- the plain refusal for a disk the agent cannot bind a format to (a virtio disk without a serial); replaces the agent's raw English text. Pinned by internal/web TestR1069_NoDurableIDIsAPlainRefusal.",
"err.web.decommission_backup_target": "BORN AS A KEY (R-1063, 2026-10-18) -- the agent's 409 to a drive retirement (the drive holds the system backup), in plain words; nothing changed. Pinned by internal/web TestFinalizeDecommission_AgentRefusalChangesNothing.",
"err.web.decommission_protected": "BORN AS A KEY (R-1063, 2026-10-18) -- the agent's 403 to a drive retirement (a protected system/backup disk), in plain words; nothing changed. Pinned by internal/web TestFinalizeDecommission_AgentRefusalChangesNothing."
"err.web.decommission_protected": "BORN AS A KEY (R-1063, 2026-10-18) -- the agent's 403 to a drive retirement (a protected system/backup disk), in plain words; nothing changed. Pinned by internal/web TestFinalizeDecommission_AgentRefusalChangesNothing.",
"disk.decommission.apps_restarted": "BORN AS A KEY (R-1144, 2026-10-18) -- after a refused „Végleges leszerelés — mégis\", the apps that were running are started again; this sentence says so. Pinned by internal/web TestR1144_RefusalAfterStopStartsTheRunningAppsAgain.",
"disk.decommission.apps_not_all_restarted": "BORN AS A KEY (R-1144, 2026-10-18) -- after a refused „Végleges leszerelés — mégis\", the apps that were running are started again; this sentence says so. Pinned by internal/web TestR1144_RefusalAfterStopStartsTheRunningAppsAgain."
},
"flash.share.already_on": "A megosztás már be van kapcsolva.",
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",