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
74 lines
2.9 KiB
Go
74 lines
2.9 KiB
Go
package aftercrash
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// R-1007 (decision 236): the repair runs ONLY after an unclean host boot that THIS start followed, only after the
|
|
// crash grace, and only once. A normal boot and an unknown fact never run it. The verdict is crashboot's own (the
|
|
// real Grace with a fake probe), so these tests assert the consequence: was the app's block run?
|
|
|
|
var start = time.Date(2026, 10, 14, 12, 8, 0, 0, time.UTC)
|
|
|
|
func probe(f crashboot.Fact, err error) crashboot.Probe {
|
|
return func(context.Context) (crashboot.Fact, error) { return f, err }
|
|
}
|
|
|
|
func runner(p crashboot.Probe) (*Runner, *[]string) {
|
|
var ran []string
|
|
g := crashboot.New(start, p, nil)
|
|
r := New(g, func() []string { return []string{"immich"} },
|
|
func(name string, _ time.Time) stacks.AfterUncleanBootResult {
|
|
ran = append(ran, name)
|
|
return stacks.AfterUncleanBootResult{}
|
|
},
|
|
func(string, time.Time, stacks.AfterUncleanBootResult) {}, nil)
|
|
return r, &ran
|
|
}
|
|
|
|
// RED-PROOF: drop the `now.Sub(r.Start) < wait` check → the run happens at +1 min → fails.
|
|
// RED-PROOF: drop `r.done = true` before the run → a second tick runs it again → fails.
|
|
func TestAfterCrash_RunsOnceAfterTheGraceOnAnUncleanBootThisStartFollowed(t *testing.T) {
|
|
r, ran := runner(probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-40 * time.Second)}, nil))
|
|
for _, at := range []time.Duration{0, time.Minute, 14 * time.Minute} {
|
|
r.Tick(start.Add(at))
|
|
}
|
|
if len(*ran) != 0 {
|
|
t.Fatalf("ran inside the crash grace: %v", *ran)
|
|
}
|
|
r.Tick(start.Add(crashboot.CrashGrace + time.Second))
|
|
r.Tick(start.Add(crashboot.CrashGrace + time.Minute))
|
|
if len(*ran) != 1 {
|
|
t.Fatalf("ran %d time(s), want exactly once after the grace", len(*ran))
|
|
}
|
|
}
|
|
|
|
// RED-PROOF: in Tick, treat `!crash` like a crash (drop the early return) → a normal boot runs the repair → fails.
|
|
func TestAfterCrash_NeverOnANormalAnUnknownOrAnOldUncleanBoot(t *testing.T) {
|
|
cases := map[string]crashboot.Probe{
|
|
"normal boot": probe(crashboot.Fact{Known: true, Unclean: false, BootAt: start.Add(-time.Minute)}, nil),
|
|
"unknown (no crash guard)": probe(crashboot.Fact{}, nil),
|
|
"unknown (agent unreachable)": probe(crashboot.Fact{}, context.DeadlineExceeded),
|
|
"no agent at all": nil,
|
|
"an unclean boot days ago": probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-72 * time.Hour)}, nil),
|
|
"an unclean boot with no time": probe(crashboot.Fact{Known: true, Unclean: true}, nil),
|
|
}
|
|
for name, p := range cases {
|
|
r, ran := runner(p)
|
|
for at := time.Duration(0); at <= 2*crashboot.CrashGrace; at += 30 * time.Second {
|
|
r.Tick(start.Add(at))
|
|
}
|
|
if len(*ran) != 0 {
|
|
t.Errorf("%s: the repair ran", name)
|
|
}
|
|
if !r.Done() {
|
|
t.Errorf("%s: the decision was never taken", name)
|
|
}
|
|
}
|
|
}
|