feat(shares): R-7b Part 3 — offsite shares leg (Model B') + B' isolation proof

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
This commit is contained in:
2026-07-18 12:51:46 +02:00
parent c81df55dcb
commit 85b76e0fc3
5 changed files with 522 additions and 4 deletions
+54 -4
View File
@@ -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
}