175 lines
6.2 KiB
Go
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
|
|
}
|