Files
felhom-controller/controller/internal/backup/restore_points.go
T
admin 43e99d160c
gates / gates (push) Successful in 26s
kept data: the choice at reinstall, the list, the read-only view, the load (09 decision 36); R-690 fixed
An install over an app's kept drive folder (appdata/<app> non-empty) asks the household:
"use my kept data" (a load from the newest copy of THIS drive's install, own unit or
second-drive mirror, then the template's after_load) or "start fresh" (the folder is
renamed into <drive>/kept/<app>/<date>/ with the removed app's unit; nothing deleted).
The install API answers 409 kept_data_choice until one is chosen; DeployStack refuses
too. New page Megorzott adatok / Kept data (/kept-data): Load / Look / Delete (typed
confirmation, the only deletion of kept data). FileBrowser gets a read-only source.
The drive-full warning names the kept folders. <drive>/kept is protected and outside
every backup leg.

R-690: the removed-app restore (R-487) never found a unit on a DATA drive — it asked
GetStackComposePath (true for every catalog app) and restored nextcloud with no env.
Now isStackDeployed; pinned with a production-shaped provider.

Red-proofs: audits/night-2026-09-26/E/redproofs/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:32:30 +02:00

97 lines
4.2 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
}
// 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
}
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
}