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)) }