0c702f834a
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
138 lines
4.9 KiB
Go
138 lines
4.9 KiB
Go
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()}
|
|
}
|