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: the newest of its manifest, .sql dumps and .tar // volume dumps. ONE rule, shared with ListRestorePoints, so the two lists cannot date a unit // differently. func unitNewestArtifact(unitDir string) (time.Time, bool) { fi, err := os.Stat(UnitManifestFile(unitDir)) if err != nil { return time.Time{}, false } newest := fi.ModTime() newest = newestArtifact(UnitDBDumpDir(unitDir), ".sql", newest) newest = newestArtifact(UnitVolumeDumpDir(unitDir), ".tar", newest) return newest, 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 }