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
|
## 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)
|
### 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
|
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"
|
> 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
|
> **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
|
> `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
|
## Summary
|
||||||
|
|
||||||
Two follow-ups on Task 3a: (1) four hardening fixes to the not-yet-live place-to-live flow surfaced
|
Task 3b: the tier-2 column of the matrix (architecture §2/§8). The tier-2 appdata leg becomes
|
||||||
by reviewer source-validation of v0.134.0, and (2) the three-link delivery chain for the
|
**class-driven** for classified apps (per-bind mandatory + optional; paperless's copy shrinks as
|
||||||
`offbox_enlarge_blocked` notification (hub ingestion + the customer whitelist/migration/checkbox on
|
`export` drops out), legacy apps keep a byte-identical resolver set — both mapped into a new **v2
|
||||||
the controller). No new architecture.
|
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)
|
## Baselines (live-verified at session start)
|
||||||
|
|
||||||
| Repo | `main` @ start | Version | → |
|
| Repo | `main` @ start | Version | → |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| felhom-controller | `482d0d9` | v0.134.0 | **v0.134.1** |
|
| felhom-controller | `1245a6c` | v0.134.1 | **v0.135.0** |
|
||||||
| 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.
|
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
**felhom-controller (v0.134.1):**
|
- **new** `internal/backup/tier2_capture.go` — `tier2CaptureSet` (TierSecondary legs + gaps), `tier2Leg`, `tier2DestRel`.
|
||||||
- `internal/backup/offbox_restore.go` — F-3a-1a/1b/2/4 in `PlaceOffsiteRestore`; F-3a-3 in `mapOffsiteRestorePaths`.
|
- **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`.
|
||||||
- `internal/settings/settings.go` — `DefaultEnabledEvents += offbox_enlarge_blocked`; `GetNotificationPrefs` append-if-absent migration + `appendIfAbsent` helper.
|
- **mod** `internal/backup/tier2_restore.go` — v2 marker gate + two-subtree missing-only merge; deleted `errTier2MultiDirRestore`.
|
||||||
- `internal/web/handlers.go` — `offbox_enlarge_blocked` in the prefs single-event slice.
|
- **mod** `internal/backup/offbox_restore.go` — Part 0.5 scratch local-preference.
|
||||||
- `internal/web/templates/settings_notifications.html` — the new checkbox.
|
- **mod** `internal/backup/backup.go` — `tier2SSDFits` seam.
|
||||||
- **new** `internal/backup/offbox_place_test.go` (5 tests) · **new** `internal/settings/notif_migration_test.go` (3 tests).
|
- **mod** `internal/backup/appbackup_bridge.go` — `ClassMandatory/Optional/Excluded` aliases.
|
||||||
- CHANGELOG / REPORT / CONTEXT.
|
- **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):**
|
Untouched: offbox engine (except `offboxRestoreScratchDir`), appexport, hub, agent; `rsyncMirror`/`rsyncRestoreMissing` unchanged.
|
||||||
- `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`
|
|
||||||
|
|
||||||
## Tests — results
|
## Tests — results
|
||||||
|
|
||||||
`go build ./... && go vet ./... && go test ./...` — **green, both repos.** Controller +8 tests, hub
|
`go build ./... && go vet ./... && go test ./...` — **green.** New: v2 suite (§7 A–H + reconcile
|
||||||
+2 tests.
|
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
|
### §10 red-proofs (mutate → FAIL → revert), all verified
|
||||||
|
|
||||||
| ID | Mutation | Test |
|
| ID | Mutation | Test |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| A | restore AppNamespaceRoot fallback (neuter undeployed guard) | `TestPlace_UndeployedRefused` (copier ran) |
|
| A | classified app routed to the legacy resolver | `ClassifiedPaperless` (`export` appears in dest) |
|
||||||
| B | delete placement headroom gate | `TestPlace_HeadroomRefused` (copier ran) |
|
| B | legacy routed through ComputeCaptureSet | `LegacyMultiDir` (empty legs) |
|
||||||
| C | neuter the stat pre-pass | `TestPlace_IncompleteScratchRefusedNoCopies` (copies > 0) |
|
| C | marker written before the legs | `MigrationAndMarkerLast` (marker present after a leg failure) |
|
||||||
| D | restore `p != oldNs &&` escape condition | `TestMapOffsiteRestorePaths_RefusesNamespaceRoot` (junk placement accepted) |
|
| D-reconcile | disable stale detection | `Reconcile` (stale dir survives) |
|
||||||
| E | drop post-success scratch cleanup | `TestPlace_ScratchLifecycle` (scratch survived) |
|
| D-boundary | neuter the safe-remove prefix assert | `Reconcile` (removal outside destBase not refused) |
|
||||||
| F2 | unconditional append | `TestGetNotificationPrefs_AlreadyPresentNoDuplicate` (duplicate) |
|
| E | SSD copies optional (no state-only filter) | `SSDStateOnly` (optional on the SSD) |
|
||||||
| Hub | remove the allowlist entry | `TestHandleEvent_OffboxEnlargeBlockedAccepted` (400) |
|
| 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
|
## Deploy / verify
|
||||||
|
|
||||||
- **Hub:** built + pushed `felhom-hub:0.55.0` on 180; `manifests/hub.yaml` → :0.55.0 (commit `08fef48`);
|
`<filled after deploy>`
|
||||||
ArgoCD hard-refresh + patch-sync → **Synced/Healthy**, `deploy/hub` rolled out to image `:0.55.0`,
|
|
||||||
startup `Listening on :8080`.
|
|
||||||
- **Controller:** built + pushed `felhom-controller:0.134.1` on 180; deployed to guest 9201 →
|
|
||||||
`Up (healthy)`. Commit `0cfcc42`.
|
|
||||||
|
|
||||||
## §13 live validation — ALL LEGS PASS
|
## §13 live validation
|
||||||
|
|
||||||
The dashboard session was established with `180:~/.config/credentials` `C4_PASSWORD` (the value is
|
`<filled after the live legs>`
|
||||||
**single-quoted** in the file — stripping the quotes is required; an earlier run that kept the quotes
|
|
||||||
produced a false `Hibás jelszó`, since corrected). Login → 302 + `felhom_session` cookie; the flow ran
|
|
||||||
entirely on 180 (LAN → dashboard) so the password was never transferred or echoed.
|
|
||||||
|
|
||||||
- **Leg 2 — manual run (LIVE):** POST `/backup/offbox/run` → 302; run log:
|
## 6D-pending
|
||||||
`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).
|
|
||||||
|
|
||||||
## NOT yet live-validated — awaiting CAMPAIGN-6D (supervised)
|
`<updated after §13>`
|
||||||
|
|
||||||
- `PlaceOffsiteRestore` against live data (the STOP boundary — merges into live app data).
|
## Observations
|
||||||
- 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 (documented, not acted on)
|
- 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).
|
||||||
- **Getter-based migration trade-off:** the append-if-absent migration lives in `GetNotificationPrefs`
|
- `tier2SSDFits` seam added because `system.GetDiskUsage` is Linux-only (nil on the Windows test host)
|
||||||
(per §2.2's explicit instruction). Consequence: because the getter always surfaces the type, a
|
— production uses the real predicate.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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
|
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.
|
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.
|
- 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:**
|
**Per-app Tier 2 contents by app type:**
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ const (
|
|||||||
DBTypeMariaDB = appbackup.DBTypeMariaDB
|
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.
|
// FelhomDataDir is the namespace directory on storage drives for all felhom-managed data.
|
||||||
const FelhomDataDir = appbackup.FelhomDataDir
|
const FelhomDataDir = appbackup.FelhomDataDir
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ type Manager struct {
|
|||||||
// (`-a --delete`, contents-of-src semantics).
|
// (`-a --delete`, contents-of-src semantics).
|
||||||
tier2Mirror func(src, dst string) error
|
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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// schedulable storage path; else a Hungarian refusal.
|
||||||
func (m *Manager) offboxRestoreScratchDir(stack string) (scratch, nsRoot string, err error) {
|
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 m.stackProvider != nil {
|
||||||
if hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack)); hdd != "" {
|
if hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack)); hdd != "" && !isNet(hdd) {
|
||||||
nr := m.namespaceRoot(hdd)
|
s, nr := scratchFor(hdd)
|
||||||
return filepath.Join(nr, "backups", "offsite-restore", stack), nr, nil
|
return s, nr, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
// (2) the first NON-network schedulable path.
|
||||||
if strings.TrimSpace(sp.Path) != "" {
|
if m.settings != nil {
|
||||||
nr := m.namespaceRoot(sp.Path)
|
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||||
return filepath.Join(nr, "backups", "offsite-restore", stack), nr, nil
|
if strings.TrimSpace(sp.Path) != "" && !sp.IsNetwork() {
|
||||||
|
s, nr := scratchFor(sp.Path)
|
||||||
|
return s, nr, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// (3) last resort ONLY: any schedulable path, with a loud WARN — a network scratch cannot
|
||||||
|
// guarantee ownership fidelity under root_squash.
|
||||||
|
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||||
|
if strings.TrimSpace(sp.Path) != "" {
|
||||||
|
m.logger.Printf("[WARN] [offbox] %s: restore scratch on network storage %s — ownership fidelity not guaranteed under squash", stack, sp.Path)
|
||||||
|
s, nr := scratchFor(sp.Path)
|
||||||
|
return s, nr, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz")
|
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"
|
"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
|
// Tier 2 = an off-drive (different physical disk) copy of an app's recovery unit + its CLASS-DRIVEN
|
||||||
// appdata/<name> dir(s). It does NOT copy the browsable userdata tree (F-S1: userdata is not backed
|
// capture legs (Task 3b, architecture §2/§8). For a classified app the legs are the Task-3-core
|
||||||
// up at any tier yet — that gap is owned by the classification redesign, see
|
// TierSecondary set — per-bind mandatory + optional HDD/userdata paths (so a classified app's copy
|
||||||
// felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md). The appdata dir NAME is
|
// tracks exactly what the catalog classed non-excluded; paperless's copy legitimately shrinks as
|
||||||
// derived from the app's compose binds, NOT assumed to be the stack name (F-S2: paperless-ngx writes
|
// `export` drops out). For a legacy app the legs are the resolver appdata dir(s), byte-identical to
|
||||||
// appdata/paperless); see tier2AppDataName. Auto-enabled for every HDD app; the target is auto-picked:
|
// v0.134.x (the SQ5 footprint promise), mapped into the same layout. The dest is the v2
|
||||||
// prefer another registered user-data drive (can hold bulk), else the internal SSD for SMALL units
|
// relpath-mirroring layout (hdd/<rel>, userdata/<rel>, recovery-unit/, .felhom-tier2-layout marker) —
|
||||||
// only — and the SSD is the guest rootfs (~8 GB), so we REFUSE rather than fill it (a size-aware
|
// N>1 dirs + nested binds represented natively (the old flat-appdata N>1 refusal is gone), migration
|
||||||
// headroom guard). When no off-drive target fits, we record an honest "needs a 2nd HDD" status
|
// is delete-and-rebuild of the derived copy, and a reconcile step prunes dest dirs a bind no longer
|
||||||
// instead of silently doing nothing useful.
|
// 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
|
const gibibyte = 1024 * 1024 * 1024
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errNoOffDiskTarget = errors.New("no off-drive target (single drive, app already on the system disk)")
|
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")
|
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
|
// errTier2NetworkOnly (F-6C-1): the only off-disk candidate is a NAS network share, which can never
|
||||||
// dir under <hddPath>/appdata (no catalog app does today). Tier 2's destination layout is flat
|
// be a tier-2 target — rsync -og chowns under a root_squash export and a wrong-owner restore is
|
||||||
// (<destBase>/appdata), so it refuses rather than silently collapse two source dirs into one.
|
// silently broken. The message IS the customer-facing reason.
|
||||||
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 = 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
|
// 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)
|
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
|
// tier2AppDataBindsPresent reports whether the app's compose declares an appdata bind (drives the
|
||||||
// WARN-on-missing-declared-dir rule; nil provider → false).
|
// WARN-on-missing-declared-dir rule; nil provider → false).
|
||||||
func (m *Manager) tier2AppDataBindsPresent(stackName, hddPath string) bool {
|
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
|
NamespaceRoot string // felhom-data namespace root on the target drive
|
||||||
Label string // human label (UI)
|
Label string // human label (UI)
|
||||||
IsSystemDrive bool // target is the internal SSD/system drive (DB/config only)
|
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)
|
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
|
// 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
|
// target wins when still valid; otherwise it auto-picks: another user-data drive (holds the FULL set),
|
||||||
// auto-picks: another user-data drive, else the internal SSD for small units (headroom-guarded).
|
// else the internal SSD for the STATE-ONLY set (unit + mandatory), headroom-guarded. NETWORK storage
|
||||||
func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tier2Target, error) {
|
// (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)
|
sourceDrive := m.GetAppDrivePath(stackName)
|
||||||
if sourceDrive == "" {
|
if sourceDrive == "" {
|
||||||
return nil, fmt.Errorf("no source drive for %s", stackName)
|
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).
|
// 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.
|
// NON-network). An invalid pin (gone / same disk / network) silently falls through to auto.
|
||||||
if m.settings != nil {
|
if m.settings != nil {
|
||||||
if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil && cd.PreferredTarget != "" {
|
if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil && cd.PreferredTarget != "" {
|
||||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||||
if sp.Path != cd.PreferredTarget {
|
if sp.Path != cd.PreferredTarget {
|
||||||
continue
|
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) {
|
if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) {
|
||||||
break // pinned target is on the same physical disk — not off-drive; fall through
|
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{
|
return &Tier2Target{
|
||||||
NamespaceRoot: NamespaceRoot(sp.Path, true),
|
NamespaceRoot: NamespaceRoot(sp.Path, true),
|
||||||
Label: label,
|
Label: label,
|
||||||
IsSystemDrive: false,
|
|
||||||
Reason: "kézi választás",
|
Reason: "kézi választás",
|
||||||
}, nil
|
}, 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 {
|
if m.settings != nil {
|
||||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||||
if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) {
|
if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if sp.IsNetwork() {
|
||||||
|
sawNetworkCandidate = true // F-6C-1: never a tier-2 target
|
||||||
|
continue
|
||||||
|
}
|
||||||
label := sp.Label
|
label := sp.Label
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = filepath.Base(sp.Path)
|
label = filepath.Base(sp.Path)
|
||||||
@@ -133,24 +137,31 @@ func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tie
|
|||||||
return &Tier2Target{
|
return &Tier2Target{
|
||||||
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
|
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
|
||||||
Label: label,
|
Label: label,
|
||||||
IsSystemDrive: false,
|
|
||||||
Reason: "másik adatmeghajtó",
|
Reason: "másik adatmeghajtó",
|
||||||
}, nil
|
}, 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
|
sys := m.systemDataPath
|
||||||
if sys == "" || system.SamePhysicalDevice(sourceDrive, sys) {
|
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
|
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 nil, errSSDNoHeadroom // would fill the ~8 GB rootfs — refuse, don't fill
|
||||||
}
|
}
|
||||||
return &Tier2Target{
|
return &Tier2Target{
|
||||||
NamespaceRoot: NamespaceRoot(sys, false), // system path is a real root → felhom-data appended
|
NamespaceRoot: NamespaceRoot(sys, false), // system path is a real root → felhom-data appended
|
||||||
Label: "belső SSD (rendszer)",
|
Label: "belső SSD (rendszer)",
|
||||||
IsSystemDrive: true,
|
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",
|
Reason: "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -164,9 +175,97 @@ func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool {
|
|||||||
return tier2FitsHeadroom(di.AvailGB, di.TotalGB, float64(unitSizeBytes)/gibibyte)
|
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.
|
// Tier-2 v2 layout (Task 3b, architecture §8): backups/secondary/<stack>/ holds
|
||||||
// Best-effort and idempotent (rsync mirror). Records status into settings for the UI; returns an
|
// .felhom-tier2-layout (marker file, content "2" — written LAST, after all legs + reconcile)
|
||||||
// error only on an actual copy failure (no valid target is a recorded status, not an error).
|
// 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 {
|
func (m *Manager) RunTier2(stackName string) error {
|
||||||
// Customer turned Tier 2 off for this app (config panel) — skip without touching status.
|
// Customer turned Tier 2 off for this app (config panel) — skip without touching status.
|
||||||
if m.settings != nil {
|
if m.settings != nil {
|
||||||
@@ -181,23 +280,25 @@ func (m *Manager) RunTier2(stackName string) error {
|
|||||||
}
|
}
|
||||||
sourceNsRoot := m.namespaceRoot(sourceDrive)
|
sourceNsRoot := m.namespaceRoot(sourceDrive)
|
||||||
unitDir := RecoveryUnitPath(sourceNsRoot, stackName)
|
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 {
|
if _, err := os.Stat(unitDir); err != nil {
|
||||||
return nil // no recovery unit yet — nothing to copy
|
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 {
|
if err != nil {
|
||||||
reason := tier2NoTargetReason(err)
|
reason := tier2NoTargetReason(err)
|
||||||
m.recordTier2NoTarget(stackName, reason)
|
m.recordTier2NoTarget(stackName, reason)
|
||||||
@@ -210,14 +311,41 @@ func (m *Manager) RunTier2(stackName string) error {
|
|||||||
return nil
|
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)
|
destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", stackName)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
mirror := m.tier2Mirror
|
mirror := m.tier2Mirror
|
||||||
if mirror == nil {
|
if mirror == nil {
|
||||||
mirror = rsyncMirror
|
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 {
|
if err := mirror(unitDir, filepath.Join(destBase, "recovery-unit")); err != nil {
|
||||||
m.recordTier2Failure(stackName, target, err)
|
m.recordTier2Failure(stackName, target, err)
|
||||||
if m.tier2Notify != nil {
|
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)
|
return fmt.Errorf("tier2 rsync unit for %s: %w", stackName, err)
|
||||||
}
|
}
|
||||||
if _, e := os.Stat(appDataDir); e == nil {
|
// Capture legs.
|
||||||
if err := mirror(appDataDir, filepath.Join(destBase, "appdata")); err != nil {
|
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)
|
m.recordTier2Failure(stackName, target, err)
|
||||||
if m.tier2Notify != nil {
|
if m.tier2Notify != nil {
|
||||||
m.tier2Notify(stackName, target.Label, time.Since(start), err)
|
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) {
|
legRels = append(legRels, lg.DestRel)
|
||||||
// F-S2: the compose DECLARES an appdata bind but the dir is missing on disk. Skipping is kept
|
mirroredSize += dirSizeBytes(lg.Src)
|
||||||
// (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)
|
// 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)
|
dur := time.Since(start)
|
||||||
m.recordTier2Success(stackName, target, unitSize, dur)
|
m.recordTier2Success(stackName, target, mirroredSize, strings.Join(warns, " "), dur)
|
||||||
if m.tier2Notify != nil {
|
if m.tier2Notify != nil {
|
||||||
m.tier2Notify(stackName, target.Label, dur, nil)
|
m.tier2Notify(stackName, target.Label, dur, nil)
|
||||||
}
|
}
|
||||||
m.logger.Printf("[INFO] [backup] Tier 2 copied %s → %s (%s, %s)%s",
|
m.logger.Printf("[INFO] [backup] Tier 2 copied %s → %s (%s, %d leg(s), %s)%s",
|
||||||
stackName, destBase, humanizeBytes(unitSize), dur.Round(time.Second),
|
stackName, destBase, humanizeBytes(mirroredSize), len(legs), dur.Round(time.Second),
|
||||||
map[bool]string{true: " [SSD: DB/config only]", false: ""}[target.IsSystemDrive])
|
map[bool]string{true: " [SSD: state-only]", false: ""}[target.StateOnly])
|
||||||
return nil
|
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).
|
// Resolve what the runner WOULD pick right now: the v2 capture legs feed both sizes (the SSD
|
||||||
// F-S2: N>1 distinct appdata dirs → the same honest refusal the runner records.
|
// headroom guard tests the state-only set).
|
||||||
sourceNsRoot := m.namespaceRoot(source)
|
sourceNsRoot := m.namespaceRoot(source)
|
||||||
appDataName, resErr := m.tier2AppDataName(stackName, sourceNsRoot)
|
legs, _ := m.tier2CaptureSet(stackName, sourceNsRoot)
|
||||||
if resErr != nil {
|
unitSize := dirSizeBytes(RecoveryUnitPath(sourceNsRoot, stackName))
|
||||||
info.NoTarget = true
|
fullSize, stateOnlySize := unitSize, unitSize
|
||||||
info.NoTargetReason = resErr.Error()
|
for _, lg := range legs {
|
||||||
return info
|
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, fullSize, stateOnlySize)
|
||||||
target, err := m.selectTier2Target(stackName, unitSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
info.NoTarget = true
|
info.NoTarget = true
|
||||||
info.NoTargetReason = tier2NoTargetReason(err)
|
info.NoTargetReason = tier2NoTargetReason(err)
|
||||||
@@ -371,7 +508,7 @@ func (m *Manager) withTier2Prefs(stackName string, cfg *settings.CrossDriveBacku
|
|||||||
return cfg
|
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 {
|
if m.settings == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -382,6 +519,7 @@ func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, size
|
|||||||
Schedule: "daily",
|
Schedule: "daily",
|
||||||
LastRun: time.Now().Format(time.RFC3339),
|
LastRun: time.Now().Format(time.RFC3339),
|
||||||
LastStatus: "ok",
|
LastStatus: "ok",
|
||||||
|
LastWarning: strings.TrimSpace(warning),
|
||||||
LastDuration: dur.Round(time.Second).String(),
|
LastDuration: dur.Round(time.Second).String(),
|
||||||
LastSizeHuman: humanizeBytes(sizeBytes),
|
LastSizeHuman: humanizeBytes(sizeBytes),
|
||||||
})); err != nil {
|
})); 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")
|
errTier2DriveGone = errors.New("a másodlagos meghajtó nincs csatlakoztatva")
|
||||||
errLiveDriveGone = errors.New("az alkalmazás meghajtója nincs csatlakoztatva")
|
errLiveDriveGone = errors.New("az alkalmazás meghajtója nincs csatlakoztatva")
|
||||||
errLiveDriveDecommed = errors.New("az alkalmazás meghajtója le van szerelve")
|
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
|
// errTier2OldLayout (3b, §7-G2): the recorded copy predates the v2 relpath-mirroring layout (no
|
||||||
// tier-2 copy layout does not represent — refused BEFORE the app is stopped.
|
// marker). Refuse rather than read a flat layout we no longer understand — safe, because tier-2
|
||||||
errTier2MultiDirRestore = errors.New("az alkalmazáshoz több adatkönyvtár tartozik — a fájl-visszaállítás jelenleg nem támogatott")
|
// 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
|
// 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)
|
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),
|
// v2 relpath-mirroring: liveNsRoot == the app's HDD_PATH (Model A). The dest hdd/ and userdata/
|
||||||
// not the stack name. N>1 distinct dirs → refuse here, BEFORE the app is stopped.
|
// 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)
|
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.
|
// Source side: the RECORDED Tier-2 copy must exist, its drive connected, and it must be v2.
|
||||||
var srcDir string
|
var destBase string
|
||||||
if m.settings != nil {
|
if m.settings != nil {
|
||||||
if cfg := m.settings.GetCrossDriveConfig(stackName); cfg != nil && cfg.LastRun != "" && cfg.DestinationPath != "" {
|
if cfg := m.settings.GetCrossDriveConfig(stackName); cfg != nil && cfg.LastRun != "" && cfg.DestinationPath != "" {
|
||||||
if m.settings.IsDisconnected(cfg.DestinationPath) {
|
if m.settings.IsDisconnected(cfg.DestinationPath) {
|
||||||
return 0, errTier2DriveGone
|
return 0, errTier2DriveGone
|
||||||
}
|
}
|
||||||
// Same layout literals as RunTier2's destBase + the appdata leg.
|
destBase = filepath.Join(cfg.DestinationPath, "backups", "secondary", stackName)
|
||||||
srcDir = filepath.Join(cfg.DestinationPath, "backups", "secondary", stackName, "appdata")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if srcDir == "" {
|
if destBase == "" {
|
||||||
return 0, errNoTier2Copy
|
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
|
return 0, errNoTier2Copy // recorded but the copy dir is gone — same honest refusal
|
||||||
}
|
}
|
||||||
|
// §7-G2 marker gate: a pre-v2 (flat) copy has no marker → refuse rather than read a layout we no
|
||||||
m.logger.Printf("[INFO] [backup] Tier-2 file restore for %s: %s → %s (additive-only)", stackName, srcDir, liveDir)
|
// 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
|
copier := m.restoreFilesCopier
|
||||||
if copier == nil {
|
if copier == nil {
|
||||||
copier = rsyncRestoreMissing
|
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).
|
// Stop → copy → start → health (the standard restore shape; F17: errors surface, never swallowed).
|
||||||
if stopErr := m.stackProvider.StopStack(stackName); stopErr != nil {
|
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)
|
m.logger.Printf("[WARN] [backup] could not stop %s before Tier-2 file restore: %v (continuing)", stackName, stopErr)
|
||||||
}
|
}
|
||||||
start := time.Now()
|
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)
|
startErr := m.stackProvider.StartStack(stackName)
|
||||||
if startErr != nil {
|
if startErr != nil {
|
||||||
m.logger.Printf("[ERROR] [backup] failed to restart %s after Tier-2 file restore: %v", stackName, startErr)
|
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 {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
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}
|
fake = &t2rFakeProvider{hdd: liveDrive}
|
||||||
m = &Manager{
|
m = &Manager{
|
||||||
@@ -100,11 +103,12 @@ func TestRestoreTier2Files_Orchestration(t *testing.T) {
|
|||||||
if n != 3 {
|
if n != 3 {
|
||||||
t.Errorf("filesRestored = %d, want 3", n)
|
t.Errorf("filesRestored = %d, want 3", n)
|
||||||
}
|
}
|
||||||
if want := filepath.Join(destDrive, "backups", "secondary", "app", "appdata"); gotSrc != want {
|
// v2: the hdd/ subtree merges whole into the live namespace root (relpath-mirroring).
|
||||||
t.Errorf("copier src = %q, want the RECORDED Tier-2 copy %q", gotSrc, want)
|
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 {
|
if gotDst != liveDrive {
|
||||||
t.Errorf("copier dst = %q, want live appdata %q", gotDst, want)
|
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" {
|
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)
|
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 {
|
if err := sett.SetTier2Preference("app", false, "/mnt/b"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
target, err := m.selectTier2Target("app", 1024)
|
target, err := m.selectTier2Target("app", 1024, 1024)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("selectTier2Target: %v", err)
|
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 {
|
if err := sett.SetTier2Preference("app", false, "/mnt/gone"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
target, err := m.selectTier2Target("app", 1024)
|
target, err := m.selectTier2Target("app", 1024, 1024)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("selectTier2Target: %v", err)
|
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
|
package settings
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,57 +25,82 @@ func count(list []string, want string) int {
|
|||||||
return n
|
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) {
|
func TestDefaultEnabledEvents_ContainsEnlargeBlocked(t *testing.T) {
|
||||||
if !contains(DefaultEnabledEvents, "offbox_enlarge_blocked") {
|
if !contains(DefaultEnabledEvents, "offbox_enlarge_blocked") {
|
||||||
t.Error("DefaultEnabledEvents must contain offbox_enlarge_blocked (new customers get it)")
|
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 —
|
// Part 0: the seed runs at LOAD (not the getter) — an existing customer's stored prefs gain the type,
|
||||||
// idempotent (two reads → one entry) and their OTHER choices are preserved.
|
// their other choices preserved, and the change is persisted (marker + type on disk).
|
||||||
func TestGetNotificationPrefs_MigratesExisting(t *testing.T) {
|
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())
|
s, err := Load(filepath.Join(t.TempDir(), "settings.json"), discardLog())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// A customer who kept only two events and never had the new one.
|
if !contains(s.GetNotificationPrefs().EnabledEvents, "offbox_enlarge_blocked") {
|
||||||
if err := s.SetNotificationPrefs(&NotificationPrefs{
|
t.Error("fresh customer must get offbox_enlarge_blocked via DefaultEnabledEvents")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ type Settings struct {
|
|||||||
// Notification preferences (Phase 2 — define struct now, leave empty)
|
// Notification preferences (Phase 2 — define struct now, leave empty)
|
||||||
Notifications *NotificationPrefs `json:"notifications,omitempty"`
|
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
|
// Cached state
|
||||||
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
|
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
|
||||||
|
|
||||||
@@ -170,6 +175,7 @@ type CrossDriveBackup struct {
|
|||||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||||
LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
|
LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
|
||||||
LastError string `json:"last_error,omitempty"`
|
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"
|
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
|
||||||
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
|
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
|
_ = 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.LoadWarning = "settings.json volt sérült — visszaállítva biztonsági másolatból"
|
||||||
s2.migrateResticToRsync()
|
s2.migrateResticToRsync()
|
||||||
|
s2.seedOffboxEnlargeNotice()
|
||||||
return s2, nil
|
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))
|
len(s.StoragePaths), len(s.Integrations), len(s.PendingEvents))
|
||||||
}
|
}
|
||||||
s.migrateResticToRsync()
|
s.migrateResticToRsync()
|
||||||
|
s.seedOffboxEnlargeNotice()
|
||||||
return s, nil
|
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.
|
// migrateResticToRsync converts any cross-drive backup configs using restic to rsync.
|
||||||
// Called once during Load() before the mutex is exposed.
|
// Called once during Load() before the mutex is exposed.
|
||||||
func (s *Settings) migrateResticToRsync() {
|
func (s *Settings) migrateResticToRsync() {
|
||||||
@@ -520,13 +546,12 @@ func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
|
|||||||
if prefs.EnabledEvents == nil {
|
if prefs.EnabledEvents == nil {
|
||||||
prefs.EnabledEvents = DefaultEnabledEvents
|
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))
|
events := make([]string, len(prefs.EnabledEvents))
|
||||||
copy(events, prefs.EnabledEvents)
|
copy(events, prefs.EnabledEvents)
|
||||||
// 3a-fix append-if-absent migration: an existing customer's stored prefs predate
|
prefs.EnabledEvents = events
|
||||||
// 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")
|
|
||||||
return &prefs
|
return &prefs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,12 @@ func newAsyncRestoreServer(t *testing.T) (*Server, *blockProvider, *backup.Manag
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// a recorded Tier-2 copy dir so RestoreTier2Files proceeds to StopStack (where we block).
|
// a recorded v2 Tier-2 copy (marker + an hdd/ leg) so RestoreTier2Files proceeds to StopStack.
|
||||||
if err := os.MkdirAll(filepath.Join(dest, "backups", "secondary", "app", "appdata"), 0o755); err != nil {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
|
|||||||
@@ -844,6 +844,7 @@ type AppBackupRow struct {
|
|||||||
Tier2LastRun string
|
Tier2LastRun string
|
||||||
Tier2LastStatus string // "ok", "error", "running", ""
|
Tier2LastStatus string // "ok", "error", "running", ""
|
||||||
Tier2LastError string
|
Tier2LastError string
|
||||||
|
Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run
|
||||||
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
||||||
Tier2SizeHuman string
|
Tier2SizeHuman string
|
||||||
|
|
||||||
@@ -996,6 +997,7 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
|
|||||||
row.Tier2LastRun = cd.LastRun
|
row.Tier2LastRun = cd.LastRun
|
||||||
row.Tier2LastStatus = cd.LastStatus
|
row.Tier2LastStatus = cd.LastStatus
|
||||||
row.Tier2LastError = cd.LastError
|
row.Tier2LastError = cd.LastError
|
||||||
|
row.Tier2LastWarning = cd.LastWarning
|
||||||
row.Tier2SizeHuman = cd.LastSizeHuman
|
row.Tier2SizeHuman = cd.LastSizeHuman
|
||||||
switch cd.LastStatus {
|
switch cd.LastStatus {
|
||||||
case "ok":
|
case "ok":
|
||||||
|
|||||||
@@ -219,6 +219,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</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-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>
|
<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">
|
<div class="layer-actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user