Files
felhom-controller/controller/internal/backup/kept_load.go
T
admin 43e99d160c
gates / gates (push) Successful in 26s
kept data: the choice at reinstall, the list, the read-only view, the load (09 decision 36); R-690 fixed
An install over an app's kept drive folder (appdata/<app> non-empty) asks the household:
"use my kept data" (a load from the newest copy of THIS drive's install, own unit or
second-drive mirror, then the template's after_load) or "start fresh" (the folder is
renamed into <drive>/kept/<app>/<date>/ with the removed app's unit; nothing deleted).
The install API answers 409 kept_data_choice until one is chosen; DeployStack refuses
too. New page Megorzott adatok / Kept data (/kept-data): Load / Look / Delete (typed
confirmation, the only deletion of kept data). FileBrowser gets a read-only source.
The drive-full warning names the kept folders. <drive>/kept is protected and outside
every backup leg.

R-690: the removed-app restore (R-487) never found a unit on a DATA drive — it asked
GetStackComposePath (true for every catalog app) and restored nextcloud with no env.
Now isStackDeployed; pinned with a production-shaped provider.

Red-proofs: audits/night-2026-09-26/E/redproofs/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:32:30 +02:00

144 lines
5.3 KiB
Go

package backup
import (
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// ── Kept data: which copy can bring it back, and the load (`09` §3 decision 36) ───────────────────
//
// „Use my kept data" (at install) and „Load" (on the kept-data list) are the SAME act: the app's
// recovery unit (definition + database + volumes) is restored — through RestoreFromRecoveryUnitAt, the
// one body every unit restore uses (R-102) — while its files stay where they are on the drive. It is the
// removed-app restore (R-487) with the unit named explicitly.
//
// WHICH COPY. The newest unit that (1) opens (a readable manifest), (2) holds the app's data state (a
// database dump or a volume tar — a definition alone would install an empty app over the kept files), and
// (3) was taken of THIS drive's install: its app.yaml's HDD_PATH names this drive. Looked for in the
// app's own unit on the drive (Tier 1, R-487) and in every connected second-drive mirror (Tier 2). The
// off-site copy is NOT looked at here: its restore is a different operation (reconstitute) and it is
// not built into this choice yet — said on the page as "none" when it is the only one.
// KeptCopy is one database copy a kept folder can be loaded from.
type KeptCopy struct {
UnitDir string
Tier int // 1 own unit, 2 second drive
Time time.Time
DriveLabel string
}
// unitHoldsData: a readable manifest that lists a database dump or a volume tar.
func unitHoldsData(unitDir string) (*RecoveryManifest, bool) {
man := readManifest(UnitManifestFile(unitDir))
if man == nil {
return nil, false
}
return man, len(man.DBDumps)+len(man.VolumeDumps) > 0
}
// unitHDDPath reads the unit's own app.yaml env HDD_PATH (a plain, non-secret field). "" when absent.
func unitHDDPath(unitDir string) string {
b, err := os.ReadFile(filepath.Join(unitDir, "compose", "app.yaml"))
if err != nil {
return ""
}
var doc struct {
Env map[string]string `yaml:"env"`
}
if yaml.Unmarshal(b, &doc) != nil {
return ""
}
return strings.TrimSpace(doc.Env["HDD_PATH"])
}
// KeptCopyAt judges one unit directory for drive: usable, and when it was taken.
func (m *Manager) KeptCopyAt(unitDir, drive string, tier int) (KeptCopy, bool) {
if _, ok := unitHoldsData(unitDir); !ok {
return KeptCopy{}, false
}
if h := unitHDDPath(unitDir); h != "" && filepath.Clean(h) != filepath.Clean(drive) {
m.logger.Printf("[INFO] [backup] kept: unit %s was taken of %s, not %s — not offered", unitDir, h, drive)
return KeptCopy{}, false
}
t, ok := unitNewestArtifact(unitDir)
if !ok {
return KeptCopy{}, false
}
return KeptCopy{UnitDir: unitDir, Tier: tier, Time: t}, true
}
// KeptDBCopy returns the NEWEST usable copy of app's data for kept files on drive: the app's own unit
// (Tier 1) and every connected second-drive mirror (Tier 2). Only for an app that is NOT installed — an
// installed app's unit is its live backup, never a kept-data offer.
func (m *Manager) KeptDBCopy(app, drive string) (KeptCopy, bool) {
if app == "" || m.isStackDeployed(app) {
return KeptCopy{}, false
}
var best KeptCopy
found := false
consider := func(c KeptCopy, ok bool) {
if ok && (!found || c.Time.After(best.Time)) {
best, found = c, true
}
}
if u, ok := m.RemovedAppUnitFor(app); ok {
c, ok := m.KeptCopyAt(u.UnitDir, drive, 1)
c.DriveLabel = u.DriveLabel
consider(c, ok)
}
for _, d := range m.Tier2MirrorDirsForApp(app) {
consider(m.KeptCopyAt(tier2UnitDir(d), drive, 2))
}
return best, found
}
// RemovedUnitOnDrive is the start-fresh hook (stacks.SetKeptUnitFinder): the removed app's OWN unit when
// it sits on drive, so it moves into the kept folder with the files it belongs to. "" otherwise.
func (m *Manager) RemovedUnitOnDrive(app, drive string) string {
if m.isStackDeployed(app) {
return ""
}
u, ok := m.RemovedAppUnitFor(app)
if !ok {
return ""
}
if !strings.HasPrefix(filepath.Clean(u.UnitDir), filepath.Clean(drive)+string(filepath.Separator)) {
return ""
}
return u.UnitDir
}
// PrimaryUnitHome is where app's own unit lives on drive (backups/primary/<app>).
func (m *Manager) PrimaryUnitHome(app, drive string) string {
return RecoveryUnitPath(m.namespaceRoot(drive), app)
}
// LoadKeptApp runs the load as a restore operation the backup pages already follow (the poll banner):
// Begin → RestoreFromRecoveryUnitAt(app, unitDir) → End, then after(ok) in the same goroutine (the
// after_load command, the kept folder's tidy-up). The caller has checked RestoreStatus/IsRunning.
func (m *Manager) LoadKeptApp(app, unitDir string, okMsg, failMsg func(err error) string, after func(ok bool)) {
m.BeginRestoreOp("restore", app)
go func() {
start := time.Now()
res, err := m.RestoreFromRecoveryUnitAt(app, unitDir)
if err != nil {
m.logger.Printf("[ERROR] [backup] kept load %s from %s FAILED after %s: %v", app, unitDir, time.Since(start).Round(time.Second), err)
m.EndRestoreOp(false, failMsg(err))
if after != nil {
after(false)
}
return
}
m.logger.Printf("[INFO] [backup] kept load %s from %s done in %s (volumes %d/%d, dbs %d/%d)", app, unitDir,
time.Since(start).Round(time.Second), res.VolumesReplayed, res.ManifestVolumes, res.DBsReplayed, res.ManifestDBs)
m.EndRestoreOp(true, okMsg(nil))
if after != nil {
after(true)
}
}()
}