Files
felhom-controller/controller/internal/backup/offbox_shares.go
T
admin 85b76e0fc3 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
2026-07-18 12:51:46 +02:00

162 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}