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
gates / gates (push) Successful in 52s
Red-proofed twice. MinAgent 0.158.1.
This commit is contained in:
@@ -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.
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user