85b76e0fc3
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
162 lines
7.0 KiB
Go
162 lines
7.0 KiB
Go
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))
|
||
}
|