From 8d862e4e690a187180f080dcd859c362b9f55090 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 14 Oct 2026 16:47:13 +0200 Subject: [PATCH] v0.311.0: after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007) 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 --- CHANGELOG.md | 29 + controller/README.md | 10 + controller/cmd/controller/main.go | 27 + controller/internal/aftercrash/aftercrash.go | 108 +++ .../internal/aftercrash/aftercrash_test.go | 73 +++ controller/internal/crashboot/crashboot.go | 15 + .../internal/crashboot/crashboot_test.go | 17 + controller/internal/i18n/locales/en.json | 10 +- controller/internal/i18n/locales/hu.json | 10 +- .../notify/app_repaired_after_crash_test.go | 72 ++ controller/internal/notify/notifier.go | 30 + .../internal/stacks/after_unclean_boot.go | 437 +++++++++++++ .../stacks/after_unclean_boot_test.go | 269 ++++++++ controller/internal/stacks/deploy.go | 2 + controller/internal/stacks/manager.go | 27 + controller/internal/stacks/metadata.go | 3 + controller/internal/web/handlers.go | 5 + controller/internal/web/i18n_parity_test.go | 7 + .../internal/web/lost_files_note_test.go | 47 ++ controller/internal/web/server.go | 3 + controller/internal/web/setup_gate.go | 33 + .../internal/web/templates/app_info.html | 17 +- .../i18n_parity/app_info_lost_files.html | 614 ++++++++++++++++++ controller/scripts/i18n_go_keys.json | 3 + 24 files changed, 1865 insertions(+), 3 deletions(-) create mode 100644 controller/internal/aftercrash/aftercrash.go create mode 100644 controller/internal/aftercrash/aftercrash_test.go create mode 100644 controller/internal/notify/app_repaired_after_crash_test.go create mode 100644 controller/internal/stacks/after_unclean_boot.go create mode 100644 controller/internal/stacks/after_unclean_boot_test.go create mode 100644 controller/internal/web/lost_files_note_test.go create mode 100644 controller/internal/web/testdata/i18n_parity/app_info_lost_files.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d33043..fd094b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +## v0.311.0 — after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007) (2026-10-14) + +**MinAgent: 0.155.0** (unchanged — the crash fact is the existing `GET /host/crash-guard`). **Hub:** v0.150.0 registers +`app_repaired_after_crash` (allow-listed + operator-only); an older hub answers 400 and the operator gets no counts — +the repair and the household's note do not depend on it. **Catalog:** Immich's `after_unclean_boot` block. Design: `09` +§3 decision 236; evidence felhom.eu `documentation/audits/immich-repair-2026-10-14/`. + +- **New template block `after_unclean_boot`** (`internal/stacks/after_unclean_boot.go`): where the zero-byte files are + (`under`, only `${HDD_PATH}` filled in), how a name maps to the app's id (one capture group), the repair command (ids + appended, plain id text only, in batches) and the app's own lost-file check (`LOST {json}` lines). The box finds + zero-byte REGULAR files only (no symlinks, its own filesystem), runs the repair, re-scans, retries a still-empty file + ONCE, then reports it. Skips, with the reason, an image the template has not proven, a key it does not know, an app + that is not running within 10 min. It never deletes or rewrites a file, never stops the app. +- **When** (`internal/aftercrash`): once per controller start that followed an UNCLEAN host boot — `crashboot`'s own + verdict (new `Grace.Decided`), after the same 15-minute crash grace; a normal or unknown boot never runs it. Runs off + the scheduler (a goroutine; never twice). +- **The household:** the Immich app page shows „Elveszett fotók egy váratlan leállás miatt" / "Photos lost in an + unexpected stop" — each lost file's name and the date it was taken, the boot time, „upload them again from the phone", + and a dismiss button (`POST /apps//lost-files/dismiss`). Nothing when nothing is lost. The list lives only in + `app.yaml` on the box. +- **The operator:** `app_repaired_after_crash` — counts only (found / repaired / still broken / lost, the boot, the + duration, a skip reason). warning when anything is lost, still broken, failed or skipped; info when all repaired; + nothing when nothing was found. File names never reach a log line or an event (tests assert both). +- Tests: `internal/stacks/after_unclean_boot_test.go`, `internal/aftercrash/aftercrash_test.go`, + `internal/crashboot` (`TestDecided_*`), `internal/notify/app_repaired_after_crash_test.go`, + `internal/web/lost_files_note_test.go`, one new parity fixture (`app_info_lost_files`, the new card only). + Red-proofed: 14 guards, each removed in turn convicted its test (the symlink rule only with the regular-file check + removed too — that check is what holds; both stay). + ## v0.310.0 — „A gép kikapcsolása": the household can turn the whole machine off (R-998); honest restart text (R-999) (2026-10-13) **MinAgent: 0.155.0** — the new route calls the agent's `POST /host/poweroff`. An older agent: the button is hidden and diff --git a/controller/README.md b/controller/README.md index b33139a..3f5b3a6 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3297,6 +3297,16 @@ alarms for 14 days (hub v0.149.0). Design: felhom.eu `documentation/architecture **„A Felhom újraindítása"** (was „Kiszolgáló újraindítása", R-999) restarts the guest — the dashboard and the apps — not the machine; its text now says so. `/api/server/reboot` is POST only (R-983, one of its seven). +### 8b. After a power cut — an app repairs its empty files (v0.311.0, R-1007) + +A power cut while an app writes can leave files ZERO BYTES. A template may carry `after_unclean_boot` (catalog README +„After an unclean boot"): where such files are, how the app rebuilds THOSE only, and its own lost-file check. Once per +controller start that followed an UNCLEAN host boot (the crash guard's fact via the agent; unknown = normal), after +the 15-minute crash grace, the box scans, asks the app to repair, re-scans, retries once, and reads the lost list. +The household sees lost files (name, date taken) on the app page until dismissed; the operator gets +`app_repaired_after_crash` with counts only. Immich is the first user (`09` §3 decision 236: Immich's own job queue — +no key into the household's account). Code: `internal/stacks/after_unclean_boot.go`, `internal/aftercrash/`. + ### 9. Central Hub Reporting #### Operator actions (`internal/report/opactions.go`, `09` §3 decision 185) diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 9e98b7d..e4039a9 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -23,6 +23,7 @@ import ( "strings" "sync" + "gitea.dooplex.hu/admin/felhom-controller/internal/aftercrash" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/api" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" @@ -963,6 +964,32 @@ func main() { } } bootGrace := crashboot.New(startTime, crashProbe, logger) + // R-1007 (`09` §3 decision 236): ONCE per start that followed an unclean host boot — the same verdict, after the same + // CrashGrace — each app with an after_unclean_boot block rebuilds the files the power cut left empty and its own + // lost-file check is read. A normal or unknown boot never runs it. The operator gets counts only + // (app_repaired_after_crash); the household's note lives on the app page. Pinned by internal/aftercrash. + aubRunner := aftercrash.New(bootGrace, stackMgr.AfterUncleanBootApps, stackMgr.RunAfterUncleanBoot, + func(name string, bootAt time.Time, res stacks.AfterUncleanBootResult) { + disp := name + if st, ok := stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" { + disp = st.Meta.DisplayName + } + d := notify.AppRepairDetails{App: disp, StackName: name, Found: res.Found, Repaired: res.Repaired, + StillBroken: res.StillBroken, Lost: res.Lost, LostChecked: res.LostChecked, + DurationS: int64(res.Duration.Round(time.Second) / time.Second), Skipped: res.Skipped, Problem: res.Problem} + if !bootAt.IsZero() { + d.BootAt = bootAt.UTC().Format(time.RFC3339) + } + notifier.NotifyAppRepairedAfterCrash(d) + }, logger) + var aubBusy sync.Mutex + sched.Every("after-unclean-boot", 30*time.Second, func(ctx context.Context) error { + if aubRunner.Done() || !aubBusy.TryLock() { + return nil + } + go func() { defer aubBusy.Unlock(); aubRunner.Tick(time.Now()) }() // a run can take minutes; never block the scheduler + return nil + }) sched.Every("deadapp-check", 30*time.Second, func(ctx context.Context) error { if bootGrace.Within(time.Now()) { return nil // still inside the startup settle window (90 s, or ~15 min after a crash boot) diff --git a/controller/internal/aftercrash/aftercrash.go b/controller/internal/aftercrash/aftercrash.go new file mode 100644 index 0000000..bb64044 --- /dev/null +++ b/controller/internal/aftercrash/aftercrash.go @@ -0,0 +1,108 @@ +// 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...) + } +} diff --git a/controller/internal/aftercrash/aftercrash_test.go b/controller/internal/aftercrash/aftercrash_test.go new file mode 100644 index 0000000..b242c10 --- /dev/null +++ b/controller/internal/aftercrash/aftercrash_test.go @@ -0,0 +1,73 @@ +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) + } + } +} diff --git a/controller/internal/crashboot/crashboot.go b/controller/internal/crashboot/crashboot.go index 12cdb10..e328545 100644 --- a/controller/internal/crashboot/crashboot.go +++ b/controller/internal/crashboot/crashboot.go @@ -58,6 +58,7 @@ type Grace struct { mu sync.Mutex resolved bool crashBoot bool + bootAt time.Time // the unclean boot this start followed (set only when crashBoot) } // New builds the grace for a controller that started at start. probe may be nil (no agent): unknown. @@ -116,10 +117,24 @@ func (g *Grace) tryResolve(final bool) { g.resolve(false, "the host's last unclean boot ("+f.BootAt.UTC().Format(time.RFC3339)+") is not the one this start followed") default: g.crashBoot = true + g.bootAt = f.BootAt g.resolve(true, "the host's last boot ("+f.BootAt.UTC().Format(time.RFC3339)+") followed an UNCLEAN stop") } } +// Decided reports the run's verdict once it is known (R-1007): resolved=false while the fact may still arrive; +// then crash (and the unclean boot's time) or not. It asks the probe like Within does — unknown resolves to a normal +// boot at the end of the normal grace, never to a crash. Pinned by TestDecided_* in crashboot_test.go. +func (g *Grace) Decided(now time.Time) (resolved, crash bool, bootAt time.Time) { + g.duration(now.Sub(g.start)) + g.mu.Lock() + defer g.mu.Unlock() + return g.resolved, g.crashBoot, g.bootAt +} + +// Start is the controller start this grace measures from. +func (g *Grace) Start() time.Time { return g.start } + func (g *Grace) resolve(crash bool, why string) { g.resolved = true if g.logger == nil { diff --git a/controller/internal/crashboot/crashboot_test.go b/controller/internal/crashboot/crashboot_test.go index 0738da2..369d2a2 100644 --- a/controller/internal/crashboot/crashboot_test.go +++ b/controller/internal/crashboot/crashboot_test.go @@ -104,3 +104,20 @@ func TestR856_AgentProbeReadsTheCrashGuardState(t *testing.T) { t.Error("an agent that predates the route (404) is unknown — a normal boot") } } + +// R-1007: Decided exposes the same verdict, with the unclean boot's time. Unknown stays unresolved inside the +// normal grace and resolves to NOT a crash at its end. +func TestDecided_CrashCarriesTheBootTimeUnknownResolvesNormal(t *testing.T) { + boot := start.Add(-time.Minute) + g := New(start, fixed(Fact{Known: true, Unclean: true, BootAt: boot}, nil), nil) + if res, crash, at := g.Decided(start.Add(time.Second)); !res || !crash || !at.Equal(boot) { + t.Fatalf("crash: resolved=%t crash=%t at=%s", res, crash, at) + } + u := New(start, fixed(Fact{}, nil), nil) + if res, _, _ := u.Decided(start.Add(10 * time.Second)); res { + t.Fatal("unknown must not resolve inside the normal grace (the agent may come up late)") + } + if res, crash, _ := u.Decided(start.Add(NormalGrace)); !res || crash { + t.Fatalf("unknown at the end of the normal grace: resolved=%t crash=%t, want a normal boot", res, crash) + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index e19124d..4c1dcd7 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2685,5 +2685,13 @@ "poweroff.refused.update": "An update is running. Wait until it ends, then try again.", "poweroff.refused.drive": "The machine is working on a drive. Wait until it ends, then try again.", "poweroff.refused.other_guests": "Another system runs on this machine, so it cannot be turned off from here.", - "poweroff.refused.generic": "The machine cannot turn itself off safely right now. Try again in a few minutes." + "poweroff.refused.generic": "The machine cannot turn itself off safely right now. Try again in a few minutes.", + "event.app_repaired_after_crash": "%s: after the unexpected stop, %d damaged preview file(s), %d repaired, %d still broken; lost originals: %d", + "event.app_repair_after_crash_skipped": "%s: the repair after the unexpected stop was skipped: %s", + "app_info.lost_files_title": "Photos lost in an unexpected stop", + "app_info.lost_files_text": "The machine stopped unexpectedly (for example in a power cut), and these files were lost. The machine started again at:", + "app_info.lost_files_taken": "taken:", + "app_info.lost_files_hint": "If your phone still has them, upload them again. Check now, before your phone frees their space.", + "app_info.lost_files_dismiss_btn": "OK, I have read this", + "err.lost_files.nothing_to_dismiss": "There is no note about lost files for this app." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index e531332..cf468d7 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2673,5 +2673,13 @@ "poweroff.refused.update": "Most frissítés fut. Várd meg, amíg befejeződik, és próbáld újra.", "poweroff.refused.drive": "A gép most egy meghajtón dolgozik. Várd meg, amíg befejeződik, és próbáld újra.", "poweroff.refused.other_guests": "Ezen a gépen más rendszer is fut, ezért innen nem lehet kikapcsolni.", - "poweroff.refused.generic": "A gép most nem tudja biztonságosan kikapcsolni magát. Próbáld újra néhány perc múlva." + "poweroff.refused.generic": "A gép most nem tudja biztonságosan kikapcsolni magát. Próbáld újra néhány perc múlva.", + "event.app_repaired_after_crash": "%s: a váratlan leállás után %d sérült előnézeti fájl, ebből %d javítva, %d maradt hibás; elveszett eredeti fájl: %d", + "event.app_repair_after_crash_skipped": "%s: a váratlan leállás utáni javítás elmaradt: %s", + "app_info.lost_files_title": "Elveszett fotók egy váratlan leállás miatt", + "app_info.lost_files_text": "A gép váratlanul leállt (például áramszünet miatt), és ezek a fájlok elvesztek. A gép újraindulásának ideje:", + "app_info.lost_files_taken": "készült:", + "app_info.lost_files_hint": "Ha a telefonodon még megvannak, töltsd fel őket újra. Nézd meg most, mielőtt a telefon felszabadítja a helyüket.", + "app_info.lost_files_dismiss_btn": "Rendben, elolvastam", + "err.lost_files.nothing_to_dismiss": "Ehhez az alkalmazáshoz nincs elveszett fájlokról szóló értesítés." } diff --git a/controller/internal/notify/app_repaired_after_crash_test.go b/controller/internal/notify/app_repaired_after_crash_test.go new file mode 100644 index 0000000..ce3d35a --- /dev/null +++ b/controller/internal/notify/app_repaired_after_crash_test.go @@ -0,0 +1,72 @@ +package notify + +import ( + "encoding/json" + "reflect" + "sort" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" +) + +// R-1007 (decision 236) — app_repaired_after_crash carries COUNTS ONLY to the operator. A file name or photo data in +// the payload would leave the household's box. Asserted on the marshalled payload, and on the struct's fields (a new +// field must come through this test). +// RED-PROOF: add `Names []string json:"names"` to AppRepairDetails → the field list test fails. +func TestAppRepairedAfterCrash_PayloadIsCountsOnly(t *testing.T) { + var fields []string + rt := reflect.TypeOf(AppRepairDetails{}) + for i := 0; i < rt.NumField(); i++ { + fields = append(fields, strings.Split(rt.Field(i).Tag.Get("json"), ",")[0]) + } + sort.Strings(fields) + want := "app,boot_at,duration_s,found,lost,lost_checked,problem,repaired,skipped,stack_name,still_broken" + if strings.Join(fields, ",") != want { + t.Fatalf("AppRepairDetails fields = %v, want exactly %s — counts only, never a file name", fields, want) + } +} + +// The severity: lost or still broken → warning; everything repaired → info; nothing found and nothing lost → no +// event at all. RED-PROOF: send `info` when Lost > 0 → the lost case reads info → fails. +func TestAppRepairedAfterCrash_SeverityByOutcome(t *testing.T) { + n := &Notifier{} + var got []string + var payloads []string + n.pushFn = func(eventType, severity, msg, _ string, d interface{}) { + b, _ := json.Marshal(d) + got = append(got, eventType+"/"+severity) + payloads = append(payloads, string(b)+" "+msg) + } + n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Found: 81, Repaired: 81}) + n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Found: 81, Repaired: 81, Lost: 1, LostChecked: true}) + n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Found: 5, Repaired: 4, StillBroken: 1}) + n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Skipped: "immich-server runs v3.3.0, which the template has not proven"}) + n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", LostChecked: true}) + want := []string{"app_repaired_after_crash/info", "app_repaired_after_crash/warning", "app_repaired_after_crash/warning", "app_repaired_after_crash/warning"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("events %v, want %v (and nothing for a run that found nothing)", got, want) + } + for _, p := range payloads { + if strings.Contains(p, ".jpg") || strings.Contains(p, "IMG_") { + t.Fatalf("a file name in the event: %s", p) + } + } +} + +// The three keys R-1007 was born with, in BOTH languages, with the same placeholders (an English box gets English). +func TestAppRepairedAfterCrash_KeysInBothLanguages(t *testing.T) { + b, err := i18n.Shared() + if err != nil { + t.Fatal(err) + } + for _, k := range []string{"event.app_repaired_after_crash", "event.app_repair_after_crash_skipped", "err.lost_files.nothing_to_dismiss"} { + hu, en := b.Msg("hu", k), b.Msg("en", k) + if hu == "" || en == "" || hu == k || en == k || hu == en { + t.Fatalf("%s: hu=%q en=%q", k, hu, en) + } + if strings.Count(hu, "%") != strings.Count(en, "%") { + t.Fatalf("%s: the two languages take different arguments", k) + } + } +} diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go index 542145d..2251fa4 100644 --- a/controller/internal/notify/notifier.go +++ b/controller/internal/notify/notifier.go @@ -851,6 +851,36 @@ type oomRun struct { // OOMStormDetails is the app_oom_storm payload. stack_name lets the hub's per-app cooldown tell two // storming apps apart. +// AppRepairDetails is app_repaired_after_crash's payload (R-1007, decision 236): COUNTS ONLY — never a file name, +// never photo data. The household's list of lost files stays on its box (app.yaml), shown on the app page. +type AppRepairDetails struct { + App string `json:"app"` + StackName string `json:"stack_name"` + BootAt string `json:"boot_at,omitempty"` + Found int `json:"found"` + Repaired int `json:"repaired"` + StillBroken int `json:"still_broken"` + Lost int `json:"lost"` + LostChecked bool `json:"lost_checked"` + DurationS int64 `json:"duration_s"` + Skipped string `json:"skipped,omitempty"` + Problem string `json:"problem,omitempty"` +} + +// NotifyAppRepairedAfterCrash tells the OPERATOR what an app's after_unclean_boot run did (operator-only at the hub). +// warning: a file is lost or still broken, a step failed, or the run was skipped; info: everything found was +// repaired (recorded at the hub, not mailed). Nothing found and nothing lost: no event. +func (n *Notifier) NotifyAppRepairedAfterCrash(d AppRepairDetails) { + switch { + case d.Skipped != "": + n.pushEventMsg("app_repaired_after_crash", "warning", "event.app_repair_after_crash_skipped", d, d.App, d.Skipped) + case d.Lost > 0 || d.StillBroken > 0 || d.Problem != "": + n.pushEventMsg("app_repaired_after_crash", "warning", "event.app_repaired_after_crash", d, d.App, d.Found, d.Repaired, d.StillBroken, d.Lost) + case d.Found > 0: + n.pushEventMsg("app_repaired_after_crash", "info", "event.app_repaired_after_crash", d, d.App, d.Found, d.Repaired, d.StillBroken, d.Lost) + } +} + type OOMStormDetails struct { App string `json:"app"` StackName string `json:"stack_name"` diff --git a/controller/internal/stacks/after_unclean_boot.go b/controller/internal/stacks/after_unclean_boot.go new file mode 100644 index 0000000..95f9b85 --- /dev/null +++ b/controller/internal/stacks/after_unclean_boot.go @@ -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 +} diff --git a/controller/internal/stacks/after_unclean_boot_test.go b/controller/internal/stacks/after_unclean_boot_test.go new file mode 100644 index 0000000..6c9be07 --- /dev/null +++ b/controller/internal/stacks/after_unclean_boot_test.go @@ -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) + } +} diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index ce4e22c..13ad844 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -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:`. diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 11e3f8d..40963d1 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -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 diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index 55c7317..1894cbb 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -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"` diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 29b4332..3678083 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -774,6 +774,11 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s // v0.279.0 (decision 45): the default-login card only while that login is in effect. data["KnownLoginLine"] = s.knownLoginLine(s.langFor(r), &found.Meta, found.AppConfig, found.Deployed) data["DefaultLoginReplaced"] = defaultLoginReplaced(&found.Meta, found.AppConfig, found.Deployed) + // R-1007 (decision 236): the files the app's own check reported LOST after an unclean boot — the household can + // upload them again from the phone. Shown until dismissed; repaired previews need no note. + if lost, at := lostFilesNote(found.Deployed, found.AppConfig); len(lost) > 0 { + data["LostFiles"], data["LostBootAt"] = lost, at + } // v0.280.0 (decision 46): the setup gate's card, while the gate stands. data["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed() data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != "" diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 43bd4ae..a8b1cee 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -413,6 +413,13 @@ func i18nCases() []i18nCase { d["SignupNativeOpenFailed"] = true // R-717: the window could not open the app's own switch return d }}) + // R-1007: the lost-files note after an unclean boot (name, date taken, the boot time, the dismiss button). + base = append(base, i18nCase{"app_info_lost_files", "app_info", func() map[string]interface{} { + d := signupCase("x", false, "").data() + d["LostFiles"] = []stacks.LostFile{{Name: "IMG_0001.jpg", Taken: "2026-10-14 10:41"}} + d["LostBootAt"] = "2026-10-14 14:07" + return d + }}) // R-718: the close card and the gate card say the app restarts once where the app has its own switch. base = append(base, i18nCase{"app_info_close_signup_native", "app_info", func() map[string]interface{} { d := signupCase("x", false, "").data() diff --git a/controller/internal/web/lost_files_note_test.go b/controller/internal/web/lost_files_note_test.go new file mode 100644 index 0000000..abdc82a --- /dev/null +++ b/controller/internal/web/lost_files_note_test.go @@ -0,0 +1,47 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-1007 (decision 236) — the household sees the LOST files only (name and date taken), until it dismisses the note; +// repaired previews never make a note. RED-PROOF: drop `|| rec.Dismissed` in lostFilesNote → the dismissed case +// still shows → fails. RED-PROOF: drop the `.LostFiles` card from app_info.html → the render case fails. +func TestLostFilesNote_OnlyTheLostOnesUntilDismissed(t *testing.T) { + rec := &stacks.AfterUncleanBootRecord{BootAt: "2026-10-14T12:07:26Z", Found: 81, Repaired: 81, + Lost: []stacks.LostFile{{Name: "IMG_0042.jpg", Taken: "2026-10-14 10:41"}}} + lost, at := lostFilesNote(true, &stacks.AppConfig{AfterUncleanBoot: rec}) + if len(lost) != 1 || lost[0].Name != "IMG_0042.jpg" || at != "2026-10-14 14:07" { + t.Fatalf("lost=%v at=%q", lost, at) + } + repairedOnly := &stacks.AfterUncleanBootRecord{BootAt: rec.BootAt, Found: 81, Repaired: 81} + if l, _ := lostFilesNote(true, &stacks.AppConfig{AfterUncleanBoot: repairedOnly}); len(l) != 0 { + t.Fatal("repaired previews made a note") + } + dismissed := *rec + dismissed.Dismissed = true + if l, _ := lostFilesNote(true, &stacks.AppConfig{AfterUncleanBoot: &dismissed}); len(l) != 0 { + t.Fatal("a dismissed note is still shown") + } + if l, _ := lostFilesNote(false, &stacks.AppConfig{AfterUncleanBoot: rec}); len(l) != 0 { + t.Fatal("a removed app shows a note") + } + + page := renderAppInfoWith(t, map[string]interface{}{"LostFiles": lost, "LostBootAt": at}) + for _, want := range []string{`id="lost-files-card"`, "IMG_0042.jpg", "2026-10-14 10:41", "2026-10-14 14:07", "/apps/gapp/lost-files/dismiss", "toltsd fel", "function appPress"} { + if !strings.Contains(asciiFold(page), asciiFold(want)) { + t.Fatalf("the card lacks %q", want) + } + } + if plain := renderAppInfoWith(t, nil); strings.Contains(plain, `id="lost-files-card"`) { + t.Fatal("a card with nothing lost") + } +} + +// asciiFold drops the Hungarian accents so the test searches ASCII fragments (the project's rule for Hungarian text). +func asciiFold(s string) string { + return strings.NewReplacer("á", "a", "é", "e", "í", "i", "ó", "o", "ö", "o", "ő", "o", "ú", "u", "ü", "u", "ű", "u").Replace(s) +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 4594a7a..3733441 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -852,6 +852,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // it" under a known default login (R-710). POST, so CsrfProtect covers them. case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/setup-gate/open") && r.Method == http.MethodPost: s.appSetupGateOpenHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/setup-gate/open")) + // R-1007: the household dismisses the lost-files note. POST, so CsrfProtect covers it. + case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/lost-files/dismiss") && r.Method == http.MethodPost: + s.appLostFilesDismissHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/lost-files/dismiss")) case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/close-signup") && r.Method == http.MethodPost: s.appCloseSignupHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/close-signup")) case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/signup-window") && r.Method == http.MethodPost: diff --git a/controller/internal/web/setup_gate.go b/controller/internal/web/setup_gate.go index 972fb27..1ffb72f 100644 --- a/controller/internal/web/setup_gate.go +++ b/controller/internal/web/setup_gate.go @@ -386,6 +386,39 @@ func (s *Server) ServeSignupClosed(w http.ResponseWriter, r *http.Request) { } } +// lostFilesNote is the app page's lost-files note (R-1007): the files the app's own check reported lost after an +// unclean boot, until the household dismisses it, and the boot time in the box's zone. Nothing for an app with no +// lost file (repaired previews need no note). Pinned by TestLostFilesNote_*. +func lostFilesNote(deployed bool, cfg *stacks.AppConfig) ([]stacks.LostFile, string) { + if !deployed || cfg == nil || cfg.AfterUncleanBoot == nil { + return nil, "" + } + rec := cfg.AfterUncleanBoot + if len(rec.Lost) == 0 || rec.Dismissed { + return nil, "" + } + at := "" + if t, err := time.Parse(time.RFC3339, rec.BootAt); err == nil { + at = t.In(getTimezone()).Format("2006-01-02 15:04") + } + return rec.Lost, at +} + +// appLostFilesDismissHandler hides the app page's lost-files note (R-1007). The record (and the operator's counts) +// stays; only the household's note goes. +func (s *Server) appLostFilesDismissHandler(w http.ResponseWriter, r *http.Request, slug string) { + found := s.stackBySlug(slug) + if found == nil { + escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app")) + return + } + if err := s.stackMgr.DismissLostFiles(found.Name); err != nil { + escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.lost_files.nothing_to_dismiss")) + return + } + escrowJSON(w, http.StatusOK, map[string]any{"dismissed": true}, "") +} + // appCloseSignupHandler is decision 49's "close sign-up now" (POST /apps//close-signup) for an app installed // before decision 47. It applies exactly what a fresh install gets after its setup; it never gates the app. func (s *Server) appCloseSignupHandler(w http.ResponseWriter, r *http.Request, slug string) { diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 379e0fe..811d939 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -28,6 +28,21 @@ +{{- if .LostFiles}} +
+

{{T "app_info.lost_files_title"}}

+

{{T "app_info.lost_files_text"}}{{if .LostBootAt}} {{.LostBootAt}}{{end}}

+
    + {{- range .LostFiles}} +
  • {{.Name}}{{if .Taken}} — {{T "app_info.lost_files_taken"}} {{.Taken}}{{end}}
  • + {{- end}} +
+

{{T "app_info.lost_files_hint"}}

+ + +
+{{- end}} + {{if .Stack.Updating}}
{{updatePhaseText .Stack}}
{{else if and .Stack.HoldReason (eq .Stack.HoldKind "unhealthy_stop")}} @@ -350,7 +365,7 @@ function icCopyPw(btn) { {{end}} {{template "layout_end" .}} -{{- if or .SetupGateClosed .SignupClosed .CloseSignupOffered (and .Stack.Deployed .KnownLoginLine)}} +{{- if or .SetupGateClosed .SignupClosed .CloseSignupOffered (and .Stack.Deployed .KnownLoginLine) .LostFiles}} + + + +
+ + +
+ + +
+ + + + + + + + + +
+

Elveszett fotók egy váratlan leállás miatt

+

A gép váratlanul leállt (például áramszünet miatt), és ezek a fájlok elvesztek. A gép újraindulásának ideje: 2026-10-14 14:07

+
    +
  • IMG_0001.jpg — készült: 2026-10-14 10:41
  • +
+

Ha a telefonodon még megvannak, töltsd fel őket újra. Nézd meg most, mielőtt a telefon felszabadítja a helyüket.

+ + +
+ + + + + + +
+ +
+ +

+ +
+ ~ RAM + + + Csak x86 + +
+ +
+
+ + +
+ + + +
+ + + + + + + + + + +
+ + + + + diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index e13cc96..8233a8c 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -230,6 +230,9 @@ "poweroff.confirm_required": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_ConfirmationWordRequired.", "poweroff.agent_down": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.", "poweroff.agent_outdated": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_HiddenForOldAgent.", + "event.app_repaired_after_crash": "BORN AS A KEY (R-1007, v0.311.0) -- the operator's counts after an unclean boot (app_repaired_after_crash). Pinned by TestAppRepairedAfterCrash_KeysInBothLanguages.", + "event.app_repair_after_crash_skipped": "BORN AS A KEY (R-1007, v0.311.0) -- the same event when the run was skipped, with its reason. Pinned by TestAppRepairedAfterCrash_KeysInBothLanguages.", + "err.lost_files.nothing_to_dismiss": "BORN AS A KEY (R-1007, v0.311.0) -- the dismiss route's answer when there is no lost-files note. Pinned by TestAppRepairedAfterCrash_KeysInBothLanguages.", "poweroff.refused.too_soon": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.", "poweroff.refused.already": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.", "poweroff.refused.backup": "BORN AS A KEY (R-998, v0.310.0) -- „A gép kikapcsolása”: the household sentence for the power-off route's answer. Pinned by TestPowerOff_EveryReasonHasASentence.",