D47 / R-1269 (P2): a new install never writes into an earlier install's backup — every install has an identity (install_id), the unit records it, every unit writer asks first; another install's unit is set aside whole (backups/set-aside/<app>/<stamp>, a D25 kept copy, „Visszaállítás” + „Eltávolítás” on the restore page); a move that fails refuses the write. Red-proved. Commit only.
gates / gates (push) Successful in 1m33s
gates / gates (push) Successful in 1m33s
This commit is contained in:
@@ -663,6 +663,13 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// D47 (R-1269): never a dump into another install's unit — it is set aside whole first, or nothing is written.
|
||||
if gErr := m.guardUnitWrite(db.StackName, m.namespaceRoot(drivePath), "database dump"); gErr != nil {
|
||||
allOK = false
|
||||
summary = append(summary, fmt.Sprintf("FAIL %s: %v", db.ContainerName, gErr))
|
||||
m.noteFailure(db.StackName, "database dump", gErr.Error())
|
||||
continue
|
||||
}
|
||||
dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), db.StackName)
|
||||
|
||||
result := m.dumpOneOrDefault(ctx, db, dumpDir)
|
||||
@@ -870,6 +877,9 @@ func (m *Manager) DumpAppVolumes(stackName string) error {
|
||||
return fmt.Errorf("cannot determine drive path for %s", stackName)
|
||||
}
|
||||
|
||||
if err := m.guardUnitWrite(stackName, m.namespaceRoot(drivePath), "volume dump"); err != nil { // D47 (R-1269)
|
||||
return err
|
||||
}
|
||||
dumpDir := AppVolumeDumpPath(m.namespaceRoot(drivePath), stackName)
|
||||
if err := os.MkdirAll(dumpDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating volume dump dir: %w", err)
|
||||
@@ -1142,6 +1152,9 @@ func (m *Manager) DumpStackDB(ctx context.Context, stackName string) error {
|
||||
if drivePath == "" || !filepath.IsAbs(drivePath) {
|
||||
return fmt.Errorf("cannot determine absolute drive path for %s (systemDataPath not configured?)", stackName)
|
||||
}
|
||||
if err := m.guardUnitWrite(stackName, m.namespaceRoot(drivePath), "pre-backup database dump"); err != nil { // D47
|
||||
return err
|
||||
}
|
||||
dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), stackName)
|
||||
|
||||
m.logger.Printf("[INFO] [backup] Running pre-backup DB dump for %s (%d database(s)) → %s", stackName, len(stackDBs), dumpDir)
|
||||
@@ -1570,6 +1583,14 @@ func (m *Manager) pruneStalePrimaryDirs() {
|
||||
filepath.Clean(primaryDir)+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
// D47: a unit of ANOTHER install on the old drive is not this app's moved unit — it is set aside whole
|
||||
// (never deleted); a guard that cannot decide leaves it as it is.
|
||||
if gErr := m.guardUnitDir(app, stalePath, nsRoot, "stale-drive prune"); gErr != nil {
|
||||
continue
|
||||
}
|
||||
if _, sErr := os.Stat(stalePath); os.IsNotExist(sErr) {
|
||||
continue // set aside just now
|
||||
}
|
||||
if err := os.RemoveAll(stalePath); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] F5: could not remove stale primary dir for %s on old drive: %v", app, err)
|
||||
} else {
|
||||
|
||||
@@ -494,6 +494,14 @@ func (m *Manager) captureLiveDefinition(stack string) (*liveDefinition, error) {
|
||||
for k, v := range m.stackProvider.RecoverStackSecrets(stack, info.SecretEnvVars) {
|
||||
d.env[k] = v
|
||||
}
|
||||
// D47: a put-back writes the live definition back through the same writer as a restore, which takes the
|
||||
// install identity from the folder's app.yaml — so the live install keeps its own (no env, no secret here).
|
||||
if info.InstallID != "" {
|
||||
if wErr := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("install_id: "+info.InstallID+"\n"), 0o600); wErr != nil {
|
||||
d.cleanup()
|
||||
return nil, wErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
@@ -100,7 +100,10 @@ func (m *Manager) SetTier2Notifier(fn func(stackName, destLabel string, dur time
|
||||
//
|
||||
// v0.275.0 (R-696): CaptureRecoveryUnit is the capture AFTER a data run (it follows the legs in
|
||||
// RunAppBackupNow). The periodic refresh calls captureRecoveryUnit(…, false), which never moves the
|
||||
// unit's data time and never rewrites the definition away from the data it belongs to.
|
||||
// unit's data time and never rewrites the definition away from the data it belongs to — for the SAME install
|
||||
// (the pins below). A unit of ANOTHER install (a reinstall after „remove, keep backups") is never written at
|
||||
// all: guardUnitWrite sets it aside whole first (D47, R-1269 — measured: the refresh wrote the new install's
|
||||
// password beside the old data). Pinned by TestD47_* (unit_owner_test.go).
|
||||
func (m *Manager) CaptureRecoveryUnit(stackName string) error {
|
||||
return m.captureRecoveryUnit(stackName, true)
|
||||
}
|
||||
@@ -118,6 +121,11 @@ func (m *Manager) captureRecoveryUnit(stackName string, dataRun bool) error {
|
||||
return fmt.Errorf("cannot determine absolute drive path for %s", stackName)
|
||||
}
|
||||
nsRoot := m.namespaceRoot(drivePath)
|
||||
// D47 (R-1269): the unit of another install is set aside whole before this install writes its own — or this
|
||||
// capture writes nothing. Before the in-memory build, so the frozen/current checks below read THIS install's unit.
|
||||
if err := m.guardUnitWrite(stackName, nsRoot, "recovery-unit capture"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build the captured config CONTENT in memory (no writes yet) so we can checksum-compare.
|
||||
type capFile struct {
|
||||
@@ -463,6 +471,14 @@ func (m *Manager) captureAllRecoveryUnits(dataRun bool) {
|
||||
if m.isHeld(stack.Name) {
|
||||
continue
|
||||
}
|
||||
// D47: a restore of this app is reading its unit (and may be bringing back another install's) — the refresh
|
||||
// waits for it; the restore's own end decides whose unit the app writes next.
|
||||
if !dataRun && m.restoreRunningFor(stack.Name) {
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [backup] recovery-unit refresh skipped for %s — a restore of it runs", stack.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// R-645 (09 §3 decision 142): the unit of an app that is not running its pinned version stays
|
||||
// as it is — the measured case wrote a just-failed definition over the good unit within seconds
|
||||
// of a hand-lifted hold. Said at WARN on a data run; the periodic refresh repeats it only at DEBUG.
|
||||
@@ -506,6 +522,8 @@ type strippedAppYaml struct {
|
||||
// SetupGate (v0.314.0, R-1057): the gate at this backup; absent for an app with no gate, so an ungated app's
|
||||
// unit bytes are unchanged (the checksum-skip guard).
|
||||
SetupGate *GateState `yaml:"setup_gate,omitempty"`
|
||||
// InstallID (D47): the install this unit's data belongs to — the unit's owner. Absent in a unit written before D47.
|
||||
InstallID string `yaml:"install_id,omitempty"`
|
||||
}
|
||||
|
||||
// buildUnitAppYaml renders the unit's app.yaml as bytes: the non-secret env PLUS the portable secret
|
||||
@@ -524,7 +542,7 @@ func buildUnitAppYaml(info RecoveryInfo) []byte {
|
||||
for k, v := range info.PortableSecrets {
|
||||
env[k] = v
|
||||
}
|
||||
body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: env, SetupGate: info.SetupGate})
|
||||
body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: env, SetupGate: info.SetupGate, InstallID: info.InstallID})
|
||||
if err != nil {
|
||||
body = []byte("deployed: true\nenv: {}\n")
|
||||
}
|
||||
|
||||
@@ -449,6 +449,10 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
} else if m.restoreRunningFor(stackName) {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 %s stopped before the unit leg — a restore of it began (R-1180)", stackName)
|
||||
return nil
|
||||
} else if gErr := m.guardUnitDir(stackName, destUnit, target.NamespaceRoot, "second-drive copy of the unit"); gErr != nil {
|
||||
// D47: the second drive's copy of an EARLIER install's unit is set aside whole before this install's is
|
||||
// mirrored — or the leg is skipped and that copy stays as it is.
|
||||
warns = append(warns, m.note("note.tier2.unit_other_install_kept"))
|
||||
} else if err := mirror(unitDir, destUnit); err != nil {
|
||||
m.recordTier2Failure(stackName, target, err)
|
||||
if m.tier2Notify != nil {
|
||||
|
||||
@@ -39,7 +39,7 @@ func (m *Manager) keepCopy(kind, source, drive, reason, destBase, path string) {
|
||||
m.logger.Printf("[WARN] [backup] kept copy %s could not be recorded (the copy itself is kept): %v", path, err)
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] second-drive copy KEPT (source gone: %s): %s %q at %s", reason, kind, source, path)
|
||||
m.logger.Printf("[INFO] [backup] copy KEPT (%s): %s %q at %s", reason, kind, source, path)
|
||||
}
|
||||
|
||||
// releaseKeptCopy forgets the record of path because its source is back (the run mirrored it again). Data untouched.
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ── D47 (operator ruling 2026-10-25 08:38; R-1269): a new install never writes into an earlier install's unit ──────
|
||||
//
|
||||
// MEASURED BEFORE (catalog sweep 2026-10-24, three times; `audits/catalog-sweep-2026-10-24/_unitprobe/tandoor/`):
|
||||
// install Tandoor, one account, „Mentés most", remove keeping data and backups, install again. Five minutes later the
|
||||
// periodic refresh (captureRecoveryUnit, dataRun=false) rewrote ONLY the unit's compose/app.yaml — the new install's
|
||||
// database password — beside the OLD install's data. Its one „same definition" test is the pins (`frozen` below), and
|
||||
// a reinstall of the same version has the same pins, so it passed. A restore then paired the new password with the old
|
||||
// data directory: „password authentication failed", the app never started, the account gone from view. The next
|
||||
// night's data legs would have overwritten the old data too.
|
||||
//
|
||||
// THE RULE. A unit carries the identity of the install its data belongs to (its app.yaml `install_id:`, minted at a
|
||||
// fresh install, taken over by a restore — stacks.AppConfig.InstallID). EVERY writer of a unit — the capture and
|
||||
// refresh, the database dump legs, the volume dump leg, the update's pre-backup dump, the second drive's mirror, the
|
||||
// stale-drive prune — asks guardUnitWrite first. A unit of ANOTHER install is SET ASIDE WHOLE (a rename on the same
|
||||
// drive, never a copy, never an edit) into <namespace>/backups/set-aside/<app>/<UTC stamp>/, recorded as a kept copy
|
||||
// (D25's record: listed on the restore page with „Visszaállítás" and „Eltávolítás"), and the writer then writes the new
|
||||
// install's own unit. A set-aside that cannot be done REFUSES the write and leaves the old unit exactly as it was, with
|
||||
// an ERROR line naming the app and both identities. Nothing deletes a set-aside unit but the household: its
|
||||
// „Eltávolítás", or the app's remove with „delete its backups too" (the D25 / R-1252 H9 rule for kept copies).
|
||||
//
|
||||
// A UNIT WRITTEN BEFORE D47 records no install. Then the secrets decide: a reinstall mints new ones, a restore writes
|
||||
// the unit's into the app, so a carried secret that differs on both sides means another install. With no differing
|
||||
// secret it is this install's (the old behaviour) and the next write records the identity.
|
||||
|
||||
// setAsideDirName is the folder beside backups/primary that holds the set-aside units.
|
||||
const setAsideDirName = "set-aside"
|
||||
|
||||
// KeptReasonEarlierInstall is the kept-copy reason of a set-aside unit (the page's „kept.reason.earlier_install").
|
||||
const KeptReasonEarlierInstall = "earlier_install"
|
||||
|
||||
// KeptKindUnit is the kept-copy kind of a set-aside recovery unit (restorable from the restore page).
|
||||
const KeptKindUnit = "unit"
|
||||
|
||||
// errUnitOfAnotherInstall is the refusal of a write into another install's unit that could not be set aside.
|
||||
var errUnitOfAnotherInstall = errors.New("the recovery unit belongs to another install and could not be set aside")
|
||||
|
||||
// setAsideRoot is <nsRoot>/backups/set-aside.
|
||||
func setAsideRoot(nsRoot string) string {
|
||||
return filepath.Join(nsRoot, "backups", setAsideDirName)
|
||||
}
|
||||
|
||||
// installIdentity is the optional provider method that mints an identity for an app installed before D47.
|
||||
type installIdentity interface {
|
||||
EnsureInstallID(name string) (string, error)
|
||||
}
|
||||
|
||||
type unitOwner int
|
||||
|
||||
const (
|
||||
ownerNoUnit unitOwner = iota // nothing to protect
|
||||
ownerSame // this install's unit (or no signal that it is not)
|
||||
ownerOther // another install's unit — set it aside before writing
|
||||
)
|
||||
|
||||
// unitOwnerVerdict is the rule as a pure function. Both identities known → they decide. Otherwise (a unit or an app
|
||||
// from before D47) the carried secrets decide: a name both sides hold with different values means another install.
|
||||
// why names the reason, never a value.
|
||||
func unitOwnerVerdict(unitID, liveID string, unitSecrets, liveSecrets map[string]string) (unitOwner, string) {
|
||||
if unitID != "" && liveID != "" {
|
||||
if unitID == liveID {
|
||||
return ownerSame, "same install id"
|
||||
}
|
||||
return ownerOther, "the install ids differ"
|
||||
}
|
||||
var differ []string
|
||||
for n, uv := range unitSecrets {
|
||||
if lv := liveSecrets[n]; uv != "" && lv != "" && uv != lv {
|
||||
differ = append(differ, n)
|
||||
}
|
||||
}
|
||||
if len(differ) > 0 {
|
||||
sort.Strings(differ)
|
||||
return ownerOther, "carried secret(s) " + strings.Join(differ, ", ") + " differ (a unit from before D47)"
|
||||
}
|
||||
return ownerSame, "no install id on one side and no differing secret"
|
||||
}
|
||||
|
||||
// unitOwnerOf reads a unit's recorded install and the secrets it carries (names from its manifest). exists=false when
|
||||
// the directory holds nothing a restore could use (no manifest, no compose/, no data folder).
|
||||
func unitOwnerOf(unitDir string) (exists bool, installID string, secrets map[string]string) {
|
||||
secrets = map[string]string{}
|
||||
any := false
|
||||
for _, p := range []string{UnitManifestFile(unitDir), UnitComposeDir(unitDir), UnitDBDumpDir(unitDir), UnitVolumeDumpDir(unitDir)} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
any = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return false, "", secrets
|
||||
}
|
||||
var portable []string
|
||||
if man := readManifest(UnitManifestFile(unitDir)); man != nil {
|
||||
portable = man.PortableSecretEnvVars
|
||||
}
|
||||
appy := filepath.Join(UnitComposeDir(unitDir), "app.yaml")
|
||||
_, secrets = readUnitEnv(appy, portable)
|
||||
if b, err := os.ReadFile(appy); err == nil {
|
||||
var doc struct {
|
||||
InstallID string `yaml:"install_id"`
|
||||
}
|
||||
if yaml.Unmarshal(b, &doc) == nil {
|
||||
installID = doc.InstallID
|
||||
}
|
||||
}
|
||||
return true, installID, secrets
|
||||
}
|
||||
|
||||
// liveInstall is the app's identity and carried secrets now; it mints an identity for an app from before D47 when the
|
||||
// provider can. err = the identity could not be read or written (the caller refuses to decide).
|
||||
func (m *Manager) liveInstall(stack string) (string, map[string]string, error) {
|
||||
info, ok := m.stackProvider.GetStackRecoveryInfo(stack)
|
||||
if !ok {
|
||||
// No record to compare at all (a provider that does not know the app): no signal — the old behaviour.
|
||||
return "", nil, nil
|
||||
}
|
||||
id := info.InstallID
|
||||
if id == "" {
|
||||
if e, ok := m.stackProvider.(installIdentity); ok {
|
||||
got, err := e.EnsureInstallID(stack)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
id = got
|
||||
}
|
||||
}
|
||||
return id, info.PortableSecrets, nil
|
||||
}
|
||||
|
||||
// guardUnitWrite is asked by EVERY writer before it writes into <nsRoot>/backups/primary/<stack> (what names the
|
||||
// writer, for the log). nil = write; an error = do not write — the unit is untouched.
|
||||
func (m *Manager) guardUnitWrite(stack, nsRoot, what string) error {
|
||||
if m.stackProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return m.guardUnitDir(stack, RecoveryUnitPath(nsRoot, stack), nsRoot, what)
|
||||
}
|
||||
|
||||
// guardUnitDir is guardUnitWrite for any unit directory (the second drive's mirror too); setAsideNs is the namespace
|
||||
// root whose backups/set-aside receives it (the unit's own drive, so the move is a rename).
|
||||
func (m *Manager) guardUnitDir(stack, unitDir, setAsideNs, what string) error {
|
||||
exists, unitID, unitSecrets := unitOwnerOf(unitDir)
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
liveID, liveSecrets, err := m.liveInstall(stack)
|
||||
if err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] %s: the %s is NOT written into %s — this install's identity cannot be read (%v); the unit is untouched (D47)", stack, what, unitDir, err)
|
||||
return fmt.Errorf("%w: %v", errUnitOfAnotherInstall, err)
|
||||
}
|
||||
verdict, why := unitOwnerVerdict(unitID, liveID, unitSecrets, liveSecrets)
|
||||
if verdict != ownerOther {
|
||||
return nil
|
||||
}
|
||||
// A restore of this app reads that unit right now (its install becomes the unit's when the definition is written):
|
||||
// never move it under the restore — refuse this write instead.
|
||||
if m.restoreRunningFor(stack) {
|
||||
m.logger.Printf("[ERROR] [backup] %s: the %s is NOT written into the unit of install %s (this install %s): a restore of the app runs — the unit is untouched (D47)", stack, what, orUnknown(unitID), orUnknown(liveID))
|
||||
return errUnitOfAnotherInstall
|
||||
}
|
||||
dest, err := m.setAsideUnit(stack, unitDir, setAsideNs)
|
||||
if err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] %s: REFUSED to write the %s into the recovery unit of another install (unit install %s, this install %s; %s): it could not be set aside: %v — the unit is untouched (D47)",
|
||||
stack, what, orUnknown(unitID), orUnknown(liveID), why, err)
|
||||
return fmt.Errorf("%w: %v", errUnitOfAnotherInstall, err)
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] %s: the recovery unit of an earlier install (%s; %s) is SET ASIDE whole → %s; this install (%s) gets its own unit from its first backup (D47)",
|
||||
stack, orUnknown(unitID), why, dest, orUnknown(liveID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func orUnknown(id string) string {
|
||||
if id == "" {
|
||||
return "unrecorded"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// renameUnitFn is the set-aside's move (tests inject a failure); production is os.Rename.
|
||||
var renameUnitFn = os.Rename
|
||||
|
||||
// setAsideUnit MOVES unitDir whole to <setAsideNs>/backups/set-aside/<stack>/<UTC stamp> and records it as a kept
|
||||
// copy. A rename only: never a copy, never across drives (EXDEV refuses, nothing moved).
|
||||
func (m *Manager) setAsideUnit(stack, unitDir, setAsideNs string) (string, error) {
|
||||
base := filepath.Join(setAsideRoot(setAsideNs), stack)
|
||||
if err := os.MkdirAll(base, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Join(base, time.Now().UTC().Format("20060102T150405Z"))
|
||||
for i := 1; ; i++ {
|
||||
if _, err := os.Lstat(dest); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
dest = filepath.Join(base, fmt.Sprintf("%s-%d", time.Now().UTC().Format("20060102T150405Z"), i))
|
||||
}
|
||||
if err := renameUnitFn(unitDir, dest); err != nil {
|
||||
_ = os.Remove(base) // only when empty
|
||||
return "", err
|
||||
}
|
||||
m.keepCopy(KeptKindUnit, stack, m.storageLabel(m.driveOfNamespace(setAsideNs)), KeptReasonEarlierInstall, setAsideRoot(setAsideNs), dest)
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// driveOfNamespace names the drive a namespace root sits on (for the record's label): the registered storage path
|
||||
// whose namespace it is, else the namespace root itself.
|
||||
func (m *Manager) driveOfNamespace(nsRoot string) string {
|
||||
if m.settings != nil {
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
if sp.Path != "" && filepath.Clean(NamespaceRootFor(sp.Path, m.systemDataPath)) == filepath.Clean(nsRoot) {
|
||||
return sp.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.systemDataPath != "" && filepath.Clean(NamespaceRootFor(m.systemDataPath, m.systemDataPath)) == filepath.Clean(nsRoot) {
|
||||
return m.systemDataPath
|
||||
}
|
||||
return nsRoot
|
||||
}
|
||||
|
||||
// SetAsideDirsForApp lists the app's set-aside folders (<root>/backups/set-aside/<stack>) that exist, on every
|
||||
// namespace root a unit of it can sit on.
|
||||
func (m *Manager) SetAsideDirsForApp(stack string) []string {
|
||||
if m == nil || !validMirrorStackName(stack) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, root := range m.tier2MirrorRoots(stack) {
|
||||
d := filepath.Join(setAsideRoot(root), stack)
|
||||
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RemoveSetAsideUnits is the app's remove with „delete its backups too": each dir only if it is exactly
|
||||
// <root>/backups/set-aside/<stack> for one of the app's roots, and the kept-copy records inside it are dropped.
|
||||
func (m *Manager) RemoveSetAsideUnits(stack string, dirs []string) []string {
|
||||
if m == nil || !validMirrorStackName(stack) {
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, root := range m.tier2MirrorRoots(stack) {
|
||||
allowed[filepath.Join(setAsideRoot(root), stack)] = true
|
||||
}
|
||||
var removed []string
|
||||
for _, d := range dirs {
|
||||
if !allowed[d] {
|
||||
m.logger.Printf("[WARN] [backup] remove %s: refusing to delete %q — not this app's set-aside folder", stack, d)
|
||||
continue
|
||||
}
|
||||
size := humanizeBytes(dirSizeBytes(d))
|
||||
if err := os.RemoveAll(d); err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] remove %s: deleting the set-aside units %s failed: %v", stack, d, err)
|
||||
continue
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] remove %s: the set-aside units of earlier installs deleted with its backups: %s (%s)", stack, d, size)
|
||||
m.dropKeptCopiesUnder(d)
|
||||
removed = append(removed, fmt.Sprintf("%s (%s)", d, size))
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// SetAsideUnitFor returns the kept-copy record of a set-aside unit by id, still the recorded directory (device +
|
||||
// inode) and still inside its set-aside tree — the only way the restore page names one.
|
||||
func (m *Manager) SetAsideUnitFor(id string) (settings.KeptCopy, error) {
|
||||
if m.settings == nil {
|
||||
return settings.KeptCopy{}, ErrKeptCopyNotFound
|
||||
}
|
||||
for _, k := range m.settings.GetKeptCopies() {
|
||||
if k.ID != id {
|
||||
continue
|
||||
}
|
||||
if k.Kind != KeptKindUnit || !validMirrorStackName(k.Source) {
|
||||
return settings.KeptCopy{}, ErrKeptCopyNotFound
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Clean(k.DestBase), filepath.Clean(k.Path))
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.Base(k.DestBase) != setAsideDirName {
|
||||
return settings.KeptCopy{}, ErrKeptCopyChanged
|
||||
}
|
||||
st, err := os.Lstat(k.Path)
|
||||
if err != nil || !st.IsDir() {
|
||||
return settings.KeptCopy{}, ErrKeptCopyChanged
|
||||
}
|
||||
if dev, ino := statIdentity(st); dev != k.Dev || ino != k.Ino {
|
||||
return settings.KeptCopy{}, ErrKeptCopyChanged
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
return settings.KeptCopy{}, ErrKeptCopyNotFound
|
||||
}
|
||||
|
||||
// RestoreSetAsideUnit restores the app from a set-aside unit (the restore page's „Visszaállítás" on a kept copy):
|
||||
// the one unit-restore body, with the unit named explicitly. The data then belongs to that unit's install, so its
|
||||
// backups follow it (the app's own unit, of the install it replaced, is set aside at the next backup — never lost).
|
||||
func (m *Manager) RestoreSetAsideUnit(id string) (string, UnitRestoreResult, error) {
|
||||
k, err := m.SetAsideUnitFor(id)
|
||||
if err != nil {
|
||||
return "", UnitRestoreResult{}, err
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] restore %s from the set-aside unit %s (an earlier install, D47)", k.Source, k.Path)
|
||||
res, err := m.RestoreFromRecoveryUnitAt(k.Source, k.Path)
|
||||
return k.Source, res, err
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// D47 (operator ruling 2026-10-25 08:38; R-1269) — a new install never writes into an earlier install's unit.
|
||||
//
|
||||
// These tests replay the catalog sweep's order (`audits/catalog-sweep-2026-10-24/_unitprobe/tandoor/`): install A,
|
||||
// a backup, „remove — keep backups", install B of the same version (new database password), then the five-minute
|
||||
// refresh — and assert the CONSEQUENCE: the old unit's bytes, where they are, what a restore of it gets.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): on `e1613ae` (no guardUnitWrite) TestD47_TheSweepOrderNeverRewritesTheKeptUnit
|
||||
// fails — the primary unit's app.yaml carries install B's password beside install A's data, and nothing is set aside.
|
||||
|
||||
const (
|
||||
d47PwA = "PASSWORD-OF-INSTALL-A-the-data-was-made-with"
|
||||
d47PwB = "PASSWORD-OF-INSTALL-B-a-fresh-reinstall"
|
||||
)
|
||||
|
||||
// d47Info is the recovery info of one install of "tandoor" (a Postgres app on the system drive).
|
||||
func d47Info(stackDir, drive, installID, pw string) RecoveryInfo {
|
||||
return RecoveryInfo{
|
||||
StackDir: stackDir,
|
||||
DisplayName: "Tandoor",
|
||||
ImagePins: []string{"example/tandoor:2.6.15"},
|
||||
NonSecretEnv: map[string]string{"SUBDOMAIN": "recipes"},
|
||||
SecretEnvVars: []string{"DB_PASSWORD"},
|
||||
PortableSecretEnvVars: []string{"DB_PASSWORD"},
|
||||
PortableSecrets: map[string]string{"DB_PASSWORD": pw},
|
||||
InstallID: installID,
|
||||
}
|
||||
}
|
||||
|
||||
type d47Rig struct {
|
||||
m *Manager
|
||||
prov *fakeRecoveryProvider
|
||||
drive string
|
||||
stackDir string
|
||||
logs *bytes.Buffer
|
||||
sett *settings.Settings
|
||||
}
|
||||
|
||||
// d47Setup: install A with a database dump and a volume tar, captured after its data run (the „Mentés most" order).
|
||||
func d47Setup(t *testing.T, idA string) *d47Rig {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
r := &d47Rig{stackDir: filepath.Join(tmp, "stack"), drive: filepath.Join(tmp, "drive"), logs: &bytes.Buffer{}}
|
||||
mustWrite(t, filepath.Join(r.stackDir, "docker-compose.yml"),
|
||||
"services:\n tandoor:\n image: example/tandoor:2.6.15\n tandoor-postgres:\n image: postgres:17-alpine\n")
|
||||
mustWrite(t, filepath.Join(r.stackDir, ".felhom.yml"), "display_name: Tandoor\n")
|
||||
mustWrite(t, filepath.Join(r.stackDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: recipes\n")
|
||||
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.sett = sett
|
||||
r.prov = &fakeRecoveryProvider{info: d47Info(r.stackDir, r.drive, idA, d47PwA), hdd: r.drive, running: true,
|
||||
secrets: map[string]string{"DB_PASSWORD": d47PwA}}
|
||||
r.m = &Manager{
|
||||
logger: log.New(r.logs, "", 0),
|
||||
systemDataPath: filepath.Join(tmp, "system"),
|
||||
stackProvider: r.prov,
|
||||
settings: sett,
|
||||
version: "vtest",
|
||||
}
|
||||
mustWrite(t, filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql"), pgDump(1)+"-- the household's account\n")
|
||||
mustWrite(t, filepath.Join(AppVolumeDumpPath(r.drive, "tandoor"), "tandoor_postgres_data.tar"), "DATA DIRECTORY OF INSTALL A")
|
||||
if err := r.m.CaptureRecoveryUnit("tandoor"); err != nil {
|
||||
t.Fatalf("install A's capture: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// reinstall is „remove — keep backups" + a fresh install of the same version: a new identity and a new password.
|
||||
func (r *d47Rig) reinstall(idB string) {
|
||||
r.prov.info = d47Info(r.stackDir, r.drive, idB, d47PwB)
|
||||
r.prov.secrets = map[string]string{"DB_PASSWORD": d47PwB}
|
||||
}
|
||||
|
||||
func (r *d47Rig) unitDir() string { return RecoveryUnitPath(r.drive, "tandoor") }
|
||||
|
||||
func (r *d47Rig) setAside(t *testing.T) []string {
|
||||
t.Helper()
|
||||
entries, _ := os.ReadDir(filepath.Join(setAsideRoot(r.drive), "tandoor"))
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
out = append(out, filepath.Join(setAsideRoot(r.drive), "tandoor", e.Name()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The sweep's measured order. Today (`e1613ae`) the refresh rewrites the kept unit's app.yaml with install B's
|
||||
// password beside install A's data. After D47 the kept unit is set aside byte for byte, it is listed, and the
|
||||
// primary place holds install B's own unit with none of A's data.
|
||||
func TestD47_TheSweepOrderNeverRewritesTheKeptUnit(t *testing.T) {
|
||||
r := d47Setup(t, "aaaa000000000001")
|
||||
before := treeFingerprint(t, r.unitDir())
|
||||
r.reinstall("bbbb000000000002")
|
||||
|
||||
if err := r.m.captureRecoveryUnit("tandoor", false); err != nil { // the five-minute refresh
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
|
||||
aside := r.setAside(t)
|
||||
if len(aside) != 1 {
|
||||
t.Fatalf("install A's unit must be set aside whole (one folder), found %v; primary app.yaml now: %q",
|
||||
aside, mustRead(t, filepath.Join(RecoveryUnitComposePath(r.drive, "tandoor"), "app.yaml")))
|
||||
}
|
||||
if after := treeFingerprint(t, aside[0]); after != before {
|
||||
t.Fatalf("the set-aside unit must be install A's unit byte for byte\nbefore:\n%s\nafter:\n%s", before, after)
|
||||
}
|
||||
primaryAppYaml := mustRead(t, filepath.Join(RecoveryUnitComposePath(r.drive, "tandoor"), "app.yaml"))
|
||||
if strings.Contains(primaryAppYaml, d47PwA) || !strings.Contains(primaryAppYaml, "bbbb000000000002") {
|
||||
t.Fatalf("the primary place must hold install B's own unit: %q", primaryAppYaml)
|
||||
}
|
||||
if fileExists(filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql")) {
|
||||
t.Fatal("install A's database copy must not stay under install B's unit")
|
||||
}
|
||||
var rec *settings.KeptCopy
|
||||
for _, k := range r.sett.GetKeptCopies() {
|
||||
if k.Path == aside[0] {
|
||||
kk := k
|
||||
rec = &kk
|
||||
}
|
||||
}
|
||||
if rec == nil || rec.Kind != KeptKindUnit || rec.Reason != KeptReasonEarlierInstall || rec.Source != "tandoor" {
|
||||
t.Fatalf("the set-aside unit must be recorded as a kept copy (listed, removable only by the household): %+v", r.sett.GetKeptCopies())
|
||||
}
|
||||
}
|
||||
|
||||
// A unit written BEFORE D47 records no install: the carried database password decides (a reinstall mints a new one).
|
||||
func TestD47_AUnitFromBeforeD47IsSetAsideWhenItsPasswordDiffers(t *testing.T) {
|
||||
r := d47Setup(t, "")
|
||||
before := treeFingerprint(t, r.unitDir())
|
||||
r.reinstall("")
|
||||
if err := r.m.captureRecoveryUnit("tandoor", false); err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
aside := r.setAside(t)
|
||||
if len(aside) != 1 || treeFingerprint(t, aside[0]) != before {
|
||||
t.Fatalf("an unrecorded unit with another install's password must be set aside whole, found %v", aside)
|
||||
}
|
||||
}
|
||||
|
||||
// The set-aside unit restores with ITS OWN password (the household's „Visszaállítás" on it), never install B's.
|
||||
func TestD47_TheSetAsideUnitRestoresWithItsOwnPassword(t *testing.T) {
|
||||
r := d47Setup(t, "aaaa000000000001")
|
||||
r.reinstall("bbbb000000000002")
|
||||
if err := r.m.captureRecoveryUnit("tandoor", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ks := r.sett.GetKeptCopies()
|
||||
if len(ks) != 1 {
|
||||
t.Fatalf("one kept copy expected, got %+v", ks)
|
||||
}
|
||||
r.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
|
||||
return []DiscoveredDB{{StackName: "tandoor", ContainerName: "tandoor-postgres", DBType: DBTypePostgres}}, nil
|
||||
}
|
||||
var imported []string
|
||||
r.m.importDBDump = func(_ context.Context, _ DiscoveredDB, p string) error { imported = append(imported, p); return nil }
|
||||
r.m.volumeReplayFrom = func(string, string) (int, error) { return 1, nil }
|
||||
r.m.undoVolumeProbeFn = func(string) (int64, bool, error) { return 10, true, nil }
|
||||
r.m.undoVolumeTarFn = func(vol, dir string) error {
|
||||
return os.WriteFile(filepath.Join(dir, vol+".tar"), []byte("live"), 0o600)
|
||||
}
|
||||
|
||||
app, res, err := r.m.RestoreSetAsideUnit(ks[0].ID)
|
||||
if err != nil || app != "tandoor" {
|
||||
t.Fatalf("restore of the set-aside unit: app=%q err=%v", app, err)
|
||||
}
|
||||
if got := r.prov.gotEnv["DB_PASSWORD"]; got != d47PwA {
|
||||
t.Fatalf("the restored app must get install A's password (the one its data opens with), got %q", got)
|
||||
}
|
||||
if res.DBsReplayed != 1 || len(imported) != 1 || !strings.Contains(imported[0], filepath.Join(setAsideDirName, "tandoor")) {
|
||||
t.Fatalf("the database copy must come from the set-aside unit: %v (replayed %d)", imported, res.DBsReplayed)
|
||||
}
|
||||
}
|
||||
|
||||
// The guard is in the WRITER: a set-aside that cannot be done refuses the write, the unit stays byte for byte, and the
|
||||
// ERROR line names the app and both identities.
|
||||
func TestD47_TheWriterRefusesWhenTheUnitCannotBeSetAside(t *testing.T) {
|
||||
r := d47Setup(t, "aaaa000000000001")
|
||||
before := treeFingerprint(t, r.unitDir())
|
||||
r.reinstall("bbbb000000000002")
|
||||
renameUnitFn = func(string, string) error { return errors.New("injected: rename refused") }
|
||||
defer func() { renameUnitFn = os.Rename }()
|
||||
|
||||
err := r.m.captureRecoveryUnit("tandoor", false)
|
||||
if !errors.Is(err, errUnitOfAnotherInstall) {
|
||||
t.Fatalf("the capture must refuse, got %v", err)
|
||||
}
|
||||
if after := treeFingerprint(t, r.unitDir()); after != before {
|
||||
t.Fatalf("a refused write must leave install A's unit untouched\nbefore:\n%s\nafter:\n%s", before, after)
|
||||
}
|
||||
line := r.logs.String()
|
||||
if !strings.Contains(line, "[ERROR]") || !strings.Contains(line, "tandoor") || !strings.Contains(line, "aaaa000000000001") || !strings.Contains(line, "bbbb000000000002") {
|
||||
t.Fatalf("the ERROR line must name the app and both identities: %s", line)
|
||||
}
|
||||
if strings.Contains(line, d47PwA) || strings.Contains(line, d47PwB) {
|
||||
t.Fatal("SECRET LEAK: a password value reached the log")
|
||||
}
|
||||
}
|
||||
|
||||
// The data legs ask too: install B's night database dump never lands in install A's unit.
|
||||
func TestD47_TheNightDatabaseDumpSetsTheEarlierUnitAsideFirst(t *testing.T) {
|
||||
r := d47Setup(t, "aaaa000000000001")
|
||||
before := treeFingerprint(t, r.unitDir())
|
||||
r.reinstall("bbbb000000000002")
|
||||
r.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
|
||||
return []DiscoveredDB{{StackName: "tandoor", ContainerName: "tandoor-postgres", DBType: DBTypePostgres}}, nil
|
||||
}
|
||||
r.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult {
|
||||
p := filepath.Join(dir, "tandoor-postgres.sql")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(p, []byte(pgDump(1)+"-- install B, empty\n"), 0o644)
|
||||
return DumpResult{DB: db, FilePath: p, Size: 10}
|
||||
}
|
||||
if err := r.m.guardUnitWrite("tandoor", r.drive, "database dump"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := r.m.dumpOneOrDefault(context.Background(), r.m.mustDB(t), AppDBDumpPath(r.drive, "tandoor"))
|
||||
if res.Error != nil {
|
||||
t.Fatal(res.Error)
|
||||
}
|
||||
aside := r.setAside(t)
|
||||
if len(aside) != 1 || treeFingerprint(t, aside[0]) != before {
|
||||
t.Fatalf("install A's unit must be set aside whole before install B's dump is written, found %v", aside)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) mustDB(t *testing.T) DiscoveredDB {
|
||||
t.Helper()
|
||||
dbs, err := m.discoverDBs(context.Background())
|
||||
if err != nil || len(dbs) == 0 {
|
||||
t.Fatal("no db")
|
||||
}
|
||||
return dbs[0]
|
||||
}
|
||||
|
||||
// The other side: a normal backup of the SAME install still refreshes the unit whole, and nothing is set aside.
|
||||
func TestD47_ASameInstallBackupStillRefreshesTheUnit(t *testing.T) {
|
||||
r := d47Setup(t, "aaaa000000000001")
|
||||
mustWrite(t, filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql"), pgDump(1)+"-- tonight's rows\n")
|
||||
if err := r.m.CaptureRecoveryUnit("tandoor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if aside := r.setAside(t); len(aside) != 0 {
|
||||
t.Fatalf("a backup of the same install must not set anything aside: %v", aside)
|
||||
}
|
||||
appy := mustRead(t, filepath.Join(RecoveryUnitComposePath(r.drive, "tandoor"), "app.yaml"))
|
||||
if !strings.Contains(appy, d47PwA) || !strings.Contains(appy, "install_id: aaaa000000000001") {
|
||||
t.Fatalf("the same install's unit carries its own password and identity: %q", appy)
|
||||
}
|
||||
if got := mustRead(t, filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql")); !strings.Contains(got, "tonight's rows") {
|
||||
t.Fatal("tonight's dump must be in the unit")
|
||||
}
|
||||
}
|
||||
|
||||
// A restore of the app runs: the refresh never moves the unit it reads (refused, untouched).
|
||||
func TestD47_NoSetAsideWhileTheAppIsRestored(t *testing.T) {
|
||||
r := d47Setup(t, "aaaa000000000001")
|
||||
before := treeFingerprint(t, r.unitDir())
|
||||
r.reinstall("bbbb000000000002")
|
||||
r.m.BeginRestoreOp("restore", "tandoor")
|
||||
if err := r.m.guardUnitWrite("tandoor", r.drive, "database dump"); !errors.Is(err, errUnitOfAnotherInstall) {
|
||||
t.Fatalf("a write during the app's restore must refuse, got %v", err)
|
||||
}
|
||||
if after := treeFingerprint(t, r.unitDir()); after != before {
|
||||
t.Fatal("the unit under a restore must stay as it is")
|
||||
}
|
||||
}
|
||||
|
||||
// The verdict as a pure function: ids decide when both are known; otherwise a differing carried secret.
|
||||
func TestD47_OwnerVerdict(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
unitID, id string
|
||||
us, ls map[string]string
|
||||
want unitOwner
|
||||
}{
|
||||
{"same id", "a", "a", nil, nil, ownerSame},
|
||||
{"other id", "a", "b", map[string]string{"P": "x"}, map[string]string{"P": "x"}, ownerOther},
|
||||
{"unrecorded unit, same password", "", "b", map[string]string{"P": "x"}, map[string]string{"P": "x"}, ownerSame},
|
||||
{"unrecorded unit, other password", "", "b", map[string]string{"P": "x"}, map[string]string{"P": "y"}, ownerOther},
|
||||
{"unrecorded unit, no secrets", "", "b", nil, nil, ownerSame},
|
||||
{"empty value never decides", "", "", map[string]string{"P": ""}, map[string]string{"P": "y"}, ownerSame},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got, _ := unitOwnerVerdict(c.unitID, c.id, c.us, c.ls); got != c.want {
|
||||
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,6 +406,9 @@ func (m *Manager) RunAppBackupNow(ctx context.Context, stackName string) error {
|
||||
if err != nil {
|
||||
return util.MsgError("err.backup.adatbazis_felderites_sikertelen", err)
|
||||
}
|
||||
if gErr := m.guardUnitWrite(stackName, nsRoot, "update pre-backup"); gErr != nil { // D47 (R-1269)
|
||||
return util.MsgError("err.backup.adatbazis_mentes_sikertelen", stackName, gErr)
|
||||
}
|
||||
dumped := 0
|
||||
for _, db := range dbs {
|
||||
if db.StackName != stackName {
|
||||
|
||||
Reference in New Issue
Block a user