hub v0.150.0: app_repaired_after_crash — allow-listed + operator-only, counts line in the operator mail (R-1007); decision 236 recorded
gates / gates (push) Successful in 6m40s

The operator's half of the repair after an unclean boot (controller 0.311.0): counts only, never a
file name; warning mails, info is recorded. Registered in allowedEventTypes and operatorOnlyEvents in
the same commit, plus the per-app cooldown list; tests pin the pair, a household that enabled the type
gets nothing, and the counts line (red-proofed). Decision 236 (operator, 2026-10-14): the box asks
Immich through Immich's own job queue. R-1007 updated; the scripts' run on 9202 added as evidence.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-14 16:58:35 +02:00
parent 5d39171e90
commit 822d039344
10 changed files with 155 additions and 3 deletions
@@ -915,6 +915,17 @@ its length, and both fixes cost something the household would notice — operato
127. **The agent's three by-design abilities (`03` §3.1) stay for now**; revisited before the first paying customer. 127. **The agent's three by-design abilities (`03` §3.1) stay for now**; revisited before the first paying customer.
*Operator ruling 2026-10-05.* (R-861) *Operator ruling 2026-10-05.* (R-861)
### 2026-10-14 — R-1007: how the box asks Immich to repair after a power cut (recorded before the build)
236. **The box repairs Immich's broken photos through Immich's OWN job queue (option 1, spike A4)** — the controller adds
the same job Immich's API adds (`AssetGenerateThumbnails {id}` on `thumbnailGeneration`, Immich's own `bullmq`
inside `immich-server`) for the asset ids of the zero-byte thumbnail/preview files only, queues Immich's own
missing-files check, and READS its report from the database (a SELECT, never a write). Not chosen: a household-made
API key (it covers only the admin's photos — a family member's were refused, measured), a database write, the
household pressing „thumbnails: all". The job/queue names are internal to Immich: the catalog pins the Immich
versions they were proven on; any other version is skipped and reported, never guessed. Evidence:
`audits/immich-repair-2026-10-14/` Part A. *Operator ruling 2026-10-14, in chat.*
### 2026-10-12 (evening) — the notification page's two questions answered (recorded before the work; the disk + shrink brief) ### 2026-10-12 (evening) — the notification page's two questions answered (recorded before the work; the disk + shrink brief)
*Operator answers in chat 2026-10-12 ~16:30, „as picked" (`08` §8.1).* *Operator answers in chat 2026-10-12 ~16:30, „as picked" (`08` §8.1).*
@@ -0,0 +1,14 @@
syntax rc=1
uploaded admin 20 member 10
damage: 4 empty files, 1 original deleted
== repair.js with 4 ids
FELHOM_REPAIR_OK queued=4 waited_ms=2005
rc=0 in 2.38 s; empty now: 0
controls rewritten: 0 of 53
== lost.js
LOST {"name":"gen-007.jpg","taken":"2026-01-03 01:00"}
FELHOM_LOST_OK lost=1 waited_ms=2040
rc=0 in 2.4 s
== a second run of lost.js (a stale or repeated report?)
FELHOM_LOST_OK lost=1 waited_ms=2040
+1 -1
View File
@@ -136,7 +136,7 @@ stopping line that lies.
| **R-707** | Apps & catalog | P4 | **[P2] 37 apps still start with a login a stranger can take (`09` §3 decision 45).** Audit of all 53 apps: `app-catalog-felhom.eu/FIRST-ADMIN.md` (class, fix route, status, measured or read). Open: **3 hard-coded defaults** — calibre-web (`admin / admin123`, measured working on demo-hp and 9202; its own `cps.py -s` route needs a generated password WITH a special character — our generator is letters+digits, a controller change), mealie (`changeme@example.com / MyPassword`), wger (`admin / adminadmin`); **34 open first-run screens** (the first visitor creates the admin: actualbudget, adventurelog, audiobookshelf, calcom, docmost, emby, ghost, gitea, gramps-web, home-assistant, homebox, immich, jellyfin, komga, n8n, navidrome, opengist, outline, papra, plant-it, radarr, rallly, recipe-importer, romm, seerr, sonarr, sparkyfitness, tandoor, termix, uptime-kuma, vikunja, wanderer, wishlist, zipline). **Stale notes:** romm's `default_creds` `admin / admin` answers 401 on demo-hp (like a wrong password) — the page now warns with a login that does not exist; zipline's looks stale too. **Measured on demo-hp 2026-09-28 (read-only):** bookstack's default still logs in on the INSTALLED app (the fix is for new installs; the page now warns). Each fix: route (a) env or (b) the app's own CLI/API via `after_install:`, proven on 9202 with the default failing and the generated password working; route (c) a page sentence. Several sessions (operator, 2026-09-28). **2026-09-29 (controller v0.280.0, catalog `d0e7e2e`):** every class-3 app fixed — mealie, wger, calibre-web by `after_install` (calibre-web with the new `password:24:special`), proven on 9202 fresh installs (`audits/login-gate-2026-09-29/D/`); the setup gate (decision 46, spike PASSED) built and live on immich, n8n, audiobookshelf (probes measured) and uptime-kuma (button) (`…/C/`); romm's and zipline's stale notes removed. **Left: 30 class-4 apps** — gate each (probe measured on 9202 where one exists — 11 upstream candidates listed in `…/B/B-VERDICT.md` §3; the button otherwise). **2026-09-29 afternoon (controller v0.281.0, catalog `6faf432`):** 28 more class-4 apps gated — 32 of 34 — each proven on 9202 (`audits/gate-rollout-2026-09-29/`B): stranger → gate page / 401, household reached the first-setup screen, the gate opened (9 by a measured probe, the rest by the press), the app answered after. seerr, outline, rallly: gated, their opening needs a media server / e-mail (not proven). **Left:** wanderer (R-714); plant-it is not installable. | **NARROWED** (2026-10-03 triage: the row's verdict was finished, but it names open work no other row carries — seerr, outline and rallly are gated, but the gate OPENING is not proven (needs a media server / e-mail)) — **CLOSED — 2026-09-29 (the rest → R-714)** | — | — | CC | | **R-707** | Apps & catalog | P4 | **[P2] 37 apps still start with a login a stranger can take (`09` §3 decision 45).** Audit of all 53 apps: `app-catalog-felhom.eu/FIRST-ADMIN.md` (class, fix route, status, measured or read). Open: **3 hard-coded defaults** — calibre-web (`admin / admin123`, measured working on demo-hp and 9202; its own `cps.py -s` route needs a generated password WITH a special character — our generator is letters+digits, a controller change), mealie (`changeme@example.com / MyPassword`), wger (`admin / adminadmin`); **34 open first-run screens** (the first visitor creates the admin: actualbudget, adventurelog, audiobookshelf, calcom, docmost, emby, ghost, gitea, gramps-web, home-assistant, homebox, immich, jellyfin, komga, n8n, navidrome, opengist, outline, papra, plant-it, radarr, rallly, recipe-importer, romm, seerr, sonarr, sparkyfitness, tandoor, termix, uptime-kuma, vikunja, wanderer, wishlist, zipline). **Stale notes:** romm's `default_creds` `admin / admin` answers 401 on demo-hp (like a wrong password) — the page now warns with a login that does not exist; zipline's looks stale too. **Measured on demo-hp 2026-09-28 (read-only):** bookstack's default still logs in on the INSTALLED app (the fix is for new installs; the page now warns). Each fix: route (a) env or (b) the app's own CLI/API via `after_install:`, proven on 9202 with the default failing and the generated password working; route (c) a page sentence. Several sessions (operator, 2026-09-28). **2026-09-29 (controller v0.280.0, catalog `d0e7e2e`):** every class-3 app fixed — mealie, wger, calibre-web by `after_install` (calibre-web with the new `password:24:special`), proven on 9202 fresh installs (`audits/login-gate-2026-09-29/D/`); the setup gate (decision 46, spike PASSED) built and live on immich, n8n, audiobookshelf (probes measured) and uptime-kuma (button) (`…/C/`); romm's and zipline's stale notes removed. **Left: 30 class-4 apps** — gate each (probe measured on 9202 where one exists — 11 upstream candidates listed in `…/B/B-VERDICT.md` §3; the button otherwise). **2026-09-29 afternoon (controller v0.281.0, catalog `6faf432`):** 28 more class-4 apps gated — 32 of 34 — each proven on 9202 (`audits/gate-rollout-2026-09-29/`B): stranger → gate page / 401, household reached the first-setup screen, the gate opened (9 by a measured probe, the rest by the press), the app answered after. seerr, outline, rallly: gated, their opening needs a media server / e-mail (not proven). **Left:** wanderer (R-714); plant-it is not installable. | **NARROWED** (2026-10-03 triage: the row's verdict was finished, but it names open work no other row carries — seerr, outline and rallly are gated, but the gate OPENING is not proven (needs a media server / e-mail)) — **CLOSED — 2026-09-29 (the rest → R-714)** | — | — | CC |
| **R-905** | Apps & catalog | P4 | **wger's collected style files (283 MB) go into every wger backup, though the app rebuilds them at every start.** Since R-762's fix (`DJANGO_DEBUG=False` + `collectstatic`, catalog `cf1ed43`) the static files sit in a named volume that the backup legs copy like data (measured 283 MB, `audits/design-build-2026-10-06/`); `collectstatic` regenerates them, so they are rebuildable bytes in every Tier-1 unit, Tier-2 copy and off-site snapshot. wger is `lifecycle: hidden` and no box runs it (hub read 2026-10-08). Options to weigh: a non-backed-up volume class for regenerable data, or an anonymous volume. Found by the R-762 helpers 2026-10-08. | **OPEN — needs a design before wger is shown** | — | Design the „regenerable volume" exclusion (catalog + controller backup legs) | CC | | **R-905** | Apps & catalog | P4 | **wger's collected style files (283 MB) go into every wger backup, though the app rebuilds them at every start.** Since R-762's fix (`DJANGO_DEBUG=False` + `collectstatic`, catalog `cf1ed43`) the static files sit in a named volume that the backup legs copy like data (measured 283 MB, `audits/design-build-2026-10-06/`); `collectstatic` regenerates them, so they are rebuildable bytes in every Tier-1 unit, Tier-2 copy and off-site snapshot. wger is `lifecycle: hidden` and no box runs it (hub read 2026-10-08). Options to weigh: a non-backed-up volume class for regenerable data, or an anonymous volume. Found by the R-762 helpers 2026-10-08. | **OPEN — needs a design before wger is shown** | — | Design the „regenerable volume" exclusion (catalog + controller backup legs) | CC |
| **R-995** | Apps & catalog | P4 | **Removing an app through the product can leave unnamed Docker volumes and an empty import folder behind.** MEASURED 2026-10-13 on demo-felhom after 61 removals (`documentation/audits/sizing-2026-10-13/METHOD.md` „Teardown”, `evidence/leftover-volumes-after-bench.txt`): 8 unnamed volumes (4–72 KB; immich 1, romm 2, privatebin 3, onlyoffice 2 — volumes the images declare themselves) and `userdata/import/{paperless,calibre,grimmory}` (empty, created by the deploy). The removal answered 200 and its own leftover check was clean, because it looks only at labelled volumes. No data risk; disk and tidiness only. Removed by name on the bench. | **READY** — filed 2026-10-13 | — | Make the removal account for the containers' unnamed volumes (and an empty import folder it created), with a test that deploys an image that declares a VOLUME | CC | | **R-995** | Apps & catalog | P4 | **Removing an app through the product can leave unnamed Docker volumes and an empty import folder behind.** MEASURED 2026-10-13 on demo-felhom after 61 removals (`documentation/audits/sizing-2026-10-13/METHOD.md` „Teardown”, `evidence/leftover-volumes-after-bench.txt`): 8 unnamed volumes (4–72 KB; immich 1, romm 2, privatebin 3, onlyoffice 2 — volumes the images declare themselves) and `userdata/import/{paperless,calibre,grimmory}` (empty, created by the deploy). The removal answered 200 and its own leftover check was clean, because it looks only at labelled volumes. No data risk; disk and tidiness only. Removed by name on the bench. | **READY** — filed 2026-10-13 | — | Make the removal account for the containers' unnamed volumes (and an empty import folder it created), with a test that deploys an image that declares a VOLUME | CC |
| **R-1007** | Apps & catalog | P3 | **After a power cut Immich shows photos as done that are broken, and nothing repairs them: of 101 photos uploaded in the last ~30 s before the cut, 81 kept their original but got an EMPTY thumbnail and preview (162 zero-byte files), and 1 — answered „201 Created" 0.3 s before the cut — has no original at all.** Immich's job queues are empty after the start, so it never redoes them (`audits/night-power-2026-10-13/` R2, `immich-check.txt`). Same ext4 effect on a plain 40-byte file. Other apps may do the same with files written just before a cut. | **OPEN** — 2026-10-14 spike (`audits/power-cut-fixes-2026-10-14/README.md` F, Immich v3.2.4): „thumbnails: missing" does not fix a zero-byte thumbnail (nor after deleting it — Immich decides from its database); „thumbnails: all" (force) fixes them; `integrity-missing-files` (`/api/admin/integrity/report?type=missing_file`) finds a lost original; nothing restores one but a backup. **Operator ruling 2026-10-14: fix only the broken photos** — after an unclean boot, find the photos with an empty thumbnail or preview, rebuild only those, run the missing-files integrity check, and tell the operator about lost originals. No „thumbnails: all". 2026-10-14 B2 spike (`audits/power-cut-fixes-2026-10-14/README.md` B2): **yes** — `POST /api/assets/jobs {assetIds, name: regenerate-thumbnail}` rebuilt exactly the 10 broken assets (1.55 s, 5.3 s CPU) and rewrote none of the other 20; an empty file maps to its asset by the first 36 characters of its name. | — | CC: build — a per-app after-unclean-boot hook that finds the zero-byte files under `thumbs/`, calls `POST /api/assets/jobs` `regenerate-thumbnail` for those asset ids only, runs `integrity-missing-files`, and tells the operator about lost originals (a task file: catalog + controller) | CC | | **R-1007** | Apps & catalog | P3 | **After a power cut Immich shows photos as done that are broken, and nothing repairs them: of 101 photos uploaded in the last ~30 s before the cut, 81 kept their original but got an EMPTY thumbnail and preview (162 zero-byte files), and 1 — answered „201 Created" 0.3 s before the cut — has no original at all.** Immich's job queues are empty after the start, so it never redoes them (`audits/night-power-2026-10-13/` R2, `immich-check.txt`). Same ext4 effect on a plain 40-byte file. Other apps may do the same with files written just before a cut. | **OPEN** — 2026-10-14 spike (`audits/power-cut-fixes-2026-10-14/README.md` F, Immich v3.2.4): „thumbnails: missing" does not fix a zero-byte thumbnail (nor after deleting it — Immich decides from its database); „thumbnails: all" (force) fixes them; `integrity-missing-files` (`/api/admin/integrity/report?type=missing_file`) finds a lost original; nothing restores one but a backup. **Operator ruling 2026-10-14: fix only the broken photos** — after an unclean boot, find the photos with an empty thumbnail or preview, rebuild only those, run the missing-files integrity check, and tell the operator about lost originals. No „thumbnails: all". **Decision 236 (operator 2026-10-14, after spike A `audits/immich-repair-2026-10-14/`): the box asks Immich through Immich's own job queue (no key; every user's photos; the lost-file report read from the database); the catalog pins the proven Immich versions.** 2026-10-14 B2 spike (`audits/power-cut-fixes-2026-10-14/README.md` B2): **yes** — `POST /api/assets/jobs {assetIds, name: regenerate-thumbnail}` rebuilt exactly the 10 broken assets (1.55 s, 5.3 s CPU) and rewrote none of the other 20; an empty file maps to its asset by the first 36 characters of its name. | — | CC: build per decision 236 — catalog block `after_unclean_boot` (Immich), controller runner after an unclean boot, household note for lost photos, operator-only event; live proof on demo-hp 9201 (`audits/immich-repair-2026-10-14/`) | CC |
## App updates — 4 rows (P3 4) ## App updates — 4 rows (P3 4)
+15
View File
@@ -1,3 +1,18 @@
## v0.150.0 — an app's repair after a power cut reaches the operator, counts only (R-1007) (2026-10-14)
Needs **controller ≥ 0.311.0** to see anything (it sends the event). Older boxes never send it — nothing changes for them.
- **`app_repaired_after_crash`** — what an app's `after_unclean_boot` run found and did after an unclean host boot:
empty files found / repaired / still broken, originals lost, the boot, the duration, or why it was skipped. Allow-listed
AND operator-only, registered in the same commit (`internal/api/handler.go`, `notify.operatorOnlyEvents`); a
per-app cooldown member (one app's event, no digest). The household's side is the lost-files note on its own app
page — never a mail with its file names. warning mails; info (all repaired) is recorded, not mailed.
- The operator mail carries one readable line: „Repair: Immich — after the unclean boot at …: 81 empty file(s) found,
81 repaired, 0 still broken; originals lost: 1; 4 s".
- Tests: `internal/api/chaosnight_events_test.go` (the pair), `internal/notify/app_repaired_after_crash_test.go` (a
household that enabled the type by name gets nothing; info is not mailed; the counts line), `r389` (the cooldown
list). Red-proofed: each of the three guards removed convicted its test.
## v0.149.0 — a box the household switched off is not an outage, for 14 days (R-998) (2026-10-13) ## v0.149.0 — a box the household switched off is not an outage, for 14 days (R-998) (2026-10-13)
Needs **agent ≥ 0.155.0** to see anything: the agent's new `deliberate_off.requested_at` report field (household Needs **agent ≥ 0.155.0** to see anything: the agent's new `deliberate_off.requested_at` report field (household
@@ -111,3 +111,15 @@ func TestAppStoppedUnhealthyIsAHouseholdEvent(t *testing.T) {
} }
} }
} }
// R-1007 (v0.150.0) — app_repaired_after_crash is allow-listed AND operator-only, pinned together.
// RED-PROOF: drop it from allowedEventTypes → "the controller's push would 400".
func TestAppRepairedAfterCrashIsAllowlistedAndOperatorOnly(t *testing.T) {
et := "app_repaired_after_crash"
if !allowedEventTypes[et] {
t.Fatalf("%s must be in allowedEventTypes — the controller's push would 400", et)
}
if !notify.IsOperatorOnly(et) {
t.Fatalf("%s must be operator-only — counts of a household's damaged files are support's", et)
}
}
+4
View File
@@ -2318,6 +2318,10 @@ var allowedEventTypes = map[string]bool{
// with NO copy on its box that brings it back whole — support must act. Operator-only // with NO copy on its box that brings it back whole — support must act. Operator-only
// (notify.operatorOnlyEvents) — registered in BOTH in the same commit. // (notify.operatorOnlyEvents) — registered in BOTH in the same commit.
"app_hold_no_whole_copy": true, "app_hold_no_whole_copy": true,
// R-1007 (hub v0.150.0, controller v0.311.0, `09` §3 decision 236): what an app's after_unclean_boot run found
// and repaired after an unclean host boot — COUNTS ONLY. Operator-only (notify.operatorOnlyEvents) — registered in
// BOTH in the same commit; the household's side is the app page's lost-files note on its own box.
"app_repaired_after_crash": true,
// Special // Special
"test": true, "test": true,
} }
@@ -0,0 +1,49 @@
package notify
import (
"io"
"log"
"strings"
"testing"
)
// R-1007 (v0.150.0, controller v0.311.0, `09` §3 decision 236) — app_repaired_after_crash reaches the OPERATOR and
// never the household, even a household that enabled it by name; warning mails, info is recorded only; the mail
// carries the counts as one line.
// RED-PROOF: drop it from operatorOnlyEvents → the customer gets a mail → fails.
func TestAppRepairedAfterCrash_OperatorOnlyEvenWhenTheHouseholdEnabledIt(t *testing.T) {
st := newDispStore(t)
if err := st.SaveNotificationPrefs("c1", "cust@example.com", []string{"app_repaired_after_crash", "node_down"}, 6); err != nil {
t.Fatal(err)
}
d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
sent := captureSeam(d)
details := `{"app":"Immich","stack_name":"immich","boot_at":"2026-10-14T12:07:26Z","found":81,"repaired":81,"still_broken":0,"lost":1,"lost_checked":true,"duration_s":4}`
d.ProcessEvent("c1", "app_repaired_after_crash", "warning", "Immich: ...", details, "controller")
if got := mailsFor(*sent, "cust@example.com"); len(got) != 0 {
t.Fatalf("the household got %d mail(s) — the counts are the operator's; its side is the app page's note", len(got))
}
if got := len(mailsFor(*sent, "op@felhom.eu")); got != 1 {
t.Fatalf("operator mails = %d, want 1", got)
}
// info (everything repaired): recorded, not mailed.
*sent = nil
d.ProcessEvent("c1", "app_repaired_after_crash", "info", "Immich: ...", strings.Replace(details, `"lost":1`, `"lost":0`, 1), "controller")
if got := len(*sent); got != 0 {
t.Fatalf("an all-repaired (info) run mailed %d time(s)", got)
}
}
// RED-PROOF: drop the renderAppRepair call → the body lacks the counts line → fails.
func TestAppRepairedAfterCrash_OperatorMailHasTheCountsLine(t *testing.T) {
_, body := FormatOperatorEmail("c1", "app_repaired_after_crash", "warning", "msg",
`{"app":"Immich","boot_at":"2026-10-14T12:07:26Z","found":81,"repaired":80,"still_broken":1,"lost":1,"lost_checked":true,"duration_s":4}`)
want := "Repair: Immich — after the unclean boot at 2026-10-14T12:07:26Z: 81 empty file(s) found, 80 repaired, 1 still broken; originals lost: 1; 4 s"
if !strings.Contains(body, want) {
t.Fatalf("body lacks %q:\n%s", want, body)
}
_, skipped := FormatOperatorEmail("c1", "app_repaired_after_crash", "warning", "msg", `{"app":"Immich","boot_at":"b","skipped":"not proven"}`)
if !strings.Contains(skipped, "SKIPPED after the unclean boot at b: not proven") {
t.Fatalf("skipped body:\n%s", skipped)
}
}
+7
View File
@@ -480,6 +480,9 @@ var perAppCooldownEvents = map[string]bool{
"app_hold_no_whole_copy": true, "app_hold_no_whole_copy": true,
// v0.123.0 (decision 28): one app's stop, no digest behind it. // v0.123.0 (decision 28): one app's stop, no digest behind it.
"app_stopped_unhealthy": true, "app_stopped_unhealthy": true,
// v0.150.0 (R-1007): one app's repair after an unclean boot, no digest behind it — two apps on one boot are two
// records, and a lost photo on the second must not be swallowed by the first's cooldown.
"app_repaired_after_crash": true,
} }
// perAppCustomerCooldownEvents is the CUSTOMER-leg sibling of perAppCooldownEvents (v0.120.0). The // perAppCustomerCooldownEvents is the CUSTOMER-leg sibling of perAppCooldownEvents (v0.120.0). The
@@ -857,6 +860,10 @@ var operatorOnlyEvents = map[string]bool{
// operator-grade (the copies seen, per tier, with dates), and the act it calls for is support's. // operator-grade (the copies seen, per tier, with dates), and the act it calls for is support's.
// Registered in the same commit that mints it. // Registered in the same commit that mints it.
"app_hold_no_whole_copy": true, "app_hold_no_whole_copy": true,
// R-1007 (v0.150.0, controller v0.311.0, `09` §3 decision 236). An app's repair after an unclean host boot: counts
// of empty files found / repaired / still broken / originals lost. The household learns of a lost photo on its own
// app page (name and date, on its box); THESE counts are the operator's. Registered in the same commit that mints it.
"app_repaired_after_crash": true,
} }
// IsOperatorOnly reports whether an event type is barred from customer dispatch. Exported so the // IsOperatorOnly reports whether an event type is barred from customer dispatch. Exported so the
@@ -71,7 +71,8 @@ func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) {
// v0.121.0 widened it again, on purpose (R-636): an OOM storm is one app's event with no digest. // v0.121.0 widened it again, on purpose (R-636): an OOM storm is one app's event with no digest.
// v0.122.0 once more (R-659): a stranded held app is one app's event with no digest. // v0.122.0 once more (R-659): a stranded held app is one app's event with no digest.
// v0.123.0 once more (decision 28): an unhealthy stop is one app's event with no digest. // v0.123.0 once more (decision 28): an unhealthy stop is one app's event with no digest.
want := []string{"app_hold_no_whole_copy", "app_oom_storm", "app_start_failed", "app_stopped_unhealthy", "app_update_held", "app_update_undone"} // v0.150.0 once more (R-1007): an app's repair after an unclean boot is one app's event with no digest.
want := []string{"app_hold_no_whole_copy", "app_oom_storm", "app_repaired_after_crash", "app_start_failed", "app_stopped_unhealthy", "app_update_held", "app_update_undone"}
ok := len(perAppCooldownEvents) == len(want) ok := len(perAppCooldownEvents) == len(want)
for _, w := range want { for _, w := range want {
ok = ok && perAppCooldownEvents[w] ok = ok && perAppCooldownEvents[w]
@@ -81,7 +82,7 @@ func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) {
for k := range perAppCooldownEvents { for k := range perAppCooldownEvents {
got = append(got, k) got = append(got, k)
} }
t.Fatalf("perAppCooldownEvents = %v, want exactly [app_hold_no_whole_copy app_oom_storm app_start_failed app_stopped_unhealthy app_update_held app_update_undone]. Adding a member is the "+ t.Fatalf("perAppCooldownEvents = %v, want exactly [app_hold_no_whole_copy app_oom_storm app_repaired_after_crash app_start_failed app_stopped_unhealthy app_update_held app_update_undone]. Adding a member is the "+
"fenced act: the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full "+ "fenced act: the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full "+
"disk sends one digest, not one mail per app. Read the fence before widening this.", got) "disk sends one digest, not one mail per app. Read the fence before widening this.", got)
} }
+39
View File
@@ -63,6 +63,13 @@ Message: %s`, customerID, eventType, severity, now, message)
// digest that renders badly still tells the operator something; a swallowed one does not. // digest that renders badly still tells the operator something; a swallowed one does not.
} }
// R-1007: the repair after an unclean boot, as one readable line of counts (the payload carries nothing else).
if eventType == "app_repaired_after_crash" {
if line, ok := renderAppRepair(detailsJSON); ok {
body += "\n" + line
}
}
if detailsJSON != "" && detailsJSON != "{}" { if detailsJSON != "" && detailsJSON != "{}" {
body += fmt.Sprintf("\nDetails: %s", detailsJSON) body += fmt.Sprintf("\nDetails: %s", detailsJSON)
} }
@@ -424,3 +431,35 @@ func stackNameOf(detailsJSON string) string {
} }
return d.StackName return d.StackName
} }
// renderAppRepair is app_repaired_after_crash's operator line (R-1007): the counts, the boot and the duration.
func renderAppRepair(detailsJSON string) (string, bool) {
var d struct {
App string `json:"app"`
BootAt string `json:"boot_at"`
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"`
Problem string `json:"problem"`
}
if json.Unmarshal([]byte(detailsJSON), &d) != nil || d.App == "" {
return "", false
}
if d.Skipped != "" {
return fmt.Sprintf("Repair: %s — SKIPPED after the unclean boot at %s: %s", d.App, d.BootAt, d.Skipped), true
}
lost := "not checked"
if d.LostChecked {
lost = fmt.Sprintf("%d", d.Lost)
}
line := fmt.Sprintf("Repair: %s — after the unclean boot at %s: %d empty file(s) found, %d repaired, %d still broken; originals lost: %s; %d s",
d.App, d.BootAt, d.Found, d.Repaired, d.StillBroken, lost, d.DurationS)
if d.Problem != "" {
line += "\nProblem: " + d.Problem
}
return line, true
}