Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b70a9e9ab | |||
| 900c870212 | |||
| 85b76e0fc3 | |||
| c81df55dcb |
@@ -1,5 +1,77 @@
|
||||
## Changelog
|
||||
|
||||
### v0.145.0 — R-7b: share data enters the live backup runs (Model B′) + samba liveness (2026-07-18)
|
||||
|
||||
**The „Felhőmentés" toggle on the Megosztás page is now true.** Before this release a customer could
|
||||
switch a share to „Felhőmentés: bekapcsolva" and the page would render exactly that while the files
|
||||
dropped on it were in **no backup at all** — `backup.RunTier2` short-circuits on `os.Stat(unitDir)`
|
||||
before the classification seam, and the offsite runner enumerates `GetOffboxApps()`. A share-only
|
||||
infra stack has neither a recovery unit nor an offbox toggle, so it fell through both engines. R-7b
|
||||
closes that with a **sibling shares source** in each tier.
|
||||
|
||||
**Model B′ (Viktor's ruling, 2026-07-18) and its invariant.** Share data enters the runs through
|
||||
NEW, ADDITIVE job/leg code that reuses the proven primitives — the tier-2 mirror seam, the restic
|
||||
wrappers, the soft-quota/enlargement gate, the status recorders — while leaving **every per-app engine
|
||||
path byte-identical**. Not Model A (a synthetic recovery unit breaks on multi-drive shares and wraps
|
||||
1 KB of JSON in dump machinery) and not engine-loop surgery. The invariant is enforced by test, in
|
||||
both tiers, with red-proofs.
|
||||
|
||||
- **Payload (`internal/backup/shares_payload.go`, new):** a staging dir holding
|
||||
`_shares-manifest.json` (the share definitions, sorted → **byte-deterministic** for an unchanged
|
||||
registry, so a no-op run gives the mirror nothing to rewrite) plus a **best-effort** `passdb.tar`
|
||||
captured from the samba container. A restore therefore returns the files, the share configuration
|
||||
AND the SMB password hash — not just bytes on a disk. The credential copy is SECRET-BEARING: 0600,
|
||||
never logged at INFO, never in a report or a committed file. A down container degrades to
|
||||
manifest-only and KEEPS any previously captured copy (a stale credential beats none for DR).
|
||||
- **Tier-2 shares job (`internal/backup/tier2_shares.go`, new):** runs after the per-stack loop in the
|
||||
same orchestrator run. Shares are grouped **by source drive** — a household's shares can span disks
|
||||
and each group needs its own cross-drive target — into
|
||||
`backups/secondary/_shares/<sourceDriveKey>/<share>` with the payload at `_payload/` and the layout
|
||||
marker written **LAST**. Reuses `selectTier2TargetFrom` (a narrow source-drive seam extracted from
|
||||
`selectTier2Target`; the headroom math is untouched), `tier2ReconcileRoots` (a pure extraction),
|
||||
`tier2SafeRemove` and the `recordTier2*` helpers.
|
||||
- **Offsite shares leg (`internal/backup/offbox_shares.go`, new):** ONE additional
|
||||
`restic backup --tag felhom-offbox --tag _shares` carrying the manifest staging dir plus every
|
||||
MANDATORY share folder, hooked in AFTER the per-app loop and BEFORE retention — so
|
||||
`forget --group-by host,tags` covers the `_shares` group with **no flag change**. Same enlargement
|
||||
arithmetic as the per-app gate. **Degradation contract:** a quota-blocked push falls back to the
|
||||
MANIFEST ONLY, never to nothing — definitions protection must not regress because the files stopped
|
||||
fitting.
|
||||
- **Restore „Megosztások" (`internal/backup/shares_restore.go`, new):** siblings of the per-app
|
||||
scratch/place pair. Files are merged **missing-only** (never overwriting) and every destination is
|
||||
**prefix-asserted** against registered LIVE storage roots — a snapshot is untrusted layout input, so
|
||||
a path that no longer sits under a live root is refused rather than created. Definitions merge with
|
||||
**existing-wins** (a restore must never silently flip a live share's settings; skipped ones are
|
||||
named in the flash). Then `ReconcileSamba` re-renders smb.conf, and the credential goes back into
|
||||
the named volume best-effort. Routes `POST /backup/shares/{restore,place}`.
|
||||
- **Samba liveness (the fold-in):** `monitor.EffectiveProtected` gains a settings-backed dynamic
|
||||
extra, so a dead sharing service raises the same protected-container issue → alert → Hungarian
|
||||
degradation e-mail as a dead traefik — but only while sharing is ON. It watches the **container**
|
||||
name (`infra.SambaContainerName`), which is deliberately NOT the stack name. **Finding: the
|
||||
alert/e-mail pipeline needed no further change** and no new event type is introduced, so the
|
||||
`allowedEventTypes` gotcha does not apply.
|
||||
- **UI truth-up:** the Megosztás page states per-tier status (2. mentés / távoli mentés, amber only on
|
||||
deviation) and links to the restore page. The reserved `_shares` key is mapped to „Megosztások" at
|
||||
the notification and Hungarian-prose boundaries ONLY — the persisted `EnlargedBlocked` set, the
|
||||
restic tag and the dest path keep the raw key, because templates index by it.
|
||||
- **RESERVED-NAME FINDING (the task's assumption was false):** `settings.nbNameRe` begins with
|
||||
`[A-Za-z0-9_]`, so „_shares" **was an accepted share name** — the underscore namespace was not in
|
||||
fact reserved. `ValidateSMBShareName` now refuses a leading underscore (on ADD only, so existing
|
||||
shares are never retroactively invalidated), and `RunAllTier2`/`RunOffboxBackup` additionally skip a
|
||||
`_shares` STACK loudly as defense in depth.
|
||||
- **`infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount`** become the single source of
|
||||
truth for the samba container identity — the compose renderer interpolates them, and stacks, backup
|
||||
and monitor all read them instead of repeating string literals.
|
||||
- **Bug found by test:** `shareSourceDrive` returned a slash-normalised path, which made the target
|
||||
selector's source-drive equality check miss — a share group could have targeted its own source
|
||||
drive (a same-disk copy pretending to be tier 2). Fixed; POSIX-only in effect, but real.
|
||||
- **Tests:** 24 new/extended cases in `internal/backup` + 2 in `internal/monitor`. **Six red-proofs
|
||||
run and reverted, all fired:** (1) shares leg appending into the app's argv → B′ isolation FAILS;
|
||||
(2) mandatory→offsite mapping inverted → Scenarios A+B FAIL; (3) manifest-only degradation dropped →
|
||||
Scenario C FAILS; (4) prefix-assert removed → place-guard traversal FAILS; (5) dynamic samba extra
|
||||
removed → Scenario E enabled-case FAILS; (6) shares destBase dropping the reserved segment → tier-2
|
||||
isolation FAILS.
|
||||
|
||||
### v0.144.0 — „Megosztás": LAN SMB file sharing (R-7 slice 1) (2026-07-18)
|
||||
|
||||
The customer turns on network sharing, sets ONE household SMB password, and exports folders. The box
|
||||
|
||||
+32
-1
@@ -7,7 +7,38 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-07-18 (v0.144.0 — „Megosztás": LAN SMB file sharing, R-7 slice 1)
|
||||
Last updated: 2026-07-18 (v0.145.0 — R-7b: share data enters the live backup runs, Model B′)
|
||||
|
||||
> **2026-07-18 — v0.145.0: R-7b — the „Felhőmentés" toggle is now TRUE (Model B′), + samba liveness.**
|
||||
> Until v0.144.0 a share could be marked „Felhőmentés: bekapcsolva" while its files were in NO backup:
|
||||
> both engines are recovery-unit shaped (`RunTier2` short-circuits on `os.Stat(unitDir)`; the offsite
|
||||
> runner enumerates `GetOffboxApps()`) and a share-only infra stack has neither. **Viktor's ruling was
|
||||
> Model B′: a SIBLING shares source** — additive job/leg code reusing the proven primitives (tier-2
|
||||
> mirror seam, restic wrappers, quota gate, status recorders) while every per-app engine path stays
|
||||
> **byte-identical**. That invariant is enforced by test in both tiers, red-proofed.
|
||||
>
|
||||
> Tier 2 → `RunSharesTier2` (legs grouped by SOURCE drive → `backups/secondary/_shares/<driveKey>/…`,
|
||||
> payload at `_payload/`, marker LAST). Tier 3 → `runOffboxSharesLeg`, ONE extra restic call tagged
|
||||
> `_shares`, placed after the app loop and BEFORE retention so `--group-by host,tags` covers it for
|
||||
> free. Restore → „Megosztások" on /backups/restore: scratch, then a missing-only merge whose every
|
||||
> destination is PREFIX-ASSERTED against live storage roots; definitions merge existing-wins; then
|
||||
> `ReconcileSamba`; then the credential, best-effort.
|
||||
>
|
||||
> The **payload** is the point: a byte-deterministic `_shares-manifest.json` + a best-effort
|
||||
> secret-bearing `passdb.tar`, so DR returns files + configuration + password, not loose bytes. A
|
||||
> quota-blocked offsite push degrades to the MANIFEST ONLY — never to nothing.
|
||||
>
|
||||
> **Findings:** (a) the reserved-name assumption was FALSE — `nbNameRe` accepted „_shares" as a share
|
||||
> name; `ValidateSMBShareName` now refuses a leading underscore and both run loops skip a `_shares`
|
||||
> stack loudly. (b) The samba-liveness fold-in needed NO alert/e-mail pipeline change and adds no new
|
||||
> event type (so the `allowedEventTypes` gotcha does not apply) — `EffectiveProtected` just gains a
|
||||
> settings-backed dynamic extra watching the CONTAINER name (`felhom-samba` ≠ stack name `samba`).
|
||||
> (c) A real bug surfaced in review-by-test: `shareSourceDrive` returned a slash-normalised path,
|
||||
> making the target selector's equality check miss so a share group could target its own source drive.
|
||||
>
|
||||
> **OPEN:** Viktor raises the managed-update floor to **v0.145.0** (supersedes the 0.144 note) so the
|
||||
> N100 rehearsal's day-0 box converges onto the honest version. Pre-existing, untouched:
|
||||
> `docker_run_volume_path_gate` fails on `internal/appexport/estimate.go` (predates this work).
|
||||
|
||||
> **2026-07-18 — v0.144.0: „Megosztás" LAN SMB sharing (R-7 slice 1), LIVE on demo.** SMB ships as an
|
||||
> **embedded controller feature** — the FOURTH protected infra stack (traefik/cloudflared/filebrowser/
|
||||
|
||||
@@ -56,6 +56,12 @@
|
||||
| `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand |
|
||||
| `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops |
|
||||
| `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
|
||||
| `backup.SharesPseudoStack` / `DisplayStackName` | controller/internal/backup/shares_payload.go | `"_shares"` / `(key) string` | THE reserved key for the shares source (restic tag, `backups/secondary/_shares`, CrossDriveBackup record) + its display mapping | NEVER let the raw key reach a Hungarian surface — map at the notification/prose boundary ONLY; the persisted `EnlargedBlocked` set and the templates index by the RAW key |
|
||||
| `Manager.buildSharesPayload` / `classifiedShares` | controller/internal/backup/shares_payload.go | `() (dir, passdbOK, error)` / `() []classifiedShare` | the definitions+credential payload and the availability-filtered share set both tiers read | payload is SECRET-BEARING (0600 passdb.tar) — never log its bytes/name at INFO. `classifiedShares` is the single place a dead mount is dropped, so both jobs agree |
|
||||
| `Manager.selectTier2TargetFrom` | controller/internal/backup/tier2.go | `(stack, sourceDrive, fullSize, stateOnlySize) (*Tier2Target, error)` | tier-2 target choice with the source drive supplied EXPLICITLY | the seam the shares job reuses — NEVER fork the headroom math; `selectTier2Target` is now a thin wrapper over it |
|
||||
| `Manager.tier2ReconcileRoots` | controller/internal/backup/tier2.go | `(destBase, roots, legRels)` | staleness pruning with explicit dest roots | pure extraction from `tier2Reconcile` (which now calls it with `hdd`/`userdata`); reuse it rather than writing a second pruner |
|
||||
| `Manager.liveShareRootOK` / `scratchJoin` | controller/internal/backup/shares_restore.go | `(dst) bool` / `(scratch, abs) string` | THE place guard for shares restore + scratch path reconstruction | a snapshot is UNTRUSTED layout input: require a STRICT descendant of a live registered root, refuse `..` and the drive root itself. `scratchJoin` strips the volume name — plain `filepath.Join` splices a drive letter mid-path |
|
||||
| `infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount` | controller/internal/infra/samba.go | consts | single source of truth for the samba container identity | the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (`felhom-samba`) is NOT the stack name (`samba`) — `EffectiveProtected` needs the container one |
|
||||
| `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 |
|
||||
| `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot |
|
||||
|
||||
|
||||
+26
-4
@@ -2374,10 +2374,32 @@ main mux behind `RequireAuth`+`CsrfProtect` (the `/api/` subtree is routed there
|
||||
no `.felhom.yml` and its binds are absolute share paths). Per-share `Felhőmentés` ON → `mandatory`
|
||||
(offsite + tier-2); OFF → `optional` (tier-2 only). smb.conf/passdb are never classified.
|
||||
|
||||
> **KNOWN GAP (design fork, deliberately not improvised).** The seam is correct, but share data is not
|
||||
> yet in a live tier-2/offsite RUN: `backup.RunTier2` short-circuits on a missing recovery unit before
|
||||
> it reaches `GetStackClassifiedBinds`, and the offsite runner enumerates `settings.GetOffboxApps()`.
|
||||
> Both engines are recovery-unit shaped, which a share-only infra stack has not. See root `REPORT.md`.
|
||||
#### Share backup EXECUTION — the sibling shares source (R-7b, v0.145.0)
|
||||
|
||||
The earlier KNOWN GAP is **closed**: share data is in both live tiers. It did not get there through
|
||||
`GetStackClassifiedBinds` — the engines are recovery-unit shaped and Model B′ deliberately left every
|
||||
per-app path byte-identical. Instead `internal/backup` runs a **sibling shares source** off the same
|
||||
registry, applying the same per-share class rule:
|
||||
|
||||
| Tier | Entry point | Shape |
|
||||
|---|---|---|
|
||||
| 2 (cross-drive) | `RunSharesTier2` — after the per-stack loop in `RunAllTier2` | legs grouped by SOURCE DRIVE → `backups/secondary/_shares/<driveKey>/<share>` + `_payload/`, layout marker LAST |
|
||||
| 3 (offsite) | `runOffboxSharesLeg` — after the per-app loop, before retention | ONE `restic backup --tag felhom-offbox --tag _shares` = manifest staging dir + every MANDATORY share |
|
||||
| restore | `RestoreSharesScratch` → `PlaceSharesRestore` | scratch first, then a missing-only merge, each destination prefix-asserted against LIVE storage roots |
|
||||
|
||||
The **payload** (`shares_payload.go`) is what makes a restore give back a working feature rather than
|
||||
loose files: a byte-deterministic `_shares-manifest.json` of the definitions plus a best-effort,
|
||||
secret-bearing `passdb.tar`. Definitions protection is the floor — a quota-blocked offsite push
|
||||
degrades to the manifest alone, never to nothing.
|
||||
|
||||
`_shares` is a **reserved key** (restic tag, dest root, status record). `ValidateSMBShareName` refuses
|
||||
a leading underscore, and both run loops skip a `_shares` stack loudly. It never reaches a customer
|
||||
surface: `backup.DisplayStackName` maps it to „Megosztások" at the notification and prose boundaries,
|
||||
while the persisted set, the tag and the paths keep the raw key.
|
||||
|
||||
**Liveness:** `monitor.EffectiveProtected` adds `infra.SambaContainerName` exactly while sharing is
|
||||
on, so a dead sharing service raises the standard protected-container issue → alert → degradation
|
||||
e-mail. Note the container name is NOT the stack name (`samba` vs `felhom-samba`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -261,6 +261,10 @@ func main() {
|
||||
// O4: restore-from-unit generates a replacement for an unrecoverable RESETTABLE secret
|
||||
// (data-keys stay fail-closed) so the app redeploys with a fresh credential, not a blank one.
|
||||
backupMgr.SetSecretGenerator(stackMgr.GenerateSecretForField)
|
||||
// R-7b: after a shares restore re-adds definitions to the registry, smb.conf must be
|
||||
// re-rendered or the restored shares exist on paper but are not exported. A seam rather than a
|
||||
// direct call — the backup package must not depend on the stacks package.
|
||||
backupMgr.SetSharesReconciler(stackMgr.ReconcileSamba)
|
||||
}
|
||||
|
||||
// SLICE 2: the offsite apply-bridge — on startup (async, non-blocking) reconcile the hub-served offsite
|
||||
@@ -385,7 +389,7 @@ func main() {
|
||||
healthInterval = 5 * time.Minute
|
||||
}
|
||||
sched.Every("system-health", healthInterval, func(ctx context.Context) error {
|
||||
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger)
|
||||
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), sett.GetSMBSettings(), logger)
|
||||
// Self-heal the base stack: call unconditionally every tick. EnsureBaseStack is single-flight
|
||||
// + idempotent (skips running stacks ⇒ a cheap 3× docker-inspect no-op when healthy), so there
|
||||
// is no need to couple to the health-report issue strings. Runs in a goroutine — never blocks
|
||||
@@ -629,7 +633,7 @@ func main() {
|
||||
pushInterval = 15 * time.Minute
|
||||
}
|
||||
sched.Every("hub-report", pushInterval, func(ctx context.Context) error {
|
||||
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
||||
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger)
|
||||
r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
|
||||
if err := hubPusher.Push(r); err != nil {
|
||||
return err
|
||||
@@ -735,7 +739,7 @@ func main() {
|
||||
// Hub report
|
||||
if hubPusher != nil {
|
||||
if cfg.Hub.Enabled {
|
||||
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
||||
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger)
|
||||
r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
|
||||
var pushErr error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
@@ -803,7 +807,7 @@ func main() {
|
||||
|
||||
// Initial alert refresh (so alerts appear immediately, not after first 5min health check)
|
||||
go func() {
|
||||
report := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger)
|
||||
report := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), sett.GetSMBSettings(), logger)
|
||||
alertMgr.Refresh(report, cfg, backupMgr, false, "")
|
||||
}()
|
||||
|
||||
@@ -817,7 +821,7 @@ func main() {
|
||||
var reportTrigger *report.Trigger
|
||||
if hubPusher != nil && cfg.Hub.Enabled {
|
||||
fireReport := func() error {
|
||||
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
||||
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger)
|
||||
rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
|
||||
return hubPusher.Push(rep)
|
||||
}
|
||||
@@ -960,7 +964,7 @@ func main() {
|
||||
dc := &web.DebugCallbacks{}
|
||||
if hubPusher != nil {
|
||||
dc.TriggerHubReportPush = func() error {
|
||||
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
||||
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), sett.GetSMBSettings(), logger)
|
||||
r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
|
||||
return hubPusher.Push(r)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,21 @@ type Manager struct {
|
||||
// (`-a --delete`, contents-of-src semantics).
|
||||
tier2Mirror func(src, dst string) error
|
||||
|
||||
// sharesPassdbCapture (R-7b) — the samba passdb capture seam (a `docker exec … tar cf -`),
|
||||
// overridable so the shares payload builder is unit-testable without docker. Nil → the real
|
||||
// defaultSharesPassdbCapture. Best-effort by contract: an error yields a manifest-only payload.
|
||||
sharesPassdbCapture func() ([]byte, error)
|
||||
|
||||
// sharesPassdbRestore (R-7b) — the mirror seam for putting a captured passdb archive BACK into the
|
||||
// samba named volume (`docker exec -i … tar xf -`). Nil → the real defaultSharesPassdbRestore.
|
||||
sharesPassdbRestore func(tar []byte) error
|
||||
|
||||
// sharesReconcile (R-7b), if set, re-renders and applies the samba stack after a shares restore
|
||||
// re-adds definitions to the registry (wired in main.go to stacks.Manager.ReconcileSamba). It is a
|
||||
// SEAM rather than a direct call because the backup package must not depend on the stacks package.
|
||||
// Nil → the registry is updated and a WARN says smb.conf will catch up on the next health tick.
|
||||
sharesReconcile func() 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.
|
||||
|
||||
@@ -598,6 +598,16 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
defer m.releaseRunning()
|
||||
|
||||
apps := m.settings.GetOffboxApps()
|
||||
// Reserved-name defense in depth (R-7b): an app keyed `_shares` would collide with the shares
|
||||
// leg's restic tag and blocked-set entry. Catalog names cannot realistically produce this, but a
|
||||
// silent collision would corrupt both sources, so it is refused loudly instead.
|
||||
for i, a := range apps {
|
||||
if a == SharesPseudoStack {
|
||||
m.logger.Printf("[ERROR] [offbox] app %q uses the RESERVED shares key — excluded from the run to protect the shares leg", a)
|
||||
apps = append(apps[:i:i], apps[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
t := m.settings.GetOffboxTarget()
|
||||
base, env := m.offboxBaseArgs(t)
|
||||
// Edge-trigger for the enlarge-blocked notification: capture the PRIOR blocked set so we notify only
|
||||
@@ -673,7 +683,9 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
// Zero-toggle honesty (take-two obs.): a configured target with NOTHING selected reports
|
||||
// its emptiness instead of a bare success — the customer thinks offsite runs, but nothing
|
||||
// is covered until at least one app is toggled.
|
||||
if len(apps) == 0 {
|
||||
// R-7b: the shares leg counts as coverage — a box whose only cloud content is its shares
|
||||
// must not be told "nothing is selected".
|
||||
if len(apps) == 0 && !runResult.sharesBackedUp {
|
||||
warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
@@ -683,9 +695,25 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
// 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed).
|
||||
warns = append(warns, runResult.warns...)
|
||||
// 3a: the pre-push enlargement gate blocked some apps' userdata — config+DB still saved.
|
||||
if len(blockedNames) > 0 {
|
||||
// R-7b: the shares source is not an "app" and its degraded floor is the DEFINITIONS, not a
|
||||
// recovery unit — so it gets its own sentence and is excluded from the app count. The
|
||||
// persisted EnlargedBlocked set keeps the RAW `_shares` key (it is a lookup key the
|
||||
// templates index by); only this prose maps it through the display vocabulary.
|
||||
var blockedApps []string
|
||||
sharesBlocked := false
|
||||
for _, n := range blockedNames {
|
||||
if n == SharesPseudoStack {
|
||||
sharesBlocked = true
|
||||
continue
|
||||
}
|
||||
blockedApps = append(blockedApps, n)
|
||||
}
|
||||
if len(blockedApps) > 0 {
|
||||
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.",
|
||||
len(blockedNames), strings.Join(blockedNames, ", ")))
|
||||
len(blockedApps), strings.Join(blockedApps, ", ")))
|
||||
}
|
||||
if sharesBlocked {
|
||||
warns = append(warns, sharesBlockedWarning())
|
||||
}
|
||||
// SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run.
|
||||
if qw := offboxQuotaWarning(o); qw != "" {
|
||||
@@ -708,7 +736,10 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
usedGB := int(t.RepoSizeBytes / offboxGiB)
|
||||
for _, b := range runResult.blocked {
|
||||
if !priorBlocked[b.stack] {
|
||||
m.offboxEnlargeBlockedNotify(b.stack, b.estBytes, usedGB, t.QuotaGB)
|
||||
// DISPLAY BOUNDARY (R-7b): the notification is a customer-facing surface (it becomes a
|
||||
// Hungarian e-mail), so the reserved `_shares` key is mapped here — and ONLY here plus
|
||||
// the warning prose above. The persisted set and the restic tag stay raw.
|
||||
m.offboxEnlargeBlockedNotify(DisplayStackName(b.stack), b.estBytes, usedGB, t.QuotaGB)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -813,6 +844,10 @@ type offboxRunResult struct {
|
||||
missing []string
|
||||
blocked []offboxBlocked
|
||||
warns []string
|
||||
// sharesBackedUp (R-7b) records that the sibling shares leg produced a snapshot this run. It keeps
|
||||
// the zero-toggle honesty notice honest: a box with no app toggled but shares in the cloud is NOT
|
||||
// "nothing is covered".
|
||||
sharesBackedUp bool
|
||||
}
|
||||
|
||||
// runOffboxInternal does the repo-ensure + per-app DISCOVER → capture-set → gate → multi-path backup +
|
||||
@@ -867,6 +902,21 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
|
||||
res.backedUp++
|
||||
m.logger.Printf("[INFO] [offbox] backed up %s (%s, %d mandatory path(s))", stack, src, len(extra))
|
||||
}
|
||||
// R-7b: the SHARES leg runs AFTER the per-app loop and BEFORE retention, so `forget --group-by
|
||||
// host,tags` covers the `_shares` group for free. It is placed BEFORE the firstErr return on
|
||||
// purpose: share protection must not be dropped because some unrelated app failed to push.
|
||||
sharesRes, sharesErr := m.runOffboxSharesLeg(ctx, base, env, t)
|
||||
m.recordSharesOffsiteStatus(sharesRes)
|
||||
res.warns = append(res.warns, sharesRes.warns...)
|
||||
if sharesRes.blocked {
|
||||
res.blocked = append(res.blocked, offboxBlocked{stack: SharesPseudoStack, estBytes: sharesRes.estBytes})
|
||||
}
|
||||
if sharesRes.ran {
|
||||
res.sharesBackedUp = true
|
||||
}
|
||||
if sharesErr != nil && firstErr == nil {
|
||||
firstErr = sharesErr
|
||||
}
|
||||
if firstErr != nil {
|
||||
return res, firstErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Offsite shares leg — R-7b Part 3, the remote leg of Model B′.
|
||||
//
|
||||
// It is a SIBLING of the per-app loop in runOffboxInternal, not a modification of it. The B′
|
||||
// invariant — every per-app restic invocation stays byte-identical — is the headline guarantee here
|
||||
// and is enforced by TestOffboxSharesLegLeavesAppCallsByteIdentical.
|
||||
//
|
||||
// Shape: ONE additional `restic backup` call tagged [felhom-offbox, _shares], whose paths are the
|
||||
// payload staging dir plus every MANDATORY (Felhőmentés-on) share folder. It reuses resticStep (so
|
||||
// it inherits the C2 crash-lock self-heal), the caller's already-ensured repo, the caller's
|
||||
// already-taken single-flight, and the SAME enlargement-gate arithmetic the per-app path uses. It
|
||||
// runs BEFORE retention, so `forget --group-by host,tags` covers the `_shares` group for free with
|
||||
// no flag change.
|
||||
//
|
||||
// Degradation contract: when the quota gate trips, the push degrades to the MANIFEST ONLY — never to
|
||||
// nothing. Definitions protection must not regress just because the files no longer fit; a customer
|
||||
// who is over quota should still get their „Megosztás" page back from a DR restore.
|
||||
|
||||
// sharesLegResult carries the outcome of the offsite shares leg back to the run.
|
||||
type sharesLegResult struct {
|
||||
ran bool // the leg produced a restic call
|
||||
count int // share folders included (0 = definitions-only push)
|
||||
blocked bool // the quota gate degraded this push to manifest-only
|
||||
estBytes int64 // the estimate the gate weighed (for the blocked notification)
|
||||
warns []string
|
||||
status string // persisted SharesLastStatus
|
||||
}
|
||||
|
||||
// runOffboxSharesLeg pushes the shares source. Caller holds the running flag and has already ensured
|
||||
// the repo. Returns the leg result plus a hard error only when the restic call itself failed.
|
||||
func (m *Manager) runOffboxSharesLeg(ctx context.Context, base, env []string, t *settings.OffboxTarget) (sharesLegResult, error) {
|
||||
var res sharesLegResult
|
||||
if !m.sharesEnabled() {
|
||||
// Sharing off / no shares registered: a clean no-op. NO `_shares` restic group is created —
|
||||
// an empty group would age through retention forever and imply a protection that isn't there.
|
||||
return res, nil
|
||||
}
|
||||
|
||||
shares := m.classifiedShares()
|
||||
var mandatory []classifiedShare
|
||||
for _, sh := range shares {
|
||||
if sh.mandatory {
|
||||
mandatory = append(mandatory, sh)
|
||||
}
|
||||
}
|
||||
if len(shares) > 0 && len(mandatory) == 0 {
|
||||
// Every share is tier-2-only. The FILES correctly stay off-site-excluded (Scenario B), but the
|
||||
// definitions still ride offsite: they are ~1 KB and they are what makes a DR restore give the
|
||||
// customer their share configuration back rather than an empty page.
|
||||
m.logger.Printf("[INFO] [shares] offsite: no share is marked for the cloud — pushing share definitions only")
|
||||
}
|
||||
|
||||
payloadDir, passdbOK, perr := m.buildSharesPayload()
|
||||
if perr != nil {
|
||||
// Without a payload there is nothing to anchor a restore on; push the files anyway rather than
|
||||
// skipping protection, but say so loudly.
|
||||
m.logger.Printf("[ERROR] [shares] offsite: payload staging failed — pushing share files without the definition manifest: %v", perr)
|
||||
res.warns = append(res.warns, "A megosztás-beállítások távoli mentése nem sikerült — a fájlok mentése megtörtént.")
|
||||
payloadDir = ""
|
||||
}
|
||||
if !passdbOK {
|
||||
res.warns = append(res.warns, "A megosztás jelszava nem került a mentésbe (a megosztás szolgáltatás nem futott) — visszaállítás után újra meg kell adni.")
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(mandatory)+1)
|
||||
if payloadDir != "" {
|
||||
paths = append(paths, payloadDir)
|
||||
}
|
||||
sharePaths := make([]string, 0, len(mandatory))
|
||||
for _, sh := range mandatory {
|
||||
sharePaths = append(sharePaths, sh.Path)
|
||||
}
|
||||
|
||||
// Pre-push enlargement gate — the SAME arithmetic as the per-app path (offbox.go): last-known repo
|
||||
// raw-data bytes + this push's estimate crossing the soft quota degrades the push instead of
|
||||
// failing it. Here the degradation floor is the manifest rather than a recovery unit.
|
||||
if len(sharePaths) > 0 && t != nil && t.QuotaGB > 0 {
|
||||
var est int64
|
||||
for _, p := range sharePaths {
|
||||
est += m.offboxSize()(p)
|
||||
}
|
||||
if t.RepoSizeBytes+est >= int64(t.QuotaGB)*offboxGiB {
|
||||
m.logger.Printf("[INFO] [shares] offsite: enlargement blocked by quota (est %s + repo %s ≥ %d GB) — definitions-only push continues",
|
||||
humanizeBytes(est), humanizeBytes(t.RepoSizeBytes), t.QuotaGB)
|
||||
res.blocked = true
|
||||
res.estBytes = est
|
||||
sharePaths = nil
|
||||
}
|
||||
}
|
||||
paths = append(paths, sharePaths...)
|
||||
if len(paths) == 0 {
|
||||
m.logger.Printf("[WARN] [shares] offsite: nothing to push (no payload, no eligible share) — skipped")
|
||||
res.status = "skipped"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
args := append([]string{"backup", "--tag", "felhom-offbox", "--tag", SharesPseudoStack}, paths...)
|
||||
bctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
|
||||
out, berr := m.resticStep(bctx, env, base, "backup:"+SharesPseudoStack, args...)
|
||||
cancel()
|
||||
if berr != nil {
|
||||
m.logger.Printf("[ERROR] [shares] offsite push failed: %v: %s", berr, truncate(out))
|
||||
res.status = "error"
|
||||
return res, fmt.Errorf("offbox backup %s: %w", SharesDisplayName, berr)
|
||||
}
|
||||
res.ran = true
|
||||
res.count = len(sharePaths)
|
||||
res.status = "ok"
|
||||
if res.blocked {
|
||||
res.status = "blocked"
|
||||
}
|
||||
m.logger.Printf("[INFO] [shares] offsite push OK: %d share folder(s) + definitions", res.count)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// recordSharesOffsiteStatus persists the per-tier status the „Megosztás" page renders. Kept separate
|
||||
// from the app-wide offsite status so a page can state SHARES truth without inferring it.
|
||||
func (m *Manager) recordSharesOffsiteStatus(res sharesLegResult) {
|
||||
if m.settings == nil || res.status == "" {
|
||||
return
|
||||
}
|
||||
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.SharesLastRun = time.Now().UTC().Format(time.RFC3339)
|
||||
o.SharesLastStatus = res.status
|
||||
o.SharesLastCount = res.count
|
||||
}); err != nil {
|
||||
m.logger.Printf("[WARN] [shares] offsite status persist failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SharesOffsiteStatus returns the last shares-leg outcome for the „Megosztás" page: the RFC3339 run
|
||||
// stamp, the status label and how many share folders the push covered. ok=false when no offsite
|
||||
// target is configured or the leg has never run.
|
||||
func (m *Manager) SharesOffsiteStatus() (lastRun, status string, count int, ok bool) {
|
||||
if m.settings == nil {
|
||||
return "", "", 0, false
|
||||
}
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil || t.SharesLastStatus == "" {
|
||||
return "", "", 0, false
|
||||
}
|
||||
return t.SharesLastRun, t.SharesLastStatus, t.SharesLastCount, true
|
||||
}
|
||||
|
||||
// sharesBlockedWarning renders the customer-facing note for a quota-degraded shares push. It goes
|
||||
// through DisplayStackName's vocabulary deliberately: the reserved `_shares` key must never appear
|
||||
// in Hungarian prose.
|
||||
func sharesBlockedWarning() string {
|
||||
return fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt a(z) %s tartalma nem került a távoli mentésbe — csak a megosztás-beállítások.",
|
||||
strings.ToLower(SharesDisplayName))
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-7b offsite shares leg. The headline test in this file is the B′ ISOLATION PROOF: adding the
|
||||
// shares source must leave every per-app restic invocation BYTE-IDENTICAL. That is the whole premise
|
||||
// of Model B′ — if it does not hold, the design has silently become engine-loop surgery.
|
||||
|
||||
// sharesOffboxEnv wires an offbox manager with one app (unit on `drive`) and the shares feature on,
|
||||
// so a run can be taken with and without shares against the SAME paths.
|
||||
type sharesOffboxEnv struct {
|
||||
m *Manager
|
||||
sett *settings.Settings
|
||||
drive string
|
||||
unit string
|
||||
}
|
||||
|
||||
func newSharesOffboxEnv(t *testing.T, app string) *sharesOffboxEnv {
|
||||
t.Helper()
|
||||
drive := t.TempDir()
|
||||
m, sett, prov := classifiedOffboxManager(t, drive)
|
||||
unit := mkUnit(t, drive, app)
|
||||
prov.hdd[app] = drive
|
||||
if err := sett.SetAppOffbox(app, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetSMBEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.SetSharesPassdbCapturer(func() ([]byte, error) { return []byte("FAKE-PASSDB"), nil })
|
||||
return &sharesOffboxEnv{m: m, sett: sett, drive: drive, unit: unit}
|
||||
}
|
||||
|
||||
// addOffsiteShare registers an available share on the env's drive.
|
||||
func (e *sharesOffboxEnv) addOffsiteShare(t *testing.T, name string, offsite bool) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(e.drive, name)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.sett.AddSMBShare(settings.SMBShare{Name: name, Path: p, Offsite: offsite, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// run takes one offsite run and returns the capture.
|
||||
func (e *sharesOffboxEnv) run(t *testing.T) *backupCapture {
|
||||
t.Helper()
|
||||
cap := &backupCapture{}
|
||||
e.m.SetOffboxRunner(cap.runner())
|
||||
if err := e.m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
// THE B′ ISOLATION PROOF. One app, then the same app plus one mandatory share: the app's restic argv
|
||||
// must be byte-identical across both runs, and the shares source must appear as exactly ONE
|
||||
// additional call. Red-proof: make the shares leg append its paths into the app's argv instead of
|
||||
// issuing its own call — this test fails.
|
||||
func TestOffboxSharesLegLeavesAppCallsByteIdentical(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
|
||||
baseline := env.run(t)
|
||||
baseArgs := baseline.byStack["immich"]
|
||||
if len(baseArgs) == 0 {
|
||||
t.Fatal("precondition: the baseline run produced no app backup call")
|
||||
}
|
||||
if baseline.backups != 1 {
|
||||
t.Fatalf("precondition: baseline should be exactly 1 backup call, got %d", baseline.backups)
|
||||
}
|
||||
|
||||
env.addOffsiteShare(t, "dokumentumok", true)
|
||||
withShares := env.run(t)
|
||||
|
||||
gotArgs := withShares.byStack["immich"]
|
||||
if strings.Join(gotArgs, "\x00") != strings.Join(baseArgs, "\x00") {
|
||||
t.Errorf("B′ INVARIANT VIOLATED — the app's restic argv changed when shares were added:\n baseline: %v\n with shares: %v", baseArgs, gotArgs)
|
||||
}
|
||||
if withShares.backups != 2 {
|
||||
t.Errorf("expected exactly ONE additional restic call for the shares source, got %d total", withShares.backups)
|
||||
}
|
||||
if _, ok := withShares.byStack[SharesPseudoStack]; !ok {
|
||||
t.Fatalf("no restic call tagged %q was issued: %v", SharesPseudoStack, withShares.byStack)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario A: a mandatory share reaches offsite — correct tags, the manifest staging dir, and the
|
||||
// share folder. Red-proof: flip the mandatory→offsite mapping (push only non-mandatory shares) and
|
||||
// this fails.
|
||||
func TestOffboxSharesLegPushesMandatoryShare(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
sharePath := env.addOffsiteShare(t, "dokumentumok", true)
|
||||
|
||||
cap := env.run(t)
|
||||
args := cap.byStack[SharesPseudoStack]
|
||||
if len(args) == 0 {
|
||||
t.Fatal("no shares call issued")
|
||||
}
|
||||
if !contains(args, "felhom-offbox") || !contains(args, SharesPseudoStack) {
|
||||
t.Errorf("shares call must carry BOTH tags [felhom-offbox, %s]: %v", SharesPseudoStack, args)
|
||||
}
|
||||
if !contains(args, sharePath) {
|
||||
t.Errorf("shares call missing the mandatory share path %q: %v", sharePath, args)
|
||||
}
|
||||
if !contains(args, env.m.SharesPayloadDir()) {
|
||||
t.Errorf("shares call missing the manifest staging dir %q: %v", env.m.SharesPayloadDir(), args)
|
||||
}
|
||||
// The manifest on disk must be the registry.
|
||||
blob, err := os.ReadFile(filepath.Join(env.m.SharesPayloadDir(), sharesManifestName))
|
||||
if err != nil {
|
||||
t.Fatalf("manifest not staged: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(blob), "dokumentumok") {
|
||||
t.Errorf("manifest does not describe the share: %s", blob)
|
||||
}
|
||||
// Per-tier status must be recorded for the „Megosztás" page.
|
||||
_, status, count, ok := env.m.SharesOffsiteStatus()
|
||||
if !ok || status != "ok" || count != 1 {
|
||||
t.Errorf("SharesOffsiteStatus = (%q, %d, %v), want (ok, 1, true)", status, count, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B: an OPTIONAL share is tier-2-only — its path must appear in NO restic argument.
|
||||
func TestOffboxSharesLegExcludesOptionalShare(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
mandatoryPath := env.addOffsiteShare(t, "dokumentumok", true)
|
||||
optionalPath := env.addOffsiteShare(t, "filmek", false)
|
||||
|
||||
cap := env.run(t)
|
||||
for tag, args := range cap.byStack {
|
||||
if contains(args, optionalPath) {
|
||||
t.Errorf("OPTIONAL share path leaked into the %q restic call: %v", tag, args)
|
||||
}
|
||||
}
|
||||
if !contains(cap.byStack[SharesPseudoStack], mandatoryPath) {
|
||||
t.Error("the mandatory share should still be pushed")
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C: the quota gate degrades the push to the MANIFEST ONLY — definitions protection never
|
||||
// regresses — the blocked set gains the reserved key, and the notification is edge-triggered so a
|
||||
// second identical run does NOT re-notify. Red-proof: drop the manifest-only degradation (skip the
|
||||
// whole leg when blocked) and the "manifest still pushed" assertion fails.
|
||||
func TestOffboxSharesLegQuotaDegradesToManifestOnly(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
sharePath := env.addOffsiteShare(t, "dokumentumok", true)
|
||||
|
||||
// A 1 GB quota with a 2 GB share estimate: the gate must trip.
|
||||
if err := env.sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 1 }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.m.SetOffboxSizer(func(string) int64 { return 2 * offboxGiB })
|
||||
|
||||
var notified []string
|
||||
env.m.SetOffboxEnlargeBlockedNotifier(func(stack string, _ int64, _, _ int) {
|
||||
notified = append(notified, stack)
|
||||
})
|
||||
|
||||
cap := env.run(t)
|
||||
args := cap.byStack[SharesPseudoStack]
|
||||
if len(args) == 0 {
|
||||
t.Fatal("the blocked run must still push the definitions, not skip the leg entirely")
|
||||
}
|
||||
if contains(args, sharePath) {
|
||||
t.Errorf("a quota-blocked push must NOT carry the share folder: %v", args)
|
||||
}
|
||||
if !contains(args, env.m.SharesPayloadDir()) {
|
||||
t.Errorf("a quota-blocked push MUST still carry the manifest (definitions protection never regresses): %v", args)
|
||||
}
|
||||
// The persisted blocked set keeps the RAW key (templates index by it)…
|
||||
tgt := env.sett.GetOffboxTarget()
|
||||
if !containsStr(tgt.EnlargedBlocked, SharesPseudoStack) {
|
||||
t.Errorf("EnlargedBlocked should contain the raw %q key, got %v", SharesPseudoStack, tgt.EnlargedBlocked)
|
||||
}
|
||||
// …while the NOTIFICATION boundary renders the Hungarian display name.
|
||||
if len(notified) != 1 || notified[0] != SharesDisplayName {
|
||||
t.Errorf("notification should fire once as %q, got %v", SharesDisplayName, notified)
|
||||
}
|
||||
// The customer-facing warning must not leak the reserved key either.
|
||||
if strings.Contains(tgt.LastWarning, SharesPseudoStack) {
|
||||
t.Errorf("the reserved key leaked into Hungarian prose: %q", tgt.LastWarning)
|
||||
}
|
||||
|
||||
// Edge-trigger: an identical second run must NOT re-notify.
|
||||
notified = nil
|
||||
env.run(t)
|
||||
if len(notified) != 0 {
|
||||
t.Errorf("a persistently-blocked shares source must not re-notify nightly, got %v", notified)
|
||||
}
|
||||
}
|
||||
|
||||
// Sharing disabled / no shares: no `_shares` restic group is created at all.
|
||||
func TestOffboxSharesLegNoOpWhenSharingOff(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
if err := env.sett.SetSMBEnabled(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cap := env.run(t)
|
||||
if _, ok := cap.byStack[SharesPseudoStack]; ok {
|
||||
t.Error("a disabled sharing feature must create no _shares snapshot group")
|
||||
}
|
||||
if cap.backups != 1 {
|
||||
t.Errorf("expected only the app's call, got %d", cap.backups)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario F, offsite side: a share on an unavailable drive reaches NO restic argument, and the run
|
||||
// still covers the healthy shares.
|
||||
func TestOffboxSharesLegSkipsDeadMount(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
live := env.addOffsiteShare(t, "elo", true)
|
||||
dead := filepath.Join(env.drive, "halott")
|
||||
if err := env.sett.AddSMBShare(settings.SMBShare{Name: "halott", Path: dead, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
} // folder deliberately never created → unavailable
|
||||
|
||||
cap := env.run(t)
|
||||
args := cap.byStack[SharesPseudoStack]
|
||||
if contains(args, dead) {
|
||||
t.Errorf("an unavailable share path reached the restic argv: %v", args)
|
||||
}
|
||||
if !contains(args, live) {
|
||||
t.Errorf("the healthy share must still be pushed: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// A run whose ONLY cloud content is shares must not be told "nothing is selected".
|
||||
func TestOffboxSharesLegSuppressesZeroToggleNotice(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
if err := env.sett.SetAppOffbox("immich", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.addOffsiteShare(t, "dokumentumok", true)
|
||||
|
||||
env.run(t)
|
||||
if w := env.sett.GetOffboxTarget().LastWarning; strings.Contains(w, "nincs mentésre jelölt alkalmazás") {
|
||||
t.Errorf("a box whose cloud content is its shares is covered — misleading warning: %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// containsStr is a small slice helper (the package's `contains` takes the restic argv shape).
|
||||
func containsStr(hay []string, needle string) bool {
|
||||
for _, h := range hay {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// SMB share backup — R-7b, Model B′ (Viktor's ruling 2026-07-18). Share data enters the live backup
|
||||
// runs through a SIBLING shares source: additive job/leg code that reuses the proven primitives
|
||||
// (tier-2 mirror seam, restic wrappers, soft-quota gate, status recording) while leaving every
|
||||
// per-app engine path BYTE-IDENTICAL. This file holds the piece both tiers share: the PAYLOAD — the
|
||||
// share DEFINITIONS plus the SMB password hash — so a DR rebuild restores the files, the share
|
||||
// configuration, and the household credential, not just the bytes on disk.
|
||||
//
|
||||
// Why a payload at all: the share folders are plain customer directories; copying them protects the
|
||||
// data but loses everything that made them shares. Without the manifest a restore leaves the customer
|
||||
// with their files back and an empty „Megosztás" page.
|
||||
|
||||
const (
|
||||
// SharesPseudoStack is the RESERVED key the shares source occupies wherever the per-app engines
|
||||
// key by stack name: the restic tag, the tier-2 dest root (backups/secondary/_shares/…), and the
|
||||
// CrossDriveBackup status record. It is NEVER shown to the customer — every UI/notification
|
||||
// boundary maps it through SharesDisplayName (see DisplayStackName).
|
||||
//
|
||||
// RESERVED-NAME VERIFICATION (R-7b): the claim that slice-1 validation already excludes a leading
|
||||
// underscore is FALSE for shares — settings.nbNameRe starts with [A-Za-z0-9_], so „_shares" was an
|
||||
// accepted share name. settings.ValidateSMBShareName now rejects the underscore-prefixed namespace
|
||||
// outright. Stack names come from the git-synced catalog (not customer input) and so cannot
|
||||
// realistically produce „_shares", but RunAllTier2 and RunOffboxBackup skip such a stack with a
|
||||
// loud WARN as defense in depth rather than let it clobber the shares tree.
|
||||
SharesPseudoStack = "_shares"
|
||||
// SharesDisplayName is the Hungarian customer-facing name for the shares source.
|
||||
SharesDisplayName = "Megosztások"
|
||||
|
||||
// sharesManifestName is the share-definition document inside the payload.
|
||||
sharesManifestName = "_shares-manifest.json"
|
||||
// sharesPassdbName is the SECRET-BEARING samba passdb archive inside the payload. It rides the
|
||||
// restic repo (encrypted at rest) and the tier-2 staging on the customer's own drives; its bytes
|
||||
// and its name never reach a log line at INFO, a report, or a committed file.
|
||||
sharesPassdbName = "passdb.tar"
|
||||
// sharesPassdbMember is the subtree inside infra.SambaPassdbMount that holds the credential.
|
||||
sharesPassdbMember = "private"
|
||||
|
||||
// sharesManifestVersion pins the payload document shape.
|
||||
sharesManifestVersion = 1
|
||||
)
|
||||
|
||||
// DisplayStackName maps an engine-internal stack key to the name a customer may see. Today the only
|
||||
// mapping is the reserved shares pseudo-stack; every other key is its own display name. THIS is the
|
||||
// single boundary that keeps „_shares" out of the Hungarian UI, alerts and e-mails.
|
||||
func DisplayStackName(key string) string {
|
||||
if key == SharesPseudoStack {
|
||||
return SharesDisplayName
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// SharesManifest is the restorable share-definition document. It is deliberately timestamp-free at
|
||||
// the document level so the rendered JSON is BYTE-DETERMINISTIC for an unchanged registry — the
|
||||
// tier-2 mirror then has nothing to rewrite on a no-op run. Per-share CreatedAt is preserved.
|
||||
type SharesManifest struct {
|
||||
Version int `json:"version"`
|
||||
ServerName string `json:"server_name"`
|
||||
Shares []settings.SMBShare `json:"shares"`
|
||||
}
|
||||
|
||||
// SetSharesPassdbCapturer overrides the samba passdb capture (tests inject a fake so no docker runs).
|
||||
func (m *Manager) SetSharesPassdbCapturer(fn func() ([]byte, error)) { m.sharesPassdbCapture = fn }
|
||||
|
||||
// sharesPassdbCapturer returns the passdb capture seam (nil → the real docker exec).
|
||||
func (m *Manager) sharesPassdbCapturer() func() ([]byte, error) {
|
||||
if m.sharesPassdbCapture != nil {
|
||||
return m.sharesPassdbCapture
|
||||
}
|
||||
return defaultSharesPassdbCapture
|
||||
}
|
||||
|
||||
// defaultSharesPassdbCapture tars the samba private/ subtree out of the running container on stdout.
|
||||
// Best-effort by contract: a stopped container simply yields an error and the payload ships
|
||||
// manifest-only (re-setting the SMB password is a cheap UX step; the definitions are the load-bearing
|
||||
// part). Uses the same `docker exec` shape as stacks.extractInitialCreds.
|
||||
func defaultSharesPassdbCapture() ([]byte, error) {
|
||||
cmd := exec.Command("docker", "exec", infra.SambaContainerName,
|
||||
"tar", "cf", "-", "-C", infra.SambaPassdbMount, sharesPassdbMember)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("passdb capture: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sharesPayloadDir is the staging directory both tiers read. It lives in the controller DATA DIR
|
||||
// (not on a customer drive) for two reasons: the payload is kilobytes — manifest JSON plus a small
|
||||
// tdb archive — so the F-A1 rootfs-filler concern does not apply, and a DETERMINISTIC absolute path
|
||||
// makes the restic snapshot path stable, which is what the restore mapper anchors on. 0700 because
|
||||
// the passdb archive is secret-bearing.
|
||||
func (m *Manager) sharesPayloadDir() string {
|
||||
return filepath.Join(m.cfg.Paths.DataDir, "shares-payload")
|
||||
}
|
||||
|
||||
// SharesPayloadDir exposes the staging path for the restore mapper and tests.
|
||||
func (m *Manager) SharesPayloadDir() string { return m.sharesPayloadDir() }
|
||||
|
||||
// buildSharesManifest renders the deterministic manifest document from the live registry. Shares are
|
||||
// sorted case-insensitively by name so an unchanged registry always marshals to identical bytes
|
||||
// regardless of the order the customer happened to add them in.
|
||||
func (m *Manager) buildSharesManifest() SharesManifest {
|
||||
mf := SharesManifest{Version: sharesManifestVersion, Shares: []settings.SMBShare{}}
|
||||
if m.settings == nil {
|
||||
return mf
|
||||
}
|
||||
smb := m.settings.GetSMBSettings()
|
||||
mf.ServerName = smb.EffectiveServerName()
|
||||
mf.Shares = append(mf.Shares, m.settings.GetSMBShares()...)
|
||||
sort.Slice(mf.Shares, func(i, j int) bool {
|
||||
a, b := strings.ToLower(mf.Shares[i].Name), strings.ToLower(mf.Shares[j].Name)
|
||||
if a != b {
|
||||
return a < b
|
||||
}
|
||||
return mf.Shares[i].Name < mf.Shares[j].Name
|
||||
})
|
||||
return mf
|
||||
}
|
||||
|
||||
// buildSharesPayload stages the payload and returns its directory. The manifest is ALWAYS written
|
||||
// (it is the definitions-protection floor that must never regress); the passdb archive is
|
||||
// best-effort and its absence is a WARN, not an error. Returns the dir plus whether the passdb made
|
||||
// it in, so callers can report honestly.
|
||||
func (m *Manager) buildSharesPayload() (dir string, passdbOK bool, err error) {
|
||||
dir = m.sharesPayloadDir()
|
||||
if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil {
|
||||
return "", false, fmt.Errorf("shares payload dir: %w", mkErr)
|
||||
}
|
||||
// Tighten an inherited-loose mode from an older layout (the passdb archive lives here).
|
||||
if chErr := os.Chmod(dir, 0o700); chErr != nil {
|
||||
m.logger.Printf("[WARN] [shares] payload dir chmod failed: %v", chErr)
|
||||
}
|
||||
|
||||
blob, mErr := json.MarshalIndent(m.buildSharesManifest(), "", " ")
|
||||
if mErr != nil {
|
||||
return "", false, fmt.Errorf("shares manifest marshal: %w", mErr)
|
||||
}
|
||||
blob = append(blob, '\n')
|
||||
if wErr := writeFileAtomic(filepath.Join(dir, sharesManifestName), blob, 0o600); wErr != nil {
|
||||
return "", false, fmt.Errorf("shares manifest write: %w", wErr)
|
||||
}
|
||||
|
||||
passdbPath := filepath.Join(dir, sharesPassdbName)
|
||||
tar, pErr := m.sharesPassdbCapturer()()
|
||||
switch {
|
||||
case pErr != nil || len(tar) == 0:
|
||||
// Container down / never deployed. Keep any PREVIOUSLY captured archive rather than deleting
|
||||
// it — a stale credential copy is strictly better for DR than none, and the payload stays
|
||||
// self-consistent because the manifest carries no password of its own.
|
||||
if _, sErr := os.Stat(passdbPath); sErr == nil {
|
||||
m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — keeping the previously captured copy: %v", pErr)
|
||||
passdbOK = true
|
||||
} else {
|
||||
m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — payload is manifest-only; the SMB password must be re-set after a restore: %v", pErr)
|
||||
}
|
||||
default:
|
||||
if wErr := writeFileAtomic(passdbPath, tar, 0o600); wErr != nil {
|
||||
// Never fatal: definitions protection must not hinge on the credential copy.
|
||||
m.logger.Printf("[WARN] [shares] passdb stage failed — payload is manifest-only: %v", wErr)
|
||||
} else {
|
||||
passdbOK = true
|
||||
m.logger.Printf("[DEBUG] [shares] passdb archive staged (%d bytes)", len(tar))
|
||||
}
|
||||
}
|
||||
m.logger.Printf("[INFO] [shares] payload staged: %d share definition(s), credential copy=%v", len(m.buildSharesManifest().Shares), passdbOK)
|
||||
return dir, passdbOK, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic writes via tmp + rename at the requested mode (mirrors stacks.sambaWriteAtomic's
|
||||
// discipline: a crash mid-write can never leave a half-manifest a restore would read).
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, mode); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifiedShare is one share the backup sources act on, with its class resolved from the registry
|
||||
// exactly as stacks.sambaClassifiedBinds does it: Offsite=true ⇒ MANDATORY (offsite + tier-2),
|
||||
// Offsite=false ⇒ OPTIONAL (tier-2 only). Availability is resolved here too, so BOTH jobs skip a
|
||||
// dead mount identically (Scenario F).
|
||||
type classifiedShare struct {
|
||||
settings.SMBShare
|
||||
mandatory bool
|
||||
}
|
||||
|
||||
// classifiedShares returns the shares both jobs act on, dropping unavailable ones with a loud WARN.
|
||||
// A share whose owning drive is disconnected/decommissioned, or whose folder simply is not there, is
|
||||
// NEVER handed to a mirror or a restic argv: mirroring a missing mountpoint would copy an empty dir
|
||||
// over a good backup, and that is the silently-wrong-restore class this codebase refuses by rule.
|
||||
func (m *Manager) classifiedShares() []classifiedShare {
|
||||
if m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
var out []classifiedShare
|
||||
for _, sh := range m.settings.GetSMBShares() {
|
||||
if !m.shareBackupAvailable(sh.Path) {
|
||||
m.logger.Printf("[WARN] [shares] share skipped — folder unavailable (drive away or path missing): name=%s", sh.Name)
|
||||
continue
|
||||
}
|
||||
out = append(out, classifiedShare{SMBShare: sh, mandatory: sh.Offsite})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
|
||||
return out
|
||||
}
|
||||
|
||||
// shareBackupAvailable mirrors stacks.shareAvailable (the export-time gate) so the backup sources and
|
||||
// the smb.conf renderer agree on what "available" means. Kept local rather than imported: the backup
|
||||
// package must not depend on the stacks package.
|
||||
func (m *Manager) shareBackupAvailable(path string) bool {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return false
|
||||
}
|
||||
if m.settings != nil {
|
||||
p := filepath.ToSlash(path)
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
root := filepath.ToSlash(sp.Path)
|
||||
if p == root || strings.HasPrefix(p, root+"/") {
|
||||
if sp.Disconnected || sp.Decommissioned {
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
return err == nil && st.IsDir()
|
||||
}
|
||||
|
||||
// sharesEnabled reports whether the shares source has anything to do at all: the feature is on AND at
|
||||
// least one share is registered. A disabled feature or an empty registry is a clean no-op in both
|
||||
// jobs — no `_shares` restic group, no empty dest dirs (the zero-shares edge case).
|
||||
func (m *Manager) sharesEnabled() bool {
|
||||
if m.settings == nil {
|
||||
return false
|
||||
}
|
||||
return m.settings.GetSMBSettings().Enabled && len(m.settings.GetSMBShares()) > 0
|
||||
}
|
||||
|
||||
// sharesNow is the timestamp helper for the shares status records (kept in one place so tests that
|
||||
// assert on recorded status have a single seam to reason about).
|
||||
func sharesNow() string { return time.Now().Format(time.RFC3339) }
|
||||
@@ -0,0 +1,321 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
)
|
||||
|
||||
// Shares restore — R-7b Part 4. A SIBLING of the per-app scratch/place flow in offbox_restore.go,
|
||||
// mirroring its shape (restore to an on-data-drive scratch first, then a separate, deliberate
|
||||
// place-to-live merge) without touching it.
|
||||
//
|
||||
// Three things come back, in this order of importance:
|
||||
// 1. the FILES — placed missing-only into each share's live folder, never overwriting;
|
||||
// 2. the DEFINITIONS — merged into the share registry so the „Megosztás" page is whole again;
|
||||
// 3. the CREDENTIAL — best-effort, into the samba named volume, so the household need not re-set it.
|
||||
//
|
||||
// The load-bearing guard is the PREFIX ASSERT: a destination is only written when it resolves
|
||||
// strictly inside a REGISTERED, LIVE storage root. A snapshot is untrusted input for this purpose —
|
||||
// it was written by an older version of this box, possibly with a different drive layout — so a path
|
||||
// that no longer sits under a live root is refused rather than created.
|
||||
|
||||
// SetSharesReconciler wires the post-restore samba re-render (main.go → stacks.ReconcileSamba).
|
||||
func (m *Manager) SetSharesReconciler(fn func() error) { m.sharesReconcile = fn }
|
||||
|
||||
// SetSharesPassdbRestorer overrides the passdb restore exec (tests).
|
||||
func (m *Manager) SetSharesPassdbRestorer(fn func(tar []byte) error) { m.sharesPassdbRestore = fn }
|
||||
|
||||
func (m *Manager) sharesPassdbRestorer() func([]byte) error {
|
||||
if m.sharesPassdbRestore != nil {
|
||||
return m.sharesPassdbRestore
|
||||
}
|
||||
return defaultSharesPassdbRestore
|
||||
}
|
||||
|
||||
// defaultSharesPassdbRestore untars a captured passdb archive back into the samba named volume. It
|
||||
// writes ONLY into infra.SambaPassdbMount inside the samba container — never onto the host — so a
|
||||
// malformed archive cannot reach anything outside the volume it came from.
|
||||
func defaultSharesPassdbRestore(tar []byte) error {
|
||||
cmd := exec.Command("docker", "exec", "-i", infra.SambaContainerName,
|
||||
"tar", "xf", "-", "-C", infra.SambaPassdbMount)
|
||||
cmd.Stdin = bytes.NewReader(tar)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("passdb restore: %s: %w", truncate(out), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sharesRestoreScratchDir returns the on-DATA-DRIVE scratch for the shares restore. Never the
|
||||
// controller data dir (the F-A1 rootfs-filler lesson) and never network storage when a local drive
|
||||
// exists (the F-6C-1 ownership-fidelity lesson).
|
||||
func (m *Manager) sharesRestoreScratchDir() (scratch, nsRoot string, err error) {
|
||||
if m.settings == nil {
|
||||
return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz")
|
||||
}
|
||||
pick := func(networkOK bool) (string, string, bool) {
|
||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||
if strings.TrimSpace(sp.Path) == "" || (!networkOK && sp.IsNetwork()) {
|
||||
continue
|
||||
}
|
||||
nr := m.namespaceRoot(sp.Path)
|
||||
return filepath.Join(nr, "backups", "offsite-restore", SharesPseudoStack), nr, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
if s, nr, ok := pick(false); ok {
|
||||
return s, nr, nil
|
||||
}
|
||||
if s, nr, ok := pick(true); ok {
|
||||
m.logger.Printf("[WARN] [shares] restore scratch on network storage — ownership fidelity not guaranteed under squash")
|
||||
return s, nr, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz")
|
||||
}
|
||||
|
||||
// RestoreSharesScratch restores the latest `_shares` snapshot into the scratch dir. Non-destructive:
|
||||
// it never touches a live share folder, the registry, or the credential — PlaceSharesRestore is the
|
||||
// deliberate second action that does.
|
||||
func (m *Manager) RestoreSharesScratch(ctx context.Context) error {
|
||||
if !m.OffboxConfigured() {
|
||||
return fmt.Errorf("a távoli mentés nincs beállítva")
|
||||
}
|
||||
scratch, nsRoot, err := m.sharesRestoreScratchDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if free := m.offboxFree()(nsRoot); free > 0 && free < offboxUnitOnlyFreeFloor {
|
||||
return fmt.Errorf("Nincs elég szabad hely a visszaállításhoz (%s szükséges, %s szabad).",
|
||||
humanizeBytes(offboxUnitOnlyFreeFloor), humanizeBytes(free))
|
||||
}
|
||||
id, _, err := m.offboxLatestSnapshot(ctx, SharesPseudoStack)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nincs visszaállítható megosztás-mentés: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(scratch, 0o755); err != nil {
|
||||
return fmt.Errorf("restore dir: %w", err)
|
||||
}
|
||||
t := m.settings.GetOffboxTarget()
|
||||
base, env := m.offboxBaseArgs(t)
|
||||
rctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
|
||||
defer cancel()
|
||||
m.unlockStale(rctx, base, env)
|
||||
out, rerr := m.resticStep(rctx, env, base, "restore:"+SharesPseudoStack, "restore", id, "--target", scratch)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("a megosztások visszaállítása sikertelen: %w: %s", rerr, truncate(out))
|
||||
}
|
||||
m.logger.Printf("[INFO] [shares] restored snapshot %s → %s", id, scratch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SharesScratchReady reports whether a completed shares scratch exists (gates the place action).
|
||||
func (m *Manager) SharesScratchReady() bool {
|
||||
scratch, _, err := m.sharesRestoreScratchDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
entries, rErr := os.ReadDir(scratch)
|
||||
return rErr == nil && len(entries) > 0
|
||||
}
|
||||
|
||||
// SharesRestoreResult is what the flash message reports back to the customer.
|
||||
type SharesRestoreResult struct {
|
||||
FilesRestored int // files merged into live share folders
|
||||
SharesPlaced []string // share folders whose files were merged
|
||||
DefinitionsAdded []string // share definitions re-added to the registry
|
||||
DefinitionsKept []string // definitions skipped because a live share already owns the name
|
||||
Refused []string // definitions refused: destination is not under a live storage root
|
||||
PasswordRestored bool // the household SMB credential was put back
|
||||
}
|
||||
|
||||
// liveShareRootOK prefix-asserts a destination against the REGISTERED, LIVE storage roots. It
|
||||
// requires a STRICT descendant: equal-to-the-root is refused too, because placing a share's contents
|
||||
// at a drive root would scatter restored files across the whole drive. A `..` segment is refused
|
||||
// outright rather than relying on Clean, so a traversal attempt is visible in the logs.
|
||||
func (m *Manager) liveShareRootOK(dst string) bool {
|
||||
if m.settings == nil || strings.TrimSpace(dst) == "" {
|
||||
return false
|
||||
}
|
||||
clean := filepath.Clean(dst)
|
||||
for _, seg := range strings.Split(filepath.ToSlash(dst), "/") {
|
||||
if seg == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
p := filepath.ToSlash(clean)
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
if sp.Decommissioned || sp.Disconnected {
|
||||
continue
|
||||
}
|
||||
root := filepath.ToSlash(filepath.Clean(sp.Path))
|
||||
if root == "" || root == "/" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(p, root+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scratchJoin reconstructs an absolute captured path UNDER a restore scratch — restic restores with
|
||||
// the absolute source structure preserved, so /mnt/hdd_1/dokumentumok lands at
|
||||
// <scratch>/mnt/hdd_1/dokumentumok. The volume name and leading separator are stripped explicitly
|
||||
// rather than relying on filepath.Join, which on a non-POSIX host would splice a drive letter into
|
||||
// the middle of the path and produce an unopenable name.
|
||||
func scratchJoin(scratch, abs string) string {
|
||||
rel := abs
|
||||
if vol := filepath.VolumeName(rel); vol != "" {
|
||||
rel = rel[len(vol):]
|
||||
}
|
||||
rel = strings.TrimLeft(filepath.ToSlash(rel), "/")
|
||||
return filepath.Join(scratch, filepath.FromSlash(rel))
|
||||
}
|
||||
|
||||
// readSharesManifestFrom reads the manifest out of a restored scratch tree.
|
||||
func (m *Manager) readSharesManifestFrom(scratch string) (SharesManifest, error) {
|
||||
var mf SharesManifest
|
||||
p := filepath.Join(scratchJoin(scratch, m.sharesPayloadDir()), sharesManifestName)
|
||||
blob, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return mf, fmt.Errorf("a mentésben nincs megosztás-leíró: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(blob, &mf); err != nil {
|
||||
return mf, fmt.Errorf("a megosztás-leíró olvashatatlan: %w", err)
|
||||
}
|
||||
return mf, nil
|
||||
}
|
||||
|
||||
// PlaceSharesRestore places a completed shares scratch into live locations: files first (missing-only
|
||||
// merge, never overwriting), then the definitions (existing live definitions WIN on a name conflict —
|
||||
// a restore must not silently flip a live share's read-only or cloud setting), then the samba
|
||||
// re-render, then the credential. Single-flight.
|
||||
func (m *Manager) PlaceSharesRestore(ctx context.Context) (SharesRestoreResult, error) {
|
||||
var res SharesRestoreResult
|
||||
if err := m.acquireRunning(); err != nil {
|
||||
return res, fmt.Errorf("egy másik mentési/visszaállítási művelet már fut")
|
||||
}
|
||||
defer m.releaseRunning()
|
||||
|
||||
scratch, _, err := m.sharesRestoreScratchDir()
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if _, sErr := os.Stat(scratch); sErr != nil {
|
||||
return res, fmt.Errorf("nincs előkészített visszaállítás — futtass előbb egy megosztás-visszaállítást")
|
||||
}
|
||||
mf, err := m.readSharesManifestFrom(scratch)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Live registry, indexed case-insensitively (AddSMBShare's own collision rule).
|
||||
live := map[string]bool{}
|
||||
if m.settings != nil {
|
||||
for _, sh := range m.settings.GetSMBShares() {
|
||||
live[strings.ToLower(sh.Name)] = true
|
||||
}
|
||||
}
|
||||
copier := m.placeCopier()
|
||||
for _, sh := range mf.Shares {
|
||||
// THE PREFIX ASSERT. The snapshot's path is untrusted layout input; a destination that is not
|
||||
// strictly inside a live registered root is refused, never created.
|
||||
if !m.liveShareRootOK(sh.Path) {
|
||||
m.logger.Printf("[WARN] [shares] restore refused for %s — destination is not under a live storage root", sh.Name)
|
||||
res.Refused = append(res.Refused, sh.Name)
|
||||
continue
|
||||
}
|
||||
src := scratchJoin(scratch, sh.Path)
|
||||
if _, sErr := os.Stat(src); sErr == nil {
|
||||
n, cErr := copier(src, sh.Path)
|
||||
if cErr != nil {
|
||||
return res, fmt.Errorf("a(z) „%s” megosztás fájljainak visszaállítása sikertelen: %w", sh.Name, cErr)
|
||||
}
|
||||
res.FilesRestored += n
|
||||
res.SharesPlaced = append(res.SharesPlaced, sh.Name)
|
||||
} else {
|
||||
// A definitions-only snapshot (the quota-degraded shape) legitimately has no file tree.
|
||||
m.logger.Printf("[DEBUG] [shares] no restored file tree for %s — definitions-only snapshot", sh.Name)
|
||||
}
|
||||
// Definitions: existing live share WINS. Restoring must never silently change a share the
|
||||
// household is using right now.
|
||||
if live[strings.ToLower(sh.Name)] {
|
||||
res.DefinitionsKept = append(res.DefinitionsKept, sh.Name)
|
||||
continue
|
||||
}
|
||||
if m.settings != nil {
|
||||
if aErr := m.settings.AddSMBShare(sh); aErr != nil {
|
||||
m.logger.Printf("[WARN] [shares] could not re-add definition %s: %v", sh.Name, aErr)
|
||||
continue
|
||||
}
|
||||
res.DefinitionsAdded = append(res.DefinitionsAdded, sh.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render smb.conf so the restored definitions are actually exported.
|
||||
if len(res.DefinitionsAdded) > 0 {
|
||||
if m.sharesReconcile == nil {
|
||||
m.logger.Printf("[WARN] [shares] no samba reconciler wired — smb.conf will catch up on the next health tick")
|
||||
} else if rErr := m.sharesReconcile(); rErr != nil {
|
||||
m.logger.Printf("[WARN] [shares] samba re-render after restore failed: %v", rErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Credential, best-effort and last: the files and definitions are the load-bearing parts, and
|
||||
// re-setting an SMB password is a cheap, well-signposted UX step.
|
||||
passdb := filepath.Join(scratchJoin(scratch, m.sharesPayloadDir()), sharesPassdbName)
|
||||
if blob, rErr := os.ReadFile(passdb); rErr == nil && len(blob) > 0 {
|
||||
if pErr := m.sharesPassdbRestorer()(blob); pErr != nil {
|
||||
m.logger.Printf("[WARN] [shares] credential restore failed — the SMB password must be re-set: %v", pErr)
|
||||
} else {
|
||||
res.PasswordRestored = true
|
||||
if m.settings != nil {
|
||||
if sErr := m.settings.SetSMBUserSet(true); sErr != nil {
|
||||
m.logger.Printf("[WARN] [shares] persist user-set flag after credential restore failed: %v", sErr)
|
||||
}
|
||||
}
|
||||
m.logger.Printf("[INFO] [shares] household credential restored into the sharing service")
|
||||
}
|
||||
}
|
||||
|
||||
if rmErr := os.RemoveAll(scratch); rmErr != nil {
|
||||
m.logger.Printf("[WARN] [shares] scratch cleanup failed (harmless): %v", rmErr)
|
||||
}
|
||||
m.logger.Printf("[INFO] [shares] restore placed: %d file(s), %d definition(s) re-added, %d kept, %d refused, credential=%v",
|
||||
res.FilesRestored, len(res.DefinitionsAdded), len(res.DefinitionsKept), len(res.Refused), res.PasswordRestored)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// FlashMessage renders the Hungarian summary the „Megosztások visszaállítása" action flashes back.
|
||||
func (r SharesRestoreResult) FlashMessage() string {
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("%s visszaállítva: %d fájl, %d megosztás-beállítás.",
|
||||
SharesDisplayName, r.FilesRestored, len(r.DefinitionsAdded)))
|
||||
for _, n := range r.DefinitionsKept {
|
||||
parts = append(parts, fmt.Sprintf("A(z) %s megosztás beállítása már létezik — a meglévő maradt.", n))
|
||||
}
|
||||
if len(r.Refused) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Nem állítható vissza (a mappa nincs élő adatmeghajtón): %s.",
|
||||
strings.Join(r.Refused, ", ")))
|
||||
}
|
||||
if !r.PasswordRestored {
|
||||
parts = append(parts, "A megosztás jelszavát újra meg kell adni.")
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// SharesRegistryCount is a small helper for the page (how many shares the registry holds).
|
||||
func (m *Manager) SharesRegistryCount() int {
|
||||
if m.settings == nil {
|
||||
return 0
|
||||
}
|
||||
return len(m.settings.GetSMBShares())
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-7b Part 4 — shares restore. Scenario D's contract: files come back byte-identical into the LIVE
|
||||
// share folder, a deleted definition reappears in the registry, a definition that still exists is
|
||||
// left alone, and the place step never writes outside a registered live root.
|
||||
|
||||
// seedSharesScratch lays down a completed restore scratch: the payload (manifest + credential) plus
|
||||
// a restored file tree for each named share, mirroring what restic's absolute-path restore produces.
|
||||
func seedSharesScratch(t *testing.T, env *sharesEnv, mf SharesManifest, files map[string]string) string {
|
||||
t.Helper()
|
||||
scratch, _, err := env.m.sharesRestoreScratchDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := scratchJoin(scratch, env.m.sharesPayloadDir())
|
||||
if err := os.MkdirAll(payload, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob := mustJSON(t, mf)
|
||||
if err := os.WriteFile(filepath.Join(payload, sharesManifestName), blob, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(payload, sharesPassdbName), []byte("FAKE-PASSDB-TAR"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for livePath, content := range files {
|
||||
p := scratchJoin(scratch, livePath)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return scratch
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := jsonMarshalIndent(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// wirePlaceSeams installs a real (missing-only) copier and a fake passdb restorer.
|
||||
func wirePlaceSeams(env *sharesEnv, passdbCalls *int) {
|
||||
env.m.offboxPlaceCopier = func(src, dst string) (int, error) {
|
||||
n := 0
|
||||
err := filepath.Walk(src, func(p string, fi os.FileInfo, err error) error {
|
||||
if err != nil || fi.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, rErr := filepath.Rel(src, p)
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if _, sErr := os.Stat(target); sErr == nil {
|
||||
return nil // missing-only: never overwrite
|
||||
}
|
||||
b, rErr := os.ReadFile(p)
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
if mErr := os.MkdirAll(filepath.Dir(target), 0o755); mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
n++
|
||||
return os.WriteFile(target, b, 0o644)
|
||||
})
|
||||
return n, err
|
||||
}
|
||||
env.m.sharesPassdbRestore = func([]byte) error { *passdbCalls++; return nil }
|
||||
}
|
||||
|
||||
// Scenario D: the round trip. A deleted file returns byte-identical; a deleted definition reappears
|
||||
// and triggers the samba re-render; a definition that still exists is KEPT (a restore must never
|
||||
// silently flip a live share's settings).
|
||||
func TestSharesRestoreRoundTrip(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
keptPath := env.addShare(t, "hdd_1", "marad", true)
|
||||
deletedPath := filepath.Join(env.drives["hdd_1"], "torolt")
|
||||
|
||||
mf := SharesManifest{
|
||||
Version: sharesManifestVersion, ServerName: "FELHOM",
|
||||
Shares: []settings.SMBShare{
|
||||
// Still live — its definition must be kept, not overwritten (note the flipped ReadOnly:
|
||||
// if the merge preferred the snapshot, this would silently change a live share).
|
||||
{Name: "marad", Path: keptPath, ReadOnly: true, Offsite: false, CreatedAt: "2020-01-01T00:00:00Z"},
|
||||
// Deleted from the registry — must come back.
|
||||
{Name: "torolt", Path: deletedPath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
|
||||
},
|
||||
}
|
||||
seedSharesScratch(t, env, mf, map[string]string{
|
||||
filepath.Join(deletedPath, "fontos.txt"): "EREDETI-TARTALOM",
|
||||
filepath.Join(keptPath, "marad.txt"): "SNAPSHOT-VERZIO",
|
||||
})
|
||||
|
||||
var reconciled, passdbCalls int
|
||||
env.m.SetSharesReconciler(func() error { reconciled++; return nil })
|
||||
wirePlaceSeams(env, &passdbCalls)
|
||||
|
||||
res, err := env.m.PlaceSharesRestore(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("PlaceSharesRestore: %v", err)
|
||||
}
|
||||
|
||||
// 1. File back, byte-identical, in the LIVE folder.
|
||||
b, rErr := os.ReadFile(filepath.Join(deletedPath, "fontos.txt"))
|
||||
if rErr != nil || string(b) != "EREDETI-TARTALOM" {
|
||||
t.Errorf("restored file wrong/missing: %q, %v", b, rErr)
|
||||
}
|
||||
// 2. Missing-only: the live file must NOT be clobbered by the snapshot version.
|
||||
b, _ = os.ReadFile(filepath.Join(keptPath, "marad.txt"))
|
||||
if string(b) != "content-of-marad" {
|
||||
t.Errorf("a live file was overwritten by the restore: %q", b)
|
||||
}
|
||||
// 3. Deleted definition reappears; live definition kept unchanged.
|
||||
if !containsStr(res.DefinitionsAdded, "torolt") {
|
||||
t.Errorf("deleted definition did not reappear: %+v", res)
|
||||
}
|
||||
if !containsStr(res.DefinitionsKept, "marad") {
|
||||
t.Errorf("live definition should be reported as kept: %+v", res)
|
||||
}
|
||||
for _, sh := range env.sett.GetSMBShares() {
|
||||
if sh.Name == "marad" && sh.ReadOnly {
|
||||
t.Error("a restore silently flipped a LIVE share's ReadOnly setting")
|
||||
}
|
||||
}
|
||||
// 4. smb.conf re-rendered so the restored share is actually exported.
|
||||
if reconciled != 1 {
|
||||
t.Errorf("ReconcileSamba should run exactly once after adding definitions, ran %d", reconciled)
|
||||
}
|
||||
// 5. Credential restored best-effort.
|
||||
if passdbCalls != 1 || !res.PasswordRestored {
|
||||
t.Errorf("credential restore did not run: calls=%d result=%v", passdbCalls, res.PasswordRestored)
|
||||
}
|
||||
}
|
||||
|
||||
// THE PLACE-GUARD RED-PROOF TARGET. A manifest whose share path escapes every registered live root
|
||||
// must be REFUSED with zero writes. Red-proof: make liveShareRootOK return true unconditionally and
|
||||
// this test fails (the traversal destination gets created).
|
||||
func TestSharesRestoreRefusesDestinationOutsideLiveRoots(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
outside := filepath.Join(env.tmp, "kivul", "gonosz")
|
||||
traversal := filepath.Join(env.drives["hdd_1"], "..", "eszkeipel")
|
||||
|
||||
mf := SharesManifest{
|
||||
Version: sharesManifestVersion, ServerName: "FELHOM",
|
||||
Shares: []settings.SMBShare{
|
||||
{Name: "kivul", Path: outside, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
|
||||
{Name: "traverz", Path: traversal, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
|
||||
},
|
||||
}
|
||||
seedSharesScratch(t, env, mf, map[string]string{
|
||||
filepath.Join(outside, "x.txt"): "SHOULD-NEVER-LAND",
|
||||
filepath.Join(traversal, "y.txt"): "SHOULD-NEVER-LAND",
|
||||
})
|
||||
|
||||
var passdbCalls int
|
||||
wirePlaceSeams(env, &passdbCalls)
|
||||
|
||||
res, err := env.m.PlaceSharesRestore(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("a refused destination must be reported, not error out: %v", err)
|
||||
}
|
||||
if len(res.Refused) != 2 {
|
||||
t.Errorf("both out-of-root destinations must be refused, got %+v", res)
|
||||
}
|
||||
if res.FilesRestored != 0 {
|
||||
t.Errorf("zero files must be written when every destination is refused, got %d", res.FilesRestored)
|
||||
}
|
||||
if _, sErr := os.Stat(filepath.Join(outside, "x.txt")); !os.IsNotExist(sErr) {
|
||||
t.Error("PLACE GUARD BREACHED — a file landed outside every registered live root")
|
||||
}
|
||||
if _, sErr := os.Stat(filepath.Join(env.tmp, "eszkeipel", "y.txt")); !os.IsNotExist(sErr) {
|
||||
t.Error("PLACE GUARD BREACHED — a traversal destination was written")
|
||||
}
|
||||
// The definitions must not be registered either — a share pointing outside is not restorable.
|
||||
if len(env.sett.GetSMBShares()) != 0 {
|
||||
t.Errorf("refused shares must not enter the registry: %+v", env.sett.GetSMBShares())
|
||||
}
|
||||
}
|
||||
|
||||
// The prefix assert's boundary cases, stated directly.
|
||||
func TestLiveShareRootOKBoundaries(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1")
|
||||
root := env.drives["hdd_1"]
|
||||
|
||||
if !env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) {
|
||||
t.Error("a strict descendant of a live root must be allowed")
|
||||
}
|
||||
if env.m.liveShareRootOK(root) {
|
||||
t.Error("the drive root ITSELF must be refused (a share is never the whole drive)")
|
||||
}
|
||||
if env.m.liveShareRootOK(filepath.Join(root, "..", "elsewhere")) {
|
||||
t.Error("a `..` segment must be refused")
|
||||
}
|
||||
if env.m.liveShareRootOK("") {
|
||||
t.Error("an empty destination must be refused")
|
||||
}
|
||||
// A drive that is away is not a LIVE root.
|
||||
if err := env.sett.SetDisconnected(root, true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.m.liveShareRootOK(filepath.Join(root, "dokumentumok")) {
|
||||
t.Error("a disconnected drive must not count as a live root")
|
||||
}
|
||||
}
|
||||
|
||||
// A definitions-only snapshot (the quota-degraded shape) restores the CONFIGURATION without error
|
||||
// even though no file tree exists — that is the whole point of the manifest-only floor.
|
||||
func TestSharesRestoreDefinitionsOnlySnapshot(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
sharePath := filepath.Join(env.drives["hdd_1"], "dokumentumok")
|
||||
|
||||
mf := SharesManifest{
|
||||
Version: sharesManifestVersion, ServerName: "FELHOM",
|
||||
Shares: []settings.SMBShare{{Name: "dokumentumok", Path: sharePath, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}},
|
||||
}
|
||||
seedSharesScratch(t, env, mf, nil) // no file tree at all
|
||||
|
||||
var passdbCalls int
|
||||
env.m.SetSharesReconciler(func() error { return nil })
|
||||
wirePlaceSeams(env, &passdbCalls)
|
||||
|
||||
res, err := env.m.PlaceSharesRestore(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("a definitions-only snapshot must restore cleanly: %v", err)
|
||||
}
|
||||
if !containsStr(res.DefinitionsAdded, "dokumentumok") {
|
||||
t.Errorf("the definition should be restored: %+v", res)
|
||||
}
|
||||
if res.FilesRestored != 0 {
|
||||
t.Errorf("no files exist in a definitions-only snapshot, got %d", res.FilesRestored)
|
||||
}
|
||||
}
|
||||
|
||||
// The flash message must speak Hungarian and never leak the reserved key.
|
||||
func TestSharesRestoreFlashMessage(t *testing.T) {
|
||||
res := SharesRestoreResult{FilesRestored: 3, DefinitionsAdded: []string{"a"}, DefinitionsKept: []string{"marad"}}
|
||||
msg := res.FlashMessage()
|
||||
if strings.Contains(msg, SharesPseudoStack) {
|
||||
t.Errorf("the reserved key leaked into the flash message: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, SharesDisplayName) {
|
||||
t.Errorf("the flash message should name %q: %q", SharesDisplayName, msg)
|
||||
}
|
||||
if !strings.Contains(msg, "A(z) marad megosztás beállítása már létezik — a meglévő maradt.") {
|
||||
t.Errorf("the kept-definition sentence is missing: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonMarshalIndent is a tiny indirection so the test file needs no direct encoding/json import
|
||||
// beyond this one helper.
|
||||
func jsonMarshalIndent(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") }
|
||||
@@ -0,0 +1,455 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// --- shared harness -------------------------------------------------------------------------------
|
||||
|
||||
// sharesEnv is a Manager wired for the R-7b shares source: a real settings store, real temp drives,
|
||||
// and FAKE copy/exec seams so nothing shells out to rsync, du, docker or restic.
|
||||
type sharesEnv struct {
|
||||
m *Manager
|
||||
// mirrored records every tier2Mirror call as "src=>dst" in call order.
|
||||
mirrored []string
|
||||
// drives maps a label to the temp dir standing in for that registered drive.
|
||||
drives map[string]string
|
||||
sett *settings.Settings
|
||||
tmp string
|
||||
}
|
||||
|
||||
// newSharesEnv registers the named drives, turns sharing on, and installs the fake seams. Shares are
|
||||
// added afterwards with addShare so each test states exactly the registry it needs.
|
||||
func newSharesEnv(t *testing.T, driveNames ...string) *sharesEnv {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &sharesEnv{drives: map[string]string{}, sett: sett, tmp: tmp}
|
||||
for _, name := range driveNames {
|
||||
p := filepath.Join(tmp, name)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: name, Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.drives[name] = p
|
||||
}
|
||||
if err := sett.SetSMBEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = filepath.Join(tmp, "data")
|
||||
sysDrive := filepath.Join(tmp, "sysdrive")
|
||||
if err := os.MkdirAll(sysDrive, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Paths.SystemDataPath = sysDrive
|
||||
|
||||
env.m = &Manager{
|
||||
cfg: cfg, settings: sett, logger: log.New(io.Discard, "", 0), systemDataPath: sysDrive,
|
||||
// Fake mirror: records the call AND actually copies, so restore tests have real bytes.
|
||||
tier2Mirror: func(src, dst string) error {
|
||||
env.mirrored = append(env.mirrored, src+"=>"+dst)
|
||||
return copyTreeForTest(src, dst)
|
||||
},
|
||||
sharesPassdbCapture: func() ([]byte, error) { return []byte("FAKE-PASSDB-TAR"), nil },
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// addShare registers a share, creating its folder with one file so it is "available".
|
||||
func (e *sharesEnv) addShare(t *testing.T, drive, name string, offsite bool) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(e.drives[drive], name)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(p, name+".txt"), []byte("content-of-"+name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.sett.AddSMBShare(settings.SMBShare{Name: name, Path: p, Offsite: offsite, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// copyTreeForTest is a minimal recursive copy standing in for rsync in the mirror seam.
|
||||
func copyTreeForTest(src, dst string) error {
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
b, rErr := os.ReadFile(src)
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
if mErr := os.MkdirAll(filepath.Dir(dst), 0o755); mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
return os.WriteFile(dst, b, 0o644)
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := copyTreeForTest(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mirroredDsts returns just the destination side of every recorded mirror call.
|
||||
func (e *sharesEnv) mirroredDsts() []string {
|
||||
var out []string
|
||||
for _, s := range e.mirrored {
|
||||
out = append(out, s[strings.Index(s, "=>")+2:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Part 1: payload ------------------------------------------------------------------------------
|
||||
|
||||
// The manifest must carry the share DEFINITIONS verbatim — that is the whole point of the payload:
|
||||
// after a DR rebuild the customer gets their files AND their „Megosztás" page back, not an empty one.
|
||||
func TestSharesPayloadManifestMatchesRegistry(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
env.addShare(t, "hdd_1", "filmek", false)
|
||||
|
||||
dir, passdbOK, err := env.m.buildSharesPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("buildSharesPayload: %v", err)
|
||||
}
|
||||
if !passdbOK {
|
||||
t.Fatal("expected the (faked) passdb capture to succeed")
|
||||
}
|
||||
blob, err := os.ReadFile(filepath.Join(dir, sharesManifestName))
|
||||
if err != nil {
|
||||
t.Fatalf("read manifest: %v", err)
|
||||
}
|
||||
var mf SharesManifest
|
||||
if err := json.Unmarshal(blob, &mf); err != nil {
|
||||
t.Fatalf("manifest is not valid JSON: %v", err)
|
||||
}
|
||||
if mf.Version != sharesManifestVersion {
|
||||
t.Errorf("manifest version = %d, want %d", mf.Version, sharesManifestVersion)
|
||||
}
|
||||
if mf.ServerName != settings.DefaultSMBServerName {
|
||||
t.Errorf("server name = %q, want %q", mf.ServerName, settings.DefaultSMBServerName)
|
||||
}
|
||||
want := env.sett.GetSMBShares()
|
||||
sort.Slice(want, func(i, j int) bool { return want[i].Name < want[j].Name })
|
||||
if len(mf.Shares) != len(want) {
|
||||
t.Fatalf("manifest has %d shares, registry has %d", len(mf.Shares), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if mf.Shares[i] != want[i] {
|
||||
t.Errorf("share %d: manifest %+v != registry %+v", i, mf.Shares[i], want[i])
|
||||
}
|
||||
}
|
||||
// The credential copy must be present and 0600 — it is secret-bearing.
|
||||
st, err := os.Stat(filepath.Join(dir, sharesPassdbName))
|
||||
if err != nil {
|
||||
t.Fatalf("passdb archive missing: %v", err)
|
||||
}
|
||||
if runtimeIsPOSIX() && st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("passdb archive mode = %o, want 600 (secret-bearing)", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// Determinism: an unchanged registry must marshal to IDENTICAL bytes regardless of the order shares
|
||||
// were added in, so a no-op nightly run gives the tier-2 mirror nothing to rewrite.
|
||||
func TestSharesPayloadManifestIsDeterministic(t *testing.T) {
|
||||
// Built from the registry only (no filesystem), so the two runs differ ONLY in insertion order —
|
||||
// otherwise the temp-dir paths baked into each share would make any comparison meaningless.
|
||||
read := func(order []string) []byte {
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range order {
|
||||
if err := sett.AddSMBShare(settings.SMBShare{
|
||||
Name: n, Path: "/mnt/hdd_1/" + n, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &Manager{settings: sett, logger: log.New(io.Discard, "", 0)}
|
||||
b, err := json.MarshalIndent(m.buildSharesManifest(), "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
a := read([]string{"alpha", "zulu", "mike"})
|
||||
b := read([]string{"zulu", "mike", "alpha"})
|
||||
if string(a) != string(b) {
|
||||
t.Errorf("manifest is order-dependent:\n%s\n---\n%s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: samba container down. The payload must still be produced (manifest-only) and must NOT
|
||||
// error — re-setting the SMB password is cheap; losing the definitions is not.
|
||||
func TestSharesPayloadPassdbAbsentIsManifestOnly(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
env.m.sharesPassdbCapture = func() ([]byte, error) { return nil, os.ErrNotExist }
|
||||
|
||||
dir, passdbOK, err := env.m.buildSharesPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("a down container must not fail the payload: %v", err)
|
||||
}
|
||||
if passdbOK {
|
||||
t.Error("passdbOK must be false when the capture failed and no prior copy exists")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, sharesManifestName)); err != nil {
|
||||
t.Errorf("manifest must still be written: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, sharesPassdbName)); !os.IsNotExist(err) {
|
||||
t.Error("no passdb archive should exist when the capture failed and none was staged before")
|
||||
}
|
||||
}
|
||||
|
||||
// A previously captured credential copy must SURVIVE a later failed capture — a stale credential is
|
||||
// strictly better for DR than none, and the manifest carries no password of its own to contradict it.
|
||||
func TestSharesPayloadKeepsPriorPassdbOnCaptureFailure(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
dir, _, err := env.m.buildSharesPayload()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.m.sharesPassdbCapture = func() ([]byte, error) { return nil, os.ErrNotExist }
|
||||
_, passdbOK, err := env.m.buildSharesPayload()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !passdbOK {
|
||||
t.Error("a previously staged passdb copy must keep passdbOK true")
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, sharesPassdbName))
|
||||
if err != nil || string(b) != "FAKE-PASSDB-TAR" {
|
||||
t.Errorf("prior passdb copy was lost: %q, %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reserved key must never reach a Hungarian surface raw.
|
||||
func TestDisplayStackNameMapsReservedKey(t *testing.T) {
|
||||
if got := DisplayStackName(SharesPseudoStack); got != SharesDisplayName {
|
||||
t.Errorf("DisplayStackName(%q) = %q, want %q", SharesPseudoStack, got, SharesDisplayName)
|
||||
}
|
||||
if got := DisplayStackName("immich"); got != "immich" {
|
||||
t.Errorf("DisplayStackName must be identity for ordinary stacks, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Part 2: tier-2 shares job --------------------------------------------------------------------
|
||||
|
||||
// Scenario B: an OPTIONAL (Felhőmentés-off) share is tier-2'd like any other — the tier-2 tier is the
|
||||
// local copy every share gets; only the offsite tier discriminates.
|
||||
func TestSharesTier2MirrorsBothClasses(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
env.addShare(t, "hdd_1", "filmek", false)
|
||||
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatalf("RunSharesTier2: %v", err)
|
||||
}
|
||||
key := sharesDriveKey(env.drives["hdd_1"])
|
||||
destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
|
||||
|
||||
for _, share := range []string{"dokumentumok", "filmek"} {
|
||||
want := filepath.Join(destBase, key, share)
|
||||
if _, err := os.Stat(filepath.Join(want, share+".txt")); err != nil {
|
||||
t.Errorf("share %s was not mirrored to %s: %v", share, want, err)
|
||||
}
|
||||
}
|
||||
// The payload rides the target so it is independently restorable.
|
||||
if _, err := os.Stat(filepath.Join(destBase, sharesPayloadDestRel, sharesManifestName)); err != nil {
|
||||
t.Errorf("payload manifest missing from the tier-2 target: %v", err)
|
||||
}
|
||||
// Marker LAST: its presence means every leg + reconcile completed.
|
||||
b, err := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker))
|
||||
if err != nil || string(b) != tier2LayoutVersion {
|
||||
t.Errorf("layout marker missing/wrong: %q, %v", b, err)
|
||||
}
|
||||
// Status is recorded under the reserved key, and renders as „Megosztások".
|
||||
cd := env.m.SharesTier2Status()
|
||||
if cd == nil || cd.LastStatus != "ok" {
|
||||
t.Fatalf("shares tier-2 status = %+v, want LastStatus=ok", cd)
|
||||
}
|
||||
}
|
||||
|
||||
// The target may NEVER be the leg's own source drive — that would be a same-disk copy pretending to
|
||||
// be a second backup. With shares on two drives each group must land on the other one.
|
||||
func TestSharesTier2NeverTargetsItsOwnSourceDrive(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
env.addShare(t, "hdd_1", "egy", true)
|
||||
env.addShare(t, "hdd_2", "ketto", true)
|
||||
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatalf("RunSharesTier2: %v", err)
|
||||
}
|
||||
for _, call := range env.mirrored {
|
||||
src, dst, _ := strings.Cut(call, "=>")
|
||||
if strings.HasPrefix(src, env.drives["hdd_1"]) && strings.HasPrefix(dst, env.drives["hdd_1"]) {
|
||||
t.Errorf("leg mirrored onto its own source drive: %s", call)
|
||||
}
|
||||
if strings.HasPrefix(src, env.drives["hdd_2"]) && strings.HasPrefix(dst, env.drives["hdd_2"]) {
|
||||
t.Errorf("leg mirrored onto its own source drive: %s", call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario F: a share whose drive is marked away is skipped LOUDLY and its path reaches NO mirror
|
||||
// argument — mirroring a dead mountpoint would copy an empty dir over a good backup.
|
||||
func TestSharesTier2SkipsDeadMountAndContinues(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
|
||||
env.addShare(t, "hdd_1", "elo", true)
|
||||
deadPath := env.addShare(t, "hdd_3", "halott", true)
|
||||
if err := env.sett.SetDisconnected(env.drives["hdd_3"], true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatalf("the run must continue for healthy shares: %v", err)
|
||||
}
|
||||
for _, call := range env.mirrored {
|
||||
if strings.Contains(call, deadPath) {
|
||||
t.Errorf("dead-mount share reached a mirror call: %s", call)
|
||||
}
|
||||
}
|
||||
var sawLive bool
|
||||
for _, dst := range env.mirroredDsts() {
|
||||
if strings.HasSuffix(dst, string(filepath.Separator)+"elo") {
|
||||
sawLive = true
|
||||
}
|
||||
}
|
||||
if !sawLive {
|
||||
t.Error("the healthy share must still be mirrored")
|
||||
}
|
||||
}
|
||||
|
||||
// Zero shares / sharing disabled: a clean no-op — no dest tree, no status record, no empty dirs.
|
||||
func TestSharesTier2ZeroSharesIsCleanNoOp(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatalf("RunSharesTier2: %v", err)
|
||||
}
|
||||
if len(env.mirrored) != 0 {
|
||||
t.Errorf("expected no mirror calls, got %v", env.mirrored)
|
||||
}
|
||||
if cd := env.m.SharesTier2Status(); cd != nil {
|
||||
t.Errorf("expected no status record for a feature nobody uses, got %+v", cd)
|
||||
}
|
||||
destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
|
||||
if _, err := os.Stat(destBase); !os.IsNotExist(err) {
|
||||
t.Errorf("expected no _shares dest tree, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A share removed from the registry must stop occupying the secondary drive within ONE run — the
|
||||
// reconcile pass prunes it, reusing the same destBase-bounded removal guard the app path uses.
|
||||
func TestSharesTier2ReconcilePrunesRemovedShare(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
env.addShare(t, "hdd_1", "marad", true)
|
||||
env.addShare(t, "hdd_1", "torolt", true)
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := sharesDriveKey(env.drives["hdd_1"])
|
||||
destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
|
||||
stale := filepath.Join(destBase, key, "torolt")
|
||||
if _, err := os.Stat(stale); err != nil {
|
||||
t.Fatalf("precondition: %s should exist after the first run: %v", stale, err)
|
||||
}
|
||||
|
||||
if err := env.sett.RemoveSMBShare("torolt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Errorf("removed share's dest dir was not pruned: stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destBase, key, "marad")); err != nil {
|
||||
t.Errorf("surviving share was pruned by mistake: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The tier-2 half of the B′ isolation proof: the shares job must write ONLY under
|
||||
// backups/secondary/_shares. A per-app dest tree standing beside it must come out byte-for-byte
|
||||
// untouched — same contents, same bytes — and no mirror call may target it. Red-proof: point the
|
||||
// shares destBase at backups/secondary/<share> (dropping the _shares segment) and this fails.
|
||||
func TestSharesTier2LeavesPerAppTreeUntouched(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
|
||||
// A pre-existing per-app tier-2 dest with a sentinel payload.
|
||||
appDest := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", "immich")
|
||||
if err := os.MkdirAll(filepath.Join(appDest, "recovery-unit"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentinel := filepath.Join(appDest, "recovery-unit", "manifest.json")
|
||||
if err := os.WriteFile(sentinel, []byte(`{"app":"immich"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(sentinel)
|
||||
if err != nil || string(b) != `{"app":"immich"}` {
|
||||
t.Errorf("B′ INVARIANT VIOLATED — the per-app tier-2 tree was modified: %q, %v", b, err)
|
||||
}
|
||||
for _, call := range env.mirrored {
|
||||
if strings.Contains(call, appDest) {
|
||||
t.Errorf("B′ INVARIANT VIOLATED — a shares mirror targeted the per-app dest: %s", call)
|
||||
}
|
||||
}
|
||||
// And everything it DID write lives under the reserved subtree.
|
||||
sharesRoot := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
|
||||
for _, dst := range env.mirroredDsts() {
|
||||
if !strings.HasPrefix(dst, sharesRoot+string(filepath.Separator)) {
|
||||
t.Errorf("shares job wrote outside its reserved subtree: %s", dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drive keys must be collision-free across drives that share a basename.
|
||||
func TestSharesDriveKeyIsCollisionFree(t *testing.T) {
|
||||
if sharesDriveKey("/mnt/a/data") == sharesDriveKey("/mnt/b/data") {
|
||||
t.Error("drives with the same basename must not map to the same dest key")
|
||||
}
|
||||
for _, bad := range []string{"/", "\x00", " "} {
|
||||
if k := sharesDriveKey(bad); strings.ContainsAny(k, `/\`) {
|
||||
t.Errorf("sharesDriveKey(%q) = %q — must be a single safe path segment", bad, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeIsPOSIX reports whether file-mode assertions are meaningful on this host (Windows reports
|
||||
// synthesised permissions, so mode checks there are noise rather than signal).
|
||||
func runtimeIsPOSIX() bool { return os.PathSeparator == '/' }
|
||||
@@ -86,6 +86,17 @@ func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool {
|
||||
// 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)
|
||||
}
|
||||
return m.selectTier2TargetFrom(stackName, sourceDrive, fullSize, stateOnlySize)
|
||||
}
|
||||
|
||||
// selectTier2TargetFrom is selectTier2Target with the source drive supplied EXPLICITLY. It exists so
|
||||
// the R-7b shares source — whose "source drive" is the drive a group of shares lives on, not an app's
|
||||
// GetAppDrivePath — can reuse this selection and its headroom math verbatim instead of forking it.
|
||||
// The app path above is a thin wrapper; nothing about its behaviour changed.
|
||||
func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize, stateOnlySize int64) (*Tier2Target, error) {
|
||||
if sourceDrive == "" {
|
||||
return nil, fmt.Errorf("no source drive for %s", stackName)
|
||||
}
|
||||
@@ -232,6 +243,13 @@ func classifyTier2Rel(dirRel string, legRels []string) tier2RelClass {
|
||||
// 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) {
|
||||
m.tier2ReconcileRoots(destBase, []string{"hdd", "userdata"}, legRels)
|
||||
}
|
||||
|
||||
// tier2ReconcileRoots is tier2Reconcile with the top-level dest roots supplied explicitly — a pure
|
||||
// extraction so the R-7b shares dest (whose roots are per-source-drive keys, not hdd/userdata) can
|
||||
// reuse the SAME staleness classification and the SAME destBase-bounded removal guard.
|
||||
func (m *Manager) tier2ReconcileRoots(destBase string, roots, legRels []string) {
|
||||
var walk func(dirAbs, dirRel string)
|
||||
walk = func(dirAbs, dirRel string) {
|
||||
entries, err := os.ReadDir(dirAbs)
|
||||
@@ -258,7 +276,7 @@ func (m *Manager) tier2Reconcile(destBase string, legRels []string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, root := range []string{"hdd", "userdata"} {
|
||||
for _, root := range roots {
|
||||
walk(filepath.Join(destBase, root), root)
|
||||
}
|
||||
}
|
||||
@@ -397,6 +415,14 @@ func (m *Manager) RunAllTier2() {
|
||||
}
|
||||
var n int
|
||||
for _, stack := range m.stackProvider.ListDeployedStacks() {
|
||||
// Reserved-name defense in depth (R-7b): the shares source owns backups/secondary/_shares and
|
||||
// the _shares status record. Stack names come from the git-synced catalog, not customer input,
|
||||
// so this cannot realistically fire — but if it ever did, the app would silently overwrite the
|
||||
// shares tree, so it is refused loudly instead.
|
||||
if stack.Name == SharesPseudoStack {
|
||||
m.logger.Printf("[ERROR] [backup] Tier 2: stack %q uses the RESERVED shares key — skipped to protect the shares backup tree", stack.Name)
|
||||
continue
|
||||
}
|
||||
// F6 (CAMPAIGN-3): volume-only apps (no HDD_PATH, backups on sys_drive) previously got NO
|
||||
// tier-2 copy — a single controller-level copy on one device. They now flow through too: their
|
||||
// recovery unit (which holds the db/volume dumps) gets a cross-drive second copy like any HDD
|
||||
@@ -416,6 +442,12 @@ func (m *Manager) RunAllTier2() {
|
||||
n++
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d app(s) processed (incl. volume-only — F6)", n)
|
||||
|
||||
// R-7b: the SHARES source runs after the per-app loop, in the SAME orchestrator run. It is a
|
||||
// sibling job — nothing above it changed — and its failure never fails the app tier.
|
||||
if err := m.RunSharesTier2(); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 shares job failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- per-app config-panel view (drives the Tier-2 "Beállítás" page) ---
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
// Tier-2 shares job — R-7b Part 2, the local cross-drive leg of Model B′.
|
||||
//
|
||||
// It runs AFTER the per-stack tier-2 loop, inside the SAME orchestrator run, and it is a SIBLING of
|
||||
// that loop, never a modification of it: RunTier2 and its per-app dest tree are untouched (the B′
|
||||
// invariant, enforced by TestSharesTier2LeavesPerAppTreeUntouched). What it reuses instead is every
|
||||
// primitive the per-app path proved: the tier2Mirror seam, selectTier2TargetFrom's target choice and
|
||||
// headroom math, tier2ReconcileRoots' staleness pruning, tier2SafeRemove's destBase-bounded removal,
|
||||
// the marker-written-LAST discipline, and the recordTier2* status helpers.
|
||||
//
|
||||
// Layout — backups/secondary/_shares/ on the target drive:
|
||||
//
|
||||
// .felhom-tier2-layout marker, content "2", written LAST (after every leg + reconcile)
|
||||
// _payload/ the share definitions + credential copy (shares_payload.go)
|
||||
// <sourceDriveKey>/<share>/ one mirrored leg per share, grouped by the drive it came from
|
||||
//
|
||||
// Shares are grouped BY SOURCE DRIVE because a household's shares can span several drives and each
|
||||
// group needs its own cross-drive target: a leg's target may never be that leg's own source drive.
|
||||
|
||||
// sharesPayloadDestRel is the payload's relpath inside the shares dest (a reserved root name; the
|
||||
// leading underscore cannot collide with a drive key because drive keys are derived from paths).
|
||||
const sharesPayloadDestRel = "_payload"
|
||||
|
||||
// sharesDriveKey turns an absolute source-drive path into ONE safe dest path segment. The full path
|
||||
// is encoded (not just its basename) so two drives whose mountpoints share a basename — /mnt/a/data
|
||||
// and /mnt/b/data — can never map onto the same dest subtree and silently overwrite each other.
|
||||
// Deterministic and stable across runs, which is what lets the reconcile pass recognise its own dirs.
|
||||
func sharesDriveKey(drive string) string {
|
||||
s := strings.Trim(filepath.ToSlash(drive), "/")
|
||||
if s == "" {
|
||||
return "root"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// shareSourceDrive resolves the registered storage root a share's folder lives under. Returns "" when
|
||||
// the share sits under no registered root — such a share has no meaningful "other drive" and is
|
||||
// skipped with a warning rather than guessed at.
|
||||
func (m *Manager) shareSourceDrive(path string) string {
|
||||
if m.settings == nil {
|
||||
return ""
|
||||
}
|
||||
p := filepath.ToSlash(path)
|
||||
best := ""
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
root := filepath.ToSlash(sp.Path)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
// Longest match wins, so a nested registered root claims its own shares. Containment is
|
||||
// compared in slash form, but the returned value is the REGISTRY'S OWN string: the target
|
||||
// selector compares the source drive against sp.Path by equality, and handing it a normalised
|
||||
// variant would make that comparison miss and let a group target its own source drive.
|
||||
if (p == root || strings.HasPrefix(p, root+"/")) && len(root) > len(best) {
|
||||
best = sp.Path
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// sharesTier2Group is one source drive's worth of shares plus the target chosen for it.
|
||||
type sharesTier2Group struct {
|
||||
sourceDrive string
|
||||
key string
|
||||
shares []classifiedShare
|
||||
}
|
||||
|
||||
// RunSharesTier2 mirrors every classified, available share to a cross-drive target and stages the
|
||||
// payload beside it. Best-effort and idempotent, exactly like RunTier2: an absent target is an
|
||||
// honest recorded status, not an error. Returns the first hard copy error.
|
||||
func (m *Manager) RunSharesTier2() error {
|
||||
if !m.sharesEnabled() {
|
||||
// Feature off or no shares registered: a clean no-op. Deliberately NOT a recorded status —
|
||||
// writing one would make the „Megosztás" page claim a backup tier for a feature in use by
|
||||
// nobody, and would create the dest tree for zero shares.
|
||||
return nil
|
||||
}
|
||||
if m.settings != nil {
|
||||
if cd := m.settings.GetCrossDriveConfig(SharesPseudoStack); cd != nil && cd.UserDisabled {
|
||||
m.logger.Printf("[INFO] [shares] tier-2 skipped — disabled by customer")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
shares := m.classifiedShares()
|
||||
if len(shares) == 0 {
|
||||
// Every registered share is on a drive that is away / a folder that vanished. That is a real
|
||||
// operational state the customer must see, not silence.
|
||||
m.recordTier2NoTarget(SharesPseudoStack, "egyetlen megosztás mappája sem érhető el — ellenőrizd a meghajtókat")
|
||||
m.logger.Printf("[WARN] [shares] tier-2: no available share folders — nothing mirrored")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Group by source drive (deterministic order so logs and dest trees are reproducible).
|
||||
byDrive := map[string]*sharesTier2Group{}
|
||||
var warns []string
|
||||
for _, sh := range shares {
|
||||
drive := m.shareSourceDrive(sh.Path)
|
||||
if drive == "" {
|
||||
m.logger.Printf("[WARN] [shares] tier-2: share %s is not under a registered storage root — skipped", sh.Name)
|
||||
warns = append(warns, fmt.Sprintf("A(z) „%s” megosztás nem regisztrált adatmeghajtón van — kimaradt a 2. mentésből.", sh.Name))
|
||||
continue
|
||||
}
|
||||
g := byDrive[drive]
|
||||
if g == nil {
|
||||
g = &sharesTier2Group{sourceDrive: drive, key: sharesDriveKey(drive)}
|
||||
byDrive[drive] = g
|
||||
}
|
||||
g.shares = append(g.shares, sh)
|
||||
}
|
||||
var groups []*sharesTier2Group
|
||||
for _, g := range byDrive {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i].sourceDrive < groups[j].sourceDrive })
|
||||
if len(groups) == 0 {
|
||||
m.recordTier2NoTarget(SharesPseudoStack, "a megosztások egyike sincs regisztrált adatmeghajtón")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Payload once per run — both tiers read the same staged directory.
|
||||
payloadDir, _, perr := m.buildSharesPayload()
|
||||
if perr != nil {
|
||||
m.logger.Printf("[WARN] [shares] tier-2: payload staging failed — mirroring files without the definition manifest: %v", perr)
|
||||
warns = append(warns, "A megosztás-beállítások mentése nem sikerült — a fájlok mentése megtörtént.")
|
||||
payloadDir = ""
|
||||
}
|
||||
|
||||
mirror := m.tier2Mirror
|
||||
if mirror == nil {
|
||||
mirror = rsyncMirror
|
||||
}
|
||||
start := time.Now()
|
||||
var (
|
||||
totalSize int64
|
||||
lastTarget *Tier2Target
|
||||
noTargetWhy []string
|
||||
mirrored int
|
||||
)
|
||||
for _, g := range groups {
|
||||
// Two sizes, mirroring RunTier2's contract: full = every share in the group; state-only = the
|
||||
// MANDATORY (Felhőmentés-on) subset, which is what the SSD headroom guard must fit.
|
||||
var fullSize, stateOnlySize int64
|
||||
for _, sh := range g.shares {
|
||||
sz := dirSizeBytes(sh.Path)
|
||||
fullSize += sz
|
||||
if sh.mandatory {
|
||||
stateOnlySize += sz
|
||||
}
|
||||
}
|
||||
target, err := m.selectTier2TargetFrom(SharesPseudoStack, g.sourceDrive, fullSize, stateOnlySize)
|
||||
if err != nil {
|
||||
why := tier2NoTargetReason(err)
|
||||
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: %s", g.sourceDrive, why))
|
||||
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for shares on %s — %s", g.sourceDrive, why)
|
||||
continue
|
||||
}
|
||||
// Defense-in-depth off-drive guard (selection already enforced it): a leg's target may never
|
||||
// be that leg's own source drive — that would be a same-disk "copy" pretending to be tier 2.
|
||||
if system.SamePhysicalDevice(g.sourceDrive, target.NamespaceRoot) {
|
||||
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: a kiválasztott cél ugyanazon a fizikai lemezen van", g.sourceDrive))
|
||||
continue
|
||||
}
|
||||
legs := g.shares
|
||||
if target.StateOnly {
|
||||
kept := legs[:0]
|
||||
dropped := false
|
||||
for _, sh := range legs {
|
||||
if sh.mandatory {
|
||||
kept = append(kept, sh)
|
||||
} else {
|
||||
dropped = true
|
||||
}
|
||||
}
|
||||
legs = kept
|
||||
if dropped {
|
||||
warns = append(warns, "A belső SSD-re csak a felhőmentésre jelölt megosztások férnek el — a többi nem került másolásra.")
|
||||
}
|
||||
}
|
||||
|
||||
destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", SharesPseudoStack)
|
||||
legRels := make([]string, 0, len(legs)+1)
|
||||
for _, sh := range legs {
|
||||
rel := g.key + "/" + sh.Name
|
||||
if err := mirror(sh.Path, filepath.Join(destBase, filepath.FromSlash(rel))); err != nil {
|
||||
m.recordTier2Failure(SharesPseudoStack, target, err)
|
||||
if m.tier2Notify != nil {
|
||||
m.tier2Notify(SharesPseudoStack, target.Label, time.Since(start), err)
|
||||
}
|
||||
return fmt.Errorf("tier2 shares mirror %s: %w", sh.Name, err)
|
||||
}
|
||||
legRels = append(legRels, rel)
|
||||
totalSize += dirSizeBytes(sh.Path)
|
||||
mirrored++
|
||||
}
|
||||
// The payload rides every target so each one is independently restorable.
|
||||
if payloadDir != "" {
|
||||
if err := mirror(payloadDir, filepath.Join(destBase, sharesPayloadDestRel)); err != nil {
|
||||
m.logger.Printf("[WARN] [shares] tier-2: payload mirror to %s failed (files are copied): %v", destBase, err)
|
||||
} else {
|
||||
legRels = append(legRels, sharesPayloadDestRel)
|
||||
}
|
||||
}
|
||||
// Prune dest dirs no share covers any more (a share deleted or renamed since the last run
|
||||
// stops occupying the secondary drive within one run), then the marker LAST — a half-written
|
||||
// dest therefore has no marker and the restore path refuses it until the next good run.
|
||||
m.tier2ReconcileRoots(destBase, sharesTier2DestRoots(destBase), legRels)
|
||||
if err := m.writeTier2Marker(destBase); err != nil {
|
||||
m.logger.Printf("[WARN] [shares] tier-2: layout marker write failed (restore will refuse until next run): %v", err)
|
||||
}
|
||||
lastTarget = target
|
||||
m.logger.Printf("[INFO] [shares] tier-2 copied %d share(s) from %s → %s (%s)",
|
||||
len(legs), g.sourceDrive, destBase, humanizeBytes(totalSize))
|
||||
}
|
||||
|
||||
dur := time.Since(start)
|
||||
if lastTarget == nil {
|
||||
reason := strings.Join(noTargetWhy, "; ")
|
||||
if reason == "" {
|
||||
reason = "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges"
|
||||
}
|
||||
m.recordTier2NoTarget(SharesPseudoStack, reason)
|
||||
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for any share group — %s", reason)
|
||||
return nil
|
||||
}
|
||||
if len(noTargetWhy) > 0 {
|
||||
warns = append(warns, "Néhány meghajtón lévő megosztásnak nincs másodlagos célja: "+strings.Join(noTargetWhy, "; "))
|
||||
}
|
||||
m.recordTier2Success(SharesPseudoStack, lastTarget, totalSize, strings.Join(warns, " "), dur)
|
||||
if m.tier2Notify != nil {
|
||||
m.tier2Notify(SharesPseudoStack, lastTarget.Label, dur, nil)
|
||||
}
|
||||
m.logger.Printf("[INFO] [shares] tier-2 run complete: %d share leg(s), %s, %s",
|
||||
mirrored, humanizeBytes(totalSize), dur.Round(time.Second))
|
||||
return nil
|
||||
}
|
||||
|
||||
// sharesTier2DestRoots lists the top-level dirs under a shares destBase that the reconcile pass may
|
||||
// walk. It reads what is ON DISK rather than what this run produced, so a drive key from a drive that
|
||||
// is no longer registered still gets visited (and pruned) instead of lingering forever.
|
||||
func sharesTier2DestRoots(destBase string) []string {
|
||||
entries, err := os.ReadDir(destBase)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var roots []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
roots = append(roots, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
// writeTier2Marker writes the shared layout marker (content "2") — the LAST write of a dest, so its
|
||||
// presence means "every leg and the reconcile completed".
|
||||
func (m *Manager) writeTier2Marker(destBase string) error {
|
||||
return os.WriteFile(filepath.Join(destBase, tier2LayoutMarker), []byte(tier2LayoutVersion), 0o644)
|
||||
}
|
||||
|
||||
// SharesTier2Status returns the recorded shares tier-2 status for the „Megosztás" page (nil when the
|
||||
// job has never run). It is the SAME CrossDriveBackup record the per-app rows use, keyed by the
|
||||
// reserved pseudo-stack.
|
||||
func (m *Manager) SharesTier2Status() *settings.CrossDriveBackup {
|
||||
if m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
return m.settings.GetCrossDriveConfig(SharesPseudoStack)
|
||||
}
|
||||
@@ -30,6 +30,21 @@ type SambaData struct {
|
||||
// SambaHouseholdUser is the single household SMB account name (matches the entrypoint's unix user).
|
||||
const SambaHouseholdUser = "felhom"
|
||||
|
||||
// SambaContainerName is the fixed container name the compose render pins. It is THE single source of
|
||||
// truth for it: the renderer below interpolates this constant, stacks.sambaContainer aliases it for
|
||||
// the exec paths, monitor's effective-protected set watches it (R-7b liveness), and the backup
|
||||
// package execs it for the passdb capture/restore. Anything that needs "which container is samba"
|
||||
// reads this — never a second string literal.
|
||||
const SambaContainerName = "felhom-samba"
|
||||
|
||||
// SambaPassdbVolume / SambaPassdbMount name the docker volume that holds the household SMB credential
|
||||
// (the passdb tdb set) and where the container mounts it. R-7b's payload capture/restore targets this
|
||||
// mount point, so it is pinned here beside the render that creates it rather than duplicated there.
|
||||
const (
|
||||
SambaPassdbVolume = "samba-passdb"
|
||||
SambaPassdbMount = "/var/lib/samba"
|
||||
)
|
||||
|
||||
// RenderSambaConfig renders smb.conf: the hardened global block (bind interfaces only = lo eth0,
|
||||
// SMB2+ floor, NetBIOS on for flat-name resolution) plus one [section] per share. Deterministic:
|
||||
// shares are emitted in the given order (the caller preserves registry order). force user/group pin
|
||||
@@ -95,21 +110,21 @@ func RenderSambaCompose(d SambaData) string {
|
||||
# receive the LAN multicast that WSD/mDNS discovery needs.
|
||||
|
||||
services:
|
||||
felhom-samba:
|
||||
image: %s
|
||||
container_name: felhom-samba
|
||||
%[1]s:
|
||||
image: %[2]s
|
||||
container_name: %[1]s
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
- FELHOM_SERVER_NAME=%s
|
||||
- FELHOM_SERVER_NAME=%[3]s
|
||||
- FELHOM_IFACE=eth0
|
||||
- FELHOM_UID=%d
|
||||
- FELHOM_GID=%d
|
||||
- FELHOM_UID=%[4]d
|
||||
- FELHOM_GID=%[4]d
|
||||
volumes:
|
||||
- ./smb.conf:/etc/samba/smb.conf:ro
|
||||
- samba-passdb:/var/lib/samba
|
||||
%s
|
||||
- %[5]s:%[6]s
|
||||
%[7]s
|
||||
volumes:
|
||||
samba-passdb:
|
||||
`, SambaImage, d.ServerName, d.UID, d.UID, binds.String())
|
||||
%[5]s:
|
||||
`, SambaContainerName, SambaImage, d.ServerName, d.UID, SambaPassdbVolume, SambaPassdbMount, binds.String())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
@@ -21,7 +23,7 @@ func TestEffectiveProtectedDropsCloudflaredWithoutToken(t *testing.T) {
|
||||
base := config.StacksConfig{Protected: []string{"traefik", "cloudflared", "felhom-controller", "filebrowser"}}
|
||||
|
||||
cfgNoTok := &config.Config{Stacks: base}
|
||||
got := EffectiveProtected(cfgNoTok)
|
||||
got := EffectiveProtected(cfgNoTok, settings.SMBSettings{})
|
||||
if contains(got, "cloudflared") {
|
||||
t.Errorf("cloudflared must be dropped when no tunnel token: %v", got)
|
||||
}
|
||||
@@ -33,7 +35,33 @@ func TestEffectiveProtectedDropsCloudflaredWithoutToken(t *testing.T) {
|
||||
|
||||
cfgTok := &config.Config{Stacks: base}
|
||||
cfgTok.Infrastructure.CFTunnelToken = "tok"
|
||||
if !contains(EffectiveProtected(cfgTok), "cloudflared") {
|
||||
if !contains(EffectiveProtected(cfgTok, settings.SMBSettings{}), "cloudflared") {
|
||||
t.Error("cloudflared must remain protected when a tunnel token is configured")
|
||||
}
|
||||
}
|
||||
|
||||
// R-7b Scenario E, BOTH directions. Sharing is a customer-toggled feature, so the samba container can
|
||||
// never be in the golden controller.yaml — the effective set must add it dynamically when sharing is
|
||||
// ON (so a dead sharing service raises the same protected-container issue as a dead traefik) and must
|
||||
// leave it out when sharing is OFF (so a box that never enabled it never reports a missing container).
|
||||
// Red-proof: delete the `if smb.Enabled` append and the enabled case fails.
|
||||
func TestEffectiveProtectedTracksSharingToggle(t *testing.T) {
|
||||
cfg := &config.Config{Stacks: config.StacksConfig{Protected: []string{"traefik", "felhom-controller"}}}
|
||||
|
||||
on := EffectiveProtected(cfg, settings.SMBSettings{Enabled: true})
|
||||
if !contains(on, infra.SambaContainerName) {
|
||||
t.Errorf("sharing ON: %q must be watched, got %v", infra.SambaContainerName, on)
|
||||
}
|
||||
off := EffectiveProtected(cfg, settings.SMBSettings{Enabled: false})
|
||||
if contains(off, infra.SambaContainerName) {
|
||||
t.Errorf("sharing OFF: %q must NOT be watched, got %v", infra.SambaContainerName, off)
|
||||
}
|
||||
// The base set is untouched in both directions.
|
||||
for _, set := range [][]string{on, off} {
|
||||
for _, must := range []string{"traefik", "felhom-controller"} {
|
||||
if !contains(set, must) {
|
||||
t.Errorf("%s must remain protected: %v", must, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
@@ -23,7 +24,7 @@ type HealthReport struct {
|
||||
}
|
||||
|
||||
// RunHealthCheck runs system checks and returns a diagnostic report.
|
||||
func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, storagePaths []settings.StoragePath, logger *log.Logger) *HealthReport {
|
||||
func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, storagePaths []settings.StoragePath, smb settings.SMBSettings, logger *log.Logger) *HealthReport {
|
||||
report := &HealthReport{
|
||||
Status: "ok",
|
||||
Timestamp: time.Now(),
|
||||
@@ -159,7 +160,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
|
||||
|
||||
// 6. Protected containers (effective set: cloudflared only counts when a tunnel token is
|
||||
// configured, so a LAN-only node doesn't report FAIL forever for a stack it intentionally skips).
|
||||
protected := EffectiveProtected(cfg)
|
||||
protected := EffectiveProtected(cfg, smb)
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Checking %d protected containers: %v", len(protected), protected)
|
||||
}
|
||||
@@ -245,18 +246,35 @@ func checkDocker() error {
|
||||
}
|
||||
|
||||
// EffectiveProtected returns the protected-container set that actually applies to this node. It is
|
||||
// the configured cfg.Stacks.Protected minus stacks that are intentionally not deployed here:
|
||||
// cloudflared is dropped when no tunnel token is configured (a LAN-only node legitimately runs
|
||||
// without it, so it must not be reported as a missing protected container forever). The bring-up
|
||||
// (stacks.EnsureBaseStack) applies the same cloudflared condition, so detection and deployment agree.
|
||||
func EffectiveProtected(cfg *config.Config) []string {
|
||||
out := make([]string, 0, len(cfg.Stacks.Protected))
|
||||
// the configured cfg.Stacks.Protected minus stacks that are intentionally not deployed here, plus
|
||||
// the DYNAMIC extras whose deployment depends on customer state rather than config:
|
||||
//
|
||||
// - cloudflared is dropped when no tunnel token is configured (a LAN-only node legitimately runs
|
||||
// without it, so it must not be reported as a missing protected container forever);
|
||||
// - the samba container is ADDED when network sharing is switched on (R-7b). Sharing is a
|
||||
// customer-toggled feature, so it can never appear in the golden controller.yaml — but once it
|
||||
// IS on, a dead sharing service is exactly as customer-visible as a dead traefik and must raise
|
||||
// the same protected-container issue → alert → Hungarian degradation e-mail. When sharing is
|
||||
// off the container is absent from the set, so a box that never enabled it stays quiet.
|
||||
//
|
||||
// The bring-up applies the same conditions (stacks.EnsureBaseStack for cloudflared, ensureSamba's
|
||||
// `if !smb.Enabled { return }` for samba), so detection and deployment agree in both directions.
|
||||
//
|
||||
// NOTE: the entries are CONTAINER names (checkProtectedContainers docker-inspects them). For the
|
||||
// base stacks the container name happens to equal the stack name; for samba it does NOT — the stack
|
||||
// is „samba" but the container is infra.SambaContainerName — which is why the constant is read here
|
||||
// rather than the stack name assumed.
|
||||
func EffectiveProtected(cfg *config.Config, smb settings.SMBSettings) []string {
|
||||
out := make([]string, 0, len(cfg.Stacks.Protected)+1)
|
||||
for _, name := range cfg.Stacks.Protected {
|
||||
if name == "cloudflared" && cfg.Infrastructure.CFTunnelToken == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
if smb.Enabled {
|
||||
out = append(out, infra.SambaContainerName)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ func BuildReport(
|
||||
version string,
|
||||
storagePaths []settings.StoragePath,
|
||||
geoRestriction *settings.GeoRestriction,
|
||||
smb settings.SMBSettings,
|
||||
logger *log.Logger,
|
||||
) *Report {
|
||||
debug := cfg.Logging.Level == "debug"
|
||||
@@ -136,7 +137,7 @@ func BuildReport(
|
||||
r.Backup = buildBackupReport(cfg, backupMgr)
|
||||
|
||||
// Health
|
||||
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, storagePaths, logger)
|
||||
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, storagePaths, smb, logger)
|
||||
r.Health = HealthReport{
|
||||
Status: healthReport.Status,
|
||||
Issues: healthReport.Issues,
|
||||
|
||||
@@ -165,6 +165,14 @@ type OffboxTarget struct {
|
||||
// OK run (sorted; empty clears). Drives the per-app "config+DB only" note on /backups/remote and
|
||||
// the edge-triggered enlarge-blocked notification. Not a secret (app-name list).
|
||||
EnlargedBlocked []string `json:"enlarged_blocked,omitempty"`
|
||||
// Shares offsite leg status (R-7b). The offsite run gained a SIBLING shares source that pushes the
|
||||
// „Felhőmentés"-marked SMB shares plus the share-definition manifest under the reserved `_shares`
|
||||
// tag. These three fields let the „Megosztás" page state per-tier truth instead of inferring it
|
||||
// from the app-wide LastStatus. SharesLastCount is the number of share folders in the push (0 = a
|
||||
// definitions-only push, e.g. quota-degraded or no share is marked for the cloud). Not secrets.
|
||||
SharesLastRun string `json:"shares_last_run,omitempty"` // RFC3339
|
||||
SharesLastStatus string `json:"shares_last_status,omitempty"` // "ok" | "error" | "blocked" | "skipped"
|
||||
SharesLastCount int `json:"shares_last_count,omitempty"`
|
||||
// EscrowState (fork-4) gates offsite RUNS on the repo password being escrowed under R: ""|"pending"
|
||||
// |"escrowed". Enabling offsite stages the password to the agent and sets "pending"; no offsite run
|
||||
// proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable
|
||||
|
||||
@@ -66,6 +66,15 @@ func ValidateSMBShareName(name string) error {
|
||||
if strings.ContainsAny(name, `/\.` ) {
|
||||
return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot")
|
||||
}
|
||||
// RESERVED NAMESPACE (R-7b). The backup engines key the shares source by the pseudo-stack „_shares"
|
||||
// — a restic tag, a tier-2 dest root and a status record. nbNameRe below starts with [A-Za-z0-9_],
|
||||
// so before this guard „_shares" was an ACCEPTED share name and the underscore namespace was not in
|
||||
// fact reserved (the R-7b task's assumption to the contrary was verified false here). Reserving the
|
||||
// whole leading-underscore space keeps future system keys collision-free too. Validation runs on
|
||||
// ADD only, so an already-registered share is never invalidated retroactively.
|
||||
if strings.HasPrefix(name, "_") {
|
||||
return fmt.Errorf("a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva")
|
||||
}
|
||||
if !nbNameRe.MatchString(name) {
|
||||
return fmt.Errorf("a megosztás neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat")
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ import (
|
||||
const (
|
||||
// SambaStackName is the stack directory name under StacksDir (and the protected-stack key).
|
||||
SambaStackName = "samba"
|
||||
// sambaContainer is the fixed container name (set in the generated compose).
|
||||
sambaContainer = "felhom-samba"
|
||||
// sambaContainer is the fixed container name. Aliased from the RENDERER's constant so the compose
|
||||
// this package writes and the execs it runs can never name different containers.
|
||||
sambaContainer = infra.SambaContainerName
|
||||
// sambaUID is the household uid/gid every SMB write is forced to, so apps (group 1000) and both
|
||||
// backup tiers see consistent ownership.
|
||||
sambaUID = 1000
|
||||
|
||||
@@ -18,12 +18,16 @@ import (
|
||||
//
|
||||
// The smb.conf / passdb mounts are deliberately NOT emitted — they are config, not customer data.
|
||||
//
|
||||
// SCOPE NOTE (the reported design fork, see REPORT.md): making this seam correct does NOT by itself
|
||||
// put share data into a live tier-2/offsite run. Both engines are structured around a per-app
|
||||
// RECOVERY UNIT — backup.RunTier2 short-circuits on `os.Stat(unitDir)` before it ever calls
|
||||
// GetStackClassifiedBinds, and the offsite runner enumerates settings.GetOffboxApps(). A share-only
|
||||
// infra stack has neither, and teaching them about one is more than an enumeration tweak, so per the
|
||||
// task's STOP clause it was reported rather than improvised inside the engines.
|
||||
// EXECUTION (R-7b, v0.145.0 — the gap this note used to describe is CLOSED). Share data now reaches
|
||||
// BOTH live tiers, but not through this seam: the engines remain structured around a per-app recovery
|
||||
// unit (backup.RunTier2 still short-circuits on `os.Stat(unitDir)`; the offsite runner still
|
||||
// enumerates settings.GetOffboxApps()), and Model B′ deliberately left those paths byte-identical.
|
||||
// Instead the backup package runs a SIBLING shares source off the SAME registry this file reads —
|
||||
// backup.RunSharesTier2 and backup.runOffboxSharesLeg — applying the identical rule below.
|
||||
//
|
||||
// So this function and the shares sources agree by construction on WHAT each share's class is, while
|
||||
// the sources own WHERE it goes. Keep the rule here in sync with internal/backup/shares_payload.go's
|
||||
// classifiedShares if it ever changes.
|
||||
func (m *Manager) sambaClassifiedBinds() ([]appbackup.ClassifiedBind, bool) {
|
||||
if m.settings == nil {
|
||||
return nil, false
|
||||
|
||||
@@ -256,7 +256,7 @@ func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Health
|
||||
healthReport := monitor.RunHealthCheck(s.cfg, s.cpuCollector, storagePaths, s.logger)
|
||||
healthReport := monitor.RunHealthCheck(s.cfg, s.cpuCollector, storagePaths, s.settings.GetSMBSettings(), s.logger)
|
||||
dump["health"] = map[string]interface{}{
|
||||
"status": healthReport.Status,
|
||||
"issues": healthReport.Issues,
|
||||
|
||||
@@ -815,6 +815,14 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
data["OffboxScratchReady"] = ready
|
||||
// R-7b: the shares source is not an app — it has no per-app toggle and no recovery unit — so it
|
||||
// gets its own restore entry rather than a synthetic row in OffboxApps (which would also make it
|
||||
// appear in the per-app offsite TOGGLE list on /backups/remote, where it does not belong).
|
||||
if s.backupMgr != nil {
|
||||
data["SharesRestoreOffered"] = s.settings != nil && len(s.settings.GetSMBShares()) > 0
|
||||
data["SharesScratchReady"] = s.backupMgr.SharesScratchReady()
|
||||
data["SharesDisplayName"] = backup.SharesDisplayName
|
||||
}
|
||||
s.executeTemplate(w, r, "backups_restore", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -355,3 +355,64 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}()
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A helyreállítás elindult — az állapot itt frissül.", false)
|
||||
}
|
||||
|
||||
// --- R-7b: „Megosztások" restore ------------------------------------------------------------------
|
||||
//
|
||||
// A SIBLING of the per-app restore pair above, not a special case of it: the shares source has no
|
||||
// recovery unit and no per-app toggle, so it gets its own two-step flow (restore to scratch, then a
|
||||
// deliberate place-to-live). The display name is always „Megosztások" — the reserved `_shares` key
|
||||
// never reaches a customer-facing surface.
|
||||
|
||||
// sharesRestoreHandler restores the latest shares snapshot into an on-data-drive scratch dir
|
||||
// (POST /backup/shares/restore). Non-destructive: nothing live is touched until the place action.
|
||||
func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
|
||||
return
|
||||
}
|
||||
if s.backupMgr.IsRunning() {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
|
||||
return
|
||||
}
|
||||
s.backupMgr.BeginRestoreOp("shares-restore", backup.SharesDisplayName)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
if err := s.backupMgr.RestoreSharesScratch(ctx); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] shares restore (async): %v", err)
|
||||
s.backupMgr.EndRestoreOp(false, "A megosztások visszaállítása sikertelen: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] shares restore completed (async)")
|
||||
s.backupMgr.EndRestoreOp(true, "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.")
|
||||
}()
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A megosztások visszaállítása elindult — az állapot itt frissül.", false)
|
||||
}
|
||||
|
||||
// sharesPlaceHandler merges a completed shares scratch into the live share folders, re-adds the
|
||||
// missing definitions and restores the household credential (POST /backup/shares/place).
|
||||
func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
|
||||
return
|
||||
}
|
||||
if s.backupMgr.IsRunning() {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
|
||||
return
|
||||
}
|
||||
s.backupMgr.BeginRestoreOp("shares-place", backup.SharesDisplayName)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
res, err := s.backupMgr.PlaceSharesRestore(ctx)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] shares place (async): %v", err)
|
||||
s.backupMgr.EndRestoreOp(false, "A megosztások helyreállítása sikertelen: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] shares place completed (async): %d file(s), %d definition(s)",
|
||||
res.FilesRestored, len(res.DefinitionsAdded))
|
||||
s.backupMgr.EndRestoreOp(true, res.FlashMessage())
|
||||
}()
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A megosztások helyreállítása elindult — az állapot itt frissül.", false)
|
||||
}
|
||||
|
||||
@@ -418,6 +418,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.offboxRestoreHandler(w, r)
|
||||
case path == "/backup/offbox/place" && r.Method == http.MethodPost:
|
||||
s.offboxPlaceHandler(w, r)
|
||||
// R-7b: „Megosztások" restore — a sibling of the per-app pair above.
|
||||
case path == "/backup/shares/restore" && r.Method == http.MethodPost:
|
||||
s.sharesRestoreHandler(w, r)
|
||||
case path == "/backup/shares/place" && r.Method == http.MethodPost:
|
||||
s.sharesPlaceHandler(w, r)
|
||||
// Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow.
|
||||
case path == "/backup/escrow" && r.Method == http.MethodGet:
|
||||
s.escrowWizardPageHandler(w, r)
|
||||
|
||||
@@ -154,6 +154,26 @@ func (s *Server) sharingPageData() map[string]interface{} {
|
||||
roots = append(roots, map[string]string{"Path": sp.Path, "Label": label})
|
||||
}
|
||||
data["StorageRoots"] = roots
|
||||
|
||||
// R-7b: per-tier backup truth. Until R-7b the „Felhőmentés" toggle promised a protection the
|
||||
// engines did not deliver; these two lines are what makes the promise checkable by the customer
|
||||
// rather than taken on faith. Amber ONLY on deviation — a green tier says nothing at all beyond
|
||||
// its timestamp, so the page stays quiet when everything is fine.
|
||||
if s.backupMgr != nil {
|
||||
if cd := s.backupMgr.SharesTier2Status(); cd != nil {
|
||||
data["SharesTier2Status"] = cd.LastStatus
|
||||
data["SharesTier2LastRun"] = cd.LastRun
|
||||
data["SharesTier2Warning"] = cd.LastWarning
|
||||
data["SharesTier2Error"] = cd.LastError
|
||||
data["SharesTier2Dest"] = cd.DestinationPath
|
||||
}
|
||||
if lastRun, status, count, ok := s.backupMgr.SharesOffsiteStatus(); ok {
|
||||
data["SharesOffsiteStatus"] = status
|
||||
data["SharesOffsiteLastRun"] = lastRun
|
||||
data["SharesOffsiteCount"] = count
|
||||
}
|
||||
data["SharesRestoreReady"] = s.backupMgr.SharesScratchReady()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,25 @@
|
||||
{{else}}
|
||||
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — a kijelölés a <a href="/backups/remote">Távoli mentés</a> oldalon történik.</p>
|
||||
{{end}}
|
||||
|
||||
<!-- R-7b: the shares source. Not an app row — it has no per-app toggle and no recovery unit —
|
||||
so it is its own entry. The reserved `_shares` key never appears here; the label always
|
||||
comes from the display mapping. -->
|
||||
{{if .SharesRestoreOffered}}
|
||||
<div class="app-row-list" style="margin-top:1rem">
|
||||
{{template "app_list_row" dict "Slug" "" "Name" .SharesDisplayName}}
|
||||
<form method="POST" action="/backup/shares/restore" style="display:inline">{{$.CSRFField}}
|
||||
<button type="submit" class="btn btn-xs btn-outline">Megosztások visszaállítása</button>
|
||||
</form>
|
||||
{{if .SharesScratchReady}}
|
||||
<form method="POST" action="/backup/shares/place" style="display:inline">{{$.CSRFField}}
|
||||
<button type="submit" class="btn btn-xs btn-outline">Helyreállítás az élő adatok közé (csak a hiányzó fájlok)</button>
|
||||
</form>
|
||||
<span class="form-hint" style="display:block;margin-top:.25rem">A meglévő fájlokat nem írja felül. A már létező megosztás-beállítások változatlanok maradnak.</span>
|
||||
{{end}}
|
||||
{{template "app_list_row_end"}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
||||
@@ -127,6 +127,51 @@
|
||||
A megosztás törlésekor <strong>a mappa és a fájlok megmaradnak</strong> — csak a hálózati
|
||||
elérés szűnik meg.
|
||||
</div>
|
||||
|
||||
<div class="backup-section-card" style="margin-top:1rem">
|
||||
<h3>A megosztások mentése</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 0.75rem">
|
||||
A megosztott mappák a többi adattal együtt mentésre kerülnek. A „Felhőmentés” bekapcsolva
|
||||
azt jelenti, hogy a mappa a távoli tárhelyre is felkerül; kikapcsolva csak a második
|
||||
meghajtóra készül másolat.
|
||||
</p>
|
||||
<ul class="plain-list">
|
||||
<li>
|
||||
<strong>2. mentés (másik meghajtó):</strong>
|
||||
{{if .SharesTier2Status}}
|
||||
{{if eq .SharesTier2Status "ok"}}
|
||||
rendben{{if .SharesTier2LastRun}} — {{.SharesTier2LastRun}}{{end}}
|
||||
{{if .SharesTier2Warning}}<span class="badge badge-warn">{{.SharesTier2Warning}}</span>{{end}}
|
||||
{{else if eq .SharesTier2Status "no_target"}}
|
||||
<span class="badge badge-warn">nincs cél — {{.SharesTier2Error}}</span>
|
||||
{{else}}
|
||||
<span class="badge badge-warn">hiba — {{.SharesTier2Error}}</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
még nem futott
|
||||
{{end}}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Távoli mentés (felhő):</strong>
|
||||
{{if .SharesOffsiteStatus}}
|
||||
{{if eq .SharesOffsiteStatus "ok"}}
|
||||
rendben — {{.SharesOffsiteCount}} megosztás{{if .SharesOffsiteLastRun}}, {{.SharesOffsiteLastRun}}{{end}}
|
||||
{{else if eq .SharesOffsiteStatus "blocked"}}
|
||||
<span class="badge badge-warn">a tárhelykeret miatt csak a beállítások kerültek fel</span>
|
||||
{{else if eq .SharesOffsiteStatus "skipped"}}
|
||||
<span class="badge badge-warn">kimaradt</span>
|
||||
{{else}}
|
||||
<span class="badge badge-warn">hiba</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
még nem futott
|
||||
{{end}}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="form-hint">
|
||||
Visszaállítani a <a href="/backups/restore">Visszaállítás</a> oldalon lehet.
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="backup-table-empty">Még nincs megosztott mappa.</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user