kept data: the choice at reinstall, the list, the read-only view, the load (09 decision 36); R-690 fixed
gates / gates (push) Successful in 26s

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
This commit is contained in:
2026-09-25 13:32:30 +02:00
parent 5a731b45b9
commit 43e99d160c
44 changed files with 3311 additions and 37 deletions
+143
View File
@@ -0,0 +1,143 @@
package backup
import (
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// ── Kept data: which copy can bring it back, and the load (`09` §3 decision 36) ───────────────────
//
// „Use my kept data" (at install) and „Load" (on the kept-data list) are the SAME act: the app's
// recovery unit (definition + database + volumes) is restored — through RestoreFromRecoveryUnitAt, the
// one body every unit restore uses (R-102) — while its files stay where they are on the drive. It is the
// removed-app restore (R-487) with the unit named explicitly.
//
// WHICH COPY. The newest unit that (1) opens (a readable manifest), (2) holds the app's data state (a
// database dump or a volume tar — a definition alone would install an empty app over the kept files), and
// (3) was taken of THIS drive's install: its app.yaml's HDD_PATH names this drive. Looked for in the
// app's own unit on the drive (Tier 1, R-487) and in every connected second-drive mirror (Tier 2). The
// off-site copy is NOT looked at here: its restore is a different operation (reconstitute) and it is
// not built into this choice yet — said on the page as "none" when it is the only one.
// KeptCopy is one database copy a kept folder can be loaded from.
type KeptCopy struct {
UnitDir string
Tier int // 1 own unit, 2 second drive
Time time.Time
DriveLabel string
}
// unitHoldsData: a readable manifest that lists a database dump or a volume tar.
func unitHoldsData(unitDir string) (*RecoveryManifest, bool) {
man := readManifest(UnitManifestFile(unitDir))
if man == nil {
return nil, false
}
return man, len(man.DBDumps)+len(man.VolumeDumps) > 0
}
// unitHDDPath reads the unit's own app.yaml env HDD_PATH (a plain, non-secret field). "" when absent.
func unitHDDPath(unitDir string) string {
b, err := os.ReadFile(filepath.Join(unitDir, "compose", "app.yaml"))
if err != nil {
return ""
}
var doc struct {
Env map[string]string `yaml:"env"`
}
if yaml.Unmarshal(b, &doc) != nil {
return ""
}
return strings.TrimSpace(doc.Env["HDD_PATH"])
}
// KeptCopyAt judges one unit directory for drive: usable, and when it was taken.
func (m *Manager) KeptCopyAt(unitDir, drive string, tier int) (KeptCopy, bool) {
if _, ok := unitHoldsData(unitDir); !ok {
return KeptCopy{}, false
}
if h := unitHDDPath(unitDir); h != "" && filepath.Clean(h) != filepath.Clean(drive) {
m.logger.Printf("[INFO] [backup] kept: unit %s was taken of %s, not %s — not offered", unitDir, h, drive)
return KeptCopy{}, false
}
t, ok := unitNewestArtifact(unitDir)
if !ok {
return KeptCopy{}, false
}
return KeptCopy{UnitDir: unitDir, Tier: tier, Time: t}, true
}
// KeptDBCopy returns the NEWEST usable copy of app's data for kept files on drive: the app's own unit
// (Tier 1) and every connected second-drive mirror (Tier 2). Only for an app that is NOT installed — an
// installed app's unit is its live backup, never a kept-data offer.
func (m *Manager) KeptDBCopy(app, drive string) (KeptCopy, bool) {
if app == "" || m.isStackDeployed(app) {
return KeptCopy{}, false
}
var best KeptCopy
found := false
consider := func(c KeptCopy, ok bool) {
if ok && (!found || c.Time.After(best.Time)) {
best, found = c, true
}
}
if u, ok := m.RemovedAppUnitFor(app); ok {
c, ok := m.KeptCopyAt(u.UnitDir, drive, 1)
c.DriveLabel = u.DriveLabel
consider(c, ok)
}
for _, d := range m.Tier2MirrorDirsForApp(app) {
consider(m.KeptCopyAt(tier2UnitDir(d), drive, 2))
}
return best, found
}
// RemovedUnitOnDrive is the start-fresh hook (stacks.SetKeptUnitFinder): the removed app's OWN unit when
// it sits on drive, so it moves into the kept folder with the files it belongs to. "" otherwise.
func (m *Manager) RemovedUnitOnDrive(app, drive string) string {
if m.isStackDeployed(app) {
return ""
}
u, ok := m.RemovedAppUnitFor(app)
if !ok {
return ""
}
if !strings.HasPrefix(filepath.Clean(u.UnitDir), filepath.Clean(drive)+string(filepath.Separator)) {
return ""
}
return u.UnitDir
}
// PrimaryUnitHome is where app's own unit lives on drive (backups/primary/<app>).
func (m *Manager) PrimaryUnitHome(app, drive string) string {
return RecoveryUnitPath(m.namespaceRoot(drive), app)
}
// LoadKeptApp runs the load as a restore operation the backup pages already follow (the poll banner):
// Begin → RestoreFromRecoveryUnitAt(app, unitDir) → End, then after(ok) in the same goroutine (the
// after_load command, the kept folder's tidy-up). The caller has checked RestoreStatus/IsRunning.
func (m *Manager) LoadKeptApp(app, unitDir string, okMsg, failMsg func(err error) string, after func(ok bool)) {
m.BeginRestoreOp("restore", app)
go func() {
start := time.Now()
res, err := m.RestoreFromRecoveryUnitAt(app, unitDir)
if err != nil {
m.logger.Printf("[ERROR] [backup] kept load %s from %s FAILED after %s: %v", app, unitDir, time.Since(start).Round(time.Second), err)
m.EndRestoreOp(false, failMsg(err))
if after != nil {
after(false)
}
return
}
m.logger.Printf("[INFO] [backup] kept load %s from %s done in %s (volumes %d/%d, dbs %d/%d)", app, unitDir,
time.Since(start).Round(time.Second), res.VolumesReplayed, res.ManifestVolumes, res.DBsReplayed, res.ManifestDBs)
m.EndRestoreOp(true, okMsg(nil))
if after != nil {
after(true)
}
}()
}
@@ -0,0 +1,65 @@
package backup
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func writeKeptUnit(t *testing.T, unitDir, hdd string, withData bool, at time.Time) {
t.Helper()
if err := os.MkdirAll(filepath.Join(unitDir, "compose"), 0o755); err != nil {
t.Fatal(err)
}
man := RecoveryManifest{SchemaVersion: 2, AppName: "nextcloud"}
if withData {
man.DBDumps = []string{"nextcloud-mariadb.sql"}
}
b, _ := json.Marshal(man)
if err := os.WriteFile(UnitManifestFile(unitDir), b, 0o644); err != nil {
t.Fatal(err)
}
_ = os.Chtimes(UnitManifestFile(unitDir), at, at)
if hdd != "" {
if err := os.WriteFile(filepath.Join(unitDir, "compose", "app.yaml"), []byte("env:\n HDD_PATH: "+hdd+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
}
// `09` §3 decision 36 — „use my kept data" loads only a copy that (1) holds the app's data and (2) was
// taken of THIS drive's install; the newest such copy on any local tier wins.
// COMPANION RED-PROOF: drop the HDD_PATH comparison in KeptCopyAt → a unit taken of ANOTHER drive is
// offered and the first assertion fails.
func TestKept_DBCopyIsTheNewestUsableForThisDrive(t *testing.T) {
m, sett, drive := r690Manager(t)
_ = sett
unit := RecoveryUnitPath(m.namespaceRoot(drive), "nextcloud")
writeKeptUnit(t, unit, "/mnt/felhom-drives/other", true, time.Now().Add(-time.Hour))
if c, ok := m.KeptDBCopy("nextcloud", drive); ok {
t.Fatalf("a unit taken of another drive was offered: %+v", c)
}
writeKeptUnit(t, unit, drive, false, time.Now().Add(-time.Hour))
if c, ok := m.KeptDBCopy("nextcloud", drive); ok {
t.Fatalf("a unit holding no data was offered: %+v", c)
}
writeKeptUnit(t, unit, drive, true, time.Now().Add(-time.Hour))
c, ok := m.KeptDBCopy("nextcloud", drive)
if !ok || c.UnitDir != unit || c.Tier != 1 {
t.Fatalf("own unit not offered: %+v ok=%v", c, ok)
}
// An INSTALLED app's unit is its live backup, never a kept-data offer.
if c, ok := m.KeptDBCopy("still-here", drive); ok {
t.Fatalf("an installed app's unit was offered: %+v", c)
}
// The start-fresh hook names the unit only on the same drive.
if got := m.RemovedUnitOnDrive("nextcloud", drive); got != unit {
t.Fatalf("RemovedUnitOnDrive = %q, want %q", got, unit)
}
if got := m.RemovedUnitOnDrive("nextcloud", "/elsewhere"); got != "" {
t.Fatalf("RemovedUnitOnDrive named a unit on another drive: %q", got)
}
}
@@ -0,0 +1,62 @@
package backup
import (
"log"
"os"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// r690Provider answers GetStackComposePath the way PRODUCTION does (main.go stackAdapter): ok for
// every stack the box knows — deployed or not, because every catalog template is a stack — while
// ListDeployedStacks names only the deployed ones. The R-487 fake answered it for deployed apps
// only, which is why its test passed while the box restored nextcloud with no env (R-690).
type r690Provider struct{ floorProvider }
func (p *r690Provider) GetStackComposePath(name string) (string, bool) {
return filepath.Join(p.dir, "stacks", name, "docker-compose.yml"), true
}
func r690Manager(t *testing.T) (*Manager, *settings.Settings, string) {
t.Helper()
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(os.Stderr, "", 0))
if err != nil {
t.Fatal(err)
}
sys := t.TempDir()
cfg := &config.Config{}
cfg.Paths.SystemDataPath = sys
m := NewManager(cfg, sett, log.New(os.Stderr, "", 0))
m.SetStackProvider(&r690Provider{floorProvider{stacks: []string{"still-here"}, dir: t.TempDir()}})
drive := t.TempDir()
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
return m, sett, drive
}
// The consequence, not the mechanism: the unit the restore OPENS and the copy the picker OFFERS are
// the removed app's unit on the data drive, while its catalog stack still exists.
// COMPANION RED-PROOF: put GetStackComposePath back as the "removed?" test in primaryUnitDirFor →
// this fails naming the system-drive path; in ListRestorePoints → the picker offers 0 copies.
func TestR690_RemovedUnitFoundWhenTheStackStillExists(t *testing.T) {
m, _, drive := r690Manager(t)
want := writeR487Unit(t, m.namespaceRoot(drive), "nextcloud", "Nextcloud", time.Now())
if got := m.primaryUnitDirFor("nextcloud"); got != want {
t.Fatalf("the restore opens %s, want the kept unit %s on the data drive", got, want)
}
pts, found := m.ListRestorePoints("nextcloud")
if !found || len(pts) != 1 || pts[0].DriveLabel != "HDD" {
t.Fatalf("the picker offers %+v (found=%v), want the one kept copy on HDD", pts, found)
}
// Negative control: a DEPLOYED app is never redirected to a removed-app unit.
writeR487Unit(t, m.namespaceRoot(drive), "still-here", "Still", time.Now())
if got := m.primaryUnitDirFor("still-here"); got == RecoveryUnitPath(m.namespaceRoot(drive), "still-here") {
t.Fatalf("a deployed app was sent to the removed-app unit %s", got)
}
}
+9 -5
View File
@@ -38,14 +38,18 @@ func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, fo
if m.stackProvider == nil {
return nil, false
}
if _, ok := m.stackProvider.GetStackComposePath(stackName); !ok {
// 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-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
}
+9 -5
View File
@@ -192,12 +192,16 @@ func (m *Manager) RestoreFromRecoveryUnit(stackName string) (UnitRestoreResult,
// backups/primary/<stack> on its own drive. For a REMOVED app (R-487) the drive is no longer known
// — GetAppDrivePath falls back to the system path — so a unit kept on a data drive was unreachable
// and the restore silently took the volume-only fallback. It is now found where it sits.
//
// R-690 (2026-09-25): "removed" is asked with isStackDeployed — the removed-app list's OWN predicate.
// It used to be GetStackComposePath's ok, which in production is true for EVERY catalog app (every
// template is a stack), so this branch never ran on a box: nextcloud's unit on a data drive was
// missed, the restore took the volume-only fallback, and the app came back with no env and no
// database. Pinned by TestR690_RemovedUnitFoundWhenTheStackStillExists (production-shaped provider).
func (m *Manager) primaryUnitDirFor(stackName string) string {
if m.stackProvider != nil {
if _, deployed := m.stackProvider.GetStackComposePath(stackName); !deployed {
if u, found := m.RemovedAppUnitFor(stackName); found {
return u.UnitDir
}
if m.stackProvider != nil && !m.isStackDeployed(stackName) {
if u, found := m.RemovedAppUnitFor(stackName); found {
return u.UnitDir
}
}
return RecoveryUnitPath(m.namespaceRoot(m.GetAppDrivePath(stackName)), stackName)