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

This commit is contained in:
2026-10-21 15:46:10 +02:00
parent 8f5bea0148
commit 5929b8e345
34 changed files with 2152 additions and 44 deletions
@@ -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())
}
}
+21 -2
View File
@@ -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.
+13 -3
View File
@@ -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
}
+174
View File
@@ -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
}
+88 -6
View File
@@ -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