From 42a73e667a9f196c10d18d3e1b84096bee76d6e2 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 13 Sep 2026 08:52:25 +0200 Subject: [PATCH] v0.236.0: "delete my data too" deletes the data, or says that it could not (R-442) Removal resolves the drive from the app's own app.yaml HDD_PATH (the 07 ~L437 rule), never the global cfg.Paths.HDDPath which no box sets. A data removal that cannot be resolved, or whose drive is absent, is refused with a typed RemoveRefusedError -> 409 + exact Hungarian sentence, before compose down, and the app is kept. SSD app -> hdd_paths_removed: [] never null; missing folders stated; backup-path refusals reach the response. 15 tests, two red-proofs run (pre-fix fallback -> C fails with err=nil and the handler 200s; "no drive refuses" -> D fails). Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 49 ++ CONTEXT.md | 9 +- REUSE.md | 2 +- controller/README.md | 6 +- controller/internal/api/remove_r442_test.go | 95 ++++ controller/internal/api/router.go | 13 + controller/internal/stacks/delete.go | 183 +++++++- .../internal/stacks/delete_r442_test.go | 418 ++++++++++++++++++ controller/internal/web/templates/layout.html | 9 + 9 files changed, 763 insertions(+), 21 deletions(-) create mode 100644 controller/internal/api/remove_r442_test.go create mode 100644 controller/internal/stacks/delete_r442_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c0f5f..da2c8f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,52 @@ +## v0.236.0 — "delete my data too" deletes the data, or says that it could not (2026-09-13, R-442) + +**The defect.** A customer removes an app and ticks *also delete my data*. The box says it worked; +**the data is still on the disk** — measured 2026-09-01 on demo-hp: `HTTP 200` with +`hdd_paths_removed: null, hdd_paths_preserved: null` and 128 MB of Nextcloud left at +`/mnt/felhom-drives/hdd_1/appdata/nextcloud`. `DeleteStack`, `RemoveStack` and `GetStackHDDData` +resolved the drive from the GLOBAL `cfg.Paths.HDDPath`, which has no default and is set on no box, so +`ParseComposeHDDMounts` returned nil on its first line and the removal reported, truthfully, that it +removed none — as a success. + +**Fix 1 — the source of truth.** The three lookups now read the app's OWN recorded `HDD_PATH` from +`app.yaml` (`Manager.appHDDPath`, via `LoadAppConfigByName`) — the rule `07-backup-architecture.md` +states (~L437: *"the drive if the app declares one (`HDD_PATH`), the system data path otherwise"*) and +that deploy, the start gate and the backup destination already implement. **Deliberately no fallback +to the global** when the per-app value is empty: that silent fallback is the exact path this closes. + +**Fix 2 — success is never reported over inaction** (the R-443 class). When the data was asked for and +the box cannot resolve where it is, the removal is REFUSED with a typed `stacks.RemoveRefusedError` +that the handler maps to **HTTP 409** and the exact sentence +„Az alkalmazás adatainak helye nem állapítható meg, ezért semmit nem töröltünk. Az alkalmazás nem +lett eltávolítva.” The refusal happens BEFORE `compose down`, so a refused removal has touched nothing +— **the app is kept too**: an app gone with its data left behind cannot even be re-run from the UI. +A recorded drive that is not mounted right now refuses the same way („A(z) %s tárhely jelenleg nem +elérhető — az alkalmazás nem távolítható el, amíg a meghajtó vissza nem csatlakozik.”), via the same +`DriveLive` signal the belt and the boot reconciler use. + +**"Declares no drive" ≠ "cannot resolve the drive".** An SSD-resident app (no `HDD_PATH`, no +`${HDD_PATH}`/`${USERDATA_PATH}` bind in its compose) is NOT refused: `hdd_paths_removed: []` — an +empty list, never `null` — plus `hdd_note` saying the app kept no data on a drive. A recorded folder +that is already gone is listed under `hdd_paths_missing` and stated, not refused. + +**The backup half gets the same honesty.** The "outside expected directory" refusal now reaches the +response as `backup_paths_refused` (path + reason), not only a WARN line. Its base is resolved by the +same rule (the app's namespace root — its own drive, or `/felhom-data` otherwise), which +is what the router's `AppNamespaceRoot` produced the paths from; under the global lookup every backup +path of every app on every box was being refused, silently. + +**Tests (15 new, two red-proofs run):** `internal/stacks/delete_r442_test.go` asserts the folder is +gone / present on a real tree, the marker-leaving stub compose never ran on a refusal, `[]` +serialisation, the userdata root untouched; `internal/api/remove_r442_test.go` drives the real +handler → real `Manager` (constructor + `ScanStacks`) → 409 + exact sentence + `app.yaml` still on +disk. Red-proof 1 (pre-fix silent fallback): C fails with `err=nil … app.yaml present=false` and the +handler test with `HTTP 200 ok=true`. Red-proof 2 ("no drive" refuses): the SSD test fails with the +refusal sentence. UI: both success modals render `hdd_note` and „Nem törölt mentések”. + +**Observation:** `cfg.Paths.HDDPath` still has readers outside removal (`report/builder.go`, +`monitor/healthcheck.go`, `api/router.go` system-info, `web/server.go`, `main.go` auto-discovery + +metrics) — left alone; not deleted here. + ## v0.235.0 — freeze the version, keep the fixes flowing (2026-09-06, update arc slice 3) **OPERATOR RULING, 2026-09-06 — Option 1.** R-447 was `BLOCKED` because R-438 established that diff --git a/CONTEXT.md b/CONTEXT.md index 430393f..68777ef 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,14 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-09-06 (v0.235.0 — the version freeze: slice 3, on the operator's ruling) +Last updated: 2026-09-13 (v0.236.0 — R-442: "delete my data too" deletes it or refuses) + +> **2026-09-13 — v0.236.0 (R-442).** Removal resolves the drive from the app's OWN `app.yaml` +> `HDD_PATH` (the `07` ~L437 rule), never the global `cfg.Paths.HDDPath` (set on no box); a data +> removal it cannot resolve is REFUSED (409, exact Hungarian sentence, typed `RemoveRefusedError`) +> before anything is touched and the app is kept. SSD app → `hdd_paths_removed: []`, never `null`. +> Backup-path refusals reach the response. Proven live on demo-hp — see `REPORT.md`. + > **2026-09-06 — v0.235.0. THE RULING, AND THE TRAP IT SET.** > diff --git a/REUSE.md b/REUSE.md index 31a0be9..6182370 100644 --- a/REUSE.md +++ b/REUSE.md @@ -117,7 +117,7 @@ | `backup.AppStopGuard` (`Begin`/`End`/`Recover`) (R-166, v0.189.0) | controller/internal/backup/appstop_marker.go | `(opID, reason, stacks) error` / `()` / `() *AppStopRecovery` | THE crash marker for stop→work→start windows (volume dump, offbox reconstitute, `.fab` export) | Its **own** file (`appstop-state.json`), never quiesce's — one file, one writer. **A `defer` is NOT the mechanism** (Campaign 8 fault 10: SIGKILL runs no defer); the marker is. Written BEFORE the stop, cleared ONLY after a restart that succeeded; a FAILED restart deliberately KEEPS it. `Recover` RETURNS its outcome rather than notifying, because it must complete before the boot reconciler while the notifier does not exist yet | | `backup.AppStopGuard.SuppressedStacks` + `markStopped` / `releaseStarted` / `ReleaseFailed` (R-330, v0.224.0) | controller/internal/backup/appstop_suppress.go | `() map[string]bool` (nil-safe on a nil *AppStopGuard); `ReleaseFailed(stacks ...string)` | **R-330** — an app a PER-APP operation is holding stopped (nightly volume dump, offbox reconstitute, `.fab` export) is not a fault | The **twin** of `quiesce.Loop.SuppressedStacks` above, and the two are unioned by `unionSuppressed` in main.go before `classifyRunStates` — **consult BOTH or the bug comes back**: R-330 shipped because the alarm read only the quiesce set while the per-app legs stopped apps through a different path. Rides `Begin`/`End`, so all three call sites got it with no call-site change. Grace is `appStopAlarmGrace` = 180 s, deliberately the SAME constant and derivation as quiesce's — two windows over one alarm that disagreed would be a bug on whichever path used the shorter one. **It must never latch**, and unlike quiesce's loop `End()` runs ONLY on a restart that succeeded: (1) every failure path calls `ReleaseFailed`, which drops the entry IMMEDIATELY so the app alarms on the next scan; (2) `Begin` REPLACES the set (one marker file = one operation); (3) `appStopMaxHold` (6 h) caps an open-ended hold and logs at WARN. **Deliberately NOT persisted** — after a crash the guard holds nothing and a down app must alarm. `ReleaseFailed` drops the suppression and KEEPS the durable marker; the two are independent and a test pins that | | `backup.ErrStartRefused` + `AppStopRecovery.Refused`/`Alarming()` (R-174, v0.191.0) | controller/internal/backup/appstop_marker.go | `errors.Is(err, ErrStartRefused)` / `() bool` | THE refusal-vs-failure split in the app-stop crash recovery | **A gated starter's refusal is NOT a restart failure.** `Recover`'s starter MUST be the gated `gatedAppStopStarter` (cmd/controller/main.go), never the raw `stacks.Manager` — that was the v0.189.0 defect, which started apps onto ABSENT drives at boot (R-171 one path over). A refusal goes to `Refused` (marker KEPT, silent), a real error to `Failed` (marker kept, ALARMS). Collapsing them routes a deliberate hold into `NotifyBackupFailed`, a customer-enabled type — the R-171 false alarm again. `main.go` must guard the notify with `Alarming()`, not `!= nil` | -| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll | +| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never `cfg.Paths.HDDPath`; a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** | | `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix | | `Manager.recordInstalledImages` (v0.233.0) | controller/internal/stacks/installed.go | `(name, stackDir string, env []string)` | writing down what each compose SERVICE is ACTUALLY running, into `app.yaml.installed_images` | Called after a successful compose up from `StartStack`/`RestartStack`/`UpdateStack`/`runComposeDeploy`. **Reads the CONTAINER, never `docker-compose.yml`** — that file is the value the syncer has already moved (spike §3: 25 minutes of disagreement). **A failed write NEVER refuses the action** — the deliberate OPPOSITE of `SetDesiredState`: intent refused, observation logged at ERROR. **NOT from `StartStackServices`** (the R-47 DB-only window would overwrite a complete record with a partial one). Skips the write when ref+digest are unchanged, and carries `at` forward so it means "running since". Its OWN seam (`installedExecFn`) with a **context + 30 s timeout** — the two existing exec helpers have neither | | `Manager.SetPin` / `AdoptPins` / `RenderPlanFor` / `AppliedComposePath` (v0.235.0) | controller/internal/stacks/pin.go | `SetPin(name, stackDir, pin, composeSrc) error` | THE version freeze — `app.yaml.pinned_images` + the stored `applied-compose.yml` | **`PinnedImages` is INTENT, `InstalledImages` is an OBSERVATION — never feed one from the other** (the R-166 category error, one field over). Four writers only: deploy, `UpdateStack` (via `advancePinToCatalog`, which advances the pin and re-renders BEFORE the pull, and REFUSES the update if the pin cannot be written), the restore adapter (this is what closes R-441), and `AdoptPins`. `AdoptPins` reuses `observationCoversTemplate` — do NOT write a second completeness rule — and skips loudly rather than inventing a pin. Absent pin = pre-v0.235.0 behaviour | diff --git a/controller/README.md b/controller/README.md index 972f6f5..e917234 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3720,10 +3720,10 @@ All daily jobs use Europe/Budapest timezone. Skip-if-running prevents concurrent | POST | `/api/stacks/{name}/update` | Pull + recreate | | POST | `/api/stacks/{name}/optional-config` | Update optional env vars | | GET | `/api/stacks/{name}/logs` | Container logs (`?raw=1` for plain text) | -| GET | `/api/stacks/{name}/hdd-data` | HDD data paths + sizes | +| GET | `/api/stacks/{name}/hdd-data` | HDD data paths + sizes — resolved from the app's OWN `app.yaml` `HDD_PATH` (v0.236.0, R-442), never the global config | | GET | `/api/stacks/{name}/backup-data` | Backup data paths + sizes (DB dumps, cross-drive rsync) | -| POST | `/api/stacks/{name}/remove` | Remove deployed stack (revert to "not deployed") | -| DELETE | `/api/stacks/{name}` | Delete orphaned stack | +| POST | `/api/stacks/{name}/remove` | Remove deployed stack (revert to "not deployed"). `remove_hdd_data: true` deletes the app's folders under its recorded `HDD_PATH` and lists them; **409 + a Hungarian sentence when the data was asked for but its location cannot be resolved or the drive is absent — nothing is touched, the app is kept** (v0.236.0, R-442). `hdd_paths_removed` is `[]` for an SSD app (never `null`); `hdd_paths_missing`, `hdd_note`, `backup_paths_refused` state what was not found / not removed | +| DELETE | `/api/stacks/{name}` | Delete orphaned stack — same R-442 resolution and refusal shape as `/remove` | | POST | `/api/sync` | Trigger catalog sync | | GET | `/api/system/info` | System info + sync status | diff --git a/controller/internal/api/remove_r442_test.go b/controller/internal/api/remove_r442_test.go new file mode 100644 index 0000000..48bcb10 --- /dev/null +++ b/controller/internal/api/remove_r442_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-442 through the PRODUCTION WIRING: the POST /api/stacks/{name}/remove handler → the real +// stacks.Manager (constructor + ScanStacks, no hand-set state) → RemoveStack. Two refusals, both +// non-2xx with the exact customer sentence, and in both cases app.yaml is still on disk afterwards. +// Neither reaches the compose boundary (the refusal precedes it), so no docker is touched. +func newR442Router(t *testing.T, appYAML string) (*Router, string) { + t.Helper() + root := t.TempDir() + dir := filepath.Join(root, "app") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + compose := "services:\n app:\n image: nginx:1.27\n volumes:\n - ${HDD_PATH}/appdata/app:/data\n" + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.StacksDir = root + cfg.Paths.SystemDataPath = "/mnt/sys_drive" + cfg.Stacks.ComposeCommand = "docker compose" // skips detection; never invoked on the refusal path + lg := log.New(io.Discard, "", 0) + m, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatal(err) + } + if err := m.ScanStacks(); err != nil { + t.Fatal(err) + } + return &Router{cfg: cfg, stackMgr: m, logger: lg}, dir +} + +func postRemove(t *testing.T, r *Router, body string) (int, apiResponse) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/stacks/app/remove", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.removeStack(w, req, "app") + var resp apiResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("non-JSON body %q: %v", w.Body.String(), err) + } + return w.Code, resp +} + +func TestRemoveHandler_R442_UnresolvedDriveIsRefusedNon2xx(t *testing.T) { + r, dir := newR442Router(t, "deployed: true\nenv: {}\n") + code, resp := postRemove(t, r, `{"remove_hdd_data":true,"remove_backups":false}`) + if code/100 == 2 || resp.OK { + t.Fatalf("HTTP %d ok=%v — a 2xx over a removal that could not resolve the data location is the R-442 defect (body error=%q)", code, resp.OK, resp.Error) + } + if code != http.StatusConflict { + t.Fatalf("status = %d, want 409", code) + } + const want = "Az alkalmazás adatainak helye nem állapítható meg, ezért semmit nem töröltünk. Az alkalmazás nem lett eltávolítva." + if resp.Error != want { + t.Fatalf("error = %q, want the exact customer sentence", resp.Error) + } + if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil { + t.Fatal("app.yaml gone — the app was removed with its data left behind") + } +} + +func TestRemoveHandler_R442_DriveAbsentIsRefusedNon2xx(t *testing.T) { + drive := t.TempDir() // a plain directory is not a mountpoint → the drive is "absent" + r, dir := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: "+drive+"\n") + code, resp := postRemove(t, r, `{"remove_hdd_data":true}`) + if code != http.StatusConflict || resp.OK { + t.Fatalf("HTTP %d ok=%v, want 409 refusal (error=%q)", code, resp.OK, resp.Error) + } + if !strings.Contains(resp.Error, drive) || !strings.Contains(resp.Error, "vissza nem csatlakozik") { + t.Fatalf("error = %q, want the drive-absent sentence naming %s", resp.Error, drive) + } + if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil { + t.Fatal("app.yaml gone on a drive-absent refusal") + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 0998f16..e75c732 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log" @@ -827,6 +828,13 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths) if err != nil { r.logger.Printf("[ERROR] [api] Remove failed for %s: %v", name, err) + // R-442: a refused data removal is a first-class, non-2xx answer with the exact customer + // sentence — never a 200 with empty lists. The app was not removed either. + var refused *stacks.RemoveRefusedError + if errors.As(err, &refused) { + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message}) + return + } status := http.StatusInternalServerError if strings.Contains(err.Error(), "protected") { status = http.StatusForbidden @@ -883,6 +891,11 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData) if err != nil { r.logger.Printf("[ERROR] [api] Delete failed for %s: %v", name, err) + var refused *stacks.RemoveRefusedError // R-442, same as removeStack + if errors.As(err, &refused) { + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message}) + return + } status := http.StatusInternalServerError if strings.Contains(err.Error(), "protected") { status = http.StatusForbidden diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index e146abf..4ee0ae4 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -18,20 +18,139 @@ import ( const felhomDataDir = "felhom-data" // DeleteResponse holds the result of a stack deletion (orphan delete). +// +// R-442 (v0.236.0): HDDPathsRemoved / HDDPathsPreserved are ALWAYS non-nil — an empty list is `[]`, +// never `null`, because `null` was what a silently inert removal looked like for months and the two +// must be distinguishable. HDDPathsMissing lists folders the app recorded that were already gone from +// the drive (a fact, not a refusal). HDDNote is one customer-facing sentence about what was NOT +// found — empty when nothing needs saying. type DeleteResponse struct { Deleted string `json:"deleted"` VolumesRemoved []string `json:"volumes_removed"` HDDPathsRemoved []string `json:"hdd_paths_removed"` HDDPathsPreserved []string `json:"hdd_paths_preserved"` + HDDPathsMissing []string `json:"hdd_paths_missing,omitempty"` + HDDNote string `json:"hdd_note,omitempty"` } -// RemoveResponse holds the result of removing a deployed (non-orphaned) stack. +// RemoveResponse holds the result of removing a deployed (non-orphaned) stack. Same R-442 shape as +// DeleteResponse, plus the backup half: BackupPathsRefused carries every backup path the removal +// declined to touch and why — until v0.236.0 that refusal existed only as a WARN log line. type RemoveResponse struct { Removed string `json:"removed"` VolumesRemoved []string `json:"volumes_removed"` HDDPathsRemoved []string `json:"hdd_paths_removed"` HDDPathsPreserved []string `json:"hdd_paths_preserved"` + HDDPathsMissing []string `json:"hdd_paths_missing,omitempty"` + HDDNote string `json:"hdd_note,omitempty"` BackupPathsRemoved []string `json:"backup_paths_removed,omitempty"` + BackupPathsRefused []string `json:"backup_paths_refused,omitempty"` +} + +// RemoveRefusedError is a removal REFUSED before anything was touched: the customer asked for the +// app's data to go with it and the box cannot honour that. It is a typed error so the API handler can +// map it to a non-2xx status and show Message verbatim (R-442). The app is NOT removed either — an app +// gone with its data left behind is unrecoverable from the UI (the customer cannot even re-run the +// removal). Same class as R-443: success is never reported over inaction. +type RemoveRefusedError struct { + Reason string // RefuseHDDUnresolved | RefuseDriveAbsent — for logs and tests + Message string // Hungarian, customer-facing, exact +} + +func (e *RemoveRefusedError) Error() string { return e.Message } + +// RemoveRefusedError reasons. +const ( + RefuseHDDUnresolved = "hdd_unresolved" // data removal requested; compose binds a drive; app.yaml records none + RefuseDriveAbsent = "drive_absent" // data removal requested; the recorded drive is not mounted right now +) + +// Customer-facing copy for the R-442 shapes. Exact strings — the live validation greps ASCII +// fragments of them (`llap` for the first, `nem el` for the second). +const ( + msgHDDUnresolved = "Az alkalmazás adatainak helye nem állapítható meg, ezért semmit nem töröltünk. Az alkalmazás nem lett eltávolítva." + msgDriveAbsentFmt = "A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem távolítható el, amíg a meghajtó vissza nem csatlakozik." + noteNoDriveData = "Az alkalmazás nem tárolt saját adatot külső meghajtón, így ott nem volt mit törölni." + noteMissingFmt = "A következő adatmappa már nem volt a meghajtón: %s" + backupRefusedFmt = "%s — a mentés helye a várt mappán kívül esik, ezért nem töröltük" +) + +// appHDDPath returns the data drive the named app RECORDED for itself at deploy time — app.yaml's +// HDD_PATH — and whether it recorded one at all. +// +// It implements, for the removal path, the rule 07-backup-architecture.md states under "[DESIGN] +// 2026-08-22 — the restore destination is resolved by the same rule as the capture destination" +// (~L437): "the drive if the app declares one (HDD_PATH), the system data path otherwise". Deploy +// (withPathVars), the start gate (api.startGatedByMissingDrive) and the backup destination +// (backup.GetAppDrivePath) all read the app's own record. Until v0.236.0 removal alone read the +// GLOBAL cfg.Paths.HDDPath — set on no box — so "delete my data" resolved zero mounts and reported +// success over 128 MB left on the drive (R-442, measured on demo-hp 2026-09-01). +// +// DELIBERATELY NO FALLBACK to m.cfg.Paths.HDDPath when the per-app value is empty. A single global +// drive is the assumption the storage arc removed (a customer can have several), and an empty answer +// must reach the caller as "not declared" so it can tell an SSD-only app (nothing to remove — a fact) +// from a removal it cannot honour (a refusal). A silent fallback is the exact path R-442 closes. +func (m *Manager) appHDDPath(name string) (string, bool) { + cfg := m.LoadAppConfigByName(name) + if cfg == nil { + return "", false + } + hdd := strings.TrimSpace(cfg.Env["HDD_PATH"]) + if hdd == "" { + return "", false + } + return filepath.Clean(hdd), true +} + +// composeBindsDrive reports whether the app's compose file binds anything under ${HDD_PATH} or +// ${USERDATA_PATH} — whether the app keeps data on a drive AT ALL. This is R-442's deduplication +// rule: "declares no drive" is a fact (an SSD-resident app — nothing to remove, empty list), while +// "binds a drive it cannot resolve" is a failure (refuse). Read through the ONE authoritative bind +// scanner; ParseComposeHDDMounts is unchanged. +func composeBindsDrive(composePath string) bool { + for _, b := range ParseComposeClassifiableBinds(composePath) { + if b.Root == appbackup.RootHDD || b.Root == appbackup.RootUserdata { + return true + } + } + return false +} + +// hddPathForRemoval resolves the drive a removal acts on, or refuses — BEFORE anything is touched. +// Returns (path, declared, nil) to proceed; a *RemoveRefusedError to stop. Every refusal is logged at +// ERROR here AND returned to the caller, never one without the other. A removal that does not ask +// for the data is never refused on HDD grounds. +func (m *Manager) hddPathForRemoval(op, name, composePath string, removeHDDData bool) (string, bool, error) { + hddPath, declared := m.appHDDPath(name) + if !removeHDDData { + return hddPath, declared, nil + } + if !declared { + if composeBindsDrive(composePath) { + m.logger.Printf("[ERROR] [stacks] %s %s refused: data removal requested, the compose binds a drive path, but app.yaml records no HDD_PATH — nothing removed, app kept (R-442)", op, name) + return "", false, &RemoveRefusedError{Reason: RefuseHDDUnresolved, Message: msgHDDUnresolved} + } + return "", false, nil // SSD-resident: there is no drive data, and that is a fact + } + if !m.DriveLive(hddPath) { + m.logger.Printf("[ERROR] [stacks] %s %s refused: data removal requested but the drive recorded in HDD_PATH is not mounted — nothing removed, app kept (R-442)", op, name) + return hddPath, true, &RemoveRefusedError{Reason: RefuseDriveAbsent, Message: fmt.Sprintf(msgDriveAbsentFmt, hddPath)} + } + return hddPath, true, nil +} + +// hddNoteFor composes the one-sentence HDDNote (see DeleteResponse). Only when the data was asked +// for: a kept-data removal has nothing to explain about what was not found. +func hddNoteFor(removeHDDData bool, mounts, missing []string) string { + switch { + case !removeHDDData: + return "" + case len(mounts) == 0: + return noteNoDriveData + case len(missing) > 0: + return fmt.Sprintf(noteMissingFmt, strings.Join(missing, ", ")) + } + return "" } // BackupDataResponse holds information about backup data associated with a stack. @@ -117,13 +236,20 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, } stackDir := filepath.Dir(stack.ComposePath) - hddPath := m.cfg.Paths.HDDPath + // R-442: the app's OWN recorded drive, never the global config — and a refusal here happens + // before compose down, so a refused removal has touched nothing. + hddPath, hddDeclared, err := m.hddPathForRemoval("DeleteStack", name, stack.ComposePath, removeHDDData) + if err != nil { + return nil, err + } - m.logger.Printf("[INFO] Deleting orphaned stack: %s (removeHDDData=%v)", name, removeHDDData) + m.logger.Printf("[INFO] Deleting orphaned stack: %s (removeHDDData=%v, hddDeclared=%v)", name, removeHDDData, hddDeclared) start := time.Now() resp := &DeleteResponse{ - Deleted: name, + Deleted: name, + HDDPathsRemoved: []string{}, + HDDPathsPreserved: []string{}, } // Step 1: Parse compose file for HDD bind mounts @@ -169,7 +295,8 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] DeleteStack %s: HDD path does not exist, skipping: %s", name, cleanPath) } - continue // path doesn't exist, nothing to do + resp.HDDPathsMissing = append(resp.HDDPathsMissing, cleanPath) // R-442: stated, not a refusal + continue // path doesn't exist, nothing to do } if removeHDDData { @@ -192,6 +319,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman)) } } + resp.HDDNote = hddNoteFor(removeHDDData, hddMounts, resp.HDDPathsMissing) // Step 5: Remove stack directory if m.isDebug() { @@ -223,14 +351,15 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) { return nil, fmt.Errorf("stack %q not found", name) } - hddPath := m.cfg.Paths.HDDPath + // R-442: the app's own recorded HDD_PATH, not the global config (which no box sets). + hddPath, declared := m.appHDDPath(name) resp := &HDDDataResponse{ Stack: name, } - if hddPath == "" { + if !declared { if m.isDebug() { - m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: no HDD path configured, returning empty", name) + m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: app.yaml records no HDD_PATH, returning empty", name) } return resp, nil } @@ -322,13 +451,20 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo } stackDir := filepath.Dir(stack.ComposePath) - hddPath := m.cfg.Paths.HDDPath + // R-442: the app's OWN recorded drive, never the global config — and a refusal here happens + // before compose down, so a refused removal has touched nothing. + hddPath, hddDeclared, err := m.hddPathForRemoval("RemoveStack", name, stack.ComposePath, removeHDDData) + if err != nil { + return nil, err + } - m.logger.Printf("[INFO] Removing deployed stack: %s (removeHDDData=%v, backupPaths=%d)", name, removeHDDData, len(backupPathsToRemove)) + m.logger.Printf("[INFO] Removing deployed stack: %s (removeHDDData=%v, hddDeclared=%v, backupPaths=%d)", name, removeHDDData, hddDeclared, len(backupPathsToRemove)) start := time.Now() resp := &RemoveResponse{ - Removed: name, + Removed: name, + HDDPathsRemoved: []string{}, + HDDPathsPreserved: []string{}, } // Step 1: Parse compose file for HDD bind mounts @@ -372,6 +508,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: HDD path does not exist, skipping: %s", name, cleanPath) } + resp.HDDPathsMissing = append(resp.HDDPathsMissing, cleanPath) // R-442: stated, not a refusal continue } @@ -395,17 +532,31 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo } } - // Step 5: Handle backup data cleanup. Model A: backups/ sits directly under the namespace-root - // drive mount (no felhom-data segment). - backupsBase := filepath.Join(hddPath, "backups") + resp.HDDNote = hddNoteFor(removeHDDData, hddMounts, resp.HDDPathsMissing) + + // Step 5: Handle backup data cleanup. Model A: backups/ sits directly under the app's felhom-data + // namespace root. R-442: that root is resolved by the SAME rule as the data half and as the + // router's AppNamespaceRoot that produced these paths — the app's own drive when it records one, + // the system data path otherwise (07-backup-architecture.md ~L437) — instead of the global + // cfg.Paths.HDDPath, under which every backup path was refused on every box. And the refusal now + // reaches the response (BackupPathsRefused), not only the log. + nsDrive := hddPath + if !hddDeclared { + nsDrive = m.sysDataPath + } + backupsBase := "" + if nsDrive != "" { + backupsBase = filepath.Join(appbackup.NamespaceRootFor(nsDrive, m.sysDataPath), "backups") + } if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: processing %d backup paths for removal (base=%s)", name, len(backupPathsToRemove), backupsBase) } for _, bkPath := range backupPathsToRemove { cleanPath := filepath.Clean(bkPath) // Validate path is under the expected backups directory - if hddPath == "" || !strings.HasPrefix(cleanPath, backupsBase+string(filepath.Separator)) { - m.logger.Printf("[WARN] Refusing to remove backup path outside expected directory: %s", cleanPath) + if backupsBase == "" || !strings.HasPrefix(cleanPath, backupsBase+string(filepath.Separator)) { + m.logger.Printf("[WARN] Refusing to remove backup path outside expected directory: %s (expected under %s)", cleanPath, backupsBase) + resp.BackupPathsRefused = append(resp.BackupPathsRefused, fmt.Sprintf(backupRefusedFmt, cleanPath)) continue } if _, err := os.Stat(cleanPath); os.IsNotExist(err) { diff --git a/controller/internal/stacks/delete_r442_test.go b/controller/internal/stacks/delete_r442_test.go new file mode 100644 index 0000000..f4b5d2a --- /dev/null +++ b/controller/internal/stacks/delete_r442_test.go @@ -0,0 +1,418 @@ +package stacks + +import ( + "encoding/json" + "errors" + "io" + "log" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// R-442 — "delete my data too" must delete the data, or say that it could not. +// +// Every test here asserts the EFFECT on a real t.TempDir() tree (the folder is gone / is still +// there / app.yaml is gone / is still there), never "no error". The compose process boundary is a +// stub script that leaves a marker file, so a refusal can be shown to have run NOTHING. +// +// COMPANION RED-PROOFS (both run, both recorded in REPORT.md): +// 1. Scenario C — model the pre-fix resolver (fall back to the global cfg.Paths.HDDPath, never +// refuse) → TestRemoveStack_R442_RefusesWhenDriveUnresolvable FAILS: err=nil, app.yaml gone, +// hdd_paths_removed empty. +// 2. Scenario D — make "declares no drive" refuse → TestRemoveStack_R442_SSDAppIsNotRefused FAILS. + +const r442SysData = "/mnt/sys_drive" + +// newR442Manager builds a Manager with ONE deployed, stopped stack from the given compose and +// app.yaml, a marker-leaving stub compose on PATH, and the mountpoint seam answering "live" for +// `liveDrive` only. Returns the manager, the stack dir and the compose marker path. +func newR442Manager(t *testing.T, name, compose, appYAML, liveDrive string) (*Manager, string, string) { + t.Helper() + if runtime.GOOS != "linux" { + t.Skip("the stub compose binary is a shell script") + } + root := t.TempDir() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.StacksDir = root + cfg.Paths.SystemDataPath = r442SysData + m := &Manager{ + cfg: cfg, + logger: log.New(io.Discard, "", 0), + encKey: []byte("0123456789abcdef0123456789abcdef"), + sysDataPath: r442SysData, + stacks: map[string]*Stack{}, + } + m.stacks[name] = &Stack{Name: name, ComposePath: filepath.Join(dir, "docker-compose.yml"), Deployed: true, State: StateStopped} + m.stacks[name].AppConfig = LoadAppConfig(dir) + + // The compose boundary: a script that records it ran. A refusal must leave NO marker. + bin := t.TempDir() + marker := filepath.Join(bin, "compose-ran") + script := "#!/bin/sh\ntouch " + marker + "\nexit 0\n" + if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + m.composeCmd = "docker-compose" + m.execFn = func(string, ...string) (string, error) { return "", nil } + m.isMountPoint = func(p string) bool { return liveDrive != "" && filepath.Clean(p) == filepath.Clean(liveDrive) } + return m, dir, marker +} + +func plantFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("customer bytes\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +const driveCompose = "services:\n" + + " app:\n" + + " image: nginx:1.27\n" + + " volumes:\n" + + " - ${HDD_PATH}/appdata/app:/data\n" + + " - app_cfg:/config\n" + + "volumes:\n" + + " app_cfg:\n" + +const ssdCompose = "services:\n" + + " app:\n" + + " image: nginx:1.27\n" + + " volumes:\n" + + " - app_cfg:/config\n" + + "volumes:\n" + + " app_cfg:\n" + +func driveAppYAML(drive string) string { + return "deployed: true\nenv:\n HDD_PATH: " + drive + "\n" +} + +// Scenario A — a drive-backed app is removed WITH its data: the folder is gone, listed with its size. +func TestRemoveStack_R442_RemovesDeclaredDriveData(t *testing.T) { + drive := t.TempDir() + m, dir, marker := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) + data := filepath.Join(drive, "appdata", "app") + plantFile(t, filepath.Join(data, "photos", "one.jpg")) + + resp, err := m.RemoveStack("app", true, nil) + if err != nil { + t.Fatalf("RemoveStack: %v", err) + } + if exists(data) { + t.Fatalf("data folder %s STILL EXISTS after remove_hdd_data=true — the R-442 defect", data) + } + if len(resp.HDDPathsRemoved) != 1 || !strings.HasPrefix(resp.HDDPathsRemoved[0], data+" (") { + t.Fatalf("hdd_paths_removed = %v, want exactly [%q (size)]", resp.HDDPathsRemoved, data) + } + if exists(filepath.Join(dir, "app.yaml")) { + t.Fatal("app.yaml still present — the app was not removed") + } + if !exists(marker) { + t.Fatal("compose down never ran on the success path") + } + // The drive's protected roots are untouched. + if !exists(filepath.Join(drive, "appdata")) { + t.Fatal("the protected appdata/ root was removed") + } +} + +// Scenario B — the same app, data KEPT: folder untouched, listed under preserved, removed is `[]` +// (never null). +func TestRemoveStack_R442_KeepsDataWhenNotAsked(t *testing.T) { + drive := t.TempDir() + m, dir, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) + data := filepath.Join(drive, "appdata", "app") + plantFile(t, filepath.Join(data, "photos", "one.jpg")) + + resp, err := m.RemoveStack("app", false, nil) + if err != nil { + t.Fatalf("RemoveStack: %v", err) + } + if !exists(filepath.Join(data, "photos", "one.jpg")) { + t.Fatal("customer file was deleted on a keep-data removal") + } + if len(resp.HDDPathsPreserved) != 1 || !strings.HasPrefix(resp.HDDPathsPreserved[0], data+" (") { + t.Fatalf("hdd_paths_preserved = %v, want [%q (size)]", resp.HDDPathsPreserved, data) + } + raw, _ := json.Marshal(resp) + if !strings.Contains(string(raw), `"hdd_paths_removed":[]`) { + t.Fatalf("hdd_paths_removed must serialise as [] — got %s", raw) + } + if exists(filepath.Join(dir, "app.yaml")) { + t.Fatal("app.yaml still present — the app was not removed") + } +} + +// Scenario C — THE R-442 CASE: data removal requested, the compose binds ${HDD_PATH}, app.yaml +// records none → REFUSED, typed, exact Hungarian sentence, and NOTHING was touched: compose never +// ran, app.yaml is still there. +func TestRemoveStack_R442_RefusesWhenDriveUnresolvable(t *testing.T) { + m, dir, marker := newR442Manager(t, "app", driveCompose, "deployed: true\nenv: {}\n", "") + + resp, err := m.RemoveStack("app", true, nil) + var refused *RemoveRefusedError + if !errors.As(err, &refused) { + removed := []string(nil) + if resp != nil { + removed = resp.HDDPathsRemoved + } + t.Fatalf("want *RemoveRefusedError, got err=%v resp.hdd_paths_removed=%v app.yaml present=%v — a 200 over inaction", + err, removed, exists(filepath.Join(dir, "app.yaml"))) + } + if refused.Reason != RefuseHDDUnresolved { + t.Fatalf("reason = %q, want %q", refused.Reason, RefuseHDDUnresolved) + } + if refused.Message != msgHDDUnresolved { + t.Fatalf("message = %q, want the exact customer sentence", refused.Message) + } + if exists(marker) { + t.Fatal("compose down RAN on a refused removal — the refusal must precede every mutation") + } + if !exists(filepath.Join(dir, "app.yaml")) { + t.Fatal("app.yaml is gone — the app was removed with its data left behind, the worst outcome") + } +} + +// Scenario D — an SSD-resident app (never declared HDD_PATH, binds no drive path) is NOT refused: +// hdd_paths_removed is `[]`, the note says there was no drive data, the app is removed. +func TestRemoveStack_R442_SSDAppIsNotRefused(t *testing.T) { + m, dir, marker := newR442Manager(t, "app", ssdCompose, "deployed: true\nenv: {}\n", "") + + resp, err := m.RemoveStack("app", true, nil) + if err != nil { + t.Fatalf("an SSD app must not be refused on HDD grounds, got: %v", err) + } + if resp.HDDPathsRemoved == nil || len(resp.HDDPathsRemoved) != 0 { + t.Fatalf("hdd_paths_removed = %#v, want a non-nil empty list", resp.HDDPathsRemoved) + } + raw, _ := json.Marshal(resp) + if !strings.Contains(string(raw), `"hdd_paths_removed":[]`) { + t.Fatalf("hdd_paths_removed must serialise as [] — got %s", raw) + } + if resp.HDDNote != noteNoDriveData { + t.Fatalf("hdd_note = %q, want %q", resp.HDDNote, noteNoDriveData) + } + if exists(filepath.Join(dir, "app.yaml")) || !exists(marker) { + t.Fatalf("SSD app not removed: app.yaml present=%v compose ran=%v", exists(filepath.Join(dir, "app.yaml")), exists(marker)) + } +} + +// Edge: HDD_PATH recorded but the drive is not mounted right now → refused with the drive-absent +// sentence naming the path; app kept; nothing ran. +func TestRemoveStack_R442_RefusesWhenDriveAbsent(t *testing.T) { + drive := t.TempDir() + m, dir, marker := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), "" /* nothing is live */) + + _, err := m.RemoveStack("app", true, nil) + var refused *RemoveRefusedError + if !errors.As(err, &refused) || refused.Reason != RefuseDriveAbsent { + t.Fatalf("want drive-absent refusal, got %v", err) + } + if !strings.Contains(refused.Message, drive) || !strings.Contains(refused.Message, "vissza nem csatlakozik") { + t.Fatalf("message = %q, want the drive-absent sentence naming %s", refused.Message, drive) + } + if exists(marker) || !exists(filepath.Join(dir, "app.yaml")) { + t.Fatal("a drive-absent refusal must run nothing and keep the app") + } +} + +// Edge: a keep-data removal is NEVER refused on HDD grounds, even with the drive absent. +func TestRemoveStack_R442_KeepDataNeverRefusedOnHDDGrounds(t *testing.T) { + drive := t.TempDir() + m, dir, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), "") + if _, err := m.RemoveStack("app", false, nil); err != nil { + t.Fatalf("keep-data removal refused: %v", err) + } + if exists(filepath.Join(dir, "app.yaml")) { + t.Fatal("app not removed") + } +} + +// Edge: HDD_PATH recorded, folder already absent → not a refusal; listed under hdd_paths_missing, +// stated in the note, app removed. +func TestRemoveStack_R442_MissingFolderIsStatedNotRefused(t *testing.T) { + drive := t.TempDir() + m, dir, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) + want := filepath.Join(drive, "appdata", "app") + + resp, err := m.RemoveStack("app", true, nil) + if err != nil { + t.Fatalf("absent folder must not refuse: %v", err) + } + if len(resp.HDDPathsMissing) != 1 || resp.HDDPathsMissing[0] != want { + t.Fatalf("hdd_paths_missing = %v, want [%s]", resp.HDDPathsMissing, want) + } + if !strings.Contains(resp.HDDNote, want) { + t.Fatalf("hdd_note = %q, must name the missing folder", resp.HDDNote) + } + if len(resp.HDDPathsRemoved) != 0 || exists(filepath.Join(dir, "app.yaml")) { + t.Fatal("removed list must be empty and the app gone") + } +} + +// Scenario E — the backup half: a path inside the app's backups/ is removed and listed; a path +// outside it is refused AND the refusal reaches the response, not only the log. +func TestRemoveStack_R442_BackupRefusalReachesResponse(t *testing.T) { + drive := t.TempDir() + m, _, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) + inside := filepath.Join(drive, "backups", "primary", "app", "db-dumps") + plantFile(t, filepath.Join(inside, "app.sql")) + outside := filepath.Join(t.TempDir(), "elsewhere", "db-dumps") + plantFile(t, filepath.Join(outside, "x.sql")) + + resp, err := m.RemoveStack("app", true, []string{inside, outside}) + if err != nil { + t.Fatalf("RemoveStack: %v", err) + } + if exists(inside) { + t.Fatalf("backup path inside the app's backups/ was not removed: %s", inside) + } + if len(resp.BackupPathsRemoved) != 1 || !strings.HasPrefix(resp.BackupPathsRemoved[0], inside+" (") { + t.Fatalf("backup_paths_removed = %v, want [%s (size)]", resp.BackupPathsRemoved, inside) + } + if !exists(filepath.Join(outside, "x.sql")) { + t.Fatal("a path OUTSIDE backups/ was deleted — the guard is gone") + } + if len(resp.BackupPathsRefused) != 1 || !strings.HasPrefix(resp.BackupPathsRefused[0], outside+" ") { + t.Fatalf("backup_paths_refused = %v, want the outside path with its reason", resp.BackupPathsRefused) + } +} + +// Scenario E for an SSD app: its DB dumps live under /felhom-data/backups — the same +// namespace root the router's AppNamespaceRoot resolves — and are removable. Under the old global +// lookup every backup path of every app was refused. +func TestRemoveStack_R442_SSDAppBackupsUnderSystemNamespace(t *testing.T) { + sys := t.TempDir() + m, _, _ := newR442Manager(t, "app", ssdCompose, "deployed: true\nenv: {}\n", "") + m.sysDataPath = sys + m.cfg.Paths.SystemDataPath = sys + inside := filepath.Join(sys, "felhom-data", "backups", "primary", "app", "db-dumps") + plantFile(t, filepath.Join(inside, "app.sql")) + + resp, err := m.RemoveStack("app", true, []string{inside}) + if err != nil { + t.Fatalf("RemoveStack: %v", err) + } + if exists(inside) || len(resp.BackupPathsRemoved) != 1 || len(resp.BackupPathsRefused) != 0 { + t.Fatalf("SSD-app backup under the system namespace must be removed: exists=%v removed=%v refused=%v", + exists(inside), resp.BackupPathsRemoved, resp.BackupPathsRefused) + } +} + +// The ${USERDATA_PATH} convention (delete.go, ExportDataMounts) keeps working from the PER-APP +// path: removal deletes the app's own ${HDD_PATH}/appdata bind and leaves the shared userdata root +// alone, and the export resolver still derives /userdata from the same per-app value. +func TestRemoveStack_R442_UserdataConventionFromPerAppPath(t *testing.T) { + drive := t.TempDir() + compose := "services:\n" + + " app:\n" + + " image: nginx:1.27\n" + + " volumes:\n" + + " - ${HDD_PATH}/appdata/app:/data\n" + + " - ${USERDATA_PATH}/media:/media\n" + m, _, _ := newR442Manager(t, "app", compose, driveAppYAML(drive), drive) + data := filepath.Join(drive, "appdata", "app") + plantFile(t, filepath.Join(data, "one.bin")) + shared := filepath.Join(drive, "userdata", "media", "holiday.jpg") + plantFile(t, shared) + + hdd, declared := m.appHDDPath("app") + if !declared || hdd != filepath.Clean(drive) { + t.Fatalf("appHDDPath = (%q,%v), want (%q,true)", hdd, declared, drive) + } + mounts := ExportDataMounts(m.stacks["app"].ComposePath, hdd, hdd) + wantUD := filepath.Join(drive, "userdata") + found := false + for _, mnt := range mounts { + if mnt == wantUD { + found = true + } + } + if !found { + t.Fatalf("ExportDataMounts from the per-app path = %v, want it to include %s", mounts, wantUD) + } + + if _, err := m.RemoveStack("app", true, nil); err != nil { + t.Fatalf("RemoveStack: %v", err) + } + if exists(data) { + t.Fatal("app data not removed") + } + if !exists(shared) { + t.Fatal("the shared userdata root was deleted by an app removal") + } +} + +// DeleteStack (orphan delete) takes the same refusal: Scenario C shape, nothing touched. +func TestDeleteStack_R442_RefusesWhenDriveUnresolvable(t *testing.T) { + m, dir, marker := newR442Manager(t, "app", driveCompose, "deployed: true\nenv: {}\n", "") + m.stacks["app"].Orphaned = true + + _, err := m.DeleteStack("app", true) + var refused *RemoveRefusedError + if !errors.As(err, &refused) || refused.Message != msgHDDUnresolved { + t.Fatalf("want the unresolved refusal, got %v (stack dir present=%v)", err, exists(dir)) + } + if exists(marker) || !exists(dir) { + t.Fatal("orphan delete refused but something ran or the stack dir is gone") + } +} + +// DeleteStack success path: the app's own drive data goes, listed. +func TestDeleteStack_R442_RemovesDeclaredDriveData(t *testing.T) { + drive := t.TempDir() + m, dir, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) + m.stacks["app"].Orphaned = true + data := filepath.Join(drive, "appdata", "app") + plantFile(t, filepath.Join(data, "one.bin")) + + resp, err := m.DeleteStack("app", true) + if err != nil { + t.Fatalf("DeleteStack: %v", err) + } + if exists(data) || len(resp.HDDPathsRemoved) != 1 || exists(dir) { + t.Fatalf("data exists=%v removed=%v stackdir exists=%v", exists(data), resp.HDDPathsRemoved, exists(dir)) + } +} + +// GetStackHDDData (the modal's source) reads the per-app record too: with HDD_PATH recorded it +// lists the folder; the global config stays empty throughout. +func TestGetStackHDDData_R442_ReadsPerAppRecord(t *testing.T) { + drive := t.TempDir() + m, _, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) + data := filepath.Join(drive, "appdata", "app") + plantFile(t, filepath.Join(data, "one.bin")) + if m.cfg.Paths.HDDPath != "" { + t.Fatal("fixture error: the global must stay empty to prove the per-app read") + } + resp, err := m.GetStackHDDData("app") + if err != nil { + t.Fatal(err) + } + if !resp.HasHDDData || len(resp.HDDPaths) != 1 || resp.HDDPaths[0].Path != data || !resp.HDDPaths[0].Exists { + t.Fatalf("hdd-data = %+v, want one existing entry for %s", resp, data) + } +} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index ff5c01f..cd9c339 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -354,6 +354,9 @@ if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) { preservedInfo = '

Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '

'; } + if (data.data && data.data.hdd_note) { + preservedInfo += '

' + data.data.hdd_note + '

'; + } modal.querySelector('.modal-card').innerHTML = '

Sikeresen törölve!

' + '

Az alkalmazás (' + name + ') törölve lett.

' + @@ -469,6 +472,12 @@ if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) { preservedInfo = '

Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '

'; } + if (data.data && data.data.hdd_note) { + preservedInfo += '

' + data.data.hdd_note + '

'; + } + if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) { + preservedInfo += '

Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '

'; + } modal.querySelector('.modal-card').innerHTML = '

Sikeresen eltávolítva!

' + '

Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.

' +