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:
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -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
@@ -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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user