Tier-2 engine rework: class-driven legs, v2 layout, NAS-target exclusion (Task 3b, v0.135.0)
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/<stack>/{marker LAST, recovery-unit/, hdd/<rel>/, userdata/<rel>/}); 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/.
This commit is contained in:
@@ -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/<stack>/` (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/<stack>/` = `.felhom-tier2-layout` marker (content
|
||||
"2", written **LAST**) + `recovery-unit/` + `hdd/<relpath>/` + `userdata/<relpath>/`. 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
|
||||
|
||||
+15
-1
@@ -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/<stack>/{.felhom-tier2-layout marker LAST, recovery-unit/, hdd/<rel>/,
|
||||
> userdata/<rel>/}`) — 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
|
||||
|
||||
@@ -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/<stack>/` (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`.
|
||||
`<filled after deploy>`
|
||||
|
||||
## §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.
|
||||
`<filled after the live legs>`
|
||||
|
||||
- **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)
|
||||
`<updated after §13>`
|
||||
|
||||
- `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.
|
||||
|
||||
@@ -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/<stack>/` is the v2
|
||||
relpath-mirroring layout — `.felhom-tier2-layout` marker (written LAST) + `recovery-unit/` +
|
||||
`hdd/<relpath>/` + `userdata/<relpath>/`. 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:**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
// (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) != "" {
|
||||
nr := m.namespaceRoot(sp.Path)
|
||||
return filepath.Join(nr, "backups", "offsite-restore", stack), nr, nil
|
||||
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")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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/<name> 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/<rel>, userdata/<rel>, 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 <hddPath>/appdata (no catalog app does today). Tier 2's destination layout is flat
|
||||
// (<destBase>/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/<stack>/ holds
|
||||
// .felhom-tier2-layout (marker file, content "2" — written LAST, after all legs + reconcile)
|
||||
// recovery-unit/ (the unit leg, layout-identical to v1)
|
||||
// hdd/<relpath>/ (per-bind HDD legs, relpath-mirroring)
|
||||
// userdata/<relpath>/ (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 {
|
||||
|
||||
@@ -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/<appDataName> 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/<appDataName>/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 → <destBase>/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 <destBase>/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/<stackName>. 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")
|
||||
}
|
||||
}
|
||||
@@ -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/<rel>" |
|
||||
// "userdata/<rel>"), slash-form, joined under backups/secondary/<stack>/ 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/<rel>; RootUserdata → userdata/<rel>; 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/<name> 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
|
||||
}
|
||||
@@ -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/<rel> ↔ liveNsRoot/<rel>;
|
||||
// destBase/userdata/<rel> ↔ liveNsRoot/userdata/<rel>. 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
|
||||
{{if .Tier2LastWarning}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2LastWarning}}</span>{{end}}
|
||||
<span class="tier-contents">{{.BackupContents}}</span>
|
||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||
<div class="layer-actions">
|
||||
|
||||
Reference in New Issue
Block a user