R-1252 (P2): a rejected or absent backup block never deletes second-drive copies — reconcile only on a valid classification; a rejected block is said on the app's status and once a day to the operator (backup_run_failures); kept copies stay kept (H2); Remove drops the kept records it deleted (H9). Commit only.
gates / gates (push) Successful in 1m18s
gates / gates (push) Successful in 1m18s
This commit is contained in:
@@ -185,6 +185,8 @@ type Manager struct {
|
||||
|
||||
// hollow (Part D, v0.279.0) — the running apps whose newest copy holds no data. See hollow_watch.go.
|
||||
hollow hollowWatch
|
||||
// tier2Unreadable (R-1252) dedupes the operator signal for a rejected backup block: once per app per day.
|
||||
tier2Unreadable tier2UnreadableWatch
|
||||
|
||||
// F17 restore seams — overridable in tests so the .sql re-import orchestration can be unit-tested
|
||||
// without Docker. Default to the real DiscoverDatabases / ImportDump (lazy-init in reimportDBDumps).
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package backup
|
||||
|
||||
// R-1252 (review round 2, HUNT-2 H1, H2, H9): a rejected backup block never deletes second-drive copies.
|
||||
//
|
||||
// The first test is the review's 9202 reproduction (audits/review-round-2-2026-10-24/proofs/H2-bad-backup-block/) as a
|
||||
// unit test: an app with its household files in a userdata leg; two good runs keep the copy (the control); one bogus
|
||||
// path in the block rejects it → the legacy legs → before the fix „Tier 2 reconcile: removed stale dest dir
|
||||
// userdata/media". RED-PROOF (2026-10-24, on the parent of this commit): TestR1252_ARejectedBlockKeepsEveryCopy failed
|
||||
// on „the household's file copy was deleted"; TestR1252_AKeptCopyStaysWhenItsLegLeaves on „the kept copy was
|
||||
// reconciled away"; TestR1252_RemoveDropsTheKeptRecordsItDeleted on „the record outlived its copy".
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rejectingProvider is the v2 fixture provider that can also say a block was rejected (the stack adapter's
|
||||
// BackupBlockRejected in production).
|
||||
type rejectingProvider struct {
|
||||
*t2v2Provider
|
||||
rejected map[string]string
|
||||
}
|
||||
|
||||
func (p *rejectingProvider) BackupBlockRejected(n string) string { return p.rejected[n] }
|
||||
|
||||
func mUD(rel string) ClassifiedBind {
|
||||
b := oUD(rel)
|
||||
b.ReadOnly = false
|
||||
b.Class = ClassMandatory
|
||||
return b
|
||||
}
|
||||
|
||||
func TestR1252_ARejectedBlockKeepsEveryCopy(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "audiobookshelf")
|
||||
rp := &rejectingProvider{t2v2Provider: prov, rejected: map[string]string{}}
|
||||
m.stackProvider = rp
|
||||
var sent []string
|
||||
m.SetTier2SettingsUnreadableNotify(func(app, why string) { sent = append(sent, app+": "+why) })
|
||||
prov.has["audiobookshelf"] = true
|
||||
prov.binds["audiobookshelf"] = []ClassifiedBind{mHDD("appdata/audiobookshelf/config"), mUD("media/audiobooks")}
|
||||
prov.mounts["audiobookshelf"] = []string{filepath.Join(src, "appdata", "audiobookshelf", "config")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "audiobookshelf", "config", "db.sqlite"), "DB")
|
||||
mustWrite(t, filepath.Join(src, "userdata", "media", "audiobooks", "r1252-marker.mp3"), "the household's book")
|
||||
destBase := filepath.Join(target, "backups", "secondary", "audiobookshelf")
|
||||
copyOfBook := filepath.Join(destBase, "userdata", "media", "audiobooks", "r1252-marker.mp3")
|
||||
|
||||
for run := 1; run <= 2; run++ { // the positive control: an unchanged block keeps the copy
|
||||
if err := m.RunTier2("audiobookshelf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(copyOfBook) {
|
||||
t.Fatalf("control run %d: the household's file was not copied", run)
|
||||
}
|
||||
}
|
||||
// one bogus path: LoadMetadata rejects the WHOLE block → no classification, the legacy legs
|
||||
prov.has["audiobookshelf"] = false
|
||||
rp.rejected["audiobookshelf"] = `userdata path "media/nope" matches no compose bind`
|
||||
if err := m.RunTier2("audiobookshelf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(copyOfBook) {
|
||||
t.Fatal("the household's file copy was deleted by the night copy after a rejected block (R-1252)")
|
||||
}
|
||||
if cd := m.settings.GetCrossDriveConfig("audiobookshelf"); cd == nil || !strings.Contains(cd.LastWarning, "nem olvashat") {
|
||||
t.Fatalf("the app's status must say the settings are unreadable and the copies kept: %+v", cd)
|
||||
}
|
||||
if len(sent) != 1 || !strings.Contains(sent[0], "media/nope") {
|
||||
t.Fatalf("the operator must be told once, with the reason: %v", sent)
|
||||
}
|
||||
if err := m.RunTier2("audiobookshelf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("a second run the same day must not tell the operator again: %v", sent)
|
||||
}
|
||||
m.tier2Unreadable.now = func() time.Time { return time.Now().Add(25 * time.Hour) }
|
||||
if err := m.RunTier2("audiobookshelf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sent) != 2 {
|
||||
t.Fatalf("the next day tells the operator again: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// An app with no block at all (absent) — the legacy legs — reconciles nothing either, and says nothing.
|
||||
func TestR1252_AnAbsentBlockReconcilesNothing(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "legacyapp")
|
||||
prov.has["legacyapp"] = false
|
||||
prov.mounts["legacyapp"] = []string{filepath.Join(src, "appdata", "legacyapp", "x")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "legacyapp", "a.txt"), "A")
|
||||
destBase := filepath.Join(target, "backups", "secondary", "legacyapp")
|
||||
stray := filepath.Join(destBase, "userdata", "photos", "p.jpg")
|
||||
mustWrite(t, stray, "a copy from an earlier, classified night")
|
||||
if err := m.RunTier2("legacyapp"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(stray) {
|
||||
t.Fatal("an app without a valid classification must not reconcile away a copy")
|
||||
}
|
||||
if cd := m.settings.GetCrossDriveConfig("legacyapp"); cd != nil && strings.Contains(cd.LastWarning, "nem olvashat") {
|
||||
t.Fatalf("an absent block is not unreadable settings: %q", cd.LastWarning)
|
||||
}
|
||||
}
|
||||
|
||||
// The control the other way: a VALID classification still reconciles a leg that was re-classed excluded.
|
||||
func TestR1252_AValidClassificationStillReconciles(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "app")
|
||||
prov.has["app"] = true
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/keep"), mHDD("appdata/app/drop")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "keep", "k"), "K")
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "drop", "d"), "D")
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dropped := filepath.Join(target, "backups", "secondary", "app", "hdd", "appdata", "app", "drop")
|
||||
if !exists(dropped) {
|
||||
t.Fatal("precondition: run 1 copied both legs")
|
||||
}
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/keep"), xHDD("appdata/app/drop")}
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(dropped) {
|
||||
t.Fatal("a leg re-classed excluded under a VALID block must still be reconciled away (§8.2)")
|
||||
}
|
||||
}
|
||||
|
||||
// HUNT-2 H2: a copy D25 keeps (its folder went missing) stays kept when its leg later leaves the declared set.
|
||||
func TestR1252_AKeptCopyStaysWhenItsLegLeaves(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "app")
|
||||
prov.has["app"] = true
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/photos"), mHDD("appdata/app/docs")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO")
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "docs", "contract.pdf"), "PDF")
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
photo := filepath.Join(target, "backups", "secondary", "app", "hdd", "appdata", "app", "photos", "family.jpg")
|
||||
if err := os.RemoveAll(filepath.Join(src, "appdata", "app", "photos")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RunTier2("app"); err != nil { // the folder is missing → kept + recorded (D25)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.KeptCopies()) != 1 {
|
||||
t.Fatalf("precondition: the photos copy is kept, got %+v", m.KeptCopies())
|
||||
}
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/docs")} // the photos leg leaves the declared set
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(photo) {
|
||||
t.Fatal("the kept copy was reconciled away while its record stays (HUNT-2 H2)")
|
||||
}
|
||||
if len(m.KeptCopies()) != 1 {
|
||||
t.Fatalf("the kept record must stay: %+v", m.KeptCopies())
|
||||
}
|
||||
}
|
||||
|
||||
// HUNT-2 H9: Remove with „remove backups" deletes the mirror — and the kept-copy records inside it go with it.
|
||||
func TestR1252_RemoveDropsTheKeptRecordsItDeleted(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "app")
|
||||
prov.has["app"] = true
|
||||
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/photos")}
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO")
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(src, "appdata", "app", "photos")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.KeptCopies()) != 1 {
|
||||
t.Fatalf("precondition: one kept copy, got %+v", m.KeptCopies())
|
||||
}
|
||||
mirror := filepath.Join(target, "backups", "secondary", "app")
|
||||
removed := m.RemoveTier2Mirrors("app", []string{mirror})
|
||||
if len(removed) != 1 || exists(mirror) {
|
||||
t.Fatalf("precondition: the mirror is deleted, got %v", removed)
|
||||
}
|
||||
if len(m.KeptCopies()) != 0 {
|
||||
t.Fatalf("the record outlived its copy: %+v", m.KeptCopies())
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ func (m *Manager) RemoveTier2Mirrors(stackName string, dirs []string) []string {
|
||||
continue
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] remove %s: Tier-2 mirror deleted: %s (%s)", stackName, d, size)
|
||||
m.dropKeptCopiesUnder(d) // R-1252 H9: the kept copies inside it are gone too
|
||||
removed = append(removed, fmt.Sprintf("%s (%s)", d, size))
|
||||
}
|
||||
return removed
|
||||
|
||||
@@ -367,7 +367,7 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
}
|
||||
|
||||
// Class-driven legs (classified) / resolver legs (legacy) + loud gap warnings (§7-H).
|
||||
legs, missingLegs, warns := m.tier2CaptureSetFull(stackName, sourceNsRoot)
|
||||
legs, missingLegs, warns, classified := m.tier2CaptureSetFull(stackName, sourceNsRoot)
|
||||
|
||||
// Two sizes: full = unit + all legs; state-only = unit + mandatory legs (the SSD ceiling).
|
||||
unitSize := dirSizeBytes(unitDir)
|
||||
@@ -488,8 +488,22 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
filepath.Join(destBase, filepath.FromSlash(lg.DestRel)))
|
||||
}
|
||||
|
||||
// HUNT-2 H2 (R-1252): a copy D25 keeps stays kept even when its leg leaves the declared set — its record is the
|
||||
// household's only handle on it, and only their „Eltávolítás" removes it.
|
||||
legRels = append(legRels, m.keptCopyRels(destBase)...)
|
||||
// Reconcile stale dest dirs (a bind removed / re-classed excluded), then write the marker LAST.
|
||||
m.tier2Reconcile(destBase, legRels)
|
||||
// R-1252: ONLY on a valid classification. A rejected or absent block yields the legacy legs, and reconciling
|
||||
// against them deleted every copy of the household's files (9202, 2026-10-24). Nothing is reconciled that night;
|
||||
// a REJECTED block is said on the app's status and to the operator.
|
||||
if classified {
|
||||
m.tier2Reconcile(destBase, legRels)
|
||||
} else if why := m.backupBlockRejected(stackName); why != "" {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 %s: the backup block is REJECTED (%s) — nothing reconciled, every existing copy on the second drive is KEPT (R-1252)", stackName, why)
|
||||
warns = append(warns, m.note("note.tier2.settings_unreadable_kept"))
|
||||
m.notifyTier2SettingsUnreadable(stackName, why)
|
||||
} else if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [backup] Tier 2 %s: no backup classification — nothing reconciled (R-1252)", stackName)
|
||||
}
|
||||
if err := os.WriteFile(markerPath, []byte(tier2LayoutVersion), 0644); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 %s: layout marker write failed (restore will refuse until next run): %v", stackName, err)
|
||||
}
|
||||
|
||||
@@ -40,14 +40,18 @@ func tier2DestRel(root appbackup.BindRoot, rel string) string {
|
||||
// footprint promise), mapped into the same v2 layout so restore has ONE reader. Missing/refused
|
||||
// mandatory paths are stat-filtered + surfaced loudly (§7-H).
|
||||
func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns []string) {
|
||||
legs, _, warns = m.tier2CaptureSetFull(stack, nsRoot)
|
||||
legs, _, warns, _ = m.tier2CaptureSetFull(stack, nsRoot)
|
||||
return legs, warns
|
||||
}
|
||||
|
||||
// tier2CaptureSetFull is tier2CaptureSet plus the DECLARED legs that are missing on disk (mandatory and optional).
|
||||
// D25 / R-1181 (2026-10-21): a missing declared folder keeps its second-drive copy — RunTier2 adds these to the
|
||||
// reconcile's keep set and lists them as kept, instead of pruning the copy that may be the only one left.
|
||||
func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tier2Leg, warns []string) {
|
||||
//
|
||||
// classified (R-1252) reports whether the legs come from a VALID backup classification. Only then may the run
|
||||
// reconcile (delete) dest dirs the legs no longer name: the legacy set is a fallback, and a block that was rejected
|
||||
// or could not be read falls back to it too — reconciled, that deleted the household's file copies (measured on 9202).
|
||||
func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tier2Leg, warns []string, classified bool) {
|
||||
// Classified path.
|
||||
if m.stackProvider != nil {
|
||||
if binds, has := m.stackProvider.GetStackClassifiedBinds(stack); has {
|
||||
@@ -75,7 +79,7 @@ func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tie
|
||||
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a másodlagos mentésbe: %s.",
|
||||
stack, strings.Join(gaps, ", ")))
|
||||
}
|
||||
return legs, missing, warns
|
||||
return legs, missing, warns, true
|
||||
}
|
||||
}
|
||||
// Legacy path: the resolver appdata dir(s) → hdd/appdata/<name> legs (mandatory-equivalent for SSD
|
||||
@@ -98,5 +102,5 @@ func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tie
|
||||
Class: appbackup.ClassMandatory,
|
||||
})
|
||||
}
|
||||
return legs, missing, warns
|
||||
return legs, missing, warns, false
|
||||
}
|
||||
|
||||
@@ -57,6 +57,46 @@ func (m *Manager) releaseKeptCopy(path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// keptCopyRels (R-1252, HUNT-2 H2) is the slash-form path, relative to destBase, of every kept copy recorded inside
|
||||
// it — added to the reconcile's keep set so a kept copy is never reconciled away while its record stays.
|
||||
func (m *Manager) keptCopyRels(destBase string) []string {
|
||||
if m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
base := filepath.Clean(destBase)
|
||||
var out []string
|
||||
for _, k := range m.settings.GetKeptCopies() {
|
||||
if filepath.Clean(k.DestBase) != base {
|
||||
continue
|
||||
}
|
||||
rel, err := filepath.Rel(base, filepath.Clean(k.Path))
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.ToSlash(rel))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dropKeptCopiesUnder (R-1252, HUNT-2 H9) drops the records of kept copies inside dir — called after dir itself was
|
||||
// deleted (Remove with „remove backups"), so the backups page never lists a copy that is gone.
|
||||
func (m *Manager) dropKeptCopiesUnder(dir string) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
d := filepath.Clean(dir)
|
||||
for _, k := range m.settings.GetKeptCopies() {
|
||||
p := filepath.Clean(k.Path)
|
||||
if p == d || strings.HasPrefix(p, d+string(filepath.Separator)) {
|
||||
if err := m.settings.DropKeptCopy(k.ID); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] the record of the deleted kept copy %s could not be dropped: %v", p, err)
|
||||
continue
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] kept copy %s was deleted with its app's backups — record dropped", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KeptCopies lists the kept copies for the backups page.
|
||||
func (m *Manager) KeptCopies() []settings.KeptCopy {
|
||||
if m.settings == nil {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-1252 (review round 2, HUNT-2 H1): a rejected backup block never deletes second-drive copies. RunTier2 reconciles
|
||||
// only on a valid classification (tier2CaptureSetFull's `classified`); for a REJECTED block it says so on the app's
|
||||
// status (note.tier2.settings_unreadable_kept) and tells the operator once a day through the existing operator-only
|
||||
// digest (backup_run_failures, leg tier2-settings-unreadable — no new event type, so no hub change). Pinned by
|
||||
// r1252_rejected_block_keeps_copies_test.go.
|
||||
|
||||
type tier2UnreadableWatch struct {
|
||||
mu sync.Mutex
|
||||
notify func(app, why string)
|
||||
sent map[string]string // app → the UTC day it was last sent
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// SetTier2SettingsUnreadableNotify wires the operator signal (main.go → notifier). INIT-ONLY.
|
||||
func (m *Manager) SetTier2SettingsUnreadableNotify(fn func(app, why string)) {
|
||||
m.tier2Unreadable.mu.Lock()
|
||||
m.tier2Unreadable.notify = fn
|
||||
m.tier2Unreadable.mu.Unlock()
|
||||
}
|
||||
|
||||
// backupBlockRejected asks the stack provider why the app's backup block was rejected ("" = valid or absent).
|
||||
func (m *Manager) backupBlockRejected(stack string) string {
|
||||
if r, ok := m.stackProvider.(interface{ BackupBlockRejected(string) string }); ok {
|
||||
return r.BackupBlockRejected(stack)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// notifyTier2SettingsUnreadable sends the operator signal at most once per app per UTC day.
|
||||
func (m *Manager) notifyTier2SettingsUnreadable(app, why string) {
|
||||
w := &m.tier2Unreadable
|
||||
w.mu.Lock()
|
||||
now := time.Now
|
||||
if w.now != nil {
|
||||
now = w.now
|
||||
}
|
||||
day := now().UTC().Format("2006-01-02")
|
||||
if w.notify == nil || w.sent[app] == day {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if w.sent == nil {
|
||||
w.sent = map[string]string{}
|
||||
}
|
||||
w.sent[app] = day
|
||||
fn := w.notify
|
||||
w.mu.Unlock()
|
||||
fn(app, why)
|
||||
}
|
||||
@@ -1897,6 +1897,7 @@
|
||||
"note.tier2.same_disk": "the target you picked is on the same physical disk",
|
||||
"note.backup.single_copy_unproven": "Felhom cannot check that the second drive is really a different disk (inside a virtual machine and behind some USB enclosures this is not visible), so it does not count it as a second copy.",
|
||||
"note.backup.single_copy": "Only one local copy is made: there is no second disk, or it is on the same disk as the data. This protects against mistakes, not against a disk failure. A different disk or the off-site copy protects against a disk failure.",
|
||||
"note.tier2.settings_unreadable_kept": "The backup settings of the app cannot be read now, so no earlier copy on the second drive was deleted. New folders are not copied until then. Felhom support has been told.",
|
||||
"note.tier2.ssd_partial": "Only the settings, the database and the required data fit on the internal SSD — the optional content was not copied.",
|
||||
"note.tier2.unit_preserved": "The data package on the main drive was incomplete, so the complete one already in the second copy was kept. The copy's data package is therefore older than this backup.",
|
||||
"note.tier2_no_coverage": "This app's data cannot be restored from this copy — the app was not stopped. Use the Start restore button on the Backup → Restore page.",
|
||||
|
||||
@@ -1885,6 +1885,7 @@
|
||||
"note.tier2.same_disk": "a kiválasztott cél ugyanazon a fizikai lemezen van",
|
||||
"note.backup.single_copy_unproven": "A Felhom nem tudja ellenőrizni, hogy a második meghajtó valóban másik lemez-e (virtuális gépben és egyes USB-házakban ez nem látszik), ezért nem számolja második másolatnak.",
|
||||
"note.backup.single_copy": "Csak egy helyi másolat készül: nincs második lemez, vagy ugyanazon a lemezen van, mint az adatok. Ez a véletlen törlés ellen véd, lemezhiba ellen nem. Lemezhiba ellen egy másik lemez vagy a távoli mentés véd.",
|
||||
"note.tier2.settings_unreadable_kept": "Az alkalmazás mentési beállításai most nem olvashatók, ezért a második meghajtón egyetlen korábbi másolat sem törlődött. Az új mappák másolása addig szünetel. A Felhom ügyfélszolgálata értesítést kapott.",
|
||||
"note.tier2.ssd_partial": "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.",
|
||||
"note.tier2.unit_preserved": "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés.",
|
||||
"note.tier2_no_coverage": "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon.",
|
||||
|
||||
@@ -215,3 +215,26 @@ func TestCatalogBlocks_Fidelity(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// R-1252: the second-drive copy must tell a REJECTED block from an absent one (a rejected one keeps every copy and
|
||||
// alarms; an absent one is quiet). Through the REAL LoadMetadata path. RED-PROOF: drop the BackupRejected assignment
|
||||
// in LoadMetadata → the rejected fixture reads "" and this fails.
|
||||
func TestR1252_BackupBlockRejectedNamesTheReason(t *testing.T) {
|
||||
compose := `services:
|
||||
app:
|
||||
volumes:
|
||||
- ${USERDATA_PATH}/media/audiobooks:/audiobooks
|
||||
`
|
||||
bad := newClassifyManager(t, "display_name: App\nbackup:\n userdata:\n - path: media/nope\n class: mandatory\n", compose)
|
||||
if why := bad.BackupBlockRejected("app"); why == "" {
|
||||
t.Fatal("a block whose path matches no compose bind must be reported as rejected, with its reason")
|
||||
}
|
||||
good := newClassifyManager(t, "display_name: App\nbackup:\n userdata:\n - path: media/audiobooks\n class: mandatory\n", compose)
|
||||
if why := good.BackupBlockRejected("app"); why != "" {
|
||||
t.Fatalf("a valid block is not rejected, got %q", why)
|
||||
}
|
||||
none := newClassifyManager(t, "display_name: App\n", compose)
|
||||
if why := none.BackupBlockRejected("app"); why != "" {
|
||||
t.Fatalf("an absent block is not rejected, got %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ type Metadata struct {
|
||||
// block (sets this back to nil + logs one ERROR) on any validation defect, so a bad catalog push
|
||||
// degrades to legacy loudly rather than partially classifying. Consumed by Task 3/4 — INERT today.
|
||||
Backup *appbackup.BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"`
|
||||
// BackupRejected (R-1252) is why LoadMetadata rejected the backup block ("" = not rejected). Never read from the
|
||||
// file: the second-drive copy must tell a REJECTED block (keep every copy, alarm) from an absent one.
|
||||
BackupRejected string `yaml:"-" json:"-"`
|
||||
// DataPaths is the customer-facing folder ANNOTATION (R-75): role + Hungarian label over paths
|
||||
// that must ALREADY exist as compose binds. It never declares a path. Validated in LoadMetadata
|
||||
// with the Fork-3 asymmetry — a malformed PATH rejects the whole block, an unknown ROLE drops
|
||||
@@ -551,9 +554,11 @@ func loadMetadata(stackDir string, validateBackup bool) Metadata {
|
||||
if composeErr != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
|
||||
meta.Backup = nil
|
||||
meta.BackupRejected = "docker-compose.yml unreadable: " + composeErr.Error()
|
||||
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
|
||||
meta.Backup = nil
|
||||
meta.BackupRejected = err.Error()
|
||||
}
|
||||
}
|
||||
// data_paths (R-75) rides the same choke point, with its own asymmetric rules: a malformed
|
||||
@@ -597,6 +602,15 @@ func (m *Manager) ClassifiedBinds(name string) ([]appbackup.ClassifiedBind, bool
|
||||
return appbackup.ClassifyBinds(meta.Backup, binds)
|
||||
}
|
||||
|
||||
// BackupBlockRejected (R-1252) is why this app's backup block was rejected, or "" when it is valid or absent.
|
||||
func (m *Manager) BackupBlockRejected(name string) string {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok || name == SambaStackName {
|
||||
return ""
|
||||
}
|
||||
return LoadMetadata(filepath.Dir(stack.ComposePath)).BackupRejected
|
||||
}
|
||||
|
||||
// ExcludedDockerVolumes returns the Docker names of the stack's named volumes its template excludes from every app-data backup
|
||||
// (R-49, D21 = B; appbackup.ExcludedDockerVolumes), and a sentence per template entry that was ignored.
|
||||
func (m *Manager) ExcludedDockerVolumes(name string) ([]string, []string) {
|
||||
|
||||
Reference in New Issue
Block a user