43e99d160c
gates / gates (push) Successful in 26s
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
144 lines
5.3 KiB
Go
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)
|
|
}
|
|
}()
|
|
}
|