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