D62: restore copies kept 30 days, listed with their date, deletable by hand (typed name, never a needed copy, operator event); D63: a test-build-only fault switch for the R-1256 H4/H5 live proofs — tests red first
gates / gates (push) Successful in 33s
gates / gates (push) Successful in 33s
This commit is contained in:
1 parent
a815b37fbd
commit
e3fce09d96
24 files changed
+1992
-11
No files matched your search
@@ -0,0 +1,182 @@
|
||||
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)
|
||||
)
|
||||
|
||||
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 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 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
|
||||
}
|
||||
Reference in new issue
Block a user