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/). 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) } }() }