8d862e4e69
gates / gates (push) Successful in 52s
New template block after_unclean_boot (decision 236): once per controller start that followed an UNCLEAN host boot (crashboot's own verdict, after the 15-minute crash grace; a normal or unknown boot never runs it), each app with the block rebuilds ONLY its zero-byte files (scan: regular files, no symlinks, its own filesystem; ids appended to the app's own command in batches; one retry, then reported) and its own lost-file check is read. The household sees lost files (name, date taken) on the app page until dismissed; the operator gets app_repaired_after_crash with counts only. Skips, with the reason, an unproven image, an unknown key, an app not running. MinAgent 0.155.0 unchanged. Tests red-proofed (14 guards). One new parity fixture (the new card only). Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
109 lines
3.5 KiB
Go
109 lines
3.5 KiB
Go
// Package aftercrash runs the apps' after_unclean_boot blocks ONCE per controller start that followed an unclean
|
|
// host boot (R-1007, `09` §3 decision 236).
|
|
//
|
|
// The fact is crashboot's (the host crash guard, read through the agent): the same verdict that holds the app mails
|
|
// for CrashGrace. Unknown is a normal boot — a repair never runs on a guess. A normal boot never runs it. After a crash
|
|
// boot it waits CrashGrace from the controller's start (the apps come up slowly after a crash), then runs each app's
|
|
// block one after the other and reports each app's counts. It never stops an app; a failed run is reported, not
|
|
// retried. Pinned by aftercrash_test.go.
|
|
package aftercrash
|
|
|
|
import (
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// Verdict is crashboot.Grace's Decided (a seam for tests).
|
|
type Verdict func(now time.Time) (resolved, crash bool, bootAt time.Time)
|
|
|
|
// Runner does the one run. Every func field is required; Logger may be nil.
|
|
type Runner struct {
|
|
Start time.Time
|
|
Verdict Verdict
|
|
Apps func() []string
|
|
Run func(name string, bootAt time.Time) stacks.AfterUncleanBootResult
|
|
Report func(name string, bootAt time.Time, res stacks.AfterUncleanBootResult)
|
|
Logger *log.Logger
|
|
// Wait is how long after Start a crash-boot run waits (default crashboot.CrashGrace).
|
|
Wait time.Duration
|
|
|
|
mu sync.Mutex
|
|
done bool
|
|
}
|
|
|
|
// New builds the runner on the controller's crash-boot grace.
|
|
func New(g *crashboot.Grace, apps func() []string,
|
|
run func(string, time.Time) stacks.AfterUncleanBootResult,
|
|
report func(string, time.Time, stacks.AfterUncleanBootResult), logger *log.Logger) *Runner {
|
|
return &Runner{Start: g.Start(), Verdict: g.Decided, Apps: apps, Run: run, Report: report, Logger: logger}
|
|
}
|
|
|
|
// Done reports whether this start's decision has been taken (ran, or decided not to).
|
|
func (r *Runner) Done() bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.done
|
|
}
|
|
|
|
// Tick is called periodically (the scheduler). It returns true once the run has happened or been ruled out.
|
|
func (r *Runner) Tick(now time.Time) bool {
|
|
r.mu.Lock()
|
|
if r.done {
|
|
r.mu.Unlock()
|
|
return true
|
|
}
|
|
resolved, crash, bootAt := r.Verdict(now)
|
|
if !resolved {
|
|
r.mu.Unlock()
|
|
return false
|
|
}
|
|
if !crash {
|
|
r.done = true
|
|
r.mu.Unlock()
|
|
r.logf("[INFO] [aftercrash] a normal (or unknown) boot — no app repair after an unclean boot (R-1007)")
|
|
return true
|
|
}
|
|
wait := r.Wait
|
|
if wait <= 0 {
|
|
wait = crashboot.CrashGrace
|
|
}
|
|
if now.Sub(r.Start) < wait {
|
|
r.mu.Unlock()
|
|
return false
|
|
}
|
|
r.done = true // set BEFORE the run: a slow or failing run is never started twice
|
|
r.mu.Unlock()
|
|
|
|
apps := r.Apps()
|
|
r.logf("[INFO] [aftercrash] the host's boot at %s followed an unclean stop — %d app(s) with an after_unclean_boot block (R-1007)",
|
|
bootAt.UTC().Format(time.RFC3339), len(apps))
|
|
for _, name := range apps {
|
|
res := r.Run(name, bootAt)
|
|
if res.Skipped != "" {
|
|
r.logf("[WARN] [aftercrash] %s: skipped — %s", name, res.Skipped)
|
|
} else {
|
|
r.logf("[INFO] [aftercrash] %s: found %d empty file(s), repaired %d, still broken %d, lost %d (checked=%t) in %s%s",
|
|
name, res.Found, res.Repaired, res.StillBroken, res.Lost, res.LostChecked, res.Duration.Round(time.Millisecond), problem(res.Problem))
|
|
}
|
|
r.Report(name, bootAt, res)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func problem(p string) string {
|
|
if p == "" {
|
|
return ""
|
|
}
|
|
return " — problem: " + p
|
|
}
|
|
|
|
func (r *Runner) logf(format string, args ...any) {
|
|
if r.Logger != nil {
|
|
r.Logger.Printf(format, args...)
|
|
}
|
|
}
|