docs(shares): R-7b v0.145.0 — CHANGELOG, CONTEXT, REUSE, README; caveats cleared

The samba_classify.go SCOPE NOTE and the README KNOWN GAP both described a gap that
R-7b closes; both now describe the sibling-shares-source execution instead.
This commit is contained in:
2026-07-18 13:06:41 +02:00
parent 900c870212
commit 3b70a9e9ab
5 changed files with 146 additions and 11 deletions
+72
View File
@@ -1,5 +1,77 @@
## Changelog ## 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) ### 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 The customer turns on network sharing, sets ONE household SMB password, and exports folders. The box
+32 -1
View File
@@ -7,7 +7,38 @@
> >
> Ask Claude Code: "Please update CONTEXT.md with what we did today" > Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-07-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 > **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/ > **embedded controller feature** — the FOURTH protected infra stack (traefik/cloudflared/filebrowser/
+6
View File
@@ -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.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 | | `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 | | `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 | | `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 | | `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
View File
@@ -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` 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. (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 #### Share backup EXECUTION — the sibling shares source (R-7b, v0.145.0)
> 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()`. The earlier KNOWN GAP is **closed**: share data is in both live tiers. It did not get there through
> Both engines are recovery-unit shaped, which a share-only infra stack has not. See root `REPORT.md`. `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`).
--- ---
+10 -6
View File
@@ -18,12 +18,16 @@ import (
// //
// The smb.conf / passdb mounts are deliberately NOT emitted — they are config, not customer data. // 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 // EXECUTION (R-7b, v0.145.0 — the gap this note used to describe is CLOSED). Share data now reaches
// put share data into a live tier-2/offsite run. Both engines are structured around a per-app // BOTH live tiers, but not through this seam: the engines remain structured around a per-app recovery
// RECOVERY UNIT — backup.RunTier2 short-circuits on `os.Stat(unitDir)` before it ever calls // unit (backup.RunTier2 still short-circuits on `os.Stat(unitDir)`; the offsite runner still
// GetStackClassifiedBinds, and the offsite runner enumerates settings.GetOffboxApps(). A share-only // enumerates settings.GetOffboxApps()), and Model B deliberately left those paths byte-identical.
// infra stack has neither, and teaching them about one is more than an enumeration tweak, so per the // Instead the backup package runs a SIBLING shares source off the SAME registry this file reads —
// task's STOP clause it was reported rather than improvised inside the engines. // 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) { func (m *Manager) sambaClassifiedBinds() ([]appbackup.ClassifiedBind, bool) {
if m.settings == nil { if m.settings == nil {
return nil, false return nil, false