Files
felhom-controller/controller/internal/backup/tier2_kept.go
T

175 lines
6.2 KiB
Go

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
}