kept second-drive copies (D25: R-1175, R-1181, R-1179); .fab import keeps the family gate (R-1191); stale restore temp (R-1188 c); the overcommit sentence made true (R-1192); escrow before the floor (R-1176); unreleased
gates / gates (push) Successful in 1m20s
gates / gates (push) Successful in 1m20s
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1175 + D25 (operator ruling 2026-10-21), scenario B. The first two tests are the data-loss hunt's C3 cases
|
||||
// (felhom.eu audits/dataloss-hunt-2026-10-20/C3-D1-shares-tier2/), now asserting instead of logging.
|
||||
|
||||
func sharesCopy(env *sharesEnv, target, srcDrive, share string) string {
|
||||
return filepath.Join(NamespaceRoot(env.drives[target], true), "backups", "secondary", SharesPseudoStack,
|
||||
sharesDriveKey(env.drives[srcDrive]), share)
|
||||
}
|
||||
|
||||
func mustExist(t *testing.T, p, why string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("%s: %s is gone (%v)", why, p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shares on hdd_2 and hdd_3 both copy to hdd_1: after EVERY run both groups' copies exist. Before the fix each group's
|
||||
// reconcile walked every drive key and removed the other group's copy.
|
||||
func TestR1175_TwoDrivesSharingOneTargetKeepBothCopies(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
|
||||
env.addShare(t, "hdd_2", "b_share", true)
|
||||
env.addShare(t, "hdd_3", "c_share", true)
|
||||
for run := 1; run <= 2; run++ {
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_2", "b_share"), "b_share.txt"), "run "+string(rune('0'+run))+": hdd_2's share copy")
|
||||
mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_3", "c_share"), "c_share.txt"), "run "+string(rune('0'+run))+": hdd_3's share copy")
|
||||
}
|
||||
}
|
||||
|
||||
// The night a drive is away its copy is NOT deleted: it stays, and it is listed as kept (drive_away).
|
||||
func TestR1175_AnAwayDrivesShareCopyIsKeptAndListed(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
|
||||
env.addShare(t, "hdd_2", "b_share", true)
|
||||
env.addShare(t, "hdd_3", "csaladi_fotok", true)
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.sett.SetDisconnected(env.drives["hdd_3"], true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cp := sharesCopy(env, "hdd_1", "hdd_3", "csaladi_fotok")
|
||||
mustExist(t, filepath.Join(cp, "csaladi_fotok.txt"), "the away drive's only second copy")
|
||||
kept := env.m.KeptCopies()
|
||||
if len(kept) != 1 || kept[0].Source != "csaladi_fotok" || kept[0].Reason != "drive_away" || kept[0].Path != filepath.Clean(cp) {
|
||||
t.Fatalf("kept copies = %+v, want csaladi_fotok (drive_away) at %s", kept, cp)
|
||||
}
|
||||
// The drive comes back: the copy is live again and its record goes.
|
||||
if err := env.sett.SetDisconnected(env.drives["hdd_3"], false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k := env.m.KeptCopies(); len(k) != 0 {
|
||||
t.Fatalf("after the drive came back, kept copies = %+v, want none", k)
|
||||
}
|
||||
}
|
||||
|
||||
// A registered share whose FOLDER is missing (drive present) keeps its copy; a share the household DELETED is pruned.
|
||||
func TestR1175_MissingFolderKeptDeletedSharePruned(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
gone := env.addShare(t, "hdd_1", "eltunt", true)
|
||||
env.addShare(t, "hdd_1", "torolt", true)
|
||||
env.addShare(t, "hdd_1", "marad", true)
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.RemoveAll(gone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.sett.RemoveSMBShare("torolt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustExist(t, filepath.Join(sharesCopy(env, "hdd_2", "hdd_1", "eltunt"), "eltunt.txt"), "a missing folder's copy")
|
||||
if _, err := os.Stat(sharesCopy(env, "hdd_2", "hdd_1", "torolt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("a deleted share's copy was not pruned (stat err %v)", err)
|
||||
}
|
||||
if k := env.m.KeptCopies(); len(k) != 1 || k[0].Source != "eltunt" || k[0].Reason != "folder_missing" {
|
||||
t.Fatalf("kept copies = %+v, want eltunt (folder_missing)", k)
|
||||
}
|
||||
}
|
||||
|
||||
// An EMPTIED present folder is not mirrored over a copy that holds files: the copy stays and the run warns.
|
||||
func TestR1181_AnEmptiedShareDoesNotEmptyItsCopy(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
if _, err := exec.LookPath("rsync"); err == nil {
|
||||
env.m.tier2Mirror = rsyncMirror // the production mirror: `rsync -a --delete` is what empties a copy
|
||||
}
|
||||
p := env.addShare(t, "hdd_1", "fotok", true)
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(p, "fotok.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustExist(t, filepath.Join(sharesCopy(env, "hdd_2", "hdd_1", "fotok"), "fotok.txt"), "an emptied folder's copy")
|
||||
st := env.m.SharesTier2Status()
|
||||
if st == nil || !strings.Contains(st.LastWarning, "fotok") {
|
||||
t.Fatalf("the run did not warn about the emptied folder: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// „Eltávolítás" removes ONE kept copy by its record, and refuses a directory that is not the recorded one.
|
||||
func TestD25_RemovingAKeptCopyIsByRecordAndIdentity(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
|
||||
env.addShare(t, "hdd_2", "b_share", true)
|
||||
env.addShare(t, "hdd_3", "c_share", true)
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.sett.SetDecommissioned(env.drives["hdd_3"], ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k := env.m.KeptCopies()
|
||||
if len(k) != 1 || k[0].Reason == "" {
|
||||
t.Fatalf("kept = %+v", k)
|
||||
}
|
||||
// Swap the directory for another one at the same path: same name, different identity → refused, nothing removed.
|
||||
cp := k[0].Path
|
||||
if err := os.Rename(cp, cp+".old"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(cp, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RemoveKeptCopy(k[0].ID); err == nil {
|
||||
t.Fatal("a directory that is not the recorded one was removed")
|
||||
}
|
||||
mustExist(t, cp, "the refused removal")
|
||||
// Put the recorded one back → removed, and only it.
|
||||
_ = os.Remove(cp)
|
||||
if err := os.Rename(cp+".old", cp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RemoveKeptCopy(k[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(cp); !os.IsNotExist(err) {
|
||||
t.Fatalf("the kept copy is still there: %v", err)
|
||||
}
|
||||
mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_2", "b_share"), "b_share.txt"), "the other drive's copy")
|
||||
if len(env.m.KeptCopies()) != 0 {
|
||||
t.Fatal("the record outlived the removal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1179: a drive marked AWAY is never the second-drive target. Seen in the R-1175 unit test of the data-loss hunt
|
||||
// (a share copied to a drive marked disconnected). COMPANION RED-PROOF: drop the Disconnected skip → the copy lands
|
||||
// on hdd_1.
|
||||
func TestR1179_AnAwayDriveIsNotChosenAsTarget(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
|
||||
env.addShare(t, "hdd_2", "b_share", true)
|
||||
if err := env.sett.SetDisconnected(env.drives["hdd_1"], true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, call := range env.mirrored {
|
||||
if strings.Contains(call, "=>"+env.drives["hdd_1"]+string(os.PathSeparator)) {
|
||||
t.Fatalf("a copy went to the AWAY drive hdd_1: %s", call)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sharesCopy(env, "hdd_3", "hdd_2", "b_share"), "b_share.txt")); err != nil {
|
||||
t.Fatalf("no copy on the present drive hdd_3: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1188 (c): the data-loss hunt's C3 case (felhom.eu audits/dataloss-hunt-2026-10-20/C3-R12-leftover-temp/): a power
|
||||
// cut mid-copy leaves `<file>.felhom-restoring`; the retry must bring the file back instead of failing „file exists".
|
||||
// COMPANION RED-PROOF: drop the leftover removal in copyFilePreserving → the retry fails and p1.jpg stays missing.
|
||||
func TestR1188c_ALeftoverTempDoesNotBlockTheRetry(t *testing.T) {
|
||||
f, _, liveLeg := wholeFixture(t)
|
||||
if err := os.MkdirAll(liveLeg, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(liveLeg, "p1.jpg.felhom-restoring"), []byte("half"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.m.RestoreTier2Whole("app"); err != nil {
|
||||
t.Fatalf("the retry failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(liveLeg, "p1.jpg")); err != nil {
|
||||
t.Fatalf("p1.jpg is not back: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(liveLeg, "p1.jpg.felhom-restoring")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the leftover temp is still there: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1181 + D25 for app folders — the data-loss hunt's C3 case (felhom.eu audits/dataloss-hunt-2026-10-20/
|
||||
// C3-D2-missing-leg/), asserting: a MISSING declared folder keeps its second-drive copy and is listed as kept; an
|
||||
// EMPTIED folder is not mirrored over its full copy (the production `rsync -a --delete` mirror is used when present).
|
||||
func TestR1181_MissingAndEmptiedAppFoldersKeepTheirCopies(t *testing.T) {
|
||||
m, src, target, prov := newTier2V2(t, "app")
|
||||
if _, err := exec.LookPath("rsync"); err == nil {
|
||||
m.tier2Mirror = rsyncMirror
|
||||
}
|
||||
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)
|
||||
}
|
||||
destBase := filepath.Join(target, "backups", "secondary", "app")
|
||||
photo := filepath.Join(destBase, "hdd", "appdata", "app", "photos", "family.jpg")
|
||||
doc := filepath.Join(destBase, "hdd", "appdata", "app", "docs", "contract.pdf")
|
||||
if !exists(photo) || !exists(doc) {
|
||||
t.Fatal("precondition: run 1 did not copy both folders")
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(src, "appdata", "app", "photos")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(src, "appdata", "app", "docs", "contract.pdf")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(photo) {
|
||||
t.Fatal("the MISSING folder's second-drive copy was deleted (R-1181)")
|
||||
}
|
||||
if !exists(doc) {
|
||||
t.Fatal("the EMPTIED folder was mirrored over its full copy (R-1181)")
|
||||
}
|
||||
kept := m.KeptCopies()
|
||||
if len(kept) != 1 || kept[0].Kind != "app" || kept[0].Reason != "folder_missing" {
|
||||
t.Fatalf("kept copies = %+v, want the photos folder", kept)
|
||||
}
|
||||
if cd := m.settings.GetCrossDriveConfig("app"); cd == nil || cd.LastWarning == "" {
|
||||
t.Fatalf("the run did not warn: %+v", cd)
|
||||
}
|
||||
// The folder comes back: mirrored again, the record goes.
|
||||
mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO")
|
||||
if err := m.RunTier2("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.KeptCopies()) != 0 {
|
||||
t.Fatalf("the record outlived the folder's return: %+v", m.KeptCopies())
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,12 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
|
||||
if sp.Path == sourceDrive {
|
||||
continue
|
||||
}
|
||||
// R-1179: a drive marked away is never a target. Its mount point is an empty folder on the guest's root
|
||||
// disk (or the host's), so the night's copy would fill the wrong disk and not be where the household
|
||||
// thinks. Decommissioned drives are already left out by GetSchedulableStoragePaths.
|
||||
if sp.Disconnected {
|
||||
continue
|
||||
}
|
||||
if sp.IsNetwork() {
|
||||
sawNetworkCandidate = true // F-6C-1: never a tier-2 target
|
||||
continue
|
||||
@@ -350,7 +356,7 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
}
|
||||
|
||||
// Class-driven legs (classified) / resolver legs (legacy) + loud gap warnings (§7-H).
|
||||
legs, warns := m.tier2CaptureSet(stackName, sourceNsRoot)
|
||||
legs, missingLegs, warns := m.tier2CaptureSetFull(stackName, sourceNsRoot)
|
||||
|
||||
// Two sizes: full = unit + all legs; state-only = unit + mandatory legs (the SSD ceiling).
|
||||
unitSize := dirSizeBytes(unitDir)
|
||||
@@ -440,7 +446,13 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
legRels := make([]string, 0, len(legs))
|
||||
mirroredSize := unitSize
|
||||
for _, lg := range legs {
|
||||
if err := mirror(lg.Src, filepath.Join(destBase, filepath.FromSlash(lg.DestRel))); err != nil {
|
||||
dst := filepath.Join(destBase, filepath.FromSlash(lg.DestRel))
|
||||
if m.emptiedSourceRefused(stackName+" "+lg.DestRel, lg.Src, dst) {
|
||||
legRels = append(legRels, lg.DestRel) // kept as it is (R-1181)
|
||||
warns = append(warns, m.note("note.tier2.emptied_kept", lg.DestRel))
|
||||
continue
|
||||
}
|
||||
if err := mirror(lg.Src, dst); err != nil {
|
||||
m.recordTier2Failure(stackName, target, err)
|
||||
if m.tier2Notify != nil {
|
||||
m.tier2Notify(stackName, target.Label, time.Since(start), err)
|
||||
@@ -449,6 +461,13 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
}
|
||||
legRels = append(legRels, lg.DestRel)
|
||||
mirroredSize += dirSizeBytes(lg.Src)
|
||||
m.releaseKeptCopy(dst)
|
||||
}
|
||||
// D25 / R-1181: a declared folder that is MISSING keeps its copy (kept + listed), never reconciled away.
|
||||
for _, lg := range missingLegs {
|
||||
legRels = append(legRels, lg.DestRel)
|
||||
m.keepCopy("app", stackName+": "+lg.DestRel, m.storageLabel(sourceDrive), "folder_missing", destBase,
|
||||
filepath.Join(destBase, filepath.FromSlash(lg.DestRel)))
|
||||
}
|
||||
|
||||
// Reconcile stale dest dirs (a bind removed / re-classed excluded), then write the marker LAST.
|
||||
|
||||
@@ -40,6 +40,14 @@ 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)
|
||||
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 path.
|
||||
if m.stackProvider != nil {
|
||||
if binds, has := m.stackProvider.GetStackClassifiedBinds(stack); has {
|
||||
@@ -58,7 +66,8 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 %s: mandatory data path missing on disk, skipped: %s", stack, p.Abs)
|
||||
gaps = append(gaps, p.RelPath)
|
||||
}
|
||||
continue // optional-missing is silent (not a gap)
|
||||
missing = append(missing, tier2Leg{Src: p.Abs, DestRel: tier2DestRel(p.Root, p.RelPath), Class: p.Class})
|
||||
continue // optional-missing is silent (not a gap); its copy is kept (D25)
|
||||
}
|
||||
legs = append(legs, tier2Leg{Src: p.Abs, DestRel: tier2DestRel(p.Root, p.RelPath), Class: p.Class})
|
||||
}
|
||||
@@ -66,7 +75,7 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
|
||||
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, warns
|
||||
return legs, missing, warns
|
||||
}
|
||||
}
|
||||
// Legacy path: the resolver appdata dir(s) → hdd/appdata/<name> legs (mandatory-equivalent for SSD
|
||||
@@ -79,6 +88,7 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
|
||||
if declared {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 %s: compose declares appdata dir %q but it is absent at %s — leg skipped",
|
||||
stack, name, src)
|
||||
missing = append(missing, tier2Leg{Src: src, DestRel: filepath.ToSlash(filepath.Join("hdd", "appdata", name)), Class: appbackup.ClassMandatory})
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -88,5 +98,5 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
|
||||
Class: appbackup.ClassMandatory,
|
||||
})
|
||||
}
|
||||
return legs, warns
|
||||
return legs, missing, warns
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// ── D25 (operator ruling 2026-10-21; R-1175 + R-1181): a second-drive copy whose SOURCE is gone is KEPT ─────────────
|
||||
//
|
||||
// A source is gone when its drive is away or decommissioned, or a declared folder is missing. The night job never
|
||||
// removes such a copy: it records it (settings.KeptCopy), the backups page lists it as kept, and only the household's
|
||||
// „Eltávolítás" removes it — by its recorded path, identity-checked (device + inode), never by size or a name pattern.
|
||||
// A share the household DELETED or RENAMED on a present drive is still pruned (that is the household's own act).
|
||||
//
|
||||
// 07 §8.2 chose „data legs are not guarded" for a leg that LEAVES the class set; a gone source is a different case,
|
||||
// and D25 decides it the other way: the copy may be the only one left.
|
||||
|
||||
// keepCopy records path (an existing directory inside destBase) as a kept copy. A missing path records nothing — there
|
||||
// is nothing to keep.
|
||||
func (m *Manager) keepCopy(kind, source, drive, reason, destBase, path string) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil || !st.IsDir() {
|
||||
return
|
||||
}
|
||||
dev, ino := statIdentity(st)
|
||||
k := settings.KeptCopy{Kind: kind, Source: source, Drive: drive, Reason: reason, DestBase: filepath.Clean(destBase),
|
||||
Path: filepath.Clean(path), Dev: dev, Ino: ino, SizeBytes: dirSizeBytes(path)}
|
||||
if err := m.settings.UpsertKeptCopy(k); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] kept copy %s could not be recorded (the copy itself is kept): %v", path, err)
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] second-drive copy KEPT (source gone: %s): %s %q at %s", reason, kind, source, path)
|
||||
}
|
||||
|
||||
// releaseKeptCopy forgets the record of path because its source is back (the run mirrored it again). Data untouched.
|
||||
func (m *Manager) releaseKeptCopy(path string) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
id := settings.KeptCopyID(filepath.Clean(path))
|
||||
for _, k := range m.settings.GetKeptCopies() {
|
||||
if k.ID == id {
|
||||
_ = m.settings.DropKeptCopy(id)
|
||||
m.logger.Printf("[INFO] [backup] kept copy %s is live again (its source is back) — record dropped", path)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KeptCopies lists the kept copies for the backups page.
|
||||
func (m *Manager) KeptCopies() []settings.KeptCopy {
|
||||
if m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
return m.settings.GetKeptCopies()
|
||||
}
|
||||
|
||||
// ErrKeptCopyNotFound / ErrKeptCopyChanged are the refusals of RemoveKeptCopy.
|
||||
var (
|
||||
ErrKeptCopyNotFound = errors.New("kept copy not found")
|
||||
ErrKeptCopyChanged = errors.New("kept copy is not the recorded directory any more")
|
||||
)
|
||||
|
||||
// RemoveKeptCopy is the household's „Eltávolítás": it removes ONE kept copy by its record — the recorded path, still
|
||||
// strictly inside the recorded copy tree, and still the same directory (device + inode) the night recorded. Anything
|
||||
// else refuses and removes nothing. A copy already gone just drops its record.
|
||||
func (m *Manager) RemoveKeptCopy(id string) error {
|
||||
if m.settings == nil {
|
||||
return ErrKeptCopyNotFound
|
||||
}
|
||||
var rec *settings.KeptCopy
|
||||
for _, k := range m.settings.GetKeptCopies() {
|
||||
if k.ID == id {
|
||||
kk := k
|
||||
rec = &kk
|
||||
break
|
||||
}
|
||||
}
|
||||
if rec == nil {
|
||||
return ErrKeptCopyNotFound
|
||||
}
|
||||
st, err := os.Lstat(rec.Path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
m.logger.Printf("[INFO] [backup] kept copy %s is already gone — record dropped", rec.Path)
|
||||
return m.settings.DropKeptCopy(id)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("kept copy %s: %w", rec.Path, err)
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return fmt.Errorf("%w: %s is not a directory", ErrKeptCopyChanged, rec.Path)
|
||||
}
|
||||
if dev, ino := statIdentity(st); dev != rec.Dev || ino != rec.Ino {
|
||||
m.logger.Printf("[WARN] [backup] kept copy removal REFUSED: %s is not the recorded directory (identity changed)", rec.Path)
|
||||
return fmt.Errorf("%w: %s", ErrKeptCopyChanged, rec.Path)
|
||||
}
|
||||
rel, err := filepath.Rel(rec.DestBase, rec.Path)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("%w: %s is not inside %s", ErrKeptCopyChanged, rec.Path, rec.DestBase)
|
||||
}
|
||||
if err := tier2SafeRemove(rec.DestBase, rec.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] kept copy REMOVED by the household: %s %q (%s) at %s", rec.Kind, rec.Source, humanizeBytes(rec.SizeBytes), rec.Path)
|
||||
return m.settings.DropKeptCopy(id)
|
||||
}
|
||||
|
||||
func statIdentity(st os.FileInfo) (dev, ino uint64) {
|
||||
if s, ok := st.Sys().(*syscall.Stat_t); ok {
|
||||
return uint64(s.Dev), s.Ino
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// ── R-1181 second half: an EMPTIED present folder is not mirrored over a full copy ────────────────────────────────
|
||||
|
||||
// hasRegularFile reports whether dir holds at least one regular file anywhere below it (stops at the first).
|
||||
func hasRegularFile(dir string) bool {
|
||||
found := false
|
||||
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.Type().IsRegular() {
|
||||
found = true
|
||||
return fs.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// countRegularFiles counts the regular files below dir (for the log line only).
|
||||
func countRegularFiles(dir string) int {
|
||||
n := 0
|
||||
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err == nil && d.Type().IsRegular() {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
// emptiedSourceRefused reports whether mirroring src over dst would turn a copy that holds files into an empty one
|
||||
// (`rsync --delete` from an emptied folder). Such a leg is refused for this run: the copy stays, the run warns.
|
||||
func (m *Manager) emptiedSourceRefused(what, src, dst string) bool {
|
||||
if hasRegularFile(src) || !hasRegularFile(dst) {
|
||||
return false
|
||||
}
|
||||
m.logger.Printf("[WARN] [backup] Tier 2: %s — the source folder is EMPTY (0 files) and its second-drive copy holds %d file(s); NOT mirrored, the copy is kept: %s",
|
||||
what, countRegularFiles(dst), dst)
|
||||
return true
|
||||
}
|
||||
|
||||
// storageLabel is the label the pages show for a registered drive path (its path when it has none).
|
||||
func (m *Manager) storageLabel(path string) string {
|
||||
if m.settings != nil {
|
||||
if l := m.settings.GetStorageLabel(path); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -128,6 +128,18 @@ func (m *Manager) RunSharesTier2() error {
|
||||
}
|
||||
g.shares = append(g.shares, sh)
|
||||
}
|
||||
// D25: every REGISTERED share per source drive, available or not — a registered share whose folder is missing
|
||||
// keeps its copy (and is listed as kept); only a share the household deleted or renamed is pruned.
|
||||
registeredByDrive := map[string][]settings.SMBShare{}
|
||||
if m.settings != nil {
|
||||
for _, sh := range m.settings.GetSMBShares() {
|
||||
if d := m.shareSourceDrive(sh.Path); d != "" {
|
||||
registeredByDrive[d] = append(registeredByDrive[d], sh)
|
||||
}
|
||||
}
|
||||
}
|
||||
touched := map[string]map[string]bool{} // destBase → the drive keys handled there this run
|
||||
|
||||
var groups []*sharesTier2Group
|
||||
for _, g := range byDrive {
|
||||
groups = append(groups, g)
|
||||
@@ -202,7 +214,13 @@ func (m *Manager) RunSharesTier2() error {
|
||||
legRels := make([]string, 0, len(legs)+1)
|
||||
for _, sh := range legs {
|
||||
rel := g.key + "/" + sh.Name
|
||||
if err := mirror(sh.Path, filepath.Join(destBase, filepath.FromSlash(rel))); err != nil {
|
||||
dst := filepath.Join(destBase, filepath.FromSlash(rel))
|
||||
if m.emptiedSourceRefused("share "+sh.Name, sh.Path, dst) {
|
||||
legRels = append(legRels, rel) // kept as it is
|
||||
warns = append(warns, m.note("note.tier2.emptied_kept", sh.Name))
|
||||
continue
|
||||
}
|
||||
if err := mirror(sh.Path, dst); err != nil {
|
||||
m.recordTier2Failure(SharesPseudoStack, target, err)
|
||||
if m.tier2Notify != nil {
|
||||
// DISPLAY BOUNDARY: the notifier feeds Hungarian customer/operator copy, so the
|
||||
@@ -215,6 +233,16 @@ func (m *Manager) RunSharesTier2() error {
|
||||
legRels = append(legRels, rel)
|
||||
totalSize += dirSizeBytes(sh.Path)
|
||||
mirrored++
|
||||
m.releaseKeptCopy(dst)
|
||||
}
|
||||
// D25: a registered share of this drive whose folder is missing keeps its copy (and is listed as kept).
|
||||
for _, sh := range registeredByDrive[g.sourceDrive] {
|
||||
if m.shareBackupAvailable(sh.Path) {
|
||||
continue
|
||||
}
|
||||
rel := g.key + "/" + sh.Name
|
||||
legRels = append(legRels, rel)
|
||||
m.keepCopy("share", sh.Name, m.storageLabel(g.sourceDrive), "folder_missing", destBase, filepath.Join(destBase, filepath.FromSlash(rel)))
|
||||
}
|
||||
// The payload rides every target so each one is independently restorable.
|
||||
if payloadDir != "" {
|
||||
@@ -227,7 +255,15 @@ func (m *Manager) RunSharesTier2() error {
|
||||
// Prune dest dirs no share covers any more (a share deleted or renamed since the last run
|
||||
// stops occupying the secondary drive within one run), then the marker LAST — a half-written
|
||||
// dest therefore has no marker and the restore path refuses it until the next good run.
|
||||
m.tier2ReconcileRoots(destBase, sharesTier2DestRoots(destBase), legRels)
|
||||
//
|
||||
// R-1175: ONLY this group's own drive key. Walking every key on disk with only this group's legs removed
|
||||
// every OTHER group's copies on a shared target (after each run only the last group had a copy) and, on the
|
||||
// night a drive died, its only second copy. Keys no group handled this run are dealt with once, below.
|
||||
m.tier2ReconcileRoots(destBase, []string{g.key}, legRels)
|
||||
if touched[destBase] == nil {
|
||||
touched[destBase] = map[string]bool{}
|
||||
}
|
||||
touched[destBase][g.key] = true
|
||||
if err := m.writeTier2Marker(destBase); err != nil {
|
||||
m.logger.Printf("[WARN] [shares] tier-2: layout marker write failed (restore will refuse until next run): %v", err)
|
||||
}
|
||||
@@ -236,6 +272,10 @@ func (m *Manager) RunSharesTier2() error {
|
||||
len(legs), g.sourceDrive, destBase, humanizeBytes(totalSize))
|
||||
}
|
||||
|
||||
for destBase, done := range touched {
|
||||
m.sharesTier2OtherKeys(destBase, done, registeredByDrive)
|
||||
}
|
||||
|
||||
dur := time.Since(start)
|
||||
if lastTarget == nil {
|
||||
reason := strings.Join(noTargetWhy, "; ")
|
||||
@@ -260,9 +300,7 @@ func (m *Manager) RunSharesTier2() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sharesTier2DestRoots lists the top-level dirs under a shares destBase that the reconcile pass may
|
||||
// walk. It reads what is ON DISK rather than what this run produced, so a drive key from a drive that
|
||||
// is no longer registered still gets visited (and pruned) instead of lingering forever.
|
||||
// sharesTier2DestRoots lists the top-level drive-key dirs under a shares destBase (the payload dir is not one).
|
||||
func sharesTier2DestRoots(destBase string) []string {
|
||||
entries, err := os.ReadDir(destBase)
|
||||
if err != nil {
|
||||
@@ -270,7 +308,7 @@ func sharesTier2DestRoots(destBase string) []string {
|
||||
}
|
||||
var roots []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
if e.IsDir() && e.Name() != sharesPayloadDestRel {
|
||||
roots = append(roots, e.Name())
|
||||
}
|
||||
}
|
||||
@@ -278,6 +316,50 @@ func sharesTier2DestRoots(destBase string) []string {
|
||||
return roots
|
||||
}
|
||||
|
||||
// sharesTier2OtherKeys handles the drive keys on destBase that no group mirrored this run (D25, R-1175):
|
||||
// - the key of a registered drive that is PRESENT: its shares were deleted, renamed or have missing folders — a
|
||||
// registered share's copy is kept (listed), a deleted/renamed share's copy is pruned (the household's own act);
|
||||
// - the key of a drive that is AWAY or DECOMMISSIONED, or of no registered drive at all: every copy under it is
|
||||
// kept and listed. Before 2026-10-21 these were pruned on the first run without them.
|
||||
func (m *Manager) sharesTier2OtherKeys(destBase string, done map[string]bool, registeredByDrive map[string][]settings.SMBShare) {
|
||||
byKey := map[string]settings.StoragePath{}
|
||||
if m.settings != nil {
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
byKey[sharesDriveKey(sp.Path)] = sp
|
||||
}
|
||||
}
|
||||
for _, key := range sharesTier2DestRoots(destBase) {
|
||||
if done[key] {
|
||||
continue
|
||||
}
|
||||
keyDir := filepath.Join(destBase, key)
|
||||
sp, known := byKey[key]
|
||||
if known && !sp.Disconnected && !sp.Decommissioned {
|
||||
var keep []string
|
||||
for _, sh := range registeredByDrive[sp.Path] {
|
||||
rel := key + "/" + sh.Name
|
||||
keep = append(keep, rel)
|
||||
m.keepCopy("share", sh.Name, m.storageLabel(sp.Path), "folder_missing", destBase, filepath.Join(destBase, filepath.FromSlash(rel)))
|
||||
}
|
||||
m.tier2ReconcileRoots(destBase, []string{key}, keep)
|
||||
continue
|
||||
}
|
||||
reason, drive := "drive_gone", key
|
||||
if known {
|
||||
drive = m.storageLabel(sp.Path)
|
||||
if sp.Disconnected {
|
||||
reason = "drive_away"
|
||||
}
|
||||
}
|
||||
entries, _ := os.ReadDir(keyDir)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
m.keepCopy("share", e.Name(), drive, reason, destBase, filepath.Join(keyDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeTier2Marker writes the shared layout marker (content "2") — the LAST write of a dest, so its
|
||||
// presence means "every leg and the reconcile completed".
|
||||
func (m *Manager) writeTier2Marker(destBase string) error {
|
||||
|
||||
@@ -84,6 +84,14 @@ func copyFilePreserving(src, dst string, fi fs.FileInfo) error {
|
||||
}
|
||||
defer in.Close()
|
||||
tmp := dst + ".felhom-restoring"
|
||||
// R-1188 (c): an interrupted earlier restore (a power cut mid-copy) leaves this exact temp behind, and O_EXCL then
|
||||
// failed EVERY retry with „file exists" — the file never came back. The temp is ours by name; a leftover REGULAR
|
||||
// file of that one name is removed before the copy. Anything else at that name (a directory, a link) still refuses.
|
||||
if st, lerr := os.Lstat(tmp); lerr == nil && st.Mode().IsRegular() {
|
||||
if rerr := os.Remove(tmp); rerr != nil {
|
||||
return fmt.Errorf("leftover %s from an interrupted restore could not be removed: %w", filepath.Base(tmp), rerr)
|
||||
}
|
||||
}
|
||||
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fi.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user