From 85b76e0fc3046111114ec7a004004e135cf79e56 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 18 Jul 2026 12:51:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(shares):=20R-7b=20Part=203=20=E2=80=94=20o?= =?UTF-8?q?ffsite=20shares=20leg=20(Model=20B')=20+=20B'=20isolation=20pro?= =?UTF-8?q?of?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ONE additional restic call tagged [felhom-offbox, _shares] carrying the payload staging dir + every mandatory share folder. Hooked into runOffboxInternal AFTER the per-app loop and BEFORE retention, so forget --group-by host,tags covers the _shares group with no flag change. Reuses resticStep, the caller's repo-ensure and single-flight, and the SAME enlargement-gate arithmetic. - quota gate degrades the push to MANIFEST-ONLY, never to nothing - EnlargedBlocked keeps the RAW _shares key (templates index by it); the display mapping applies only at the notification + Hungarian-prose boundaries - OffboxTarget gains SharesLastRun/Status/Count for per-tier page truth - zero-toggle notice suppressed when the shares leg provided coverage - reserved-name defense: an app keyed _shares is excluded from the run loudly RED-PROOFS RUN AND REVERTED (all fired): 1. shares leg appends into the app's argv -> isolation test FAILS 2. mandatory->offsite mapping inverted -> Scenario A + B FAIL 3. manifest-only degradation dropped -> Scenario C FAILS --- controller/internal/backup/offbox.go | 58 +++- controller/internal/backup/offbox_shares.go | 161 +++++++++++ .../internal/backup/offbox_shares_test.go | 259 ++++++++++++++++++ controller/internal/backup/shares_test.go | 40 +++ controller/internal/settings/settings.go | 8 + 5 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 controller/internal/backup/offbox_shares.go create mode 100644 controller/internal/backup/offbox_shares_test.go diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 089886d..a64564f 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -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 } diff --git a/controller/internal/backup/offbox_shares.go b/controller/internal/backup/offbox_shares.go new file mode 100644 index 0000000..a153dd2 --- /dev/null +++ b/controller/internal/backup/offbox_shares.go @@ -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)) +} diff --git a/controller/internal/backup/offbox_shares_test.go b/controller/internal/backup/offbox_shares_test.go new file mode 100644 index 0000000..e3e3cd5 --- /dev/null +++ b/controller/internal/backup/offbox_shares_test.go @@ -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 +} diff --git a/controller/internal/backup/shares_test.go b/controller/internal/backup/shares_test.go index cfb3d8c..9be03f6 100644 --- a/controller/internal/backup/shares_test.go +++ b/controller/internal/backup/shares_test.go @@ -398,6 +398,46 @@ func TestSharesTier2ReconcilePrunesRemovedShare(t *testing.T) { } } +// 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/ (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") { diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index eb051a2..4e1a026 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -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