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