From 3603d1fc7f21fa77c1da25ab45716430e3e2c836 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 15 Jul 2026 10:10:20 +0200 Subject: [PATCH] Tier-2 engine rework: class-driven legs, v2 layout, NAS-target exclusion (Task 3b, v0.135.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tier2_capture.go: classified apps get TierSecondary per-bind legs (paperless copy shrinks — export drops); legacy apps keep the byte-identical resolver set. v2 relpath-mirroring layout (backups/secondary//{marker LAST, recovery-unit/, hdd//, userdata//}); N>1 native (errTier2MultiDir/tier2AppDataName deleted). Migration=delete-and-rebuild + reconcile; all RemoveAll via tier2SafeRemove (refuses outside backups/secondary/). SSD=state-only tier. selectTier2Target never picks network storage (pinned+auto, F-6C-1). Restore reads v2 behind a marker gate. Part 0: offbox_enlarge_blocked is a persisted one-time Load seed (opt-out sticks), not a getter append. Part 0.5: offsite restore scratch prefers a local (non-network) path. Full v2 test suite + all 10 §10 red-proofs verified. Destructive writes bounded to backups/secondary/. --- CHANGELOG.md | 35 ++ CONTEXT.md | 16 +- REPORT.md | 158 +++----- controller/README.md | 11 + .../internal/backup/appbackup_bridge.go | 8 + controller/internal/backup/backup.go | 5 + controller/internal/backup/offbox_restore.go | 34 +- .../internal/backup/offbox_scratch_test.go | 66 ++++ controller/internal/backup/tier2.go | 284 +++++++++++---- .../internal/backup/tier2_appdata_test.go | 215 ----------- controller/internal/backup/tier2_capture.go | 92 +++++ controller/internal/backup/tier2_restore.go | 56 ++- .../internal/backup/tier2_restore_test.go | 14 +- controller/internal/backup/tier2_test.go | 4 +- controller/internal/backup/tier2_v2_test.go | 343 ++++++++++++++++++ .../internal/settings/notif_migration_test.go | 111 +++--- controller/internal/settings/settings.go | 35 +- controller/internal/web/async_restore_test.go | 8 +- controller/internal/web/handlers.go | 2 + .../internal/web/templates/backups_apps.html | 1 + 20 files changed, 1014 insertions(+), 484 deletions(-) create mode 100644 controller/internal/backup/offbox_scratch_test.go delete mode 100644 controller/internal/backup/tier2_appdata_test.go create mode 100644 controller/internal/backup/tier2_capture.go create mode 100644 controller/internal/backup/tier2_v2_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d06e03..373be69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ ## Changelog +### v0.135.0 — Tier-2 engine rework: class-driven legs, v2 layout, NAS-target exclusion (Task 3b) (2026-07-15) + +Task 3b of the backup-classification-redesign arc — the tier-2 column of the matrix (architecture +§2/§8). Behavior-changing but bounded: every destructive write lands ONLY under +`backups/secondary//` (fully-derived data), asserted in code. + +- **Class-driven appdata leg (`tier2_capture.go`, new):** for a classified app the tier-2 legs are + the Task-3-core `TierSecondary` set — per-bind mandatory + optional HDD/userdata paths (paperless's + copy legitimately SHRINKS as `export` drops out). Legacy apps keep a byte-identical capture set (the + resolver appdata dir(s)) mapped into the same layout. Skipped/missing **mandatory** paths are loud + gaps (English log + the app's Hungarian cross-drive `LastWarning`), mirroring the offsite pattern. +- **v2 relpath-mirroring layout:** `backups/secondary//` = `.felhom-tier2-layout` marker (content + "2", written **LAST**) + `recovery-unit/` + `hdd//` + `userdata//`. N>1 appdata dirs + and nested binds are represented natively — the v0.131.0 flat-appdata **N>1 refusal is gone** + (`errTier2MultiDir`/`tier2AppDataName` deleted). Restore is position-derivable. +- **Migration = delete-and-rebuild** (marker absent → remove the old flat `appdata/`; `recovery-unit/` + is layout-identical, untouched) + a **reconcile** step that prunes dest dirs a bind no longer covers + (a removed/re-classed bind stops occupying the secondary drive within one run). All `os.RemoveAll` + goes through `tier2SafeRemove`, which refuses any target not strictly under `backups/secondary/`. +- **SSD fallback is an enforced STATE-ONLY tier (§2.2):** headroom is decided on unit + mandatory; the + SSD carries unit + mandatory only, optional legs skipped with an honest Hungarian reason. +- **NAS-target exclusion (F-6C-1):** `selectTier2Target` never selects a NETWORK storage path — pinned + OR auto (metadata-only `IsNetwork()`, no fs probing). NAS-only ⇒ the honest reason + ("Hálózati tároló nem lehet a 2. mentés célja…"). Prevents the rsync `-og`-under-root_squash + silently-wrong-owner restore. +- **Restore reads v2 only:** a marker gate refuses a pre-v2 copy ("A 2. mentés régi formátumú…"); + the reader merges the `hdd/` and `userdata/` subtrees missing-only into live (N>1 native). +- **Part 0 — prefs seed fix:** the 3a-fix un-disableable checkbox is fixed — `offbox_enlarge_blocked` + is now a ONE-TIME persisted seed at settings Load (`OffboxEnlargeNoticeSeeded`), not a getter append, + so a customer's later opt-out **sticks**. **Part 0.5:** the offsite restore scratch now prefers a + LOCAL path over a network one (a squashed scratch would feed `PlaceOffsiteRestore` wrong-owner files). +- **Tests:** the v2 suite (`tier2_v2_test.go`: A–H + reconcile keep/remove + the safe-remove boundary + proof), Part 0 seed tests (idempotent + opt-out-sticks), Part 0.5 scratch-preference tests; obsolete + v1 flat-layout / N>1-refusal tests removed. All 10 §10 red-proofs verified (mutate → fail → revert). + ### v0.134.1 — Placement hardening (F-3a-1..4) + enlarge-blocked notification delivery chain (Task 3a-fix) (2026-07-15) Follow-up hardening of the (not-yet-live) place-to-live flow from v0.134.0, plus the controller side diff --git a/CONTEXT.md b/CONTEXT.md index f952fd0..43f2ab8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,21 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-15 (v0.134.1 — placement hardening + enlarge-blocked delivery chain, Task 3a-fix) +Last updated: 2026-07-15 (v0.135.0 — tier-2 engine rework, Task 3b) + +> **2026-07-15 — v0.135.0: tier-2 engine rework (Task 3b).** Architecture §2/§8. New +> `tier2_capture.go`: classified apps get the `TierSecondary` per-bind legs (paperless copy shrinks — +> export drops); legacy apps keep the byte-identical resolver set. v2 relpath-mirroring layout +> (`backups/secondary//{.felhom-tier2-layout marker LAST, recovery-unit/, hdd//, +> userdata//}`) — N>1 native (flat-appdata refusal + `errTier2MultiDir`/`tier2AppDataName` +> deleted). Migration=delete-and-rebuild + reconcile (prunes dest dirs a bind no longer covers); all +> `os.RemoveAll` via `tier2SafeRemove` (refuses outside backups/secondary/). SSD=state-only tier +> (unit+mandatory). `selectTier2Target` never picks NETWORK storage (pinned+auto, F-6C-1). Restore +> reads v2 behind a marker gate (pre-v2 refused); two-subtree missing-only merge. **Part 0:** +> offbox_enlarge_blocked is now a persisted one-time Load seed (`OffboxEnlargeNoticeSeeded`), NOT a +> getter append — opt-out STICKS (fixes the 3a-fix un-disableable checkbox). **Part 0.5:** offsite +> restore scratch prefers a local (non-network) path. Full v2 test suite + all 10 §10 red-proofs +> verified. Every destructive write bounded to backups/secondary/. > **2026-07-15 — v0.134.1 (+ hub v0.55.0): Task 3a-fix.** Placement hardening in > `offbox_restore.go`: F-3a-1a live target uses raw `GetStackHDDPath` (not `AppNamespaceRoot` — its diff --git a/REPORT.md b/REPORT.md index 23d5bf9..a441a65 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,143 +1,75 @@ -# REPORT — Placement hardening + enlarge-blocked delivery chain (Task 3a-fix) — controller v0.134.1 / hub v0.55.0 +# REPORT — Tier-2 engine rework (Task 3b) — controller v0.135.0 ## Summary -Two follow-ups on Task 3a: (1) four hardening fixes to the not-yet-live place-to-live flow surfaced -by reviewer source-validation of v0.134.0, and (2) the three-link delivery chain for the -`offbox_enlarge_blocked` notification (hub ingestion + the customer whitelist/migration/checkbox on -the controller). No new architecture. +Task 3b: the tier-2 column of the matrix (architecture §2/§8). The tier-2 appdata leg becomes +**class-driven** for classified apps (per-bind mandatory + optional; paperless's copy shrinks as +`export` drops out), legacy apps keep a byte-identical resolver set — both mapped into a new **v2 +relpath-mirroring layout** that lifts the N>1 refusal structurally and gains a reconcile step; the SSD +fallback is an enforced **state-only** tier; and `selectTier2Target` gains the F-6C-1 **network-target +exclusion** (pinned + auto). Restore reads v2 behind a marker gate. Plus **Part 0** (persisted prefs +seed — fixes the 3a-fix un-disableable checkbox) and **Part 0.5** (offsite scratch prefers local). +Every destructive write lands only under `backups/secondary//` (code-asserted). ## Baselines (live-verified at session start) | Repo | `main` @ start | Version | → | |---|---|---|---| -| felhom-controller | `482d0d9` | v0.134.0 | **v0.134.1** | -| felhom.eu (hub) | `8d85da7` | hub v0.54.0 | **hub v0.55.0** | - -## WIP fence (§9.0) — recorded - -The felhom.eu clone was **CLEAN** at session start (`git status` empty; `hub/internal/claim/` is -**tracked/committed** at `8d85da7`, not WIP). The ~215-line foreign WIP the prompt warned about was -already resolved (committed) — **no fence trigger**. After my edits, `git status` showed only the 5 -task files (4 named + `hub/internal/notify/templates_offbox_test.go`, see note below); no foreign WIP -appeared or was touched. Staged with explicit per-file `git add`; pulled with `--rebase --autostash`. - -**Deviation noted transparently:** Part 11 / §15 explicitly require a `FormatCustomerEmail` fallback -assertion, which can only live in `hub/internal/notify/`. §9.0(b)/§12 restrict hub edits to 4 named -files and say "do not touch internal/notify/" — but that prohibition's stated rationale (WIP zone + -the customerMessages trap) is void here: the WIP is absent and the test adds NO `customerMessages` -entry and touches NO notify logic (a new isolated file). I added it to satisfy the explicit -deliverable; it locks in the deliberate non-change. Flagged here for the reviewer's judgment. +| felhom-controller | `1245a6c` | v0.134.1 | **v0.135.0** | ## Files -**felhom-controller (v0.134.1):** -- `internal/backup/offbox_restore.go` — F-3a-1a/1b/2/4 in `PlaceOffsiteRestore`; F-3a-3 in `mapOffsiteRestorePaths`. -- `internal/settings/settings.go` — `DefaultEnabledEvents += offbox_enlarge_blocked`; `GetNotificationPrefs` append-if-absent migration + `appendIfAbsent` helper. -- `internal/web/handlers.go` — `offbox_enlarge_blocked` in the prefs single-event slice. -- `internal/web/templates/settings_notifications.html` — the new checkbox. -- **new** `internal/backup/offbox_place_test.go` (5 tests) · **new** `internal/settings/notif_migration_test.go` (3 tests). -- CHANGELOG / REPORT / CONTEXT. +- **new** `internal/backup/tier2_capture.go` — `tier2CaptureSet` (TierSecondary legs + gaps), `tier2Leg`, `tier2DestRel`. +- **mod** `internal/backup/tier2.go` — `selectTier2Target` (network exclusion, two sizes, `StateOnly`), `RunTier2` v2 body (migration/mirror/reconcile/marker-last), `tier2SafeRemove` + `tier2Reconcile` + `classifyTier2Rel`, `Tier2Info` rewired, header + `RunTier2` comments truthful; deleted `errTier2MultiDir`/`tier2AppDataName`. +- **mod** `internal/backup/tier2_restore.go` — v2 marker gate + two-subtree missing-only merge; deleted `errTier2MultiDirRestore`. +- **mod** `internal/backup/offbox_restore.go` — Part 0.5 scratch local-preference. +- **mod** `internal/backup/backup.go` — `tier2SSDFits` seam. +- **mod** `internal/backup/appbackup_bridge.go` — `ClassMandatory/Optional/Excluded` aliases. +- **mod** `internal/settings/settings.go` — Part 0 seed (`OffboxEnlargeNoticeSeeded` + `seedOffboxEnlargeNotice` at Load; getter append removed); `CrossDriveBackup.LastWarning`. +- **mod** `internal/web/handlers.go` + `templates/backups_apps.html` — surface `Tier2LastWarning`. +- **tests** new `tier2_v2_test.go`, `offbox_scratch_test.go`; rewrote `notif_migration_test.go` (Part 0), `tier2_restore_test.go` fixture (v2), `async_restore_test.go` fixture (v2), `tier2_test.go` signatures; **deleted** obsolete `tier2_appdata_test.go`. +- CHANGELOG / REPORT / CONTEXT / README. -**felhom.eu (hub v0.55.0):** -- `hub/internal/api/handler.go` — `offbox_enlarge_blocked` in `allowedEventTypes` (+ gofmt realignment). -- `hub/internal/api/event_test.go` — acceptance case (+ the 400 red-proof target). -- **new** `hub/internal/notify/templates_offbox_test.go` — raw-message fallback assertion. -- `hub/CHANGELOG.md` · `manifests/hub.yaml` (image → :0.55.0). - -Untouched: hub `internal/notify/dispatcher.go`/`templates.go`/`store.go`; no `customerMessages` entry; -placement stays non-auto-deploying; no engine changes beyond `offbox_restore.go`. - -## Commits - -- felhom.eu hub: `08fef48` -- felhom-controller: `0cfcc42` +Untouched: offbox engine (except `offboxRestoreScratchDir`), appexport, hub, agent; `rsyncMirror`/`rsyncRestoreMissing` unchanged. ## Tests — results -`go build ./... && go vet ./... && go test ./...` — **green, both repos.** Controller +8 tests, hub -+2 tests. +`go build ./... && go vet ./... && go test ./...` — **green.** New: v2 suite (§7 A–H + reconcile +keep/remove + safe-remove boundary proof + `classifyTier2Rel` table), Part 0 (idempotent seed + +opt-out-sticks + fresh-customer), Part 0.5 (local-preference + network-only WARN). ### §10 red-proofs (mutate → FAIL → revert), all verified | ID | Mutation | Test | |---|---|---| -| A | restore AppNamespaceRoot fallback (neuter undeployed guard) | `TestPlace_UndeployedRefused` (copier ran) | -| B | delete placement headroom gate | `TestPlace_HeadroomRefused` (copier ran) | -| C | neuter the stat pre-pass | `TestPlace_IncompleteScratchRefusedNoCopies` (copies > 0) | -| D | restore `p != oldNs &&` escape condition | `TestMapOffsiteRestorePaths_RefusesNamespaceRoot` (junk placement accepted) | -| E | drop post-success scratch cleanup | `TestPlace_ScratchLifecycle` (scratch survived) | -| F2 | unconditional append | `TestGetNotificationPrefs_AlreadyPresentNoDuplicate` (duplicate) | -| Hub | remove the allowlist entry | `TestHandleEvent_OffboxEnlargeBlockedAccepted` (400) | +| A | classified app routed to the legacy resolver | `ClassifiedPaperless` (`export` appears in dest) | +| B | legacy routed through ComputeCaptureSet | `LegacyMultiDir` (empty legs) | +| C | marker written before the legs | `MigrationAndMarkerLast` (marker present after a leg failure) | +| D-reconcile | disable stale detection | `Reconcile` (stale dir survives) | +| D-boundary | neuter the safe-remove prefix assert | `Reconcile` (removal outside destBase not refused) | +| E | SSD copies optional (no state-only filter) | `SSDStateOnly` (optional on the SSD) | +| F1 | drop the pinned IsNetwork check | `NetworkExclusion/pinned` (NAS pin honored) | +| F2 | drop the auto IsNetwork check (NFS iterated first) | `NetworkExclusion/auto` (NAS selected) | +| G2 | neuter the restore marker gate | `RestoreRefusesOldLayout` (merge runs on old layout) | +| Part 0 | don't persist the seed marker | `SeedOffboxEnlargeNotice_OptOutSticks` (type re-enabled) | -All reverted; post-revert both suites green; no mutation residue. +All reverted; post-revert full suite green; no residue. ## Deploy / verify -- **Hub:** built + pushed `felhom-hub:0.55.0` on 180; `manifests/hub.yaml` → :0.55.0 (commit `08fef48`); - ArgoCD hard-refresh + patch-sync → **Synced/Healthy**, `deploy/hub` rolled out to image `:0.55.0`, - startup `Listening on :8080`. -- **Controller:** built + pushed `felhom-controller:0.134.1` on 180; deployed to guest 9201 → - `Up (healthy)`. Commit `0cfcc42`. +`` -## §13 live validation — ALL LEGS PASS +## §13 live validation -The dashboard session was established with `180:~/.config/credentials` `C4_PASSWORD` (the value is -**single-quoted** in the file — stripping the quotes is required; an earlier run that kept the quotes -produced a false `Hibás jelszó`, since corrected). Login → 302 + `felhom_session` cookie; the flow ran -entirely on 180 (LAN → dashboard) so the password was never transferred or echoed. +`` -- **Leg 2 — manual run (LIVE):** POST `/backup/offbox/run` → 302; run log: - `backed up calibre-web (…, 1 mandatory path(s))` (enlarged shape), `audiobookshelf`/`immich` - `not deployed — offsite push is unit-only` (the §2.4 undeployed WARN firing live), `backup OK: 3 - app(s) backed up, 15 snapshot(s), 49s`. **Leg 2a** confirmed by `snapshots --no-lock --json`: - calibre-web newest = `[…/backups/primary/calibre-web, …/userdata/media/books]` (unit + mandatory). -- **Leg 2b — raw-data quota (LIVE):** stored `repo_size_bytes = 280,932,890` (267.9 MB) == live - `stats --mode raw-data` — down from 744 MB modeless pre-3a. -- **Leg 2c — forget grouping:** both call sites carry `--group-by host,tags` (deployed + unit-tested); - a successful forget is logged SILENTLY by design (only failures log), so no live line — the ~40s gap - before "backup OK" + the stable 15-snapshot retention evidence it ran. -- **Leg 3 — unit-only scratch restore (LIVE):** POST `mode=unit` → scratch landed on the DATA DRIVE - `/mnt/felhom-drives/nas-media/backups/offsite-restore/calibre-web` (NOT the rootfs — F-A1); the - reconstructed tree contains ONLY `…/backups/primary/calibre-web/{compose,volume-dumps}` (unit-only - scope, `--include` working); rootfs `DataDir/offbox-restore` absent. -- **Leg 4 — full restore two-step (LIVE):** step 1 `mode=full` → 302 to - `/backups/restore?full_prep=calibre-web&full_size=258.3+MB` (size shown BEFORE start); step 2 - `mode=full&confirm=1` → the scratch now also holds `…/userdata/media/books/.calnotes/…` (whole - snapshot, SP-3.1 abs-path reconstruction). -- **Leg 5 — prefs round-trip (LIVE):** `/settings/notifications` renders `event_offbox_enlarge_blocked` - **checked** (migration surfaced it enabled); a save round-trip → the type **survived** (F3 - checkbox-drop trap avoided); the hub logged `Notification preferences updated for demo-felhom: - events=[… offbox_enlarge_blocked …]` — the migrated type reached the hub whitelist end-to-end. -- **Leg 6 — delivery (LIVE, one synthetic event):** pushed `offbox_enlarge_blocked`/warning through the - real controller→hub path → **hub_status 200** (was 400 before v0.55.0); hub logged - `Event from demo-felhom: offbox_enlarge_blocked (warning)` + `Operator email sent for - demo-felhom/offbox_enlarge_blocked`. The message was the debug endpoint's generic text (it fixes the - body); the dynamic two-number message survival is unit-proven (`templates_offbox_test.go`) — no - `customerMessages` entry, so `FormatCustomerEmail` falls back to the raw message. -- **Leg 7 — hygiene:** the calibre-web scratch (legs 3–4) removed; 180 session temp files removed; - read-only inspection scripts removed from the container. `PlaceOffsiteRestore` was NOT run (6D). +## 6D-pending -## NOT yet live-validated — awaiting CAMPAIGN-6D (supervised) +`` -- `PlaceOffsiteRestore` against live data (the STOP boundary — merges into live app data). -- Organic enlarge-block firing (demo repo 268 MB / quota 50 GB won't trip; unit-tested + the delivery - chain proven live via the synthetic event). -- The SQ3 immich offsite-only full-circle restore-and-boot. +## Observations -## Observations (documented, not acted on) - -- **Getter-based migration trade-off:** the append-if-absent migration lives in `GetNotificationPrefs` - (per §2.2's explicit instruction). Consequence: because the getter always surfaces the type, a - customer who later unchecks *this one warning* and saves will see it re-enabled on the next page - load — the getter can't distinguish "never had it" from "opted out" without a persisted - migration-marker. Acceptable for a first delivery (a quota warning), but a future one-time persisted - migration would honor a deliberate opt-out. Noted, not changed (the task specified the getter). -- The stale `documentation/controller/backup-architecture.md` ("restic is gone from the controller") - remains — flagged in the v0.134.0 report; still its own task. -- **Demo-state touches during §13 (benign, noted):** the leg-5 prefs save persisted the demo - customer's `enabled_events` to explicitly include `offbox_enlarge_blocked` (previously getter-migrated - — idempotent, no behavior change); leg 6 sent one operator test email. Legs 3–4 scratch dirs were - removed (leg 7). No live app data was modified (`PlaceOffsiteRestore` not run). -- **Credential note:** `C4_PASSWORD` in `180:~/.config/credentials` is **single-quoted** — strip the - quotes before use (an unstripped value yields a false `Hibás jelszó`). The credential is valid. +- The `Tier2LastWarning` surfacing is minimal (one line on the app's tier-2 row); the SSD state-only + hint was already rendered via `EffectiveIsSSD`/`EffectiveDesc` (no new template needed there). +- `tier2SSDFits` seam added because `system.GetDiskUsage` is Linux-only (nil on the Windows test host) + — production uses the real predicate. diff --git a/controller/README.md b/controller/README.md index 5795825..54c6c1c 100644 --- a/controller/README.md +++ b/controller/README.md @@ -436,6 +436,17 @@ retired in v0.126.0 when the moved blocks were legitimately rewritten onto the s ONE local copy — `FullBackupStatus.SingleCopyWarning` surfaces an honest Hungarian notice on the backup page ("Csak egy másolat készül…") instead of implying a 3-2-1 guarantee the box cannot keep. - The `AppBackupPrefs.Enabled` field in settings.json is legacy and not read by any code. +- **v2 layout + class-driven legs (v0.135.0, Task 3b):** `backups/secondary//` is the v2 + relpath-mirroring layout — `.felhom-tier2-layout` marker (written LAST) + `recovery-unit/` + + `hdd//` + `userdata//`. For a **classified** app the appdata leg is the + `TierSecondary` capture set (per-bind mandatory + optional; excluded drops out — `tier2_capture.go`); + **legacy** apps keep a byte-identical resolver set in the same layout. N>1 appdata dirs + nested + binds are native (the old flat-appdata N>1 refusal is gone). First v2 run per app = delete-and-rebuild + of the old flat `appdata/` + a reconcile pass that prunes dest dirs a bind no longer covers; all + removals go through `tier2SafeRemove` (refuses anything outside `backups/secondary/`). The **SSD** + fallback is a state-only tier (unit + mandatory; optional skipped, honest reason). **NETWORK (NAS) + storage is never a tier-2 target** — pinned or auto (F-6C-1: rsync `-og` under root_squash → + wrong-owner restore). Restore reads v2 behind the marker gate; a pre-v2 copy is refused. **Per-app Tier 2 contents by app type:** diff --git a/controller/internal/backup/appbackup_bridge.go b/controller/internal/backup/appbackup_bridge.go index 90b81df..ba9c18c 100644 --- a/controller/internal/backup/appbackup_bridge.go +++ b/controller/internal/backup/appbackup_bridge.go @@ -44,6 +44,14 @@ const ( DBTypeMariaDB = appbackup.DBTypeMariaDB ) +// Backup-classification class constants (Task 3-core) — aliased so the tier engines can switch on +// class without importing appbackup directly. +const ( + ClassMandatory = appbackup.ClassMandatory + ClassOptional = appbackup.ClassOptional + ClassExcluded = appbackup.ClassExcluded +) + // FelhomDataDir is the namespace directory on storage drives for all felhom-managed data. const FelhomDataDir = appbackup.FelhomDataDir diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 14bf4d2..43fdc6b 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -85,6 +85,11 @@ type Manager struct { // (`-a --delete`, contents-of-src semantics). tier2Mirror func(src, dst string) error + // tier2SSDFits (3b) — the SSD-headroom predicate seam, overridable in tests (system.GetDiskUsage is + // Linux-only → nil on the Windows test host, which would always refuse the SSD branch). Nil → the + // real tier2FitsSystemDrive. + tier2SSDFits func(sys string, sizeBytes int64) bool + // migrationRunning, if set, reports whether a data migration is in progress. The scheduled // backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a // nightly dump/Tier-2 can't race a migration copy/cleanup on the same drive. diff --git a/controller/internal/backup/offbox_restore.go b/controller/internal/backup/offbox_restore.go index 4b5ce22..6f259cd 100644 --- a/controller/internal/backup/offbox_restore.go +++ b/controller/internal/backup/offbox_restore.go @@ -128,16 +128,36 @@ func (m *Manager) offboxSnapshotSize(ctx context.Context, id string) (int64, err // probe). NEVER cfg.Paths.DataDir (the rootfs — the F-A1 filler). App's HDD drive first; else the first // schedulable storage path; else a Hungarian refusal. func (m *Manager) offboxRestoreScratchDir(stack string) (scratch, nsRoot string, err error) { + scratchFor := func(root string) (string, string) { + nr := m.namespaceRoot(root) + return filepath.Join(nr, "backups", "offsite-restore", stack), nr + } + isNet := func(path string) bool { return m.settings != nil && m.settings.IsNetworkStoragePath(path) } + // (1) the app's own drive — preferred, but ONLY if it is not NETWORK storage (F-3afix-1). restic + // restores uid/gid/setgid fully onto a LOCAL fs (SP-3.3); a squashed network scratch would feed + // PlaceOffsiteRestore wrong-owner files — the F-6C-1 silently-broken-restore class, offsite-side. if m.stackProvider != nil { - if hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack)); hdd != "" { - nr := m.namespaceRoot(hdd) - return filepath.Join(nr, "backups", "offsite-restore", stack), nr, nil + if hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack)); hdd != "" && !isNet(hdd) { + s, nr := scratchFor(hdd) + return s, nr, nil } } - for _, sp := range m.settings.GetSchedulableStoragePaths() { - if strings.TrimSpace(sp.Path) != "" { - nr := m.namespaceRoot(sp.Path) - return filepath.Join(nr, "backups", "offsite-restore", stack), nr, nil + // (2) the first NON-network schedulable path. + if m.settings != nil { + for _, sp := range m.settings.GetSchedulableStoragePaths() { + if strings.TrimSpace(sp.Path) != "" && !sp.IsNetwork() { + s, nr := scratchFor(sp.Path) + return s, nr, nil + } + } + // (3) last resort ONLY: any schedulable path, with a loud WARN — a network scratch cannot + // guarantee ownership fidelity under root_squash. + for _, sp := range m.settings.GetSchedulableStoragePaths() { + if strings.TrimSpace(sp.Path) != "" { + m.logger.Printf("[WARN] [offbox] %s: restore scratch on network storage %s — ownership fidelity not guaranteed under squash", stack, sp.Path) + s, nr := scratchFor(sp.Path) + return s, nr, nil + } } } return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz") diff --git a/controller/internal/backup/offbox_scratch_test.go b/controller/internal/backup/offbox_scratch_test.go new file mode 100644 index 0000000..3907679 --- /dev/null +++ b/controller/internal/backup/offbox_scratch_test.go @@ -0,0 +1,66 @@ +package backup + +import ( + "bytes" + "log" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Part 0.5 (F-3afix-1): the offsite restore scratch must prefer a LOCAL path — a network (squashed) +// scratch would feed PlaceOffsiteRestore wrong-owner files. A network app-drive is skipped when a +// local candidate exists; a network-only environment is a last resort WITH a loud WARN. +func TestOffboxScratchDir_PrefersLocal(t *testing.T) { + m, sett := newOffboxManager(t) + prov := &offbox3aProvider{hdd: map[string]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}} + m.SetStackProvider(prov) + + nas := "/mnt/nas" + local := t.TempDir() + if err := sett.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{Path: local, Label: "local", Schedulable: true}); err != nil { + t.Fatal(err) + } + prov.hdd["immich"] = nas // the app lives ON the NAS + + _, nsRoot, err := m.offboxRestoreScratchDir("immich") + if err != nil { + t.Fatalf("scratchDir: %v", err) + } + if strings.Contains(nsRoot, "nas") { + t.Errorf("network app-drive must not be the scratch — got %q, want the local drive", nsRoot) + } + if !strings.HasPrefix(nsRoot, filepath.Clean(local)) { + t.Errorf("scratch nsRoot = %q, want under the local drive %q", nsRoot, local) + } +} + +func TestOffboxScratchDir_NetworkOnlyLastResortWarns(t *testing.T) { + var buf bytes.Buffer + m, sett := newOffboxManager(t) + m.logger = log.New(&buf, "", 0) + prov := &offbox3aProvider{hdd: map[string]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}} + m.SetStackProvider(prov) + + nas := "/mnt/nas" + if err := sett.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil { + t.Fatal(err) + } + prov.hdd["immich"] = nas + + _, nsRoot, err := m.offboxRestoreScratchDir("immich") + if err != nil { + t.Fatalf("scratchDir: %v", err) + } + if !strings.Contains(nsRoot, "nas") { + t.Errorf("network-only → the NAS is the last resort, got %q", nsRoot) + } + if !strings.Contains(buf.String(), "ownership fidelity not guaranteed") { + t.Errorf("last-resort network scratch must log a loud WARN, got: %s", buf.String()) + } +} diff --git a/controller/internal/backup/tier2.go b/controller/internal/backup/tier2.go index 9a4fd71..f903441 100644 --- a/controller/internal/backup/tier2.go +++ b/controller/internal/backup/tier2.go @@ -14,26 +14,29 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) -// Tier 2 = an off-drive (different physical disk) copy of an HDD app's recovery unit + its resolved -// appdata/ dir(s). It does NOT copy the browsable userdata tree (F-S1: userdata is not backed -// up at any tier yet — that gap is owned by the classification redesign, see -// felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md). The appdata dir NAME is -// derived from the app's compose binds, NOT assumed to be the stack name (F-S2: paperless-ngx writes -// appdata/paperless); see tier2AppDataName. Auto-enabled for every HDD app; the target is auto-picked: -// prefer another registered user-data drive (can hold bulk), else the internal SSD for SMALL units -// only — and the SSD is the guest rootfs (~8 GB), so we REFUSE rather than fill it (a size-aware -// headroom guard). When no off-drive target fits, we record an honest "needs a 2nd HDD" status -// instead of silently doing nothing useful. +// Tier 2 = an off-drive (different physical disk) copy of an app's recovery unit + its CLASS-DRIVEN +// capture legs (Task 3b, architecture §2/§8). For a classified app the legs are the Task-3-core +// TierSecondary set — per-bind mandatory + optional HDD/userdata paths (so a classified app's copy +// tracks exactly what the catalog classed non-excluded; paperless's copy legitimately shrinks as +// `export` drops out). For a legacy app the legs are the resolver appdata dir(s), byte-identical to +// v0.134.x (the SQ5 footprint promise), mapped into the same layout. The dest is the v2 +// relpath-mirroring layout (hdd/, userdata/, recovery-unit/, .felhom-tier2-layout marker) — +// N>1 dirs + nested binds represented natively (the old flat-appdata N>1 refusal is gone), migration +// is delete-and-rebuild of the derived copy, and a reconcile step prunes dest dirs a bind no longer +// covers. Target auto-pick: another registered NON-NETWORK user-data drive (holds the full set), else +// the internal SSD for the STATE-ONLY set (unit + mandatory), rootfs-headroom-guarded. NETWORK (NAS) +// storage is never a target (F-6C-1: rsync -og under root_squash → silently-wrong-owner restore). No +// off-drive target ⇒ an honest recorded status, not a silent no-op. const gibibyte = 1024 * 1024 * 1024 var ( errNoOffDiskTarget = errors.New("no off-drive target (single drive, app already on the system disk)") errSSDNoHeadroom = errors.New("the internal SSD lacks headroom for this app's data — a 2nd drive is required for off-drive backup") - // errTier2MultiDir is raised when an app's compose resolves to MORE THAN ONE distinct appdata - // dir under /appdata (no catalog app does today). Tier 2's destination layout is flat - // (/appdata), so it refuses rather than silently collapse two source dirs into one. - errTier2MultiDir = errors.New("az alkalmazáshoz több adatkönyvtár tartozik — a 2. mentés jelenleg alkalmazásonként egy könyvtárat támogat") + // errTier2NetworkOnly (F-6C-1): the only off-disk candidate is a NAS network share, which can never + // be a tier-2 target — rsync -og chowns under a root_squash export and a wrong-owner restore is + // silently broken. The message IS the customer-facing reason. + errTier2NetworkOnly = errors.New("Hálózati tároló nem lehet a 2. mentés célja — a fájltulajdonos-adatok megőrzése ott nem garantálható.") ) // appDataDirNames resolves the app's real appdata dir name(s) under hddPath from its compose HDD @@ -47,17 +50,6 @@ func (m *Manager) appDataDirNames(stackName, hddPath string) []string { return AppDataDirNames(hddPath, stackName, mounts) } -// tier2AppDataName resolves the SINGLE appdata dir name for tier-2's flat destination. N>1 distinct -// names → errTier2MultiDir (the one place the tier-2 multi-dir refusal is built). It always returns -// at least one name from appDataDirNames' fallback, so name is meaningful only when err == nil. -func (m *Manager) tier2AppDataName(stackName, hddPath string) (string, error) { - names := m.appDataDirNames(stackName, hddPath) - if len(names) > 1 { - return "", errTier2MultiDir - } - return names[0], nil -} - // tier2AppDataBindsPresent reports whether the app's compose declares an appdata bind (drives the // WARN-on-missing-declared-dir rule; nil provider → false). func (m *Manager) tier2AppDataBindsPresent(stackName, hddPath string) bool { @@ -72,6 +64,7 @@ type Tier2Target struct { NamespaceRoot string // felhom-data namespace root on the target drive Label string // human label (UI) IsSystemDrive bool // target is the internal SSD/system drive (DB/config only) + StateOnly bool // §2.2: this target carries unit + MANDATORY only (optional legs skipped) — the SSD tier Reason string // why this target (Hungarian, for UI/logs) } @@ -87,22 +80,30 @@ func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool { } // selectTier2Target picks the off-drive destination for an app's Tier 2 copy. A customer-pinned -// target (PreferredTarget, set from the config panel) wins when it is still valid; otherwise it -// auto-picks: another user-data drive, else the internal SSD for small units (headroom-guarded). -func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tier2Target, error) { +// target wins when still valid; otherwise it auto-picks: another user-data drive (holds the FULL set), +// else the internal SSD for the STATE-ONLY set (unit + mandatory), headroom-guarded. NETWORK storage +// (NAS) is never a valid target at any step — pinned or auto (F-6C-1: rsync -og under root_squash → +// silently-wrong-owner restore). fullSize sizes the real-drive path; stateOnlySize sizes the SSD path. +func (m *Manager) selectTier2Target(stackName string, fullSize, stateOnlySize int64) (*Tier2Target, error) { sourceDrive := m.GetAppDrivePath(stackName) if sourceDrive == "" { return nil, fmt.Errorf("no source drive for %s", stackName) } + sawNetworkCandidate := false // an off-disk candidate existed but was network-only → better reason - // 0. Honor a customer-pinned target if it is still valid (registered, schedulable, off-disk). - // An invalid pin (gone / same physical disk) silently falls through to the auto-pick. + // 0. Honor a customer-pinned target if it is still valid (registered, schedulable, off-disk, + // NON-network). An invalid pin (gone / same disk / network) silently falls through to auto. if m.settings != nil { if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil && cd.PreferredTarget != "" { for _, sp := range m.settings.GetSchedulableStoragePaths() { if sp.Path != cd.PreferredTarget { continue } + if sp.IsNetwork() { + m.logger.Printf("[INFO] [backup] Tier 2 %s: pinned target %s is network storage — invalid (F-6C-1); falling through to auto", stackName, sp.Path) + sawNetworkCandidate = true + break + } if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) { break // pinned target is on the same physical disk — not off-drive; fall through } @@ -113,19 +114,22 @@ func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tie return &Tier2Target{ NamespaceRoot: NamespaceRoot(sp.Path, true), Label: label, - IsSystemDrive: false, Reason: "kézi választás", }, nil } } } - // 1. Prefer another registered user-data drive on a DIFFERENT physical disk (can hold bulk userdata). + // 1. Prefer another registered user-data drive on a DIFFERENT physical disk, NON-network. if m.settings != nil { for _, sp := range m.settings.GetSchedulableStoragePaths() { if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) { continue } + if sp.IsNetwork() { + sawNetworkCandidate = true // F-6C-1: never a tier-2 target + continue + } label := sp.Label if label == "" { label = filepath.Base(sp.Path) @@ -133,24 +137,31 @@ func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tie return &Tier2Target{ NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root Label: label, - IsSystemDrive: false, Reason: "másik adatmeghajtó", }, nil } } - // 2. Fall back to the internal SSD (system data path) — SMALL units only. + // 2. Fall back to the internal SSD (system data path) — STATE-ONLY set only. sys := m.systemDataPath if sys == "" || system.SamePhysicalDevice(sourceDrive, sys) { + if sawNetworkCandidate { + return nil, errTier2NetworkOnly // the only off-disk candidate was a NAS + } return nil, errNoOffDiskTarget // single drive / app already on the system disk } - if !m.tier2FitsSystemDrive(sys, unitSizeBytes) { + fits := m.tier2FitsSystemDrive + if m.tier2SSDFits != nil { + fits = m.tier2SSDFits + } + if !fits(sys, stateOnlySize) { return nil, errSSDNoHeadroom // would fill the ~8 GB rootfs — refuse, don't fill } return &Tier2Target{ NamespaceRoot: NamespaceRoot(sys, false), // system path is a real root → felhom-data appended Label: "belső SSD (rendszer)", IsSystemDrive: true, + StateOnly: true, Reason: "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell", }, nil } @@ -164,9 +175,97 @@ func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool { return tier2FitsHeadroom(di.AvailGB, di.TotalGB, float64(unitSizeBytes)/gibibyte) } -// RunTier2 makes/refreshes the off-drive copy of a single HDD app's recovery unit + userdata. -// Best-effort and idempotent (rsync mirror). Records status into settings for the UI; returns an -// error only on an actual copy failure (no valid target is a recorded status, not an error). +// Tier-2 v2 layout (Task 3b, architecture §8): backups/secondary// holds +// .felhom-tier2-layout (marker file, content "2" — written LAST, after all legs + reconcile) +// recovery-unit/ (the unit leg, layout-identical to v1) +// hdd// (per-bind HDD legs, relpath-mirroring) +// userdata// (per-bind USERDATA legs) +// Relpath-mirroring represents N>1 dirs + nested binds natively (the v1 flat-appdata N>1 refusal is +// lifted structurally) and makes restore position-derivable (dest relpath → live path under the app's +// current HDD_PATH). The whole tree is DERIVED from live data — migration is delete-and-rebuild. +const ( + tier2LayoutMarker = ".felhom-tier2-layout" + tier2LayoutVersion = "2" +) + +// tier2SafeRemove os.RemoveAll's target ONLY if it is strictly WITHIN a backups/secondary/<...> +// destBase (§9.2 destructive-write boundary — defense in depth against a mapping bug ever pointing a +// removal at live data; red-proofed). Never removes destBase itself. +func tier2SafeRemove(destBase, target string) error { + cb := filepath.Clean(destBase) + ct := filepath.Clean(target) + if !strings.Contains(filepath.ToSlash(cb), "/backups/secondary/") { + return fmt.Errorf("refusing tier-2 removal: destBase %q is not under backups/secondary/", cb) + } + if !strings.HasPrefix(ct, cb+string(filepath.Separator)) { + return fmt.Errorf("refusing tier-2 removal: %q is not strictly within %q", ct, cb) + } + return os.RemoveAll(ct) +} + +// tier2RelClass classifies a dest dir (relpath dirRel, slash-form) against the current leg relpaths: +// keepInside = the dir is a leg or inside one (keep, don't descend); keepAncestor = on the path to a +// leg (keep, descend to find stale deeper); stale = neither (remove the topmost). +type tier2RelClass int + +const ( + tier2Stale tier2RelClass = iota + tier2KeepInside + tier2KeepAncestor +) + +func classifyTier2Rel(dirRel string, legRels []string) tier2RelClass { + for _, lr := range legRels { + if dirRel == lr || strings.HasPrefix(dirRel, lr+"/") { + return tier2KeepInside // exact leg or descendant content + } + } + for _, lr := range legRels { + if strings.HasPrefix(lr, dirRel+"/") { + return tier2KeepAncestor // on the path to a deeper leg + } + } + return tier2Stale +} + +// tier2Reconcile removes dest subdirs under hdd/ and userdata/ that are neither an ancestor nor a +// descendant of any current leg relpath (§7-D — the deferred-pruning answer: a bind removed/re-classed +// stops occupying the secondary drive within one run). Runs strictly inside destBase. +func (m *Manager) tier2Reconcile(destBase string, legRels []string) { + var walk func(dirAbs, dirRel string) + walk = func(dirAbs, dirRel string) { + entries, err := os.ReadDir(dirAbs) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() { + continue + } + childRel := dirRel + "/" + e.Name() + childAbs := filepath.Join(dirAbs, e.Name()) + switch classifyTier2Rel(childRel, legRels) { + case tier2KeepInside: + // mirrored leg content — keep, no descent + case tier2KeepAncestor: + walk(childAbs, childRel) + default: + if err := tier2SafeRemove(destBase, childAbs); err != nil { + m.logger.Printf("[WARN] [backup] Tier 2 reconcile: %v", err) + } else { + m.logger.Printf("[INFO] [backup] Tier 2 reconcile: removed stale dest dir %s", childRel) + } + } + } + } + for _, root := range []string{"hdd", "userdata"} { + walk(filepath.Join(destBase, root), root) + } +} + +// RunTier2 makes/refreshes the off-drive v2 copy of a single app's recovery unit + its class-driven +// capture legs (§8). Best-effort and idempotent. Records status for the UI; returns an error only on +// an actual copy failure (no valid target is a recorded status, not an error). func (m *Manager) RunTier2(stackName string) error { // Customer turned Tier 2 off for this app (config panel) — skip without touching status. if m.settings != nil { @@ -181,23 +280,25 @@ func (m *Manager) RunTier2(stackName string) error { } sourceNsRoot := m.namespaceRoot(sourceDrive) unitDir := RecoveryUnitPath(sourceNsRoot, stackName) - // F-S2: resolve the app's REAL appdata dir name from its compose binds (paperless-ngx writes - // appdata/paperless, not appdata/paperless-ngx). For an HDD app HDD_PATH == nsRoot (Model A), so - // the mounts (resolved against HDD_PATH) share the nsRoot prefix. N>1 distinct names → refuse. - appDataName, resErr := m.tier2AppDataName(stackName, sourceNsRoot) - if resErr != nil { - m.recordTier2NoTarget(stackName, resErr.Error()) - m.logger.Printf("[ERROR] [backup] Tier 2 for %s refused: %v", stackName, resErr) - return nil - } - appDataDir := AppDataDir(sourceNsRoot, appDataName) if _, err := os.Stat(unitDir); err != nil { return nil // no recovery unit yet — nothing to copy } - unitSize := dirSizeBytes(unitDir) + dirSizeBytes(appDataDir) + // Class-driven legs (classified) / resolver legs (legacy) + loud gap warnings (§7-H). + legs, warns := m.tier2CaptureSet(stackName, sourceNsRoot) - target, err := m.selectTier2Target(stackName, unitSize) + // Two sizes: full = unit + all legs; state-only = unit + mandatory legs (the SSD ceiling). + unitSize := dirSizeBytes(unitDir) + var fullSize, stateOnlySize int64 = unitSize, unitSize + for _, lg := range legs { + sz := dirSizeBytes(lg.Src) + fullSize += sz + if lg.Class == ClassMandatory { + stateOnlySize += sz + } + } + + target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize) if err != nil { reason := tier2NoTargetReason(err) m.recordTier2NoTarget(stackName, reason) @@ -210,14 +311,41 @@ func (m *Manager) RunTier2(stackName string) error { return nil } + // §2.2: the SSD is a STATE-ONLY tier — drop optional legs, tell the customer honestly. + if target.StateOnly { + kept := legs[:0] + droppedOptional := false + for _, lg := range legs { + if lg.Class == ClassMandatory { + kept = append(kept, lg) + } else { + droppedOptional = true + } + } + legs = kept + if droppedOptional { + warns = append(warns, "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.") + } + } + destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", stackName) start := time.Now() - mirror := m.tier2Mirror if mirror == nil { mirror = rsyncMirror } + // Migration = delete-and-rebuild (marker LAST). First v2 run (marker absent): remove the old flat + // appdata/ leg (recovery-unit/ is layout-identical — untouched). A half-migrated dest self-heals on + // the next run because the marker is only written after every leg + reconcile succeed. + markerPath := filepath.Join(destBase, tier2LayoutMarker) + if _, mErr := os.Stat(markerPath); mErr != nil { + if rmErr := tier2SafeRemove(destBase, filepath.Join(destBase, "appdata")); rmErr != nil && !os.IsNotExist(rmErr) { + m.logger.Printf("[WARN] [backup] Tier 2 %s: migration cleanup of old flat appdata failed: %v", stackName, rmErr) + } + } + + // Unit leg (always). if err := mirror(unitDir, filepath.Join(destBase, "recovery-unit")); err != nil { m.recordTier2Failure(stackName, target, err) if m.tier2Notify != nil { @@ -225,29 +353,35 @@ func (m *Manager) RunTier2(stackName string) error { } return fmt.Errorf("tier2 rsync unit for %s: %w", stackName, err) } - if _, e := os.Stat(appDataDir); e == nil { - if err := mirror(appDataDir, filepath.Join(destBase, "appdata")); err != nil { + // Capture legs. + legRels := make([]string, 0, len(legs)) + mirroredSize := unitSize + for _, lg := range legs { + if err := mirror(lg.Src, filepath.Join(destBase, filepath.FromSlash(lg.DestRel))); err != nil { m.recordTier2Failure(stackName, target, err) if m.tier2Notify != nil { m.tier2Notify(stackName, target.Label, time.Since(start), err) } - return fmt.Errorf("tier2 rsync appdata for %s: %w", stackName, err) + return fmt.Errorf("tier2 rsync leg %s for %s: %w", lg.DestRel, stackName, err) } - } else if m.tier2AppDataBindsPresent(stackName, sourceNsRoot) { - // F-S2: the compose DECLARES an appdata bind but the dir is missing on disk. Skipping is kept - // (nothing to copy) but the silence that hid F-S2 for months is now a loud WARN. - m.logger.Printf("[WARN] [backup] Tier 2 for %s: compose declares appdata dir %q but it is absent at %s — appdata leg skipped", - stackName, appDataName, appDataDir) + legRels = append(legRels, lg.DestRel) + mirroredSize += dirSizeBytes(lg.Src) + } + + // Reconcile stale dest dirs (a bind removed / re-classed excluded), then write the marker LAST. + m.tier2Reconcile(destBase, legRels) + if err := os.WriteFile(markerPath, []byte(tier2LayoutVersion), 0644); err != nil { + m.logger.Printf("[WARN] [backup] Tier 2 %s: layout marker write failed (restore will refuse until next run): %v", stackName, err) } dur := time.Since(start) - m.recordTier2Success(stackName, target, unitSize, dur) + m.recordTier2Success(stackName, target, mirroredSize, strings.Join(warns, " "), dur) if m.tier2Notify != nil { m.tier2Notify(stackName, target.Label, dur, nil) } - m.logger.Printf("[INFO] [backup] Tier 2 copied %s → %s (%s, %s)%s", - stackName, destBase, humanizeBytes(unitSize), dur.Round(time.Second), - map[bool]string{true: " [SSD: DB/config only]", false: ""}[target.IsSystemDrive]) + m.logger.Printf("[INFO] [backup] Tier 2 copied %s → %s (%s, %d leg(s), %s)%s", + stackName, destBase, humanizeBytes(mirroredSize), len(legs), dur.Round(time.Second), + map[bool]string{true: " [SSD: state-only]", false: ""}[target.StateOnly]) return nil } @@ -335,17 +469,20 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info { } } - // Resolve what the runner WOULD pick right now (real unit size feeds the SSD headroom guard). - // F-S2: N>1 distinct appdata dirs → the same honest refusal the runner records. + // Resolve what the runner WOULD pick right now: the v2 capture legs feed both sizes (the SSD + // headroom guard tests the state-only set). sourceNsRoot := m.namespaceRoot(source) - appDataName, resErr := m.tier2AppDataName(stackName, sourceNsRoot) - if resErr != nil { - info.NoTarget = true - info.NoTargetReason = resErr.Error() - return info + legs, _ := m.tier2CaptureSet(stackName, sourceNsRoot) + unitSize := dirSizeBytes(RecoveryUnitPath(sourceNsRoot, stackName)) + fullSize, stateOnlySize := unitSize, unitSize + for _, lg := range legs { + sz := dirSizeBytes(lg.Src) + fullSize += sz + if lg.Class == ClassMandatory { + stateOnlySize += sz + } } - unitSize := dirSizeBytes(RecoveryUnitPath(sourceNsRoot, stackName)) + dirSizeBytes(AppDataDir(sourceNsRoot, appDataName)) - target, err := m.selectTier2Target(stackName, unitSize) + target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize) if err != nil { info.NoTarget = true info.NoTargetReason = tier2NoTargetReason(err) @@ -371,7 +508,7 @@ func (m *Manager) withTier2Prefs(stackName string, cfg *settings.CrossDriveBacku return cfg } -func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, sizeBytes int64, dur time.Duration) { +func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, sizeBytes int64, warning string, dur time.Duration) { if m.settings == nil { return } @@ -382,6 +519,7 @@ func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, size Schedule: "daily", LastRun: time.Now().Format(time.RFC3339), LastStatus: "ok", + LastWarning: strings.TrimSpace(warning), LastDuration: dur.Round(time.Second).String(), LastSizeHuman: humanizeBytes(sizeBytes), })); err != nil { diff --git a/controller/internal/backup/tier2_appdata_test.go b/controller/internal/backup/tier2_appdata_test.go deleted file mode 100644 index adf0bd4..0000000 --- a/controller/internal/backup/tier2_appdata_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package backup - -import ( - "errors" - "io" - "log" - "path/filepath" - "testing" - - "gitea.dooplex.hu/admin/felhom-controller/internal/config" - "gitea.dooplex.hu/admin/felhom-controller/internal/settings" -) - -// newRunTier2Manager builds a Manager wired to a fake provider, a real temp-dir source drive holding -// a recovery unit + an appdata/ dir (created iff appDataName != ""), and a (non-existent) -// schedulable off-drive target so selectTier2Target resolves a real target without touching the disk -// (SamePhysicalDevice returns false for an unstattable path on both Linux and Windows). Compose -// mounts default to appdata//media + /export; callers override fake.mounts for the -// legacy/multi-dir shapes. The tier2Mirror seam captures the (src,dst) of each leg. -func newRunTier2Manager(t *testing.T, stack, appDataName string) (m *Manager, src string, captured *[][2]string) { - t.Helper() - tmp := t.TempDir() - src = filepath.Join(tmp, "usb") // the source drive == HDD_PATH == namespace root (Model A) - sysPath := filepath.Join(tmp, "sys") - target := filepath.Join(tmp, "off-drive-target") // never created → treated as a different device - - sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0)) - if err != nil { - t.Fatal(err) - } - if err := sett.AddStoragePath(settings.StoragePath{Path: target, Label: "off", Schedulable: true}); err != nil { - t.Fatal(err) - } - - // A recovery unit (gates RunTier2) + the resolved appdata dir, both with real bytes. - mustWrite(t, filepath.Join(RecoveryUnitPath(src, stack), "manifest.json"), "{}") - var mounts []string - if appDataName != "" { - mustWrite(t, filepath.Join(AppDataDir(src, appDataName), "media", "a.jpg"), "JPEGDATA") - mounts = []string{ - filepath.Join(src, "appdata", appDataName, "media"), - filepath.Join(src, "appdata", appDataName, "export"), - } - } - - fake := &t2rFakeProvider{hdd: src, mounts: mounts} - cfg := &config.Config{} - cfg.Paths.SystemDataPath = sysPath - m = NewManager(cfg, sett, log.New(io.Discard, "", 0)) - m.stackProvider = fake - m.systemDataPath = sysPath - - pairs := &[][2]string{} - m.tier2Mirror = func(s, d string) error { - *pairs = append(*pairs, [2]string{s, d}) - return nil - } - return m, src, pairs -} - -// TestRunTier2_PaperlessShape (Scenario A / RP-2): the appdata leg is mirrored from the REAL -// compose-derived dir (appdata/paperless), and the recorded size includes its bytes. Companion -// RP-2: reverting the L146 site to AppDataDir(nsRoot, stackName) makes the appdata leg mirror a -// non-existent dir — the appdata capture below (dst ".../appdata") never fires. -func TestRunTier2_PaperlessShape(t *testing.T) { - m, srcDrive, captured := newRunTier2Manager(t, "paperless-ngx", "paperless") - - if err := m.RunTier2("paperless-ngx"); err != nil { - t.Fatalf("RunTier2: %v", err) - } - - // The appdata leg must mirror appdata/paperless → /appdata. - wantSrc := AppDataDir(srcDrive, "paperless") - var appdataLeg *[2]string - for i := range *captured { - if filepath.Base((*captured)[i][1]) == "appdata" { - appdataLeg = &(*captured)[i] - } - } - if appdataLeg == nil { - t.Fatalf("appdata leg was never mirrored (F-S2 regression); captured=%v", *captured) - } - if appdataLeg[0] != wantSrc { - t.Errorf("appdata mirror src = %q, want the resolved dir %q", appdataLeg[0], wantSrc) - } - if filepath.Base(appdataLeg[1]) != "appdata" { - t.Errorf("appdata mirror dst = %q, want flat /appdata", appdataLeg[1]) - } - // Recorded success size must include the paperless bytes (non-empty, > 0). - cd := m.settings.GetCrossDriveConfig("paperless-ngx") - if cd == nil || cd.LastStatus != "ok" { - t.Fatalf("expected recorded ok status, got %+v", cd) - } - if cd.LastSizeHuman == "" || cd.LastSizeHuman == "0 B" { - t.Errorf("recorded size = %q, want it to include the appdata bytes", cd.LastSizeHuman) - } -} - -// TestRunTier2_LegacyShape (Scenario B): a match-name app AND a no-binds app both mirror the exact -// same src the pre-fix code keyed by stack name — byte-identical behavior. -func TestRunTier2_LegacyShape(t *testing.T) { - t.Run("matching name (nextcloud)", func(t *testing.T) { - m, srcDrive, captured := newRunTier2Manager(t, "nextcloud", "nextcloud") - m.stackProvider.(*t2rFakeProvider).mounts = []string{filepath.Join(srcDrive, "appdata", "nextcloud")} - if err := m.RunTier2("nextcloud"); err != nil { - t.Fatalf("RunTier2: %v", err) - } - assertAppdataSrc(t, captured, AppDataDir(srcDrive, "nextcloud")) - }) - t.Run("no appdata binds (fallback == stack name)", func(t *testing.T) { - // The appdata dir is created under the stack name; mounts are cleared → resolver falls back. - m, srcDrive, captured := newRunTier2Manager(t, "vaultwarden", "vaultwarden") - m.stackProvider.(*t2rFakeProvider).mounts = nil - if err := m.RunTier2("vaultwarden"); err != nil { - t.Fatalf("RunTier2: %v", err) - } - assertAppdataSrc(t, captured, AppDataDir(srcDrive, "vaultwarden")) - }) -} - -func assertAppdataSrc(t *testing.T, captured *[][2]string, want string) { - t.Helper() - for _, p := range *captured { - if filepath.Base(p[1]) == "appdata" { - if p[0] != want { - t.Errorf("appdata mirror src = %q, want %q", p[0], want) - } - return - } - } - t.Fatalf("appdata leg not mirrored; captured=%v", *captured) -} - -// TestRunTier2_MultiDirRefusal (Scenario C / RP-5): two distinct appdata dirs → an honest no_target -// status with the EXACT Hungarian reason, an [ERROR] log, and NO mirror call. Companion RP-5: -// deleting the N>1 guard in tier2AppDataName makes the mirror fire (call-count > 0). -func TestRunTier2_MultiDirRefusal(t *testing.T) { - m, srcDrive, captured := newRunTier2Manager(t, "twodir", "alpha") - m.stackProvider.(*t2rFakeProvider).mounts = []string{ - filepath.Join(srcDrive, "appdata", "alpha", "x"), - filepath.Join(srcDrive, "appdata", "beta", "y"), - } - if err := m.RunTier2("twodir"); err != nil { - t.Fatalf("RunTier2 must record a status, not error: %v", err) - } - if n := len(*captured); n != 0 { - t.Errorf("mirror was called %d time(s) on a multi-dir refusal — want 0", n) - } - cd := m.settings.GetCrossDriveConfig("twodir") - if cd == nil || cd.LastStatus != "no_target" { - t.Fatalf("expected no_target status, got %+v", cd) - } - if cd.LastError != errTier2MultiDir.Error() { - t.Errorf("reason = %q, want %q", cd.LastError, errTier2MultiDir.Error()) - } -} - -// TestTier2Info_MultiDirRefusal (Scenario C, info tier): the config-panel view surfaces the same -// refusal reason. -func TestTier2Info_MultiDirRefusal(t *testing.T) { - m, srcDrive, _ := newRunTier2Manager(t, "twodir", "alpha") - m.stackProvider.(*t2rFakeProvider).mounts = []string{ - filepath.Join(srcDrive, "appdata", "alpha", "x"), - filepath.Join(srcDrive, "appdata", "beta", "y"), - } - info := m.Tier2Info("twodir") - if !info.NoTarget { - t.Fatal("expected NoTarget on a multi-dir app") - } - if info.NoTargetReason != errTier2MultiDir.Error() { - t.Errorf("reason = %q, want %q", info.NoTargetReason, errTier2MultiDir.Error()) - } -} - -// TestRestoreTier2Files_ResolvedLiveDir (Scenario E / RP-3): the copier's dst is the RESOLVED live -// dir (appdata/paperless), not appdata/. Companion RP-3: reverting liveDir to stack-name -// keying makes dst appdata/paperless-ngx and this fails. -func TestRestoreTier2Files_ResolvedLiveDir(t *testing.T) { - m, fake, liveDrive, _ := newT2RManager(t) - fake.mounts = []string{ - filepath.Join(liveDrive, "appdata", "paperless", "media"), - } - var gotDst string - m.restoreFilesCopier = func(_, dst string) (int, error) { gotDst = dst; return 0, nil } - - if _, err := m.RestoreTier2Files("app"); err != nil { - t.Fatalf("restore: %v", err) - } - if want := AppDataDir(liveDrive, "paperless"); gotDst != want { - t.Errorf("restore dst = %q, want resolved live dir %q", gotDst, want) - } -} - -// TestRestoreTier2Files_MultiDirRefusal (Scenario C, restore tier): two dirs → errTier2MultiDirRestore -// BEFORE the app is stopped (effect assertion: StopStack never invoked). -func TestRestoreTier2Files_MultiDirRefusal(t *testing.T) { - m, fake, liveDrive, _ := newT2RManager(t) - fake.mounts = []string{ - filepath.Join(liveDrive, "appdata", "alpha", "x"), - filepath.Join(liveDrive, "appdata", "beta", "y"), - } - called := false - m.restoreFilesCopier = func(string, string) (int, error) { called = true; return 0, nil } - - _, err := m.RestoreTier2Files("app") - if !errors.Is(err, errTier2MultiDirRestore) { - t.Fatalf("err = %v, want errTier2MultiDirRestore", err) - } - if len(fake.stopped) != 0 { - t.Errorf("app was STOPPED on a multi-dir refusal: %v", fake.stopped) - } - if called { - t.Error("copier invoked on a refusal") - } -} diff --git a/controller/internal/backup/tier2_capture.go b/controller/internal/backup/tier2_capture.go new file mode 100644 index 0000000..b9d0798 --- /dev/null +++ b/controller/internal/backup/tier2_capture.go @@ -0,0 +1,92 @@ +package backup + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" +) + +// Tier-2 SECONDARY capture-set resolution (Task 3b, architecture §2/§8). The analog of +// offboxCaptureSet, differing only in tier (TierSecondary = mandatory + optional, vs offsite's +// mandatory-only) and surfacing target (the app's cross-drive status warning). SP-3.4's loud-gap +// discipline is identical: a structurally-refused or on-disk-missing MANDATORY path is detected here +// and surfaced in BOTH the English log and the Hungarian per-app warning — never a silent gap. + +// tier2Leg is one source→dest mirror leg. DestRel is the v2-layout relpath ("hdd/" | +// "userdata/"), slash-form, joined under backups/secondary// by the engine. +type tier2Leg struct { + Src string // absolute source dir + DestRel string // v2 relpath under destBase (slash-form) + Class appbackup.BindClass // mandatory | optional (legacy legs are mandatory-equivalent) +} + +// tier2DestRel maps a classified bind's (Root, RelPath) to its v2 dest relpath (slash-form). +// RootHDD → hdd/; RootUserdata → userdata/; rel=="" → the base itself. +func tier2DestRel(root appbackup.BindRoot, rel string) string { + base := "hdd" + if root == appbackup.RootUserdata { + base = "userdata" + } + return filepath.ToSlash(filepath.Join(base, rel)) +} + +// tier2CaptureSet computes an app's tier-2 secondary mirror legs + Hungarian gap warnings, resolved +// against nsRoot (== the app's HDD_PATH, Model A). Classified apps yield the TierSecondary set +// (mandatory + optional), per-bind, into the v2 layout. Legacy / no-block / no-provider apps yield the +// resolver appdata dir(s) as mandatory-equivalent legs — the SAME capture set as v0.134.x (the SQ5 +// footprint promise), mapped into the same v2 layout so restore has ONE reader. Missing/refused +// mandatory paths are stat-filtered + surfaced loudly (§7-H). +func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns []string) { + // Classified path. + if m.stackProvider != nil { + if binds, has := m.stackProvider.GetStackClassifiedBinds(stack); has { + cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, nsRoot) + var gaps []string + for _, sk := range cs.Skipped { + if sk.Class == appbackup.ClassMandatory { + m.logger.Printf("[ERROR] [backup] Tier 2 %s: mandatory path refused by a structural guard (%s): %s/%s — NOT in the secondary copy", + stack, sk.Reason, sk.Root, sk.RelPath) + gaps = append(gaps, sk.RelPath) + } + } + for _, p := range cs.Paths { + if _, err := os.Stat(p.Abs); err != nil { + if p.Class == appbackup.ClassMandatory { + m.logger.Printf("[WARN] [backup] Tier 2 %s: mandatory data path missing on disk, skipped: %s", stack, p.Abs) + gaps = append(gaps, p.RelPath) + } + continue // optional-missing is silent (not a gap) + } + legs = append(legs, tier2Leg{Src: p.Abs, DestRel: tier2DestRel(p.Root, p.RelPath), Class: p.Class}) + } + if len(gaps) > 0 { + warns = append(warns, fmt.Sprintf("Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a másodlagos mentésbe: %s.", + stack, strings.Join(gaps, ", "))) + } + return legs, warns + } + } + // Legacy path: the resolver appdata dir(s) → hdd/appdata/ legs (mandatory-equivalent for SSD + // purposes — the resolver set IS the app's state). N>1 dirs now succeed (one leg each; the v0.131.0 + // errTier2MultiDir refusal is lifted structurally). Preserve the F-S2 declared-but-absent WARN. + declared := m.tier2AppDataBindsPresent(stack, nsRoot) + for _, name := range m.appDataDirNames(stack, nsRoot) { + src := AppDataDir(nsRoot, name) + if _, err := os.Stat(src); err != nil { + if declared { + m.logger.Printf("[WARN] [backup] Tier 2 %s: compose declares appdata dir %q but it is absent at %s — leg skipped", + stack, name, src) + } + continue + } + legs = append(legs, tier2Leg{ + Src: src, + DestRel: filepath.ToSlash(filepath.Join("hdd", "appdata", name)), + Class: appbackup.ClassMandatory, + }) + } + return legs, warns +} diff --git a/controller/internal/backup/tier2_restore.go b/controller/internal/backup/tier2_restore.go index c15f17d..e061d00 100644 --- a/controller/internal/backup/tier2_restore.go +++ b/controller/internal/backup/tier2_restore.go @@ -29,9 +29,10 @@ var ( errTier2DriveGone = errors.New("a másodlagos meghajtó nincs csatlakoztatva") errLiveDriveGone = errors.New("az alkalmazás meghajtója nincs csatlakoztatva") errLiveDriveDecommed = errors.New("az alkalmazás meghajtója le van szerelve") - // errTier2MultiDirRestore (F-S2): the app resolves to more than one appdata dir, which the flat - // tier-2 copy layout does not represent — refused BEFORE the app is stopped. - errTier2MultiDirRestore = errors.New("az alkalmazáshoz több adatkönyvtár tartozik — a fájl-visszaállítás jelenleg nem támogatott") + // errTier2OldLayout (3b, §7-G2): the recorded copy predates the v2 relpath-mirroring layout (no + // marker). Refuse rather than read a flat layout we no longer understand — safe, because tier-2 + // restore is missing-file recovery and the live data still exists in that scenario. + errTier2OldLayout = errors.New("A 2. mentés régi formátumú — futtass előbb egy új másodlagos mentést.") ) // RestoreTier2Files restores the app's MISSING user files in place from its recorded Tier-2 copy @@ -63,46 +64,63 @@ func (m *Manager) RestoreTier2Files(stackName string) (filesRestored int, err er return 0, fmt.Errorf("%w (%s)", errLiveDriveDecommed, drive) } } - // F-S2: the live appdata dir is the app's REAL compose-derived dir (paperless-ngx → paperless), - // not the stack name. N>1 distinct dirs → refuse here, BEFORE the app is stopped. + // v2 relpath-mirroring: liveNsRoot == the app's HDD_PATH (Model A). The dest hdd/ and userdata/ + // subtrees mirror the live relpath structure exactly, so restore is two whole-subtree merges (N>1 + // dirs + nested binds handled natively — no per-appdata-dir resolution, no N>1 refusal). liveNsRoot := m.namespaceRoot(drive) - appDataName, resErr := m.tier2AppDataName(stackName, liveNsRoot) - if resErr != nil { - return 0, errTier2MultiDirRestore - } - liveDir := AppDataDir(liveNsRoot, appDataName) - // Source side: the RECORDED Tier-2 copy must exist and its drive must be connected. - var srcDir string + // Source side: the RECORDED Tier-2 copy must exist, its drive connected, and it must be v2. + var destBase string if m.settings != nil { if cfg := m.settings.GetCrossDriveConfig(stackName); cfg != nil && cfg.LastRun != "" && cfg.DestinationPath != "" { if m.settings.IsDisconnected(cfg.DestinationPath) { return 0, errTier2DriveGone } - // Same layout literals as RunTier2's destBase + the appdata leg. - srcDir = filepath.Join(cfg.DestinationPath, "backups", "secondary", stackName, "appdata") + destBase = filepath.Join(cfg.DestinationPath, "backups", "secondary", stackName) } } - if srcDir == "" { + if destBase == "" { return 0, errNoTier2Copy } - if _, statErr := os.Stat(srcDir); statErr != nil { + if _, statErr := os.Stat(destBase); statErr != nil { return 0, errNoTier2Copy // recorded but the copy dir is gone — same honest refusal } - - m.logger.Printf("[INFO] [backup] Tier-2 file restore for %s: %s → %s (additive-only)", stackName, srcDir, liveDir) + // §7-G2 marker gate: a pre-v2 (flat) copy has no marker → refuse rather than read a layout we no + // longer understand (live data still exists for missing-file recovery). + if _, mErr := os.Stat(filepath.Join(destBase, tier2LayoutMarker)); mErr != nil { + return 0, errTier2OldLayout + } copier := m.restoreFilesCopier if copier == nil { copier = rsyncRestoreMissing } + // The two v2 subtree merges: destBase/hdd/ ↔ liveNsRoot/; + // destBase/userdata/ ↔ liveNsRoot/userdata/. Each missing-only, additive. + merges := []struct{ src, dst string }{ + {filepath.Join(destBase, "hdd"), liveNsRoot}, + {filepath.Join(destBase, "userdata"), filepath.Join(liveNsRoot, "userdata")}, + } + m.logger.Printf("[INFO] [backup] Tier-2 file restore for %s: %s (v2) → %s (additive-only)", stackName, destBase, liveNsRoot) + // Stop → copy → start → health (the standard restore shape; F17: errors surface, never swallowed). if stopErr := m.stackProvider.StopStack(stackName); stopErr != nil { m.logger.Printf("[WARN] [backup] could not stop %s before Tier-2 file restore: %v (continuing)", stackName, stopErr) } start := time.Now() - filesRestored, copyErr := copier(srcDir, liveDir) + var copyErr error + for _, mg := range merges { + if _, err := os.Stat(mg.src); err != nil { + continue // that subtree is absent in this copy (e.g. no userdata legs) — skip + } + n, err := copier(mg.src, mg.dst) + filesRestored += n + if err != nil { + copyErr = err + break + } + } startErr := m.stackProvider.StartStack(stackName) if startErr != nil { m.logger.Printf("[ERROR] [backup] failed to restart %s after Tier-2 file restore: %v", stackName, startErr) diff --git a/controller/internal/backup/tier2_restore_test.go b/controller/internal/backup/tier2_restore_test.go index 650a83d..3d4c521 100644 --- a/controller/internal/backup/tier2_restore_test.go +++ b/controller/internal/backup/tier2_restore_test.go @@ -70,7 +70,10 @@ func newT2RManager(t *testing.T) (m *Manager, fake *t2rFakeProvider, liveDrive, }); err != nil { t.Fatal(err) } - mustWrite(t, filepath.Join(destDrive, "backups", "secondary", "app", "appdata", "photos", "a.jpg"), "JPEGDATA") + // v2 layout: marker + an hdd/ leg with content. + destBase := filepath.Join(destDrive, "backups", "secondary", "app") + mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "photos", "a.jpg"), "JPEGDATA") + mustWrite(t, filepath.Join(destBase, tier2LayoutMarker), tier2LayoutVersion) fake = &t2rFakeProvider{hdd: liveDrive} m = &Manager{ @@ -100,11 +103,12 @@ func TestRestoreTier2Files_Orchestration(t *testing.T) { if n != 3 { t.Errorf("filesRestored = %d, want 3", n) } - if want := filepath.Join(destDrive, "backups", "secondary", "app", "appdata"); gotSrc != want { - t.Errorf("copier src = %q, want the RECORDED Tier-2 copy %q", gotSrc, want) + // v2: the hdd/ subtree merges whole into the live namespace root (relpath-mirroring). + if want := filepath.Join(destDrive, "backups", "secondary", "app", "hdd"); gotSrc != want { + t.Errorf("copier src = %q, want the v2 hdd/ subtree %q", gotSrc, want) } - if want := AppDataDir(liveDrive, "app"); gotDst != want { - t.Errorf("copier dst = %q, want live appdata %q", gotDst, want) + if gotDst != liveDrive { + t.Errorf("copier dst = %q, want the live namespace root %q", gotDst, liveDrive) } if len(fake.order) != 3 || fake.order[0] != "stop" || fake.order[1] != "copy" || fake.order[2] != "start" { t.Errorf("order = %v, want [stop copy start]", fake.order) diff --git a/controller/internal/backup/tier2_test.go b/controller/internal/backup/tier2_test.go index 0af2844..31d3c72 100644 --- a/controller/internal/backup/tier2_test.go +++ b/controller/internal/backup/tier2_test.go @@ -38,7 +38,7 @@ func TestSelectTier2Target_HonorsPreferred(t *testing.T) { if err := sett.SetTier2Preference("app", false, "/mnt/b"); err != nil { t.Fatal(err) } - target, err := m.selectTier2Target("app", 1024) + target, err := m.selectTier2Target("app", 1024, 1024) if err != nil { t.Fatalf("selectTier2Target: %v", err) } @@ -59,7 +59,7 @@ func TestSelectTier2Target_InvalidPreferredFallsBack(t *testing.T) { if err := sett.SetTier2Preference("app", false, "/mnt/gone"); err != nil { t.Fatal(err) } - target, err := m.selectTier2Target("app", 1024) + target, err := m.selectTier2Target("app", 1024, 1024) if err != nil { t.Fatalf("selectTier2Target: %v", err) } diff --git a/controller/internal/backup/tier2_v2_test.go b/controller/internal/backup/tier2_v2_test.go new file mode 100644 index 0000000..7520b04 --- /dev/null +++ b/controller/internal/backup/tier2_v2_test.go @@ -0,0 +1,343 @@ +package backup + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// t2v2Provider is a configurable StackDataProvider for the v2 tier-2 tests (per-stack hdd/mounts/binds). +type t2v2Provider struct { + hdd string + mounts map[string][]string + binds map[string][]ClassifiedBind + has map[string]bool +} + +func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", false } +func (p *t2v2Provider) ListDeployedStacks() []StackSummary { return nil } +func (p *t2v2Provider) GetStackHDDMounts(n string) []string { return p.mounts[n] } +func (p *t2v2Provider) GetStackHDDPath(string) string { return p.hdd } +func (p *t2v2Provider) GetDockerVolumes(string) []string { return nil } +func (p *t2v2Provider) StopStack(string) error { return nil } +func (p *t2v2Provider) StartStack(string) error { return nil } +func (p *t2v2Provider) RefreshAndIsRunning(string) bool { return true } +func (p *t2v2Provider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) { return RecoveryInfo{}, false } +func (p *t2v2Provider) RecoverStackSecrets(string, []string) map[string]string { return nil } +func (p *t2v2Provider) RecreateStackFromUnit(string, string, map[string]string) error { return nil } +func (p *t2v2Provider) GetStackClassifiedBinds(n string) ([]ClassifiedBind, bool) { + return p.binds[n], p.has[n] +} + +// newTier2V2 builds a Manager with a source drive (holding a recovery unit) + a registered off-drive +// target, a real (copyTree) mirror seam so the v2 dest tree forms on disk, and the SSD headroom seam +// forced to "fits" so the state-only branch is testable on any platform. +func newTier2V2(t *testing.T, stack string) (m *Manager, src, target string, prov *t2v2Provider) { + t.Helper() + tmp := t.TempDir() + src = filepath.Join(tmp, "usb") + target = filepath.Join(tmp, "flash") + sys := filepath.Join(tmp, "sys") + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{Path: target, Label: "flash", Schedulable: true}); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(RecoveryUnitPath(src, stack), "manifest.json"), "{}") + prov = &t2v2Provider{hdd: src, mounts: map[string][]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}} + cfg := &config.Config{} + cfg.Paths.SystemDataPath = sys + m = NewManager(cfg, sett, log.New(io.Discard, "", 0)) + m.stackProvider = prov + m.systemDataPath = sys + m.tier2Mirror = copyTree + m.tier2SSDFits = func(string, int64) bool { return true } + return m, src, target, prov +} + +func mHDD(rel string) ClassifiedBind { + return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory} +} +func oUD(rel string) ClassifiedBind { + return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel, ReadOnly: true}, Class: appbackup.ClassOptional} +} +func xHDD(rel string) ClassifiedBind { + return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded} +} +func exists(p string) bool { _, err := os.Stat(p); return err == nil } + +// A — classified paperless: v2 tree, export ABSENT (copy shrinks), marker written. +func TestTier2V2_ClassifiedPaperless(t *testing.T) { + m, src, target, prov := newTier2V2(t, "paperless-ngx") + prov.has["paperless-ngx"] = true + prov.binds["paperless-ngx"] = []ClassifiedBind{mHDD("appdata/paperless/media"), xHDD("appdata/paperless/export"), xHDD("import/paperless")} + // mounts point at the whole appdata/paperless dir — the LEGACY resolver set (captured only if a bug + // routes a classified app through it; the A red-proof). The classified path ignores mounts. + prov.mounts["paperless-ngx"] = []string{filepath.Join(src, "appdata", "paperless", "media"), filepath.Join(src, "appdata", "paperless", "export")} + mustWrite(t, filepath.Join(src, "appdata", "paperless", "media", "doc.pdf"), "PDF") + mustWrite(t, filepath.Join(src, "appdata", "paperless", "export", "junk.zip"), "ZIP") + + if err := m.RunTier2("paperless-ngx"); err != nil { + t.Fatalf("RunTier2: %v", err) + } + destBase := filepath.Join(target, "backups", "secondary", "paperless-ngx") + if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" { + t.Errorf("marker = %q, want 2", got) + } + if !exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "media", "doc.pdf")) { + t.Error("mandatory media leg missing from v2 dest") + } + if exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "export")) { + t.Error("excluded export must NOT appear in the tier-2 copy (legacy set leaking)") + } + if !exists(filepath.Join(destBase, "recovery-unit", "manifest.json")) { + t.Error("recovery-unit leg missing") + } +} + +// B — legacy: resolver dirs as legs, N>1 works (no refusal). +func TestTier2V2_LegacyMultiDir(t *testing.T) { + m, src, target, prov := newTier2V2(t, "twodir") + prov.has["twodir"] = false + prov.mounts["twodir"] = []string{filepath.Join(src, "appdata", "alpha", "x"), filepath.Join(src, "appdata", "beta", "y")} + mustWrite(t, filepath.Join(src, "appdata", "alpha", "a.txt"), "A") + mustWrite(t, filepath.Join(src, "appdata", "beta", "b.txt"), "B") + + if err := m.RunTier2("twodir"); err != nil { + t.Fatalf("RunTier2 must SUCCEED for N>1 now (refusal lifted): %v", err) + } + destBase := filepath.Join(target, "backups", "secondary", "twodir") + if !exists(filepath.Join(destBase, "hdd", "appdata", "alpha", "a.txt")) || !exists(filepath.Join(destBase, "hdd", "appdata", "beta", "b.txt")) { + t.Error("both legacy appdata dirs must be mirrored as separate v2 legs") + } + if cd := m.settings.GetCrossDriveConfig("twodir"); cd == nil || cd.LastStatus != "ok" { + t.Errorf("want recorded ok, got %+v", cd) + } +} + +// C — migration = rebuild, marker LAST; a leg failure leaves NO marker. +func TestTier2V2_MigrationAndMarkerLast(t *testing.T) { + m, src, target, prov := newTier2V2(t, "app") + prov.has["app"] = true + prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/data")} + mustWrite(t, filepath.Join(src, "appdata", "app", "data", "f"), "F") + destBase := filepath.Join(target, "backups", "secondary", "app") + // Pre-v2 dest: recovery-unit + old flat appdata, NO marker. + mustWrite(t, filepath.Join(destBase, "recovery-unit", "old.json"), "{}") + mustWrite(t, filepath.Join(destBase, "appdata", "stale.bin"), "STALE") + + if err := m.RunTier2("app"); err != nil { + t.Fatalf("RunTier2: %v", err) + } + if exists(filepath.Join(destBase, "appdata")) { + t.Error("old flat appdata/ must be removed on migration") + } + if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "data", "f")) { + t.Error("v2 legs must be mirrored") + } + if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" { + t.Errorf("marker not written LAST: %q", got) + } + + // Marker-LAST on failure: a fresh app whose leg mirror fails must leave NO marker. + m2, src2, target2, prov2 := newTier2V2(t, "boom") + prov2.has["boom"] = true + prov2.binds["boom"] = []ClassifiedBind{mHDD("appdata/boom/x")} + mustWrite(t, filepath.Join(src2, "appdata", "boom", "x", "f"), "F") + m2.tier2Mirror = func(s, d string) error { + if strings.Contains(d, "boom") && strings.Contains(d, filepath.Join("hdd", "appdata")) { + return os.ErrPermission // fail the LEG (not the unit) + } + return copyTree(s, d) + } + if err := m2.RunTier2("boom"); err == nil { + t.Fatal("a leg mirror failure must surface as an error") + } + if exists(filepath.Join(target2, "backups", "secondary", "boom", tier2LayoutMarker)) { + t.Error("marker must NOT be written when a leg fails (self-heal next run)") + } +} + +// D — reconcile: stale dir removed, ancestor/descendant kept; + the safe-remove boundary proof. +func TestTier2V2_Reconcile(t *testing.T) { + m, src, target, prov := newTier2V2(t, "app") + prov.has["app"] = true + prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/media")} + mustWrite(t, filepath.Join(src, "appdata", "app", "media", "keep.jpg"), "J") + destBase := filepath.Join(target, "backups", "secondary", "app") + // Seed the dest with the marker + stale dirs a prior run left. + mustWrite(t, filepath.Join(destBase, tier2LayoutMarker), "2") + mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg"), "C") // descendant of leg → keep + mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "export", "e.bin"), "E") // sibling of leg → stale + mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "other", "o.bin"), "O") // unrelated → stale + + if err := m.RunTier2("app"); err != nil { + t.Fatalf("RunTier2: %v", err) + } + if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg")) { + t.Error("descendant content under a leg must be kept") + } + if exists(filepath.Join(destBase, "hdd", "appdata", "app", "export")) { + t.Error("stale sibling dir (re-classed/removed bind) must be reconciled away") + } + if exists(filepath.Join(destBase, "hdd", "appdata", "other")) { + t.Error("unrelated stale dir must be reconciled away") + } + + // Boundary proof: tier2SafeRemove refuses a target outside backups/secondary/. + if err := tier2SafeRemove(destBase, filepath.Join(target, "live-appdata")); err == nil { + t.Error("tier2SafeRemove must REFUSE a target outside destBase") + } + if err := tier2SafeRemove("/some/other/root", filepath.Join("/some/other/root", "x")); err == nil { + t.Error("tier2SafeRemove must REFUSE a destBase not under backups/secondary/") + } +} + +// E — SSD state-only: optional legs dropped, mandatory kept, honest warning. +func TestTier2V2_SSDStateOnly(t *testing.T) { + m, src, target, prov := newTier2V2(t, "app") + // Remove the off-drive data-drive target so the auto-pick falls to the SSD (state-only). + _ = target + _ = m.settings.RemoveStoragePath(target) + prov.has["app"] = true + prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/state"), oUD("media/pics")} + mustWrite(t, filepath.Join(src, "appdata", "app", "state", "db.sql"), "SQL") + mustWrite(t, filepath.Join(src, "userdata", "media", "pics", "p.jpg"), "J") + + if err := m.RunTier2("app"); err != nil { + t.Fatalf("RunTier2: %v", err) + } + // The SSD dest is under the system data path (NamespaceRoot(sys, false) → sys/felhom-data). + destBase := filepath.Join(NamespaceRoot(m.systemDataPath, false), "backups", "secondary", "app") + if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "state", "db.sql")) { + t.Error("mandatory state leg must be on the SSD") + } + if exists(filepath.Join(destBase, "userdata", "media", "pics")) { + t.Error("optional leg must NOT be copied to the SSD (state-only tier)") + } + cd := m.settings.GetCrossDriveConfig("app") + if cd == nil || !strings.Contains(cd.LastWarning, "választható tartalom nem került másolásra") { + t.Errorf("state-only warning missing: %+v", cd) + } +} + +// F1/F2 — network exclusion, pinned AND auto (two separate assertions). +func TestTier2V2_NetworkExclusion(t *testing.T) { + // F2 (auto): an NFS candidate registered BEFORE a real local drive → the NFS is skipped and the + // local drive wins (NFS-first ordering makes this a genuine red-proof of the auto IsNetwork skip). + t.Run("auto skips network, picks local", func(t *testing.T) { + m, _, fixtureTarget, _ := newTier2V2(t, "app") + _ = m.settings.RemoveStoragePath(fixtureTarget) // drop the fixture drive, re-add after the NAS + nas := "/mnt/nas" + if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil { + t.Fatal(err) + } + local := t.TempDir() + if err := m.settings.AddStoragePath(settings.StoragePath{Path: local, Label: "local", Schedulable: true}); err != nil { + t.Fatal(err) + } + target, err := m.selectTier2Target("app", 1<<20, 1<<20) + if err != nil { + t.Fatalf("selectTier2Target: %v", err) + } + if strings.Contains(target.NamespaceRoot, "nas") { + t.Errorf("the NFS path (iterated first) must be skipped, got %q", target.NamespaceRoot) + } + if target.NamespaceRoot != filepath.FromSlash(local) { + t.Errorf("want the local drive %q, got %q", local, target.NamespaceRoot) + } + }) + // F2b: NAS is the ONLY off-disk candidate → the honest network reason. + t.Run("network-only → honest reason", func(t *testing.T) { + m, _, target, _ := newTier2V2(t, "app") + _ = m.settings.RemoveStoragePath(target) // drop the local off-drive target + nas := "/mnt/nas" + if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil { + t.Fatal(err) + } + m.systemDataPath = "" // no SSD either → network is the only candidate + _, err := m.selectTier2Target("app", 1<<20, 1<<20) + if err == nil || !strings.Contains(err.Error(), "Hálózati tároló nem lehet") { + t.Fatalf("network-only must refuse with the honest reason, got %v", err) + } + }) + // F1 (pinned): a pinned NFS target is invalid → falls through to auto (the local drive). + t.Run("pinned network invalid → falls through", func(t *testing.T) { + m, _, target, _ := newTier2V2(t, "app") + nas := "/mnt/nas" + if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil { + t.Fatal(err) + } + if err := m.settings.SetTier2Preference("app", false, nas); err != nil { + t.Fatal(err) + } + got, err := m.selectTier2Target("app", 1<<20, 1<<20) + if err != nil { + t.Fatalf("selectTier2Target: %v", err) + } + if got.NamespaceRoot != filepath.FromSlash(target) { + t.Errorf("pinned NFS must fall through to the local auto-pick %q, got %q", target, got.NamespaceRoot) + } + }) +} + +// G2 — restore refuses a pre-v2 (no marker) copy. +func TestTier2V2_RestoreRefusesOldLayout(t *testing.T) { + m, fake, _, destDrive := newT2RManager(t) + _ = fake + // Remove the marker the v2 fixture wrote → simulate an old flat copy. + if err := os.Remove(filepath.Join(destDrive, "backups", "secondary", "app", tier2LayoutMarker)); err != nil { + t.Fatal(err) + } + m.restoreFilesCopier = func(string, string) (int, error) { t.Fatal("copier must not run on an old-layout refusal"); return 0, nil } + if _, err := m.RestoreTier2Files("app"); err == nil || !strings.Contains(err.Error(), "régi formátumú") { + t.Fatalf("restore must refuse a pre-v2 copy with the marker-refusal string, got %v", err) + } +} + +// H — a structurally-refused MANDATORY path is a loud gap in the app's warning. +func TestTier2V2_CaptureGapLoud(t *testing.T) { + m, src, _, prov := newTier2V2(t, "app") + prov.has["app"] = true + prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/ok"), mHDD("../evil")} // traversal → Skipped + mustWrite(t, filepath.Join(src, "appdata", "app", "ok", "f"), "F") + + if err := m.RunTier2("app"); err != nil { + t.Fatalf("RunTier2: %v", err) + } + cd := m.settings.GetCrossDriveConfig("app") + if cd == nil || !strings.Contains(cd.LastWarning, "nem kerültek a másodlagos mentésbe") { + t.Errorf("capture-gap warning missing from tier-2 status: %+v", cd) + } +} + +// classifyTier2Rel keep/remove truth table (the reconcile core, pure). +func TestClassifyTier2Rel(t *testing.T) { + legs := []string{"hdd/appdata/app/media", "userdata/media/books"} + cases := []struct { + rel string + want tier2RelClass + }{ + {"hdd/appdata/app/media", tier2KeepInside}, // exact leg + {"hdd/appdata/app/media/covers", tier2KeepInside}, // descendant + {"hdd/appdata", tier2KeepAncestor}, // ancestor + {"hdd/appdata/app", tier2KeepAncestor}, // ancestor + {"hdd/appdata/app/export", tier2Stale}, // sibling → stale + {"hdd/other", tier2Stale}, // unrelated → stale + {"userdata/media", tier2KeepAncestor}, // ancestor of userdata leg + } + for _, c := range cases { + if got := classifyTier2Rel(c.rel, legs); got != c.want { + t.Errorf("classifyTier2Rel(%q) = %d, want %d", c.rel, got, c.want) + } + } +} diff --git a/controller/internal/settings/notif_migration_test.go b/controller/internal/settings/notif_migration_test.go index 2ccbb3e..7918db8 100644 --- a/controller/internal/settings/notif_migration_test.go +++ b/controller/internal/settings/notif_migration_test.go @@ -1,7 +1,9 @@ package settings import ( + "os" "path/filepath" + "strings" "testing" ) @@ -23,57 +25,82 @@ func count(list []string, want string) int { return n } -// F2a: the new warning type is on by default for new customers. +func writeSettings(t *testing.T, json string) (string, *Settings) { + t.Helper() + p := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(p, []byte(json), 0644); err != nil { + t.Fatal(err) + } + s, err := Load(p, discardLog()) + if err != nil { + t.Fatal(err) + } + return p, s +} + +// The new warning type is on by default for new customers. func TestDefaultEnabledEvents_ContainsEnlargeBlocked(t *testing.T) { if !contains(DefaultEnabledEvents, "offbox_enlarge_blocked") { t.Error("DefaultEnabledEvents must contain offbox_enlarge_blocked (new customers get it)") } } -// F2b: an EXISTING customer's stored prefs (predating the type) gain it via append-if-absent — -// idempotent (two reads → one entry) and their OTHER choices are preserved. -func TestGetNotificationPrefs_MigratesExisting(t *testing.T) { +// Part 0: the seed runs at LOAD (not the getter) — an existing customer's stored prefs gain the type, +// their other choices preserved, and the change is persisted (marker + type on disk). +func TestSeedOffboxEnlargeNotice_AtLoad(t *testing.T) { + p, s := writeSettings(t, `{"notifications":{"enabled_events":["backup_failed","disk_warning"],"cooldown_hours":6}}`) + ev := s.GetNotificationPrefs().EnabledEvents + if !contains(ev, "offbox_enlarge_blocked") { + t.Error("seed must append the type at load") + } + if !contains(ev, "backup_failed") || !contains(ev, "disk_warning") { + t.Error("existing choices must be preserved") + } + raw, _ := os.ReadFile(p) + if !strings.Contains(string(raw), "offbox_enlarge_notice_seeded") { + t.Error("seed marker must persist to disk") + } + if !strings.Contains(string(raw), "offbox_enlarge_blocked") { + t.Error("the seeded type must persist to disk (so the getter returns it verbatim)") + } +} + +// Part 0: the seed is idempotent across loads (a customer already having the type keeps exactly one). +func TestSeedOffboxEnlargeNotice_Idempotent(t *testing.T) { + p, _ := writeSettings(t, `{"notifications":{"enabled_events":["offbox_enlarge_blocked","backup_failed"],"cooldown_hours":6}}`) + s2, err := Load(p, discardLog()) + if err != nil { + t.Fatal(err) + } + if c := count(s2.GetNotificationPrefs().EnabledEvents, "offbox_enlarge_blocked"); c != 1 { + t.Errorf("idempotent seed → exactly one entry, got %d", c) + } +} + +// Part 0 (the whole point): a deliberate opt-out STICKS — after the seed, unchecking + saving and +// reloading must NOT re-enable the type (the 3a-fix getter append re-enabled it forever; this fixes it). +func TestSeedOffboxEnlargeNotice_OptOutSticks(t *testing.T) { + p, s := writeSettings(t, `{"notifications":{"enabled_events":["backup_failed","offbox_enlarge_blocked"],"cooldown_hours":6}}`) + // seed already ran at Load (seeded=true persisted). Customer unchecks the warning and saves. + if err := s.SetNotificationPrefs(&NotificationPrefs{EnabledEvents: []string{"backup_failed"}, CooldownHours: 6}); err != nil { + t.Fatal(err) + } + s2, err := Load(p, discardLog()) + if err != nil { + t.Fatal(err) + } + if contains(s2.GetNotificationPrefs().EnabledEvents, "offbox_enlarge_blocked") { + t.Error("opt-out must STICK — the seed must not re-enable the type after a deliberate uncheck") + } +} + +// Fresh customer (no file → nil prefs) still gets the type via DefaultEnabledEvents. +func TestGetNotificationPrefs_FreshCustomer(t *testing.T) { s, err := Load(filepath.Join(t.TempDir(), "settings.json"), discardLog()) if err != nil { t.Fatal(err) } - // A customer who kept only two events and never had the new one. - if err := s.SetNotificationPrefs(&NotificationPrefs{ - Email: "c@example.com", - EnabledEvents: []string{"backup_failed", "disk_warning"}, - CooldownHours: 6, - }); err != nil { - t.Fatal(err) - } - - p1 := s.GetNotificationPrefs() - if !contains(p1.EnabledEvents, "offbox_enlarge_blocked") { - t.Error("existing prefs must gain offbox_enlarge_blocked (append-if-absent migration)") - } - if !contains(p1.EnabledEvents, "backup_failed") || !contains(p1.EnabledEvents, "disk_warning") { - t.Error("the customer's existing choices must be preserved") - } - - // idempotent: a second read still has exactly ONE entry. - p2 := s.GetNotificationPrefs() - if c := count(p2.EnabledEvents, "offbox_enlarge_blocked"); c != 1 { - t.Errorf("migration must be idempotent, got %d entries", c) - } -} - -// A customer who already has the type keeps exactly one (no duplication). -func TestGetNotificationPrefs_AlreadyPresentNoDuplicate(t *testing.T) { - s, err := Load(filepath.Join(t.TempDir(), "settings.json"), discardLog()) - if err != nil { - t.Fatal(err) - } - if err := s.SetNotificationPrefs(&NotificationPrefs{ - EnabledEvents: []string{"offbox_enlarge_blocked", "backup_failed"}, - CooldownHours: 6, - }); err != nil { - t.Fatal(err) - } - if c := count(s.GetNotificationPrefs().EnabledEvents, "offbox_enlarge_blocked"); c != 1 { - t.Errorf("already-present type must not duplicate, got %d", c) + if !contains(s.GetNotificationPrefs().EnabledEvents, "offbox_enlarge_blocked") { + t.Error("fresh customer must get offbox_enlarge_blocked via DefaultEnabledEvents") } } diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 229fa71..7253179 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -42,6 +42,11 @@ type Settings struct { // Notification preferences (Phase 2 — define struct now, leave empty) Notifications *NotificationPrefs `json:"notifications,omitempty"` + // OffboxEnlargeNoticeSeeded (v0.135.0) guards the ONE-TIME seed of offbox_enlarge_blocked into an + // existing customer's stored prefs. Persisted so a later opt-out sticks (the 3a-fix getter append + // re-enabled it on every read — this replaces it). + OffboxEnlargeNoticeSeeded bool `json:"offbox_enlarge_notice_seeded,omitempty"` + // Cached state DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"` @@ -170,6 +175,7 @@ type CrossDriveBackup struct { LastRun string `json:"last_run,omitempty"` // RFC3339 LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running" LastError string `json:"last_error,omitempty"` + LastWarning string `json:"last_warning,omitempty"` // Tier-2 3b: capture-gap / state-only notice (Hungarian) LastDuration string `json:"last_duration,omitempty"` // "2m34s" LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB" @@ -318,6 +324,7 @@ func Load(path string, logger *log.Logger) (*Settings, error) { _ = os.WriteFile(path, bak, 0644) // best-effort promote s2.LoadWarning = "settings.json volt sérült — visszaállítva biztonsági másolatból" s2.migrateResticToRsync() + s2.seedOffboxEnlargeNotice() return s2, nil } } @@ -333,9 +340,28 @@ func Load(path string, logger *log.Logger) (*Settings, error) { len(s.StoragePaths), len(s.Integrations), len(s.PendingEvents)) } s.migrateResticToRsync() + s.seedOffboxEnlargeNotice() return s, nil } +// seedOffboxEnlargeNotice runs ONCE (guarded by OffboxEnlargeNoticeSeeded): an existing customer whose +// stored prefs predate offbox_enlarge_blocked (v0.135.0) gets it appended enabled — they could not have +// deliberately disabled a type that did not exist. Persisted, so a LATER opt-out sticks (unlike the +// 3a-fix getter append, which re-enabled it on every read). Fresh customers (nil prefs) get the type via +// DefaultEnabledEvents; the seed only touches customers with an explicit stored EnabledEvents list. +func (s *Settings) seedOffboxEnlargeNotice() { + if s.OffboxEnlargeNoticeSeeded { + return + } + s.OffboxEnlargeNoticeSeeded = true + if s.Notifications != nil && s.Notifications.EnabledEvents != nil { + s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "offbox_enlarge_blocked") + } + if err := s.save(); err != nil && s.log != nil { + s.log.Printf("[ERROR] [settings] Failed to save offbox-enlarge-notice seed: %v", err) + } +} + // migrateResticToRsync converts any cross-drive backup configs using restic to rsync. // Called once during Load() before the mutex is exposed. func (s *Settings) migrateResticToRsync() { @@ -520,13 +546,12 @@ func (s *Settings) GetNotificationPrefs() *NotificationPrefs { if prefs.EnabledEvents == nil { prefs.EnabledEvents = DefaultEnabledEvents } - // Return a copy of the slice + // Return a copy of the slice verbatim. The offbox_enlarge_blocked seed is a ONE-TIME persisted + // migration (seedOffboxEnlargeNotice at Load), NOT a getter append — so a customer's later opt-out + // sticks instead of being re-enabled on every read. events := make([]string, len(prefs.EnabledEvents)) copy(events, prefs.EnabledEvents) - // 3a-fix append-if-absent migration: an existing customer's stored prefs predate - // offbox_enlarge_blocked, so they cannot have deliberately disabled it — surface it enabled so the - // checkbox renders checked and the startup sync (main.go:782) carries it to the hub. Idempotent. - prefs.EnabledEvents = appendIfAbsent(events, "offbox_enlarge_blocked") + prefs.EnabledEvents = events return &prefs } diff --git a/controller/internal/web/async_restore_test.go b/controller/internal/web/async_restore_test.go index 204b4bd..92b52a2 100644 --- a/controller/internal/web/async_restore_test.go +++ b/controller/internal/web/async_restore_test.go @@ -69,8 +69,12 @@ func newAsyncRestoreServer(t *testing.T) (*Server, *blockProvider, *backup.Manag }); err != nil { t.Fatal(err) } - // a recorded Tier-2 copy dir so RestoreTier2Files proceeds to StopStack (where we block). - if err := os.MkdirAll(filepath.Join(dest, "backups", "secondary", "app", "appdata"), 0o755); err != nil { + // a recorded v2 Tier-2 copy (marker + an hdd/ leg) so RestoreTier2Files proceeds to StopStack. + destBase := filepath.Join(dest, "backups", "secondary", "app") + if err := os.MkdirAll(filepath.Join(destBase, "hdd", "appdata"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(destBase, ".felhom-tier2-layout"), []byte("2"), 0o644); err != nil { t.Fatal(err) } cfg := &config.Config{} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index f291a06..d6d9875 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -844,6 +844,7 @@ type AppBackupRow struct { Tier2LastRun string Tier2LastStatus string // "ok", "error", "running", "" Tier2LastError string + Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—" Tier2SizeHuman string @@ -996,6 +997,7 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup row.Tier2LastRun = cd.LastRun row.Tier2LastStatus = cd.LastStatus row.Tier2LastError = cd.LastError + row.Tier2LastWarning = cd.LastWarning row.Tier2SizeHuman = cd.LastSizeHuman switch cd.LastStatus { case "ok": diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html index 001f347..297c21e 100644 --- a/controller/internal/web/templates/backups_apps.html +++ b/controller/internal/web/templates/backups_apps.html @@ -219,6 +219,7 @@ {{end}} {{if .Tier2SizeHuman}}{{.Tier2SizeHuman}}{{end}} + {{if .Tier2LastWarning}}{{.Tier2LastWarning}}{{end}} {{.BackupContents}}