217 lines
7.5 KiB
Go
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]
|
|
}
|