68b3a3932e
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CDMFpFx84pfviCTVuGGhf
86 lines
3.5 KiB
Go
86 lines
3.5 KiB
Go
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 newest mtime among the unit's artifacts (manifest.json,
|
|
// db-dumps/*.sql, volume-dumps/*.tar): the manifest is only rewritten when the app's config
|
|
// changes (checksum-skip), so the nightly-refreshed dumps are usually the freshest artifact.
|
|
func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, found bool) {
|
|
if m.stackProvider == nil {
|
|
return nil, false
|
|
}
|
|
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
|
|
}
|
|
|
|
fi, err := os.Stat(RecoveryUnitManifestPath(nsRoot, stackName))
|
|
if err != nil {
|
|
return []RestorePoint{}, true // no recovery unit yet — "no backup" is a valid answer
|
|
}
|
|
newest := fi.ModTime()
|
|
newest = newestArtifact(AppDBDumpPath(nsRoot, stackName), ".sql", newest)
|
|
newest = newestArtifact(AppVolumeDumpPath(nsRoot, stackName), ".tar", newest)
|
|
|
|
return []RestorePoint{{
|
|
Time: newest.UTC().Format(time.RFC3339),
|
|
ShortID: restorePointShortID,
|
|
Tier: 1,
|
|
DriveLabel: m.sysDriveLabelFor(stackName),
|
|
}}, true
|
|
}
|
|
|
|
// newestArtifact returns the newest mtime among cur and the files with the given extension in
|
|
// dir (non-recursive; a missing dir contributes nothing).
|
|
func newestArtifact(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) {
|
|
continue
|
|
}
|
|
if info, err := e.Info(); err == nil && info.ModTime().After(cur) {
|
|
cur = info.ModTime()
|
|
}
|
|
}
|
|
return cur
|
|
}
|