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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user