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
+161
View File
@@ -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))
}