package backup import ( "os" "path/filepath" "strings" "time" ) // RestorePoint describes one restorable keep-side backup for the /backups restore panel // (GET /api/backup/snapshots). The field names/shape are the payload contract of the // backups.html restore JS (formatSnapshot): time / short_id / tier / drive_label. // // The keep-side restore has exactly ONE restore point per app — the current recovery unit // (RestoreFromRecoveryUnit reads "the unit", not a history; snapshot_id is logging-only). // Tier is always 1: Tier-2 copies are NOT restorable through POST /backup/restore (it only // reads the app's primary unit), so listing them would silently restore tier-1 data while // claiming tier-2 — never emit them here. type RestorePoint struct { Time string `json:"time"` // RFC3339 — newest artifact in the unit ShortID string `json:"short_id"` // opaque label; POST /backup/restore uses it for logging only Tier int `json:"tier"` // always 1 (see above) DriveLabel string `json:"drive_label"` // registered storage label; empty for the SSD fallback } // restorePointShortID is the single keep-side restore point's identifier. Hungarian ("local"), // because the JS renders it verbatim inside the snapshot dropdown label. const restorePointShortID = "helyi" // ListRestorePoints returns the app's restorable keep-side backups, and whether the stack is // known at all (found=false → the caller should 404). A known stack with no recovery unit on // disk returns an EMPTY list (a valid answer — "no backup yet"), not an error. // // The single point's Time is the unit's DATA time (unitNewestArtifact, v0.275.0 — R-696). func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, found bool) { if m.stackProvider == nil { return nil, false } // R-487: a removed app whose backups were kept is not deployed, but its unit is on a drive // and POST /backup/restore reinstalls from it. The picker used to be told 404 here while the // restore itself worked — the list is keyed on the drive now, the way R-237 keyed the // off-site list on the store. // R-690: "removed" is isStackDeployed, not GetStackComposePath — the latter is true for every // catalog app on a box, so the picker offered 0 copies for a removed app on a data drive. if !m.isStackDeployed(stackName) { if u, found := m.RemovedAppUnitFor(stackName); found { return []RestorePoint{{Time: u.Time, ShortID: restorePointShortID, Tier: 1, DriveLabel: u.DriveLabel}}, true } } if _, ok := m.stackProvider.GetStackComposePath(stackName); !ok { return nil, false } nsRoot := m.AppNamespaceRoot(stackName) if nsRoot == "" || !filepath.IsAbs(nsRoot) { // Stack is known but its backup location is unresolvable (e.g. systemDataPath unset in a // misconfigured environment) — honest empty list rather than a path walk from "". m.logger.Printf("[WARN] [backup] ListRestorePoints(%s): cannot resolve namespace root", stackName) return []RestorePoint{}, true } // v0.275.0 (R-696): the unit's DATA time (unitNewestArtifact), never the manifest's refresh time. newest, ok := unitNewestArtifact(RecoveryUnitPath(nsRoot, stackName)) if !ok { return []RestorePoint{}, true // no recovery unit yet — "no backup" is a valid answer } return []RestorePoint{{ Time: newest.UTC().Format(time.RFC3339), ShortID: restorePointShortID, Tier: 1, DriveLabel: m.sysDriveLabelFor(stackName), }}, true } // newestDataFile returns the newest mtime among cur and the DATA files with the given extension in dir // (non-recursive; a missing dir contributes nothing). The undo copies (`pre-restore-*`) are not data of // the unit (R-361) and never date it. func newestDataFile(dir, ext string, cur time.Time) time.Time { entries, err := os.ReadDir(dir) if err != nil { return cur } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ext) || strings.HasPrefix(e.Name(), preRestoreDumpPrefix) { continue } if info, err := e.Info(); err == nil && info.ModTime().After(cur) { cur = info.ModTime() } } return cur }