7fcda8f1a4
gates / gates (push) Successful in 25s
A just-installed app's unit, captured by the status refresh before any backup, satisfied the precondition on its manifest time; tandoor's PostgreSQL was converted with no backup of its database. Listed still; never a copy on Tier 1 or Tier 2. Red-proof RP6. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
170 lines
6.3 KiB
Go
170 lines
6.3 KiB
Go
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/<stack> 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
|
|
}
|