v0.311.0: after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007)
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
This commit is contained in:
2026-10-14 16:47:13 +02:00
parent acbbf4b305
commit 8d862e4e69
24 changed files with 1865 additions and 3 deletions
@@ -0,0 +1,437 @@
package stacks
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"syscall"
"time"
)
// ── after_unclean_boot (R-1007, `09` §3 decision 236): an app repairs the files a power cut left empty ──
//
// A power cut while photos upload leaves some of Immich's thumbnails and previews ZERO BYTES (ext4 had the name, not
// the data). Immich still lists the photos as done and never redoes them; rarely an original is gone too. The
// template declares, for its own app, where such files are, how the app is asked to rebuild THOSE (and only those),
// and how the app's own lost-file check is read:
//
// after_unclean_boot:
// service: immich-server # the commands run in this compose service
// images: ["ghcr.io/immich-app/immich-server:v3.2.4"] # the images it was PROVEN on; any other: skip
// empty_files:
// under: "${HDD_PATH}/appdata/immich/thumbs" # only ${HDD_PATH} is filled in
// id_from_name: "^([0-9a-f-]{36})_" # one capture group: the app's id for that file
// repair:
// command: ["node", "-e", "…"] # the ids are appended as arguments
// success: FELHOM_REPAIR_OK
// batch: 200
// report_lost: # optional
// command: ["node", "-e", "…"] # prints `LOST {"name":…,"taken":…}` lines
// success: FELHOM_LOST_OK
//
// The controller holds NO app knowledge: the catalog block is all of it. Ran ONLY once per controller start that
// followed an unclean host boot (internal/aftercrash decides when). Never deletes or rewrites anything itself; the
// app rebuilds its own files. A run that fails never stops the app. File names never reach a log line or an event:
// the lost list lives only in app.yaml on this box, for the household's note. Pinned by after_unclean_boot_test.go.
// AfterUncleanBootSpec is `.felhom.yml`'s `after_unclean_boot:`.
type AfterUncleanBootSpec struct {
Service string `yaml:"service" json:"service"`
Images []string `yaml:"images" json:"images"`
EmptyFiles AUBEmptyFiles `yaml:"empty_files" json:"empty_files"`
Repair AUBCommand `yaml:"repair" json:"repair"`
ReportLost *AUBCommand `yaml:"report_lost,omitempty" json:"report_lost,omitempty"`
Extra map[string]any `yaml:",inline" json:"-"` // a newer key this box does not know → skip
}
// AUBEmptyFiles says where the empty files are and how a file name maps to the app's id.
type AUBEmptyFiles struct {
Under string `yaml:"under" json:"under"`
IDFromName string `yaml:"id_from_name" json:"id_from_name"`
}
// AUBCommand is one command run in the service's container.
type AUBCommand struct {
Command []string `yaml:"command" json:"command"`
Success string `yaml:"success" json:"success"`
Batch int `yaml:"batch,omitempty" json:"batch,omitempty"`
}
// LostFile is one file the app's own check reports lost: the household's note shows it, nothing else does.
type LostFile struct {
Name string `yaml:"name" json:"name"`
Taken string `yaml:"taken,omitempty" json:"taken,omitempty"`
}
// AfterUncleanBootRecord is app.yaml's `after_unclean_boot:` — what the last run found and did.
type AfterUncleanBootRecord struct {
At string `yaml:"at" json:"at"`
BootAt string `yaml:"boot_at,omitempty" json:"boot_at,omitempty"`
Found int `yaml:"found" json:"found"`
Repaired int `yaml:"repaired" json:"repaired"`
StillBroken int `yaml:"still_broken" json:"still_broken"`
Lost []LostFile `yaml:"lost,omitempty" json:"lost,omitempty"`
LostChecked bool `yaml:"lost_checked" json:"lost_checked"`
DurationMS int64 `yaml:"duration_ms" json:"duration_ms"`
Skipped string `yaml:"skipped,omitempty" json:"skipped,omitempty"`
Problem string `yaml:"problem,omitempty" json:"problem,omitempty"`
Dismissed bool `yaml:"dismissed,omitempty" json:"dismissed,omitempty"`
}
// AfterUncleanBootResult is a run's outcome for the log and the operator event (counts only).
type AfterUncleanBootResult struct {
Found, Repaired, StillBroken, Lost int
LostChecked bool
Duration time.Duration
Skipped string // why nothing ran ("" = it ran)
Problem string // a step that failed (the counts say what was done)
}
var (
aubIDSafe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
aubHealthyWait = 10 * time.Minute
aubDefaultBatch = 200
aubMaxLostListed = 200
)
// aubUnsupported names why this box cannot run the block, or "" when it can.
func aubUnsupported(s *AfterUncleanBootSpec) string {
if len(s.Extra) > 0 {
keys := make([]string, 0, len(s.Extra))
for k := range s.Extra {
keys = append(keys, k)
}
sort.Strings(keys)
return "the template has keys this controller does not know: " + strings.Join(keys, ", ")
}
switch {
case s.Service == "" || len(s.Images) == 0:
return "the template names no service or no proven image"
case s.EmptyFiles.Under == "" || s.EmptyFiles.IDFromName == "":
return "the template names no folder or no id pattern"
case len(s.Repair.Command) == 0 || s.Repair.Success == "":
return "the template names no repair command"
case s.ReportLost != nil && (len(s.ReportLost.Command) == 0 || s.ReportLost.Success == ""):
return "the template's lost-file check is incomplete"
}
re, err := regexp.Compile(s.EmptyFiles.IDFromName)
if err != nil || re.NumSubexp() != 1 {
return "the template's id pattern is not one capture group"
}
return ""
}
// aubUnder fills ${HDD_PATH} (and nothing else) into the folder; an empty or relative result refuses.
func aubUnder(tmpl string, env map[string]string) (string, error) {
var bad []string
out := os.Expand(tmpl, func(k string) string {
if k != "HDD_PATH" || strings.TrimSpace(env["HDD_PATH"]) == "" {
bad = append(bad, k)
return ""
}
return strings.TrimSpace(env["HDD_PATH"])
})
if len(bad) > 0 {
return "", fmt.Errorf("the folder needs %v, which this app does not have", bad)
}
out = filepath.Clean(out)
if !filepath.IsAbs(out) || out == "/" {
return "", fmt.Errorf("the folder %q is not an absolute app folder", out)
}
return out, nil
}
// ScanEmptyFiles walks root and returns the ids (de-duplicated, sorted) of the REGULAR, zero-byte files whose names
// match re. Symlinks are never followed nor counted; the walk stays on root's own filesystem; a name whose id is not
// plain id text is skipped. A missing root is no error (nothing to repair).
func ScanEmptyFiles(root string, re *regexp.Regexp) ([]string, error) {
rootInfo, err := os.Lstat(root)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
if !rootInfo.IsDir() {
return nil, fmt.Errorf("%s is not a folder", root)
}
rootDev, haveDev := devOf(rootInfo)
seen := map[string]bool{}
err = filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
if p == root {
return werr
}
return nil // an unreadable corner is skipped, not fatal
}
if d.Type()&fs.ModeSymlink != 0 {
return nil
}
info, ierr := d.Info()
if ierr != nil {
return nil
}
if d.IsDir() {
if haveDev && p != root {
if dev, ok := devOf(info); ok && dev != rootDev {
return filepath.SkipDir // another filesystem mounted inside: not the app's folder
}
}
return nil
}
if !info.Mode().IsRegular() || info.Size() != 0 {
return nil
}
m := re.FindStringSubmatch(d.Name())
if len(m) != 2 || !aubIDSafe.MatchString(m[1]) {
return nil
}
seen[m[1]] = true
return nil
})
ids := make([]string, 0, len(seen))
for id := range seen {
ids = append(ids, id)
}
sort.Strings(ids)
return ids, err
}
func devOf(info fs.FileInfo) (uint64, bool) {
if st, ok := info.Sys().(*syscall.Stat_t); ok {
return uint64(st.Dev), true
}
return 0, false
}
// parseLost reads the lost-file check's `LOST {json}` lines.
func parseLost(out string) []LostFile {
var lost []LostFile
for _, line := range strings.Split(out, "\n") {
rest, ok := strings.CutPrefix(strings.TrimSpace(line), "LOST ")
if !ok {
continue
}
var f LostFile
if json.Unmarshal([]byte(rest), &f) == nil && strings.TrimSpace(f.Name) != "" {
f.Name = truncateStr(f.Name, 200)
f.Taken = truncateStr(f.Taken, 40)
lost = append(lost, f)
}
}
return lost
}
// AfterUncleanBootApps lists the deployed apps whose template carries the block.
func (m *Manager) AfterUncleanBootApps() []string {
var out []string
for _, st := range m.GetStacks() {
if st.Deployed && st.Meta.AfterUncleanBoot != nil {
out = append(out, st.Name)
}
}
sort.Strings(out)
return out
}
// RunAfterUncleanBoot runs one app's block once (internal/aftercrash calls it after an unclean boot). It records
// the outcome in app.yaml and returns the counts. It never stops, restarts or changes the app.
func (m *Manager) RunAfterUncleanBoot(name string, bootAt time.Time) AfterUncleanBootResult {
t0 := time.Now()
res := AfterUncleanBootResult{}
st, ok := m.GetStack(name)
if !ok || st.Meta.AfterUncleanBoot == nil {
res.Skipped = "the app has no after_unclean_boot block"
return res
}
spec := st.Meta.AfterUncleanBoot
dir := filepath.Dir(st.ComposePath)
var lost []LostFile
defer func() {
res.Duration = time.Since(t0)
rec := &AfterUncleanBootRecord{
At: m.now().UTC().Format(time.RFC3339), Found: res.Found, Repaired: res.Repaired,
StillBroken: res.StillBroken, Lost: lost, LostChecked: res.LostChecked,
DurationMS: res.Duration.Milliseconds(), Skipped: res.Skipped, Problem: truncateStr(res.Problem, 300),
}
if !bootAt.IsZero() {
rec.BootAt = bootAt.UTC().Format(time.RFC3339)
}
m.mutateAppConfig(name, dir, "after_unclean_boot", func(cfg *AppConfig) bool { cfg.AfterUncleanBoot = rec; return true })
}()
if why := aubUnsupported(spec); why != "" {
res.Skipped = why
return res
}
cfg := LoadAppConfigDecrypted(dir, m.encKey)
if cfg == nil {
res.Skipped = "the app's settings could not be read"
return res
}
if img := installedRef(cfg, spec.Service); !containsString(spec.Images, img) {
if img == "" {
img = "unknown"
}
res.Skipped = fmt.Sprintf("%s runs %s, which the template has not proven (%s)", spec.Service, img, strings.Join(spec.Images, ", "))
return res
}
root, err := aubUnder(spec.EmptyFiles.Under, cfg.Env)
if err != nil {
res.Skipped = err.Error()
return res
}
re := regexp.MustCompile(spec.EmptyFiles.IDFromName)
// The app must be up: it rebuilds its own files.
deadline := time.Now().Add(aubHealthyWait)
for {
if m.aubRunning(name) {
break
}
if time.Now().After(deadline) {
res.Skipped = fmt.Sprintf("the app was not running and healthy within %s", aubHealthyWait)
return res
}
m.aubSleep(5 * time.Second)
}
ids, err := ScanEmptyFiles(root, re)
if err != nil {
res.Problem = "the scan failed: " + err.Error()
return res
}
res.Found = len(ids)
if len(ids) > 0 {
still := ids
for attempt := 1; attempt <= 2 && len(still) > 0; attempt++ { // the repair, and at most ONE retry
if perr := m.aubRepair(dir, spec, still); perr != nil {
res.Problem = perr.Error()
}
after, serr := ScanEmptyFiles(root, re)
if serr != nil {
res.Problem = "the re-scan failed: " + serr.Error()
break
}
still = intersect(still, after)
}
res.StillBroken = len(still)
res.Repaired = res.Found - res.StillBroken
}
if spec.ReportLost != nil {
out, lerr := m.aubExec(dir, spec.Service, spec.ReportLost.Command)
switch {
case lerr != nil || !strings.Contains(out, spec.ReportLost.Success):
if res.Problem == "" {
res.Problem = fmt.Sprintf("the lost-file check did not finish (err=%v)", lerr)
}
default:
res.LostChecked = true
lost = parseLost(out)
res.Lost = len(lost)
if len(lost) > aubMaxLostListed {
lost = lost[:aubMaxLostListed]
}
}
}
return res
}
func (m *Manager) aubRepair(dir string, spec *AfterUncleanBootSpec, ids []string) error {
batch := spec.Repair.Batch
if batch <= 0 {
batch = aubDefaultBatch
}
for i := 0; i < len(ids); i += batch {
j := min(i+batch, len(ids))
cmd := append(append([]string{}, spec.Repair.Command...), ids[i:j]...)
out, err := m.aubExec(dir, spec.Service, cmd)
if err != nil || !strings.Contains(out, spec.Repair.Success) {
return fmt.Errorf("the repair command did not finish for %d id(s) (err=%v)", j-i, err)
}
}
return nil
}
// aubExec runs one command in the service's container (the after_load seam; tests replace afterLoadFn). The output
// may carry file names: it is returned, never logged.
func (m *Manager) aubExec(dir, service string, cmd []string) (string, error) {
args := append([]string{"exec", "-T", service}, cmd...)
return m.afterLoadExec(dir, args...)
}
// aubRunning: the app is up (its own containers running and healthy). Seam: aubRunningFn.
func (m *Manager) aubRunning(name string) bool {
if m.aubRunningFn != nil {
return m.aubRunningFn(name)
}
_ = m.RefreshStatus()
s, ok := m.GetStack(name)
return ok && s.State == StateRunning
}
func (m *Manager) aubSleep(d time.Duration) {
if m.aubSleepFn != nil {
m.aubSleepFn(d)
return
}
time.Sleep(d)
}
// DismissLostFiles hides the household's note (the record stays for the operator's counts).
func (m *Manager) DismissLostFiles(name string) error {
st, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
dir := filepath.Dir(st.ComposePath)
var had bool
m.mutateAppConfig(name, dir, "after_unclean_boot_dismiss", func(cfg *AppConfig) bool {
if cfg.AfterUncleanBoot == nil || len(cfg.AfterUncleanBoot.Lost) == 0 {
return false
}
had = true
cfg.AfterUncleanBoot.Dismissed = true
return true
})
if !had {
return fmt.Errorf("no lost-file note for %s", name)
}
return nil
}
func installedRef(cfg *AppConfig, service string) string {
if cfg == nil || cfg.InstalledImages == nil {
return ""
}
return cfg.InstalledImages[service].Ref
}
func containsString(list []string, s string) bool {
for _, x := range list {
if x == s && s != "" {
return true
}
}
return false
}
func intersect(a, b []string) []string {
in := map[string]bool{}
for _, x := range b {
in[x] = true
}
var out []string
for _, x := range a {
if in[x] {
out = append(out, x)
}
}
return out
}
@@ -0,0 +1,269 @@
package stacks
import (
"bytes"
"errors"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
)
// R-1007 (decision 236) — after_unclean_boot: the app rebuilds ONLY the files a power cut left empty, and its own
// lost-file check is read. The exec is the seam; nothing reaches Docker. Each guard's red-proof is named on its test.
const (
idA = "11111111-1111-4111-8111-111111111111"
idB = "22222222-2222-4222-8222-222222222222"
idC = "33333333-3333-4333-8333-333333333333"
idD = "44444444-4444-4444-8444-444444444444"
)
var aubRe = regexp.MustCompile(`^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_`)
func aubWrite(t *testing.T, p string, data string) {
t.Helper()
must(t, os.MkdirAll(filepath.Dir(p), 0o755))
must(t, os.WriteFile(p, []byte(data), 0o644))
}
// RED-PROOF: drop the `info.Size() != 0` test in ScanEmptyFiles → idB (non-empty) is sent → fails.
// RED-PROOF: drop the ModeSymlink skip → the link to an empty file OUTSIDE the folder is counted → fails.
func TestAUB_ScanFindsOnlyEmptyRegularFilesInsideTheFolder(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "thumbs")
aubWrite(t, filepath.Join(root, "u1/11/11", idA+"_thumbnail.webp"), "") // empty: found
aubWrite(t, filepath.Join(root, "u1/11/11", idA+"_preview.jpeg"), "") // the same id twice: once
aubWrite(t, filepath.Join(root, "u1/22/22", idB+"_thumbnail.webp"), "data") // the non-empty CONTROL
aubWrite(t, filepath.Join(root, "u2/33/33", idC+"_preview.jpeg"), "") // another user: found
aubWrite(t, filepath.Join(root, "u2/notes.txt"), "") // an empty file with no id
aubWrite(t, filepath.Join(root, "u2", "-rf_thumbnail.webp"), "") // a name that is no plain id
aubWrite(t, filepath.Join(dir, "outside", idD+"_thumbnail.webp"), "") // empty, but OUTSIDE the folder
must(t, os.Symlink(filepath.Join(dir, "outside", idD+"_thumbnail.webp"), filepath.Join(root, "u2", idD+"_thumbnail.webp")))
ids, err := ScanEmptyFiles(root, aubRe)
must(t, err)
if strings.Join(ids, ",") != idA+","+idC {
t.Fatalf("ids = %v, want exactly [%s %s]", ids, idA, idC)
}
if ids, err := ScanEmptyFiles(filepath.Join(dir, "nope"), aubRe); err != nil || len(ids) != 0 {
t.Fatalf("a missing folder: %v %v", ids, err)
}
}
// aubManager: the kept-test manager with an Immich-shaped block on "cloudapp", running, its image proven.
func aubManager(t *testing.T) (*Manager, string, *AfterUncleanBootSpec, *bytes.Buffer) {
t.Helper()
m, drive := keptManager(t)
var logBuf bytes.Buffer
m.logger = log.New(&logBuf, "", 0)
dir := filepath.Dir(m.stacks["cloudapp"].ComposePath)
must(t, SaveAppConfig(dir, &AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": drive},
InstalledImages: map[string]InstalledImage{"cloudapp": {Ref: "example/app:v1"}}}, m.encKey, nil))
spec := &AfterUncleanBootSpec{
Service: "cloudapp", Images: []string{"example/app:v1"},
EmptyFiles: AUBEmptyFiles{Under: "${HDD_PATH}/appdata/cloudapp/thumbs", IDFromName: aubRe.String()},
Repair: AUBCommand{Command: []string{"node", "-e", "repair"}, Success: "FELHOM_REPAIR_OK", Batch: 2},
ReportLost: &AUBCommand{Command: []string{"node", "-e", "lost"}, Success: "FELHOM_LOST_OK"},
}
m.mu.Lock()
m.stacks["cloudapp"].Meta.AfterUncleanBoot = spec
m.stacks["cloudapp"].Deployed = true
m.mu.Unlock()
m.aubRunningFn = func(string) bool { return true }
m.aubSleepFn = func(time.Duration) {}
return m, drive, spec, &logBuf
}
// fakeApp plays the app: the repair fills the files of the ids it is given (unless told to leave one), the lost
// check prints its report. It records every command.
type fakeApp struct {
root string
leave map[string]bool // ids whose files the app fails to rebuild
calls [][]string
lostOut string
repairErr error
}
func (f *fakeApp) exec(_ string, args ...string) (string, error) {
f.calls = append(f.calls, append([]string(nil), args...))
if len(args) >= 6 && args[5] == "repair" {
for _, id := range args[6:] {
if f.leave[id] {
continue
}
_ = filepath.Walk(f.root, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && strings.HasPrefix(info.Name(), id+"_") {
_ = os.WriteFile(p, []byte("rebuilt"), 0o644)
}
return nil
})
}
if f.repairErr != nil {
return "boom", f.repairErr
}
return "FELHOM_REPAIR_OK", nil
}
return f.lostOut, nil
}
func (f *fakeApp) repairCalls() [][]string {
var out [][]string
for _, c := range f.calls {
if len(c) >= 6 && c[5] == "repair" {
out = append(out, c[6:])
}
}
return out
}
// The consequence: only the empty files' ids are sent; the non-empty control is not; batches hold; one still-empty
// file is retried ONCE and then reported; the lost list is exactly the app's report; the log carries no file name.
// RED-PROOF: let the retry loop run 3 times (`attempt <= 3`) → the still-broken id is sent 3 times → fails.
// RED-PROOF: drop the intersect() (re-scan) → StillBroken stays 0 for the file the app never rebuilt → fails.
func TestAUB_RepairsOnlyTheEmptyFilesRetriesOnceAndReadsTheLostList(t *testing.T) {
m, drive, _, logBuf := aubManager(t)
root := filepath.Join(drive, "appdata/cloudapp/thumbs")
aubWrite(t, filepath.Join(root, "u1", idA+"_thumbnail.webp"), "")
aubWrite(t, filepath.Join(root, "u1", idB+"_preview.jpeg"), "real") // control
aubWrite(t, filepath.Join(root, "u2", idC+"_preview.jpeg"), "")
aubWrite(t, filepath.Join(root, "u2", idD+"_thumbnail.webp"), "")
ctlInfo, _ := os.Stat(filepath.Join(root, "u1", idB+"_preview.jpeg"))
app := &fakeApp{root: root, leave: map[string]bool{idD: true},
lostOut: "LOST {\"name\":\"IMG_0042.jpg\",\"taken\":\"2026-10-14 10:41\"}\nnoise\nFELHOM_LOST_OK lost=1\n"}
m.afterLoadFn = app.exec
boot := time.Date(2026, 10, 14, 12, 7, 26, 0, time.UTC)
res := m.RunAfterUncleanBoot("cloudapp", boot)
if res.Skipped != "" || res.Problem != "" {
t.Fatalf("skipped=%q problem=%q", res.Skipped, res.Problem)
}
if res.Found != 3 || res.Repaired != 2 || res.StillBroken != 1 || res.Lost != 1 || !res.LostChecked {
t.Fatalf("counts %+v, want found 3, repaired 2, still 1, lost 1", res)
}
sent := map[string]int{}
for _, batch := range app.repairCalls() {
if len(batch) > 2 {
t.Fatalf("a batch of %d, the template says 2", len(batch))
}
for _, id := range batch {
sent[id]++
}
}
if sent[idB] != 0 {
t.Fatal("the non-empty control was sent for repair")
}
if sent[idA] != 1 || sent[idC] != 1 || sent[idD] != 2 {
t.Fatalf("sent %v: the repaired ones once, the still-broken one twice (one retry)", sent)
}
if after, _ := os.Stat(filepath.Join(root, "u1", idB+"_preview.jpeg")); !after.ModTime().Equal(ctlInfo.ModTime()) {
t.Fatal("the control file was rewritten")
}
c := LoadAppConfig(filepath.Dir(m.stacks["cloudapp"].ComposePath))
r := c.AfterUncleanBoot
if r == nil || r.Found != 3 || r.StillBroken != 1 || r.BootAt != "2026-10-14T12:07:26Z" {
t.Fatalf("record %+v", r)
}
if len(r.Lost) != 1 || r.Lost[0].Name != "IMG_0042.jpg" || r.Lost[0].Taken != "2026-10-14 10:41" {
t.Fatalf("the household's lost list %+v, want exactly the app's report", r.Lost)
}
if strings.Contains(logBuf.String(), "IMG_0042") {
t.Fatal("a lost file's name reached the controller log")
}
}
// A run never guesses: an image the template has not proven, a key this box does not know, a stopped app — each is
// skipped with its reason and NO command runs. RED-PROOF: drop the containsString(spec.Images, …) check → the
// unproven image's run sends commands → fails.
func TestAUB_SkipsAnUnprovenImageAnUnknownKeyAndAStoppedApp(t *testing.T) {
m, drive, spec, _ := aubManager(t)
aubWrite(t, filepath.Join(drive, "appdata/cloudapp/thumbs/u1", idA+"_thumbnail.webp"), "")
app := &fakeApp{root: drive}
m.afterLoadFn = app.exec
spec.Images = []string{"example/app:v2"}
if res := m.RunAfterUncleanBoot("cloudapp", time.Time{}); !strings.Contains(res.Skipped, "has not proven") || len(app.calls) != 0 {
t.Fatalf("unproven image: %+v, calls %v", res, app.calls)
}
spec.Images = []string{"example/app:v1"}
spec.Extra = map[string]any{"verify_after": true}
if res := m.RunAfterUncleanBoot("cloudapp", time.Time{}); !strings.Contains(res.Skipped, "verify_after") || len(app.calls) != 0 {
t.Fatalf("unknown key: %+v, calls %v", res, app.calls)
}
spec.Extra = nil
m.aubRunningFn = func(string) bool { return false }
aubHealthyWait = 0
t.Cleanup(func() { aubHealthyWait = 10 * time.Minute })
if res := m.RunAfterUncleanBoot("cloudapp", time.Time{}); !strings.Contains(res.Skipped, "not running") || len(app.calls) != 0 {
t.Fatalf("stopped app: %+v, calls %v", res, app.calls)
}
}
// A failing repair is a reported problem, never a stopped app or an endless loop.
func TestAUB_AFailingRepairIsReportedNotRetriedForever(t *testing.T) {
m, drive, _, _ := aubManager(t)
root := filepath.Join(drive, "appdata/cloudapp/thumbs")
aubWrite(t, filepath.Join(root, "u1", idA+"_thumbnail.webp"), "")
app := &fakeApp{root: root, leave: map[string]bool{idA: true}, repairErr: errors.New("exit 1"), lostOut: "FELHOM_LOST_OK lost=0"}
m.afterLoadFn = app.exec
res := m.RunAfterUncleanBoot("cloudapp", time.Time{})
if res.Problem == "" || res.StillBroken != 1 || len(app.repairCalls()) != 2 {
t.Fatalf("%+v, repair calls %d (want the run + one retry)", res, len(app.repairCalls()))
}
}
// The catalog block parses as written, and a newer key lands in Extra (so the box skips, never half-runs).
func TestAUB_TheCatalogShapeParses(t *testing.T) {
src := `
after_unclean_boot:
service: immich-server
images: ["ghcr.io/immich-app/immich-server:v3.2.4"]
empty_files:
under: "${HDD_PATH}/appdata/immich/thumbs"
id_from_name: "^([0-9a-f-]{36})_"
repair:
command: ["node", "-e", "x"]
success: FELHOM_REPAIR_OK
batch: 200
report_lost:
command: ["node", "-e", "y"]
success: FELHOM_LOST_OK
some_future_key: 1
`
var meta Metadata
must(t, yaml.Unmarshal([]byte(src), &meta))
s := meta.AfterUncleanBoot
if s == nil || s.Service != "immich-server" || s.Repair.Batch != 200 || s.ReportLost == nil || s.EmptyFiles.IDFromName == "" {
t.Fatalf("parsed %+v", s)
}
if _, ok := s.Extra["some_future_key"]; !ok || aubUnsupported(s) == "" {
t.Fatalf("a newer key must be seen and refused: extra=%v", s.Extra)
}
delete(s.Extra, "some_future_key")
if why := aubUnsupported(s); why != "" {
t.Fatalf("the shipped shape must be supported: %s", why)
}
}
// The dismiss hides the household's note and keeps the counts.
func TestAUB_DismissHidesTheNoteKeepsTheRecord(t *testing.T) {
m, _, _, _ := aubManager(t)
dir := filepath.Dir(m.stacks["cloudapp"].ComposePath)
if err := m.DismissLostFiles("cloudapp"); err == nil {
t.Fatal("nothing to dismiss must refuse")
}
m.mutateAppConfig("cloudapp", dir, "t", func(c *AppConfig) bool {
c.AfterUncleanBoot = &AfterUncleanBootRecord{Found: 3, Lost: []LostFile{{Name: "a.jpg"}}}
return true
})
must(t, m.DismissLostFiles("cloudapp"))
r := LoadAppConfig(dir).AfterUncleanBoot
if r == nil || !r.Dismissed || r.Found != 3 || len(r.Lost) != 1 {
t.Fatalf("%+v", r)
}
}
+2
View File
@@ -198,6 +198,8 @@ type AppConfig struct {
DefaultLogin *DefaultLoginRecord `yaml:"default_login,omitempty" json:"default_login,omitempty"`
// AfterSetup (v0.282.0) is what the template's after_setup (the app's own sign-up switch) last did.
AfterSetup *AfterInstallRecord `yaml:"after_setup,omitempty" json:"after_setup,omitempty"`
// AfterUncleanBoot (R-1007) is what the template's after_unclean_boot last found and did (after_unclean_boot.go).
AfterUncleanBoot *AfterUncleanBootRecord `yaml:"after_unclean_boot,omitempty" json:"after_unclean_boot,omitempty"`
}
// DefaultLoginRecord is app.yaml's `default_login:`.
+27
View File
@@ -310,6 +310,10 @@ type Manager struct {
updateUndoHealthFn func(ctx context.Context, name string, timeout time.Duration, meta *Metadata) (bool, string)
// afterLoadFn is RunAfterLoad's exec seam (kept.go); nil = compose exec with the app's env.
afterLoadFn func(dir string, args ...string) (string, error)
// aubSleepFn is RunAfterUncleanBoot's wait seam (tests); nil = time.Sleep.
aubSleepFn func(time.Duration)
// aubRunningFn is its "is the app up" seam (tests); nil = RefreshStatus + StateRunning.
aubRunningFn func(name string) bool
// keptUnitFn names the removed app's recovery unit on a drive (backup.RemovedAppUnitFor, wired in
// main.go) so a start-fresh moves it in with the files it belongs to. nil = files only.
keptUnitFn func(app, drive string) string
@@ -1150,6 +1154,11 @@ func deepCopyStack(s *Stack) Stack {
acCopy.LockedFields = make([]string, len(s.AppConfig.LockedFields))
copy(acCopy.LockedFields, s.AppConfig.LockedFields)
}
if r := s.AppConfig.AfterUncleanBoot; r != nil { // R-1007: the household's lost list is a slice
rc := *r
rc.Lost = append([]LostFile(nil), r.Lost...)
acCopy.AfterUncleanBoot = &rc
}
cp.AppConfig = &acCopy
}
@@ -1242,6 +1251,24 @@ func deepCopyStack(s *Stack) Stack {
as.Args, as.Command, as.OpenCommand = cloneStrs(as.Args), cloneStrs(as.Command), cloneStrs(as.OpenCommand)
cp.Meta.AfterSetup = &as
}
if s.Meta.AfterUncleanBoot != nil { // R-1007
ab := *s.Meta.AfterUncleanBoot
ab.Images = cloneStrs(ab.Images)
ab.Repair.Command = cloneStrs(ab.Repair.Command)
if ab.ReportLost != nil {
rl := *ab.ReportLost
rl.Command = cloneStrs(rl.Command)
ab.ReportLost = &rl
}
if ab.Extra != nil {
ex := make(map[string]any, len(ab.Extra))
for k, v := range ab.Extra {
ex[k] = v
}
ab.Extra = ex
}
cp.Meta.AfterUncleanBoot = &ab
}
if s.Meta.SetupDoneProbe != nil {
sp := *s.Meta.SetupDoneProbe
cp.Meta.SetupDoneProbe = &sp
+3
View File
@@ -71,6 +71,9 @@ type Metadata struct {
// (anchored at a segment boundary). See family_gate.go.
FamilyGate bool `yaml:"family_gate,omitempty" json:"family_gate,omitempty"`
FamilyGateExcept []string `yaml:"family_gate_except,omitempty" json:"family_gate_except,omitempty"`
// AfterUncleanBoot (R-1007, `09` §3 decision 236): after an unclean host boot, the app rebuilds the files a power
// cut left empty, and its own lost-file check is read for the household's note. See after_unclean_boot.go.
AfterUncleanBoot *AfterUncleanBootSpec `yaml:"after_unclean_boot,omitempty" json:"after_unclean_boot,omitempty"`
// MinController (v0.287.0): the lowest box software that installs this template correctly; a lower one refuses.
MinController string `yaml:"min_controller,omitempty" json:"min_controller,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`