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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user