package backup import ( "os" "path/filepath" "sort" "time" ) // RemovedAppUnit is a recovery unit that sits on a registered drive while its app is NOT deployed — // the state „Töröld az adataimat is" leaves behind when the customer keeps the backups (R-487). // // It exists because the unit was restorable through POST /backup/restore the whole time and listed // on NEITHER backup page, so the customer's remove-by-mistake route existed only as an endpoint. // The off-site list had exactly this defect and was fixed by keying it on the STORE (R-237); the // local list is now keyed on the drives the same way — what is on disk decides, not what is deployed. type RemovedAppUnit struct { StackName string DisplayName string // from the unit's own manifest; the stack name when the manifest has none UnitDir string // the recovery-unit directory, backups/primary/ on the drive it sits on DriveLabel string // registered storage label; the system-drive label for the SSD fallback Time string // RFC3339 UTC — newest artifact in the unit (same rule as ListRestorePoints) } // primaryUnitRoots names every felhom-data namespace root a recovery unit can sit under: the system // data path and every registered storage path that is still connected. Deduplicated; a disconnected // drive is skipped — a unit nobody can open is not an offer (R-102's rule, one tier down). func (m *Manager) primaryUnitRoots() []string { seen := make(map[string]bool) var roots []string add := func(drive string) { if drive == "" || !filepath.IsAbs(drive) { return } root := m.namespaceRoot(drive) if root == "" || seen[root] { return } seen[root] = true roots = append(roots, root) } add(m.systemDataPath) if m.settings != nil { for _, sp := range m.settings.GetStoragePaths() { if sp.Disconnected { continue } add(sp.Path) } } return roots } // driveLabelForRoot maps a namespace root back to the label the page shows for it. func (m *Manager) driveLabelForRoot(root string) string { if m.systemDataPath != "" && root == m.namespaceRoot(m.systemDataPath) { return systemDriveLabel } if m.settings != nil { for _, sp := range m.settings.GetStoragePaths() { if m.namespaceRoot(sp.Path) == root { return m.settings.GetStorageLabel(sp.Path) } } } return "" } // unitNewestArtifact is the unit's DATA time. ONE rule, shared by ListRestorePoints (Tier 1), the Tier-2 // copy's date, the removed-app list and kept data, so no two of them can date a unit differently. // // v0.275.0 (R-696) — THE TIME OF THE DATA, NEVER OF THE MANIFEST. It used to be the newest of the // manifest, the .sql dumps and the .tar dumps; a refresh rewrites the manifest when the app's pins move, // so a unit re-captured two minutes after an update read as two minutes old over data from before the // update (9202 2026-09-25 11:06; demo-hp 2026-09-26 02:20, where it released the kept pre-conversion // copy). Now: the manifest's `data.at` when the data is stamped; else the newest DATA file (the undo // copies `pre-restore-*` excluded — an update's own safety dump is not a backup); the manifest's time // only for a unit that holds no data file at all, whose whole content is its definition. func unitNewestArtifact(unitDir string) (time.Time, bool) { t, _, ok := unitDataTime(unitDir) return t, ok } // unitDataTime is unitNewestArtifact plus WHETHER THE TIME IS A PROVEN DATA TIME (R-699, v0.275.0): true // when a data run confirmed the unit (`data` block) or it holds data files; false when the unit is only a // captured definition — a just-installed app's unit, written by the status refresh before any backup // ran. Such a unit is still LISTED (it can be restored: it is the app's definition), but it is never a // copy the update's precondition may lean on — measured on 9202 2026-09-27: tandoor's two-minute-old, // dump-less unit satisfied it and PostgreSQL was converted with no backup of the database. func unitDataTime(unitDir string) (time.Time, bool, bool) { fi, err := os.Stat(UnitManifestFile(unitDir)) if err != nil { return time.Time{}, false, false } if man := readManifest(UnitManifestFile(unitDir)); man != nil { if t, ok := man.Data.DataTime(); ok { return t, true, true } } var newest time.Time newest = newestDataFile(UnitDBDumpDir(unitDir), ".sql", newest) newest = newestDataFile(UnitVolumeDumpDir(unitDir), ".tar", newest) if newest.IsZero() { return fi.ModTime(), false, true } return newest, true, true } // ListRemovedAppUnits walks backups/primary/ on every connected registered drive and returns the // units whose app is not deployed, sorted by stack name. A unit without a readable manifest is not // listed — the restore would fall back to the volume-only path, which is not the offer this row makes. // A nil provider lists nothing: with no provider "not deployed" cannot be told from "unknown", and an // offer to overwrite must fail closed (the isStackDeployed rule). func (m *Manager) ListRemovedAppUnits() []RemovedAppUnit { if m.stackProvider == nil { return nil } deployed := make(map[string]bool) for _, name := range m.knownStackNames() { deployed[name] = true } seen := make(map[string]bool) var out []RemovedAppUnit for _, root := range m.primaryUnitRoots() { entries, err := os.ReadDir(PrimaryBackupPath(root)) if err != nil { continue } for _, e := range entries { name := e.Name() if !e.IsDir() || deployed[name] || seen[name] { continue } unitDir := RecoveryUnitPath(root, name) man := readManifest(UnitManifestFile(unitDir)) if man == nil { continue } newest, ok := unitNewestArtifact(unitDir) if !ok { continue } display := man.DisplayName if display == "" { display = name } seen[name] = true out = append(out, RemovedAppUnit{ StackName: name, DisplayName: display, UnitDir: unitDir, DriveLabel: m.driveLabelForRoot(root), Time: newest.UTC().Format(time.RFC3339), }) } } sort.Slice(out, func(i, j int) bool { return out[i].StackName < out[j].StackName }) return out } // RemovedAppUnitFor returns the removed app's unit, if one exists on a connected drive. func (m *Manager) RemovedAppUnitFor(stackName string) (RemovedAppUnit, bool) { for _, u := range m.ListRemovedAppUnits() { if u.StackName == stackName { return u, true } } return RemovedAppUnit{}, false }