v0.279.0: after_install (decision 45), known default logins on the page, Part D empty-backup alarm, night chain (R-705), R-706
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
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:
@@ -160,6 +160,9 @@ type Manager struct {
|
||||
// INIT/TEST ONLY.
|
||||
keptUnitRestoreFn func(app, unitDir string) (UnitRestoreResult, error)
|
||||
|
||||
// hollow (Part D, v0.279.0) — the running apps whose newest copy holds no data. See hollow_watch.go.
|
||||
hollow hollowWatch
|
||||
|
||||
// F17 restore seams — overridable in tests so the .sql re-import orchestration can be unit-tested
|
||||
// without Docker. Default to the real DiscoverDatabases / ImportDump (lazy-init in reimportDBDumps).
|
||||
discoverDBs func(ctx context.Context) ([]DiscoveredDB, error)
|
||||
@@ -667,6 +670,8 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest). A DATA run:
|
||||
// the capture folds the stamps the legs above just wrote (v0.275.0).
|
||||
m.captureAllRecoveryUnits(true)
|
||||
// Part D (v0.279.0): a RUNNING app whose unit still holds no data after this run is an alarm.
|
||||
m.checkLocalCopies()
|
||||
|
||||
// F5 (CAMPAIGN-3): after the units are fresh on the CURRENT drives, prune any orphaned
|
||||
// backups/primary/<app> dir an app left on an OLD drive when its HDD_PATH moved — pure disk
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Part D (v0.279.0): a RUNNING app whose newest copy holds no data is an alarm ─────────────────────
|
||||
//
|
||||
// Measured 2026-09-28 on demo-hp (controller 0.277.0): a freshly installed nextcloud carried a leftover
|
||||
// hold (R-704), so the dump leg skipped its volumes and its unit was never captured; the off-site run then
|
||||
// pushed a snapshot that "carried NO database dump and NO volume tar". The ONLY trace was one WARN line in
|
||||
// the container log (R-412 leg 1 made it honest wording, nothing more): no event, no page sentence. The app
|
||||
// was running and unprotected, and nobody could know.
|
||||
//
|
||||
// R-704 closed that cause. This closes the CLASS: at the end of each data leg, every installed app that is
|
||||
// RUNNING and has named volumes (the data a unit must carry — a database lives in a volume) must have a
|
||||
// copy that carries data (unitCarriesData — the manifest lists a dump or a tar). If not:
|
||||
// - the operator hears it once per app, per tier, per day (the existing operator-only backup_run_failures
|
||||
// digest, wired in main.go — no new event type);
|
||||
// - the household sees one sentence per app on the backup page, until a later check finds data.
|
||||
// A HELD app that is really stopped is not flagged — a hold is a deliberate stop, and its copy is the one
|
||||
// the hold names. A held app that RUNS (yesterday's shape) is flagged.
|
||||
// Pinned by internal/backup/r_partd_hollow_watch_test.go.
|
||||
|
||||
// Hollow-copy tiers.
|
||||
const (
|
||||
HollowTierLocal = "local" // the app's own recovery unit (Tier 1), checked at the end of the dump leg
|
||||
HollowTierOffsite = "offsite" // the unit the off-site run just pushed (Tier 3)
|
||||
)
|
||||
|
||||
// HollowCopy is one running app whose newest copy on a tier holds no data.
|
||||
type HollowCopy struct {
|
||||
App string
|
||||
Tier string
|
||||
At time.Time // when the check found it
|
||||
}
|
||||
|
||||
type hollowWatch struct {
|
||||
mu sync.Mutex
|
||||
current map[string]HollowCopy // key app|tier
|
||||
notified map[string]string // key app|tier → the day (YYYY-MM-DD) the operator was told
|
||||
notify func(app, tier string)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// SetHollowCopyNotify wires the operator signal (main.go → notifier). INIT-ONLY.
|
||||
func (m *Manager) SetHollowCopyNotify(fn func(app, tier string)) {
|
||||
m.hollow.mu.Lock()
|
||||
m.hollow.notify = fn
|
||||
m.hollow.mu.Unlock()
|
||||
}
|
||||
|
||||
// HollowCopies returns the flagged apps, sorted, for the backup page.
|
||||
func (m *Manager) HollowCopies() []HollowCopy {
|
||||
m.hollow.mu.Lock()
|
||||
defer m.hollow.mu.Unlock()
|
||||
out := make([]HollowCopy, 0, len(m.hollow.current))
|
||||
for _, h := range m.hollow.current {
|
||||
out = append(out, h)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].App != out[j].App {
|
||||
return out[i].App < out[j].App
|
||||
}
|
||||
return out[i].Tier < out[j].Tier
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// watchesForData: a deployed app that RUNS and has named volumes. A held app that is stopped is skipped.
|
||||
func (m *Manager) watchesForData(app string) bool {
|
||||
if m.stackProvider == nil || m.cfg != nil && m.cfg.IsProtectedStack(app) {
|
||||
return false
|
||||
}
|
||||
if len(m.stackProvider.GetDockerVolumes(app)) == 0 {
|
||||
return false
|
||||
}
|
||||
return m.stackProvider.RefreshAndIsRunning(app)
|
||||
}
|
||||
|
||||
// judgeCopy records the verdict for one app's copy on a tier: flags (and tells the operator, once a day) a
|
||||
// running app's copy with no data, clears the flag when the copy carries data.
|
||||
func (m *Manager) judgeCopy(app, tier, unitDir string) {
|
||||
key := app + "|" + tier
|
||||
hollow := m.watchesForData(app) && !unitCarriesData(unitDir)
|
||||
m.hollow.mu.Lock()
|
||||
if m.hollow.current == nil {
|
||||
m.hollow.current = map[string]HollowCopy{}
|
||||
m.hollow.notified = map[string]string{}
|
||||
}
|
||||
now := time.Now
|
||||
if m.hollow.now != nil {
|
||||
now = m.hollow.now
|
||||
}
|
||||
if !hollow {
|
||||
delete(m.hollow.current, key)
|
||||
m.hollow.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t := now()
|
||||
m.hollow.current[key] = HollowCopy{App: app, Tier: tier, At: t}
|
||||
day := t.Format("2006-01-02")
|
||||
tell := m.hollow.notify != nil && m.hollow.notified[key] != day
|
||||
if tell {
|
||||
m.hollow.notified[key] = day
|
||||
}
|
||||
fn := m.hollow.notify
|
||||
m.hollow.mu.Unlock()
|
||||
m.logger.Printf("[ERROR] [backup] %s is RUNNING but its newest %s copy (%s) holds NO database dump and NO volume tar — it is not protected (Part D)", app, tier, unitDir)
|
||||
if tell {
|
||||
fn(app, tier)
|
||||
}
|
||||
}
|
||||
|
||||
// checkLocalCopies judges every deployed app's own unit at the end of the dump leg.
|
||||
func (m *Manager) checkLocalCopies() {
|
||||
if m.stackProvider == nil {
|
||||
return
|
||||
}
|
||||
for _, s := range m.stackProvider.ListDeployedStacks() {
|
||||
m.judgeCopy(s.Name, HollowTierLocal, m.primaryUnitDirFor(s.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// FlagHollowCopyForTest records a flagged copy without a backup run (the web package's render test).
|
||||
// Test-only by name: only judgeCopy may decide a copy is hollow.
|
||||
func (m *Manager) FlagHollowCopyForTest(app, tier string) {
|
||||
m.hollow.mu.Lock()
|
||||
defer m.hollow.mu.Unlock()
|
||||
if m.hollow.current == nil {
|
||||
m.hollow.current = map[string]HollowCopy{}
|
||||
m.hollow.notified = map[string]string{}
|
||||
}
|
||||
m.hollow.current[app+"|"+tier] = HollowCopy{App: app, Tier: tier, At: time.Now()}
|
||||
}
|
||||
@@ -1384,6 +1384,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
|
||||
// WORDING ONLY. This adds no guard and does NOT touch the capture — `07` §8.2 records why
|
||||
// guarding the capture would make the manifest lie. The reader of a log gets the fact; whether
|
||||
// the push should refuse is R-412 leg 2 and is still open.
|
||||
m.judgeCopy(stack, HollowTierOffsite, src) // Part D (v0.279.0): a running app's empty snapshot is an alarm
|
||||
if unitIsHollow(src) {
|
||||
m.logger.Printf("[WARN] [offbox] backed up %s (%s, %d mandatory path(s)) — but the recovery unit carried NO database dump and NO volume tar, so this snapshot holds none of the app's data; the next run with a dump leg will replace it",
|
||||
stack, src, len(extra))
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// hollowProvider: per-app volumes and running state; everything else from floorProvider.
|
||||
type hollowProvider struct {
|
||||
floorProvider
|
||||
volumes map[string][]string
|
||||
running map[string]bool
|
||||
}
|
||||
|
||||
func (p *hollowProvider) GetDockerVolumes(n string) []string { return p.volumes[n] }
|
||||
func (p *hollowProvider) RefreshAndIsRunning(n string) bool { return p.running[n] }
|
||||
|
||||
// Part D (v0.279.0) — the CONSEQUENCE: a running app whose newest copy holds no data reaches the operator
|
||||
// (once per app per tier per day) and the backup page; a copy with data clears it; a stopped (held) app
|
||||
// and an app with no volumes are never flagged.
|
||||
// COMPANION RED-PROOF: make judgeCopy ignore unitCarriesData (the pre-0.279.0 shape: nothing but a log) →
|
||||
// no notification and the first assertion fails.
|
||||
func TestPartD_ARunningAppWithAnEmptyCopyIsAnAlarm(t *testing.T) {
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.SystemDataPath = t.TempDir()
|
||||
m := NewManager(cfg, sett, log.New(io.Discard, "", 0))
|
||||
p := &hollowProvider{floorProvider: floorProvider{stacks: []string{"cloud", "held", "novol"}, dir: t.TempDir()},
|
||||
volumes: map[string][]string{"cloud": {"cloud_db"}, "held": {"held_db"}},
|
||||
running: map[string]bool{"cloud": true, "novol": true}}
|
||||
m.SetStackProvider(p)
|
||||
day := time.Date(2026, 9, 28, 15, 20, 0, 0, time.UTC)
|
||||
m.hollow.now = func() time.Time { return day }
|
||||
var told []string
|
||||
m.SetHollowCopyNotify(func(app, tier string) { told = append(told, app+"/"+tier) })
|
||||
|
||||
unit := t.TempDir()
|
||||
writeMan := func(withData bool) {
|
||||
man := RecoveryManifest{SchemaVersion: 2, AppName: "cloud"}
|
||||
if withData {
|
||||
man.DBDumps = []string{"cloud.sql"}
|
||||
}
|
||||
b, _ := json.Marshal(man)
|
||||
_ = os.WriteFile(UnitManifestFile(unit), b, 0o644)
|
||||
}
|
||||
writeMan(false) // yesterday's shape: a manifest listing nothing
|
||||
|
||||
for _, app := range []string{"cloud", "held", "novol"} {
|
||||
m.judgeCopy(app, HollowTierOffsite, unit)
|
||||
}
|
||||
if len(told) != 1 || told[0] != "cloud/offsite" {
|
||||
t.Fatalf("operator told %v, want exactly [cloud/offsite] (held+stopped and volume-less apps are not flagged)", told)
|
||||
}
|
||||
if h := m.HollowCopies(); len(h) != 1 || h[0].App != "cloud" || h[0].Tier != HollowTierOffsite {
|
||||
t.Fatalf("page list %+v, want the one running app", h)
|
||||
}
|
||||
// Same day again: still flagged, NOT told twice.
|
||||
m.judgeCopy("cloud", HollowTierOffsite, unit)
|
||||
if len(told) != 1 {
|
||||
t.Fatalf("told twice in one day: %v", told)
|
||||
}
|
||||
// A missing unit (no manifest) is also no data — fail closed.
|
||||
m.judgeCopy("cloud", HollowTierLocal, filepath.Join(unit, "absent"))
|
||||
if len(told) != 2 || told[1] != "cloud/local" {
|
||||
t.Fatalf("an absent local unit was not flagged: %v", told)
|
||||
}
|
||||
// The next day: told again.
|
||||
m.hollow.now = func() time.Time { return day.Add(24 * time.Hour) }
|
||||
m.judgeCopy("cloud", HollowTierOffsite, unit)
|
||||
if len(told) != 3 {
|
||||
t.Fatalf("not told again the next day: %v", told)
|
||||
}
|
||||
// The copy carries data again: cleared from the page.
|
||||
writeMan(true)
|
||||
m.judgeCopy("cloud", HollowTierOffsite, unit)
|
||||
m.judgeCopy("cloud", HollowTierLocal, unit)
|
||||
if h := m.HollowCopies(); len(h) != 0 {
|
||||
t.Fatalf("a copy with data is still listed: %+v", h)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user