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,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
|
||||
}
|
||||
Reference in New Issue
Block a user