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

217 lines
7.5 KiB
Go

package backup
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// D62 (operator ruling 2026-10-29; `07` §6.3): the household sees its restore copies in the kept-copies list
// („Megőrzött másolatok") on the restore page — the app, the date of the restore the copy protects, its size, and the
// day the box deletes it by itself (restoreCopyUnusedAge after the restore) — and may delete one earlier with
// „Törlés most", after a warning and the app's name typed.
//
// A copy a hold or a pending put-back needs is NEVER deletable (the same three states the sweep leaves alone, R-1256
// H8): the row says why in one line and the button is off; the handler refuses it too. While any backup or restore
// operation runs on the box, a delete is refused (409). The check and the removal are ONE step under m.mu, so a
// restore cannot begin in between (the R-1256 H6 shape). Pinned by TestD62_*.
// Why a restore copy is needed — the row's one line, and the refusal's reason. "" = not needed: deletable.
const (
RestoreCopyNeededHold = "hold" // the app is held (a failed put-back keeps its copy, D20) — support decides
RestoreCopyNeededPutBack = "putback" // a put-back waits for its drive (R-1251) or a cut restore names it (D22)
RestoreCopyNeededUnknown = "unknown" // the app-stop marker cannot be read: nothing is offered (fail closed)
// RestoreCopyNeededChecking (R-1342): the box still watches whether the restored app runs stably (up to 30 minutes
// after the restore, R-1270) — the copy is the way back if it does not.
RestoreCopyNeededChecking = "checking"
)
var (
ErrRestoreCopyNotFound = errors.New("restore copy not found")
ErrRestoreCopyNeeded = errors.New("restore copy is needed by a hold or a pending put-back")
ErrRestoreCopyBusy = errors.New("a backup or restore operation runs")
)
// RestoreCopy is one restore copy (<namespace>/backups/restore-undo/<app>/<stamp>).
type RestoreCopy struct {
App string
Stamp string // the folder's name; with App, the copy's identity on the page
At time.Time // when the restore began (the stamp; the folder's time when the stamp does not parse)
SizeBytes int64
Path string
Needed string // "" or one of RestoreCopyNeeded*
DeleteOn time.Time // when the sweep removes it; zero when Needed != "" (a needed copy is never swept)
}
// restoreCopyAt is a copy's restore time: its stamp, or the folder's time when the stamp does not parse.
func restoreCopyAt(dir, name string) (time.Time, bool) {
if at, err := time.Parse(restoreCopyStampLayout, name); err == nil {
return at, true
}
fi, err := os.Stat(dir)
if err != nil {
return time.Time{}, false
}
return fi.ModTime(), true
}
// restoreCopyNeededFor is why app's copies are needed ("" = deletable). named is appStopNamedApps' answer, ok its ok.
func (m *Manager) restoreCopyNeededFor(app string, named map[string]bool, ok bool) string {
if !ok || m.settings == nil {
return RestoreCopyNeededUnknown
}
if named[app] {
return RestoreCopyNeededPutBack
}
if h, held := m.settings.GetRestoreHold(app); held {
if h.Reason == settings.HoldReasonPutBackPending {
return RestoreCopyNeededPutBack
}
return RestoreCopyNeededHold
}
return ""
}
func dirSize(dir string) int64 {
var n int64
_ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
if err == nil && d.Type().IsRegular() {
if fi, e := d.Info(); e == nil {
n += fi.Size()
}
}
return nil
})
return n
}
// RestoreCopies lists every restore copy on the box's drives, oldest first.
func (m *Manager) RestoreCopies() []RestoreCopy {
named, ok := m.appStopNamedApps()
var out []RestoreCopy
for _, nsRoot := range m.primaryUnitRoots() {
base := filepath.Join(nsRoot, "backups", "restore-undo")
apps, err := os.ReadDir(base)
if err != nil {
continue
}
for _, a := range apps {
if !a.IsDir() {
continue
}
app := a.Name()
needed := m.restoreCopyNeededFor(app, named, ok)
copies, err := os.ReadDir(filepath.Join(base, app))
if err != nil {
continue
}
for _, c := range copies {
if !c.IsDir() {
continue
}
dir := filepath.Join(base, app, c.Name())
at, ok := restoreCopyAt(dir, c.Name())
if !ok {
continue
}
rc := RestoreCopy{App: app, Stamp: c.Name(), At: at, SizeBytes: dirSize(dir), Path: dir, Needed: needed}
if rc.Needed == "" && m.watchingCopy(dir) {
rc.Needed = RestoreCopyNeededChecking
}
if rc.Needed == "" {
rc.DeleteOn = at.Add(restoreCopyUnusedAge)
}
out = append(out, rc)
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
return out
}
// validCopyName is a single path element: no separator, not empty, not "." or "..".
func validCopyName(s string) bool {
return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, `/\`) && filepath.Base(s) == s
}
// DeleteRestoreCopy removes ONE restore copy, named by its app and its folder's name, and nothing else (the path
// guard: both names are single path elements, the folder is a real directory — not a link — directly under
// <namespace>/backups/restore-undo/<app>/). Refused while any backup or restore operation runs (ErrRestoreCopyBusy)
// and for a copy a hold or a pending put-back needs (ErrRestoreCopyNeeded). The check and the removal hold m.mu, so
// no restore can begin in between. Returns the copy as it was (for the log line and the operator event).
func (m *Manager) DeleteRestoreCopy(app, stamp string) (RestoreCopy, error) {
if !validCopyName(app) || !validCopyName(stamp) {
return RestoreCopy{}, ErrRestoreCopyNotFound
}
m.mu.Lock()
defer m.mu.Unlock()
if busy, _ := m.restoreBusyLocked(); busy != "" {
return RestoreCopy{}, ErrRestoreCopyBusy
}
var found *RestoreCopy
for _, nsRoot := range m.primaryUnitRoots() {
appDir := filepath.Join(nsRoot, "backups", "restore-undo", app)
dir := filepath.Join(appDir, stamp)
fi, err := os.Lstat(dir)
if err != nil || !fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 {
continue
}
if filepath.Dir(dir) != appDir || filepath.Dir(appDir) != filepath.Join(nsRoot, "backups", "restore-undo") {
continue
}
at, ok := restoreCopyAt(dir, stamp)
if !ok {
continue
}
found = &RestoreCopy{App: app, Stamp: stamp, At: at, SizeBytes: dirSize(dir), Path: dir}
break
}
if found == nil {
return RestoreCopy{}, ErrRestoreCopyNotFound
}
named, ok := m.appStopNamedApps()
if found.Needed = m.restoreCopyNeededFor(app, named, ok); found.Needed != "" {
return *found, ErrRestoreCopyNeeded
}
if m.watching[found.Path] { // R-1342: under m.mu, the same lock the watch marks its copy under
found.Needed = RestoreCopyNeededChecking
return *found, ErrRestoreCopyNeeded
}
if err := os.RemoveAll(found.Path); err != nil {
return *found, fmt.Errorf("removing the restore copy %s: %w", found.Path, err)
}
_ = os.Remove(filepath.Dir(found.Path)) // the per-app folder, when it is now empty
return *found, nil
}
// setWatching marks (on) or clears a copy a stable-start watch holds (R-1342).
func (m *Manager) setWatching(dir string, on bool) {
if dir == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if on {
if m.watching == nil {
m.watching = map[string]bool{}
}
m.watching[dir] = true
return
}
delete(m.watching, dir)
}
// watchingCopy reports whether a stable-start watch still holds the copy.
func (m *Manager) watchingCopy(dir string) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.watching[dir]
}