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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user