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:
@@ -598,6 +598,16 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
defer m.releaseRunning()
|
||||
|
||||
apps := m.settings.GetOffboxApps()
|
||||
// Reserved-name defense in depth (R-7b): an app keyed `_shares` would collide with the shares
|
||||
// leg's restic tag and blocked-set entry. Catalog names cannot realistically produce this, but a
|
||||
// silent collision would corrupt both sources, so it is refused loudly instead.
|
||||
for i, a := range apps {
|
||||
if a == SharesPseudoStack {
|
||||
m.logger.Printf("[ERROR] [offbox] app %q uses the RESERVED shares key — excluded from the run to protect the shares leg", a)
|
||||
apps = append(apps[:i:i], apps[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
t := m.settings.GetOffboxTarget()
|
||||
base, env := m.offboxBaseArgs(t)
|
||||
// Edge-trigger for the enlarge-blocked notification: capture the PRIOR blocked set so we notify only
|
||||
@@ -673,7 +683,9 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
// Zero-toggle honesty (take-two obs.): a configured target with NOTHING selected reports
|
||||
// its emptiness instead of a bare success — the customer thinks offsite runs, but nothing
|
||||
// is covered until at least one app is toggled.
|
||||
if len(apps) == 0 {
|
||||
// R-7b: the shares leg counts as coverage — a box whose only cloud content is its shares
|
||||
// must not be told "nothing is selected".
|
||||
if len(apps) == 0 && !runResult.sharesBackedUp {
|
||||
warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
@@ -683,9 +695,25 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
// 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed).
|
||||
warns = append(warns, runResult.warns...)
|
||||
// 3a: the pre-push enlargement gate blocked some apps' userdata — config+DB still saved.
|
||||
if len(blockedNames) > 0 {
|
||||
// R-7b: the shares source is not an "app" and its degraded floor is the DEFINITIONS, not a
|
||||
// recovery unit — so it gets its own sentence and is excluded from the app count. The
|
||||
// persisted EnlargedBlocked set keeps the RAW `_shares` key (it is a lookup key the
|
||||
// templates index by); only this prose maps it through the display vocabulary.
|
||||
var blockedApps []string
|
||||
sharesBlocked := false
|
||||
for _, n := range blockedNames {
|
||||
if n == SharesPseudoStack {
|
||||
sharesBlocked = true
|
||||
continue
|
||||
}
|
||||
blockedApps = append(blockedApps, n)
|
||||
}
|
||||
if len(blockedApps) > 0 {
|
||||
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.",
|
||||
len(blockedNames), strings.Join(blockedNames, ", ")))
|
||||
len(blockedApps), strings.Join(blockedApps, ", ")))
|
||||
}
|
||||
if sharesBlocked {
|
||||
warns = append(warns, sharesBlockedWarning())
|
||||
}
|
||||
// SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run.
|
||||
if qw := offboxQuotaWarning(o); qw != "" {
|
||||
@@ -708,7 +736,10 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
usedGB := int(t.RepoSizeBytes / offboxGiB)
|
||||
for _, b := range runResult.blocked {
|
||||
if !priorBlocked[b.stack] {
|
||||
m.offboxEnlargeBlockedNotify(b.stack, b.estBytes, usedGB, t.QuotaGB)
|
||||
// DISPLAY BOUNDARY (R-7b): the notification is a customer-facing surface (it becomes a
|
||||
// Hungarian e-mail), so the reserved `_shares` key is mapped here — and ONLY here plus
|
||||
// the warning prose above. The persisted set and the restic tag stay raw.
|
||||
m.offboxEnlargeBlockedNotify(DisplayStackName(b.stack), b.estBytes, usedGB, t.QuotaGB)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -813,6 +844,10 @@ type offboxRunResult struct {
|
||||
missing []string
|
||||
blocked []offboxBlocked
|
||||
warns []string
|
||||
// sharesBackedUp (R-7b) records that the sibling shares leg produced a snapshot this run. It keeps
|
||||
// the zero-toggle honesty notice honest: a box with no app toggled but shares in the cloud is NOT
|
||||
// "nothing is covered".
|
||||
sharesBackedUp bool
|
||||
}
|
||||
|
||||
// runOffboxInternal does the repo-ensure + per-app DISCOVER → capture-set → gate → multi-path backup +
|
||||
@@ -867,6 +902,21 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
|
||||
res.backedUp++
|
||||
m.logger.Printf("[INFO] [offbox] backed up %s (%s, %d mandatory path(s))", stack, src, len(extra))
|
||||
}
|
||||
// R-7b: the SHARES leg runs AFTER the per-app loop and BEFORE retention, so `forget --group-by
|
||||
// host,tags` covers the `_shares` group for free. It is placed BEFORE the firstErr return on
|
||||
// purpose: share protection must not be dropped because some unrelated app failed to push.
|
||||
sharesRes, sharesErr := m.runOffboxSharesLeg(ctx, base, env, t)
|
||||
m.recordSharesOffsiteStatus(sharesRes)
|
||||
res.warns = append(res.warns, sharesRes.warns...)
|
||||
if sharesRes.blocked {
|
||||
res.blocked = append(res.blocked, offboxBlocked{stack: SharesPseudoStack, estBytes: sharesRes.estBytes})
|
||||
}
|
||||
if sharesRes.ran {
|
||||
res.sharesBackedUp = true
|
||||
}
|
||||
if sharesErr != nil && firstErr == nil {
|
||||
firstErr = sharesErr
|
||||
}
|
||||
if firstErr != nil {
|
||||
return res, firstErr
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-7b offsite shares leg. The headline test in this file is the B′ ISOLATION PROOF: adding the
|
||||
// shares source must leave every per-app restic invocation BYTE-IDENTICAL. That is the whole premise
|
||||
// of Model B′ — if it does not hold, the design has silently become engine-loop surgery.
|
||||
|
||||
// sharesOffboxEnv wires an offbox manager with one app (unit on `drive`) and the shares feature on,
|
||||
// so a run can be taken with and without shares against the SAME paths.
|
||||
type sharesOffboxEnv struct {
|
||||
m *Manager
|
||||
sett *settings.Settings
|
||||
drive string
|
||||
unit string
|
||||
}
|
||||
|
||||
func newSharesOffboxEnv(t *testing.T, app string) *sharesOffboxEnv {
|
||||
t.Helper()
|
||||
drive := t.TempDir()
|
||||
m, sett, prov := classifiedOffboxManager(t, drive)
|
||||
unit := mkUnit(t, drive, app)
|
||||
prov.hdd[app] = drive
|
||||
if err := sett.SetAppOffbox(app, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetSMBEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.SetSharesPassdbCapturer(func() ([]byte, error) { return []byte("FAKE-PASSDB"), nil })
|
||||
return &sharesOffboxEnv{m: m, sett: sett, drive: drive, unit: unit}
|
||||
}
|
||||
|
||||
// addOffsiteShare registers an available share on the env's drive.
|
||||
func (e *sharesOffboxEnv) addOffsiteShare(t *testing.T, name string, offsite bool) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(e.drive, name)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.sett.AddSMBShare(settings.SMBShare{Name: name, Path: p, Offsite: offsite, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// run takes one offsite run and returns the capture.
|
||||
func (e *sharesOffboxEnv) run(t *testing.T) *backupCapture {
|
||||
t.Helper()
|
||||
cap := &backupCapture{}
|
||||
e.m.SetOffboxRunner(cap.runner())
|
||||
if err := e.m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
// THE B′ ISOLATION PROOF. One app, then the same app plus one mandatory share: the app's restic argv
|
||||
// must be byte-identical across both runs, and the shares source must appear as exactly ONE
|
||||
// additional call. Red-proof: make the shares leg append its paths into the app's argv instead of
|
||||
// issuing its own call — this test fails.
|
||||
func TestOffboxSharesLegLeavesAppCallsByteIdentical(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
|
||||
baseline := env.run(t)
|
||||
baseArgs := baseline.byStack["immich"]
|
||||
if len(baseArgs) == 0 {
|
||||
t.Fatal("precondition: the baseline run produced no app backup call")
|
||||
}
|
||||
if baseline.backups != 1 {
|
||||
t.Fatalf("precondition: baseline should be exactly 1 backup call, got %d", baseline.backups)
|
||||
}
|
||||
|
||||
env.addOffsiteShare(t, "dokumentumok", true)
|
||||
withShares := env.run(t)
|
||||
|
||||
gotArgs := withShares.byStack["immich"]
|
||||
if strings.Join(gotArgs, "\x00") != strings.Join(baseArgs, "\x00") {
|
||||
t.Errorf("B′ INVARIANT VIOLATED — the app's restic argv changed when shares were added:\n baseline: %v\n with shares: %v", baseArgs, gotArgs)
|
||||
}
|
||||
if withShares.backups != 2 {
|
||||
t.Errorf("expected exactly ONE additional restic call for the shares source, got %d total", withShares.backups)
|
||||
}
|
||||
if _, ok := withShares.byStack[SharesPseudoStack]; !ok {
|
||||
t.Fatalf("no restic call tagged %q was issued: %v", SharesPseudoStack, withShares.byStack)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario A: a mandatory share reaches offsite — correct tags, the manifest staging dir, and the
|
||||
// share folder. Red-proof: flip the mandatory→offsite mapping (push only non-mandatory shares) and
|
||||
// this fails.
|
||||
func TestOffboxSharesLegPushesMandatoryShare(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
sharePath := env.addOffsiteShare(t, "dokumentumok", true)
|
||||
|
||||
cap := env.run(t)
|
||||
args := cap.byStack[SharesPseudoStack]
|
||||
if len(args) == 0 {
|
||||
t.Fatal("no shares call issued")
|
||||
}
|
||||
if !contains(args, "felhom-offbox") || !contains(args, SharesPseudoStack) {
|
||||
t.Errorf("shares call must carry BOTH tags [felhom-offbox, %s]: %v", SharesPseudoStack, args)
|
||||
}
|
||||
if !contains(args, sharePath) {
|
||||
t.Errorf("shares call missing the mandatory share path %q: %v", sharePath, args)
|
||||
}
|
||||
if !contains(args, env.m.SharesPayloadDir()) {
|
||||
t.Errorf("shares call missing the manifest staging dir %q: %v", env.m.SharesPayloadDir(), args)
|
||||
}
|
||||
// The manifest on disk must be the registry.
|
||||
blob, err := os.ReadFile(filepath.Join(env.m.SharesPayloadDir(), sharesManifestName))
|
||||
if err != nil {
|
||||
t.Fatalf("manifest not staged: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(blob), "dokumentumok") {
|
||||
t.Errorf("manifest does not describe the share: %s", blob)
|
||||
}
|
||||
// Per-tier status must be recorded for the „Megosztás" page.
|
||||
_, status, count, ok := env.m.SharesOffsiteStatus()
|
||||
if !ok || status != "ok" || count != 1 {
|
||||
t.Errorf("SharesOffsiteStatus = (%q, %d, %v), want (ok, 1, true)", status, count, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B: an OPTIONAL share is tier-2-only — its path must appear in NO restic argument.
|
||||
func TestOffboxSharesLegExcludesOptionalShare(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
mandatoryPath := env.addOffsiteShare(t, "dokumentumok", true)
|
||||
optionalPath := env.addOffsiteShare(t, "filmek", false)
|
||||
|
||||
cap := env.run(t)
|
||||
for tag, args := range cap.byStack {
|
||||
if contains(args, optionalPath) {
|
||||
t.Errorf("OPTIONAL share path leaked into the %q restic call: %v", tag, args)
|
||||
}
|
||||
}
|
||||
if !contains(cap.byStack[SharesPseudoStack], mandatoryPath) {
|
||||
t.Error("the mandatory share should still be pushed")
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C: the quota gate degrades the push to the MANIFEST ONLY — definitions protection never
|
||||
// regresses — the blocked set gains the reserved key, and the notification is edge-triggered so a
|
||||
// second identical run does NOT re-notify. Red-proof: drop the manifest-only degradation (skip the
|
||||
// whole leg when blocked) and the "manifest still pushed" assertion fails.
|
||||
func TestOffboxSharesLegQuotaDegradesToManifestOnly(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
sharePath := env.addOffsiteShare(t, "dokumentumok", true)
|
||||
|
||||
// A 1 GB quota with a 2 GB share estimate: the gate must trip.
|
||||
if err := env.sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 1 }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.m.SetOffboxSizer(func(string) int64 { return 2 * offboxGiB })
|
||||
|
||||
var notified []string
|
||||
env.m.SetOffboxEnlargeBlockedNotifier(func(stack string, _ int64, _, _ int) {
|
||||
notified = append(notified, stack)
|
||||
})
|
||||
|
||||
cap := env.run(t)
|
||||
args := cap.byStack[SharesPseudoStack]
|
||||
if len(args) == 0 {
|
||||
t.Fatal("the blocked run must still push the definitions, not skip the leg entirely")
|
||||
}
|
||||
if contains(args, sharePath) {
|
||||
t.Errorf("a quota-blocked push must NOT carry the share folder: %v", args)
|
||||
}
|
||||
if !contains(args, env.m.SharesPayloadDir()) {
|
||||
t.Errorf("a quota-blocked push MUST still carry the manifest (definitions protection never regresses): %v", args)
|
||||
}
|
||||
// The persisted blocked set keeps the RAW key (templates index by it)…
|
||||
tgt := env.sett.GetOffboxTarget()
|
||||
if !containsStr(tgt.EnlargedBlocked, SharesPseudoStack) {
|
||||
t.Errorf("EnlargedBlocked should contain the raw %q key, got %v", SharesPseudoStack, tgt.EnlargedBlocked)
|
||||
}
|
||||
// …while the NOTIFICATION boundary renders the Hungarian display name.
|
||||
if len(notified) != 1 || notified[0] != SharesDisplayName {
|
||||
t.Errorf("notification should fire once as %q, got %v", SharesDisplayName, notified)
|
||||
}
|
||||
// The customer-facing warning must not leak the reserved key either.
|
||||
if strings.Contains(tgt.LastWarning, SharesPseudoStack) {
|
||||
t.Errorf("the reserved key leaked into Hungarian prose: %q", tgt.LastWarning)
|
||||
}
|
||||
|
||||
// Edge-trigger: an identical second run must NOT re-notify.
|
||||
notified = nil
|
||||
env.run(t)
|
||||
if len(notified) != 0 {
|
||||
t.Errorf("a persistently-blocked shares source must not re-notify nightly, got %v", notified)
|
||||
}
|
||||
}
|
||||
|
||||
// Sharing disabled / no shares: no `_shares` restic group is created at all.
|
||||
func TestOffboxSharesLegNoOpWhenSharingOff(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
if err := env.sett.SetSMBEnabled(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cap := env.run(t)
|
||||
if _, ok := cap.byStack[SharesPseudoStack]; ok {
|
||||
t.Error("a disabled sharing feature must create no _shares snapshot group")
|
||||
}
|
||||
if cap.backups != 1 {
|
||||
t.Errorf("expected only the app's call, got %d", cap.backups)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario F, offsite side: a share on an unavailable drive reaches NO restic argument, and the run
|
||||
// still covers the healthy shares.
|
||||
func TestOffboxSharesLegSkipsDeadMount(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
live := env.addOffsiteShare(t, "elo", true)
|
||||
dead := filepath.Join(env.drive, "halott")
|
||||
if err := env.sett.AddSMBShare(settings.SMBShare{Name: "halott", Path: dead, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
} // folder deliberately never created → unavailable
|
||||
|
||||
cap := env.run(t)
|
||||
args := cap.byStack[SharesPseudoStack]
|
||||
if contains(args, dead) {
|
||||
t.Errorf("an unavailable share path reached the restic argv: %v", args)
|
||||
}
|
||||
if !contains(args, live) {
|
||||
t.Errorf("the healthy share must still be pushed: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// A run whose ONLY cloud content is shares must not be told "nothing is selected".
|
||||
func TestOffboxSharesLegSuppressesZeroToggleNotice(t *testing.T) {
|
||||
env := newSharesOffboxEnv(t, "immich")
|
||||
if err := env.sett.SetAppOffbox("immich", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.addOffsiteShare(t, "dokumentumok", true)
|
||||
|
||||
env.run(t)
|
||||
if w := env.sett.GetOffboxTarget().LastWarning; strings.Contains(w, "nincs mentésre jelölt alkalmazás") {
|
||||
t.Errorf("a box whose cloud content is its shares is covered — misleading warning: %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// containsStr is a small slice helper (the package's `contains` takes the restic argv shape).
|
||||
func containsStr(hay []string, needle string) bool {
|
||||
for _, h := range hay {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -398,6 +398,46 @@ func TestSharesTier2ReconcilePrunesRemovedShare(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The tier-2 half of the B′ isolation proof: the shares job must write ONLY under
|
||||
// backups/secondary/_shares. A per-app dest tree standing beside it must come out byte-for-byte
|
||||
// untouched — same contents, same bytes — and no mirror call may target it. Red-proof: point the
|
||||
// shares destBase at backups/secondary/<share> (dropping the _shares segment) and this fails.
|
||||
func TestSharesTier2LeavesPerAppTreeUntouched(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
|
||||
// A pre-existing per-app tier-2 dest with a sentinel payload.
|
||||
appDest := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", "immich")
|
||||
if err := os.MkdirAll(filepath.Join(appDest, "recovery-unit"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentinel := filepath.Join(appDest, "recovery-unit", "manifest.json")
|
||||
if err := os.WriteFile(sentinel, []byte(`{"app":"immich"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(sentinel)
|
||||
if err != nil || string(b) != `{"app":"immich"}` {
|
||||
t.Errorf("B′ INVARIANT VIOLATED — the per-app tier-2 tree was modified: %q, %v", b, err)
|
||||
}
|
||||
for _, call := range env.mirrored {
|
||||
if strings.Contains(call, appDest) {
|
||||
t.Errorf("B′ INVARIANT VIOLATED — a shares mirror targeted the per-app dest: %s", call)
|
||||
}
|
||||
}
|
||||
// And everything it DID write lives under the reserved subtree.
|
||||
sharesRoot := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
|
||||
for _, dst := range env.mirroredDsts() {
|
||||
if !strings.HasPrefix(dst, sharesRoot+string(filepath.Separator)) {
|
||||
t.Errorf("shares job wrote outside its reserved subtree: %s", dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drive keys must be collision-free across drives that share a basename.
|
||||
func TestSharesDriveKeyIsCollisionFree(t *testing.T) {
|
||||
if sharesDriveKey("/mnt/a/data") == sharesDriveKey("/mnt/b/data") {
|
||||
|
||||
@@ -165,6 +165,14 @@ type OffboxTarget struct {
|
||||
// OK run (sorted; empty clears). Drives the per-app "config+DB only" note on /backups/remote and
|
||||
// the edge-triggered enlarge-blocked notification. Not a secret (app-name list).
|
||||
EnlargedBlocked []string `json:"enlarged_blocked,omitempty"`
|
||||
// Shares offsite leg status (R-7b). The offsite run gained a SIBLING shares source that pushes the
|
||||
// „Felhőmentés"-marked SMB shares plus the share-definition manifest under the reserved `_shares`
|
||||
// tag. These three fields let the „Megosztás" page state per-tier truth instead of inferring it
|
||||
// from the app-wide LastStatus. SharesLastCount is the number of share folders in the push (0 = a
|
||||
// definitions-only push, e.g. quota-degraded or no share is marked for the cloud). Not secrets.
|
||||
SharesLastRun string `json:"shares_last_run,omitempty"` // RFC3339
|
||||
SharesLastStatus string `json:"shares_last_status,omitempty"` // "ok" | "error" | "blocked" | "skipped"
|
||||
SharesLastCount int `json:"shares_last_count,omitempty"`
|
||||
// EscrowState (fork-4) gates offsite RUNS on the repo password being escrowed under R: ""|"pending"
|
||||
// |"escrowed". Enabling offsite stages the password to the agent and sets "pending"; no offsite run
|
||||
// proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable
|
||||
|
||||
Reference in New Issue
Block a user