Compare commits

..

275 Commits

Author SHA1 Message Date
admin 00a0d91976 v0.323.0 released to the three test boxes: REPORT, CONTEXT
gates / gates (push) Successful in 44s
2026-10-16 21:36:06 +02:00
admin 8587198781 v0.323.0: CHANGELOG live results and scanner; README — the image's pinned restic, Samba 1.2.0, the update path
gates / gates (push) Successful in 49s
2026-10-16 21:22:42 +02:00
admin fbcc3cf042 Debian 13 runtime with restic 0.18.1 pinned outside the OS; restic assumptions re-proven; felhom-samba 1.2.0 (R-1105, R-972)
gates / gates (push) Successful in 49s
2026-10-16 20:33:57 +02:00
admin 29a3ce8cf2 v0.322.0 released: REPORT, CONTEXT, README (R-970 routes removed)
gates / gates (push) Successful in 52s
2026-10-16 19:07:43 +02:00
admin e9d0d723c1 v0.322.0: CHANGELOG; the build runs apt-get upgrade (Debian 12 security fixes newer than 12.13)
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 18:56:27 +02:00
admin 52619e89bd R-972: the controller runtime on Debian 12.13 (restic stays 0.14.0); Debian 13 is its own change (R-1105)
gates / gates (push) Successful in 47s
Debian 13 ships restic 0.18.0; the off-site engine's measured behaviour is 0.14.0's. The binary stays on
Go 1.27.2; the runtime moves to the current Debian 12 point release, pinned by digest.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 18:48:08 +02:00
admin fc77fcb89b R-972: Go 1.27.2, x/crypto v0.58.0, debian 13.7-slim — pinned by version and digest
gates / gates (push) Successful in 48s
Go 1.24 had no security fixes left; the shipped binary carried 62 known Go vulnerabilities and the
bookworm base 12 fixable criticals. Full suite green on Go 1.27.2 (i18n parity included).

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 18:37:52 +02:00
admin f037be7a6a R-970: the box's hub key opens no route; Data tab leftovers; R-1103: the install form's dead branches
gates / gates (push) Successful in 48s
R-970: /api/config, /api/config/hash, /api/config/apply removed (they read and replaced controller.yaml with
every secret, reachable with the box's own hub API key, unsigned; nothing called them). The hub-key Bearer
door on /api/selfupdate/ and /api/geo/ and the matching CSRF exemption are gone; the dashboard keeps using
those with a session. The route table is buildMux, so a test drives the real one.

Data tab: the export password field is autocomplete=new-password with a non-login name; the 2nd copy's
status rows that repeated the form are gone (the no-target reason moves into the form).

R-1103: app_fields.html loses its installed-only branches (no page serves them since R-1095); the tests
that rendered that dead path are retired, their rules asserted on the settings tab (R-694 moved there).

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 18:24:00 +02:00
admin 8fe46a1313 v0.321.0 released: REPORT, CONTEXT
gates / gates (push) Successful in 47s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 17:30:31 +02:00
admin 7e791d9b5d v0.321.0: FileBrowser's stale password and its own banners; one backup section on the Data tab; install address of an installed app
gates / gates (push) Successful in 43s
R-1101: the change-it line is gone for FileBrowser; in the generated state the page asks FileBrowser whether
the stored password still logs in (only its own 401/403 counts) and, refused, says so and offers the
new-password press. The rendered FileBrowser config switches off its update notice and its Install-app
prompt. The Data tab's backup part is one section in plain words (no off-drive, no PBS); the export
password eye is a text button. R-1095: /apps/{slug}/install of an installed app 302s to its settings tab.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 17:17:26 +02:00
admin c02c9d833a v0.320.0 released: REPORT, CONTEXT
gates / gates (push) Successful in 52s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 15:50:28 +02:00
admin 1296e7aa46 v0.320.0: the FileBrowser login line under the folder list — readable link, spaced from the list
gates / gates (push) Successful in 45s
Seen in the headless-browser check on demo-hp (rc.1): the link kept the browser's default dark blue on
the navy panel. It takes app-info-link (--blue-bright) and sits .75rem under the list.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 15:31:17 +02:00
admin f30a80841e v0.320.0: FileBrowser is part of the box again; every app says where its login is; a „Fájlok" button
gates / gates (push) Successful in 50s
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the
app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login.
A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every
other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab.
The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now
refuse a protected stack.

In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel
offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp:
new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated.

Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the
login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind
(first_visit default, email, none; none behind the family gate says the family sign-in is the login).

"Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present
drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound
into FileBrowser is text, never a link.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 15:19:14 +02:00
admin 39bf56298d v0.319.0 released: REPORT, CONTEXT; CHANGELOG MinAgent 0.158.1 (the floor's, carried from 0.318.0)
gates / gates (push) Successful in 48s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:43:40 +02:00
admin 0f82eba7c6 v0.319.0: 16px under the hero on the install page too (the memory box touched it)
gates / gates (push) Successful in 43s
Seen in the browser check on demo-hp (rc.2). CSS only; on the tabs the tab bar's 16px collapses into it.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:30:43 +02:00
admin 7adbbb17c0 v0.319.0: the export panel says „Exportálás" and its inputs use the dashboard's style
gates / gates (push) Successful in 55s
Seen in the browser check on demo-hp (rc.1): a page-style title with the app name inside a data section,
and a white select + password box. Two parity fixtures re-captured (app_data_deployed, app_data_export).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:25:05 +02:00
admin d170a2aecf v0.319.0: the app page in panels, the login in one place, a password the box never had is said in words
gates / gates (push) Successful in 52s
- style A (operator 2026-10-16): hero, tab bar and every section are panels on --bg-1, one width, 16px apart;
  the install page's form groups use the same rule; the settings tab's 700px inner wrapper is gone
- „Belépés az alkalmazásba": name + password side by side, fetched on demand (R-254, R-709); pairing in app_login.go
- the empty-value rule: no stored value -> a sentence, never an eye; page and reveal endpoint share storedValuePresent
- „Cím és kulcsok" is rows (no Domain input, no disabled storage select, no install form); hint under the title
- tests red-proved: app_page_tuning_test.go (4); 36 app-page parity fixtures re-captured, 4 new; install fixtures unchanged

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:12:06 +02:00
admin 8d44416610 REPORT + CONTEXT v0.318.0: build B live proof; R-1093/R-1094 filed
gates / gates (push) Successful in 49s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 10:09:52 +02:00
admin 36da4089ca v0.318.0: a part is offered for formatting only while nothing on its disk is mounted (agent 0.158.1 formattable)
gates / gates (push) Successful in 45s
The agent's root mkfs wrapper (security review of agent 0.158.0) refuses any mounted node and trusts no sibling claim; the
wizard now matches it. A free filesystem part of an in-use disk stays offered for use. Red-proved.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 04:05:09 +02:00
admin a43d0423d5 v0.318.0: the internal data place, partitions, an honest whole-box backup offer (R-1084), sys_drive never offered (R-1082)
gates / gates (push) Successful in 46s
felhom.eu architecture/16 §4-§6 (build B). MinAgent 0.157.0 — the internal place and the partitions appear only when
agent >= 0.158.0 reports them. Tests red-proved; four parity fixtures refreshed (only the new lines).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 03:57:49 +02:00
admin 123d2d553f REPORT v0.317.0: the live proof on Tester 1, demo-hp and the N100; R-1083 and R-1084 filed
gates / gates (push) Successful in 53s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 21:54:23 +02:00
admin a86c864b06 REPORT-app-page-2026-10-15.md: the app-page arc, in a file a second session cannot overwrite
gates / gates (push) Successful in 50s
REPORT.md is per-session and overwritten, and tonight two sessions released this repo within an
hour — the second (v0.317.0, the storage work) took it. This sibling holds the app-page arc:
Part 0's measurement and its caveat, what shipped, every red-proof, the three incidents, what is
deployed and what must never be, the browser check, and what is still owed.

The convention is felhom.eu/CLAUDE.md's, for exactly this case; this is the first use of it in
this repo. Whether REPORT.md should stay a single overwritten file in a repo two sessions can
release is a question for the operator, and is left as one rather than answered by a change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:48:19 +02:00
admin e7b64af1f0 build.sh's workspace/ is generated output, not a source change
gates / gates (push) Successful in 49s
The clean-tree gate added minutes ago immediately caught its own build: build.sh creates its
scratch directory beside the script, so a build run from a CLONE — which is exactly what the gate
pushes you to when the shared tree is busy — dirties the tree it is checking. It is generated
output and belongs in .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:32:08 +02:00
admin 0626b9fc8a v0.317.0: a second copy counts only on a proven different disk; the no-drive install speaks in words (felhom.eu architecture/16, build A)
gates / gates (push) Successful in 46s
Tier-2 and the one-copy notice ask the host agent (POST /disks/same-disk, agent 0.157.0): different +
proven -> made and counted; same disk -> not made (D1); unknown (VM, hidden USB serial) -> made, not
counted, the row says why (D2); a NAS never counts and no longer hides the notice (R-1075). An older
agent keeps the st_dev rule, logged once. D4: a data-folder or media app on a box with no drive is
refused in words with a link to the drives page (R-1068's raw-error half). MinAgent 0.157.0.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 21:21:54 +02:00
admin c6a567122e v0.316.4: build.sh refuses a push from a dirty or unpushed tree
gates / gates (push) Failing after 50s
An admission and its mechanism. Building 0.316.3 published another session's work: the shared
DooPlex clone held 31 uncommitted files, and the build directory's build.sh is a symlink into that
clone's WORKING COPY — which predated the FELHOM_REPO_DIR override, so the override was ignored and
the build copied the shared tree. The banner printed 'Commit: 0225255' and no 'Repo:' line; the
evidence was on screen and the image was already pushed. Nobody deployed it, which is luck, not a
control.

The clean-tree rule has been a sentence in a runbook all along. A rule without a mechanism is a
wish, so it is now two questions the build asks and refuses on: is every tracked file committed,
and is HEAD what origin/main has. A directory that is not a git checkout is refused too — a build
whose source cannot be identified is not reproducible.

Four new cases in the test (ten in total), each driving the real script; red-proved by deleting the
gate, which fails all four.

0.316.3 must never be deployed. It is not re-pushed or deleted: a version number is pushed once,
and that applies to a number I spoiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:09:38 +02:00
admin 4261a38bf0 build.sh: the source repo is overridable, and the build says which tree it used
gates / gates (push) Failing after 54s
The default is unchanged — the shared DooPlex clone the release runbook names. It is overridable
because that clone is SHARED: tonight another session had 30 files of work in progress in it, and
the clean-tree gate rightly forbids building over them. The way to ship is then a clean clone at
the pushed commit, not stashing somebody else's work.

The banner now prints the repo it built from, so an image is attributable to a tree as well as to
a commit. That matters more now that a version number can only be pushed once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:05:45 +02:00
admin f9151f0875 v0.316.3: the content column sits closer to the top and the sidebar
gates / gates (push) Failing after 45s
.app-page carried the mockup's 32px 48px 64px, but the .content wrapper it lives in already
supplies 2rem (1rem on a phone). The two added up: 64px from the top and 80px from the sidebar,
which is the gap the operator called too big. .app-page now adds no top or side padding of its
own — .content's is the page's — and keeps only the extra room at the bottom.

Reported from the rendered page with DevTools open, where both paddings are visible at once. The
mockup's numbers were the TOTAL the page should show, not an amount to add to what was already
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:04:52 +02:00
admin ec438479e2 REPORT + CONTEXT for v0.316.x: the diagnosis, the two mechanisms, and the bad push I caused
gates / gates (push) Failing after 48s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:56:58 +02:00
admin 8c7aa10a6e v0.316.3: the layout polish ships under its own number
gates / gates (push) Failing after 44s
The two data-tab leftovers and the form de-boxing were committed after 0.316.2 was already pushed,
so they ship as 0.316.3. A version is pushed once; the number moves, the image does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:52:04 +02:00
admin 9e68e526e9 v0.316.2: two more leftovers on the data tab
gates / gates (push) Failing after 48s
Biztonsagi mentes was still a card — it sits directly in the tab, not inside a section, so the
.app-section scoping rule did not reach it. It is a section now.

The 2nd-copy heading still read '2. mentes beallitasa — <app>', because the locale key carries the
app name: it was a PAGE title, and a tab already says which app it is. New key
app_page.second_backup ('2. mentes' / 'Second backup'). This is the leftover the task named.

Found by looking at the rendered page, not the markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:51:45 +02:00
admin 02252556b3 v0.316.2 CHANGELOG
gates / gates (push) Failing after 50s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:46:24 +02:00
admin f908eee2a3 v0.316.1: the settings tab's reused form markup loses the install page's boxes
gates / gates (push) Failing after 47s
The fields on Beallitasok are the install wizard's markup, which carries that page's card around
the whole form and one around every generated value. On a TAB those are sections, so the boxes come
off; the input itself is still a field, which is what tells the household it holds a value. Scoped
to .app-section so the install page — same column, its own form — is untouched.

Found in a browser by asking the page which elements still paint a background, rather than reading
the markup and guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:45:57 +02:00
admin 006ceab58a v0.316.1: the asset tag actually reaches the dashboard pages
gates / gates (push) Failing after 48s
v0.316.0 shipped it dead. Every shell asked for `?v={{.AssetTag}}`, TestAssetTag_EveryShellUsesIt
passed, and the first live load through the public address rendered `/static/style.css?v=` —
EMPTY. addShellAssetData was called only by executeTemplateLang (the sign-in, claim, recovery,
guest-share and catch-all pages). The dashboard path had never needed it, because layout.html used
to ask for {{.Version}}, which baseData supplies; moving it to {{.AssetTag}} left nothing to
supply the value.

An empty buster is a CONSTANT. It would never have changed again — worse than the per-version one
it replaced, and invisible to a test that only checks the markup. executeTemplate now calls the
same function executeTemplateLang does, which is literally what the v0.255.0 comment above that
call asked for: "setting it at the single choke point every shell renders through means a new
shell cannot miss it". There were two choke points.

TestAssetTag_ReachesTheRenderedPage renders six real URLs through the production router and asserts
the buster is non-empty, 8 hex, and not a version. Red-proved by removing the call again: four
pages fail naming the empty value.

A PATCH VERSION, not a second push of 0.316.0 — the rule this release added, applied to its own
mistake rather than around it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:41:09 +02:00
admin 48d8674876 v0.316.0 CHANGELOG; the push-once fact in this repo's unprompted-work rule
gates / gates (push) Failing after 49s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:33:22 +02:00
admin 7f3e58ff0a The app page as one column — the approved mockup
gates / gates (push) Failing after 49s
v0.315.0 worked and looked, in the operator's words, messy: boxes of different widths stacked down
the page, with the same card shape around things you act on and things you only read. The mockup
approved on 2026-10-15 replaces it, and each of its three rules has a reason:

  ONE COLUMN OF ONE WIDTH (820px). Differing widths read as a page assembled by accident.
  SECTIONS ARE HAIRLINES, NOT CARDS. A card says "this is a thing you act on"; most of an app page
    is a thing you READ, so a card there is a promise the content cannot keep.
  A BOX MEANS SOMETHING. Exactly two remain: blue when the app is waiting for the household,
    amber when something is risky. The exception-colour principle applied to shape as well.

What moved:
  - the hero loses its box: a 64px logo tile, the name at 26px, the tagline, then hairline chips
    (state, lifecycle, update) and meta text (the address, the last automatic update);
  - the tabs are links with a 2px blue underline under the open one — no pills, no fills;
  - sections are `padding: 28px 0` with a bottom hairline; rows are a 200px label column so every
    value on the page starts at the same x, and stack under 720px;
  - the after-start callout is now the FIRST thing on Beallitasok. A household opening a Plex that
    wants its claim code should meet the code box, not a Restart button;
  - "2. mentes beallitasa — <app>" and "<app> — Exportalas" are gone: they were the headings of
    pages that no longer exist, on a tab that already says which app this is;
  - the settings tab gains "Cim es kulcsok" with one address row, and the disabled subdomain input
    that said the same thing is skipped for an installed app (the install wizard still asks);
  - the "this app is already installed, the settings are read-only" BANNER becomes the hint under
    the fields it is about;
  - the install wizard gets the same back link, hero and column, so pressing Telepites does not
    land the household on a page that looks like a different product.

THE AUTO-UPDATE SUCCESS BANNER IS GONE. It was a full-width blue box above the tabs, level with
"the update failed" and "your files are missing". A success that asked nothing of the household is
a date, and it is now meta text in the hero. Every banner that DOES need a decision — a failed
update, a hold, lost files, a missing drive, an interrupted install — is untouched, above the tabs,
exactly where R-571 puts it.

Tests: TestAppPage_OneColumnLayout (one column, one aria-current, no card-shaped section, no
success banner), TestAppPage_AutoUpdateSuccessIsMetaNotBanner, TestAppPage_CalloutsOnlyForAskAndWarn
(one blue callout on a waiting app and it comes first; one amber on a same-disk backup; none on the
good case). Four red-proofs recorded in REPORT.

app_row_dedup_gate convicted `.app-row`, correctly: that class already means "a row in the app
list". The app page's label/value row is `.app-field-row`. Restoring the list's own rule after an
over-broad rename was part of the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:29:56 +02:00
admin 9cc7ffcf3b A version number is pushed once, and every build addresses its CSS differently
gates / gates (push) Failing after 44s
MEASURED FIRST (audits/app-page-2026-10-15b/P0/VERDICT.md). The tab bar rendered unstyled through
the public address for one reason, and it was not the box: exactly one guest runs 0.315.0 and it
runs the registry's current digest (8887343011d6, identical to the registry's). The floor is still
0.314.0, so nothing else could have taken it.

What it was, in four measured facts:
  1. the stylesheet URL is /static/style.css?v={{.Version}} -> ?v=0.315.0 for all THREE different
     0.315.0 builds (e66d466, fe0228c, db52f5a), the first of which had no .app-tabs rules at all;
  2. the origin sends NO validator at all (ETag present = 0, Last-Modified present = 0), so a
     cached copy cannot be revalidated - it can only expire;
  3. Cloudflare caches that URL (cf-cache-status HIT, age climbing) and rewrites the browser TTL
     from the origin's 3600 to 14400 - four hours;
  4. the session's first request returned cf-cache-status EXPIRED, which only happens when the edge
     holds an entry that has aged out.

Origin, edge and box now agree byte-for-byte. The stale object itself is gone - that first probe
forced its revalidation - and the REPORT says so rather than claiming a HIT nobody can reproduce.

THE FIX IS IN TWO PARTS, NEITHER OF WHICH IS PURGING A CACHE:

build.sh refuses to push a version that already exists, before building, for --push and
--multiarch. A registry that cannot be asked is a refusal too: proving the tag is new is the point,
and "I could not ask" is not that proof. Local builds are untouched - reusing a version while
iterating is normal. scripts/test_build_push_once.sh drives the real build.sh with a fake docker on
PATH over six cases; red-proved twice (check deleted -> a present tag builds and pushes; an
unreachable registry treated as absent -> two cases fail).

assetTag() now hashes version + GitCommit + BuildTime, and EVERY shell's stylesheet link uses it
(seven of the nine asked for ?v={{.Version}}; only the two stranger-facing share pages already had
it). It stays opaque and 8 hex, because the share pages are reached by a capability URL and naming
the build to whoever holds one is a gift to somebody hunting a known fault. Cache-Control is
unchanged: the new address is the fix, not a shorter cache.

The parity harness normalises the asset tag on BOTH sides of the comparison, as it already does for
the per-session CSRF token, so all 148 fixtures stay byte-identical on disk and stop depending on
which build rendered them. The same normalisation is applied at the second comparison site in
i18n_wiring_test.go, which had the same assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:08:01 +02:00
admin 83392064ae REPORT + CONTEXT for v0.315.0: the live walk on demo-hp, and the two defects only a browser found
gates / gates (push) Failing after 46s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 19:32:47 +02:00
admin db52f5a4cf v0.315.0: close the log tab's <script> — the live poller was dead, and a test now says so
gates / gates (push) Failing after 49s
FOUND IN A BROWSER. The log tab's body was lifted out of the old logs.html by line range and the
range stopped one line short, so the closing </script> never came with it. Everything downstream
agreed the page was fine:

  - html/template parsed it,
  - the page rendered and looked right,
  - TestAppPage_EachTabRendersItsOwnContent found `setInterval(window.fetchLogs` exactly where it
    expected it, because the TEXT was there.

Chrome refused the whole block with "SyntaxError: Unexpected token '<'" at the `</div>` that
followed, so the live log never refreshed. A marker proves a string is present; it does not prove
the browser will execute it.

TestAppPage_ScriptTagsAreBalanced now counts <script> against </script> on all seven app-page URLs
and checks the last block is closed. Red-proved by removing the tag again: it fails naming the logs
tab, 3 opened and 2 closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 19:27:21 +02:00
admin fe0228c877 v0.315.0: style the app page's tab bar, and reuse the hero classes that already have rules
gates / gates (push) Failing after 46s
FOUND IN A BROWSER, and findable no other way. Every endpoint-level marker passed and the page was
unusable: the tab bar rendered as four bare underlined links run together, and the app logo filled a
third of the screen. The shell had been written with new class names — app-hero, app-hero-logo,
app-tabs — and only the first two of those had any CSS at all, under the names the design system
already uses.

  - the shell now reuses .app-info-hero / .app-info-logo / .app-info-hero-text, which carry the
    80px logo box, the hairline border and the 2px radius. Inventing a parallel name is what left
    the logo unsized; REUSE.md's first rule, learned again.
  - .app-tabs / .app-tab / .app-tab-active are genuinely new and are now styled to design system
    v2: 2px radius, hairline --line border, no shadow, --text-2 for an inactive tab and
    --blue-bright only for the one the household is on (blue marks where you are, not a deviation),
    the active tab sitting on the bar's own line, and a narrower gutter under 640px.
  - .after-start-card gets a blue left hairline — it is a request, not a fault, so no amber.
  - .app-faq gets its dt/dd rules.

TestAppPage_DeployedDefaultsToSettings sliced the header at `class="app-hero"`; it now slices at the
reused class and fails loudly if either marker is gone, instead of slicing on -1. The 36 app-page
parity fixtures are recaptured — and no other fixture moved, which is the evidence that the shell
change reaches only the app page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 19:04:57 +02:00
admin e66d4669d6 v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.

  settings  Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
            household + the install-time and optional settings. The default for an installed app,
            RENDERED at the bare URL and never redirected to.
  data      the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
  guide     tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
            link — and the WHOLE page for an app that is not installed
  logs      the log, and the only tab whose poller runs

The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.

The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.

A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.

New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.

The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).

template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 18:37:50 +02:00
admin 85f65c70d8 v0.314.0: CONTEXT and REPORT after the release (floor 0.314.0 raised by CC's token at 13:11 UTC)
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 15:17:51 +02:00
admin 97c992ad44 v0.314.0: the R-1060 web test (a fake Plex that refuses the name „plex”), left out of 17863fe
gates / gates (push) Successful in 47s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 14:34:46 +02:00
admin 17863fe8bc v0.314.0: a restore puts the setup gate back as it was (R-1057); a test build moves with the floor (R-1053); Prowlarr links itself (R-1058); the box claims Plex by address (R-1060)
gates / gates (push) Successful in 47s
MinAgent 0.155.0 (unchanged). Tests red-proved by mutation (A: 10, B: 7, C: 1, C2: 2).

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 14:34:33 +02:00
admin e209a8d724 Rule file: a release needs no golden; the floor carries it after the three test boxes; golden weekly (09 §3 decision 250, operator 2026-10-15)
gates / gates (push) Successful in 44s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 11:43:16 +02:00
admin cac92a407e v0.313.0: media apps on every drive, drive events, a real restart, Plex without the race — release notes (MinAgent 0.155.0); golden next (bake → vouch → floor)
gates / gates (push) Successful in 44s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:48:58 +02:00
admin f327bc53a9 after_start deploy fields: asked after the start by this controller, still required for older ones (R-1028/R-1045, security review of catalog 0188430)
gates / gates (push) Successful in 47s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:32:51 +02:00
admin 372d6e5994 Plex (R-1045): the controller asks Plex itself whether it is claimed — after the claim and on the household's „Kész”; no template probe (an older controller would refuse the press forever)
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:29:47 +02:00
admin 37ffa3beb5 Safe removal (R-1044), found on Tester 1 G4: the drive gate leaves a safely removed drive alone (no false RETURNED/ABSENT, no mail); presence = backing device, not the agent's state
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:25:08 +02:00
admin a09cab124a mediawire: qBittorrent 5 answers a good login with 204 — accept it (found on Tester 1, Part G1); fake answers 204 like the app
gates / gates (push) Successful in 41s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:02:26 +02:00
admin f580d88a14 Media build part 2 (unreleased): drive wiring in every app (R-1024/R-1037), old-install repair (R-1047), drive events notice + real restart now/later + stream check, file browser lets go of dead/removed drives and recreates on return (R-1038), safe removal keeps the drive (R-1044), Plex claim on the running server + probe accept (R-1028/R-1045)
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 09:54:49 +02:00
admin 78c894234c Media build part 1 (unreleased): real restart (R-1026), media apps on every PRESENT drive via an owned compose override (R-1023), dead-drive check from mountinfo, removal never deletes shared/:ro binds (R-1027), safety list knows userdata (R-1032), no drive question for media apps (R-1031)
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 09:25:43 +02:00
admin 331be17e1e Rule file: bake first — bake → vouch → floor, never a floor with no golden (09 §3 decision 247, operator 2026-10-15)
gates / gates (push) Successful in 49s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 08:18:12 +02:00
admin 8bc7ec54cd v0.312.0: every public name answers only over https; HSTS one day (R-1033, P1)
gates / gates (push) Successful in 48s
forwarded.yml: felhom-forwarded is now a chain [felhom-https-only (redirectScheme https, 301 when the trusted tunnel
says X-Forwarded-Proto: http), felhom-forwarded-headers (the clean-up + HSTS 86400, no subdomains, no preload)] plus a
priority-1 catch-all router for unknown names over http. traefik.yml unchanged; hot-loaded.
Tests red-proofed. Evidence: felhom.eu documentation/audits/https-only-2026-10-14/.
2026-10-14 22:39:58 +02:00
admin 9b95e8e09b REPORT: R-1007 — the repair after an unclean boot, proven live on demo-hp
gates / gates (push) Successful in 42s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-14 17:52:20 +02:00
admin 3b85a0c29a v0.311.0 (before the build): the repair's commands run quietly — no household file name can reach a log or the operator event
gates / gates (push) Successful in 50s
Security review of 8d862e4: compose's shared runner logs a failed command's stdout and puts its
stderr in the error, and that error became the event's `problem`. The after_unclean_boot commands now
run through a quiet runner (nothing logged, stderr discarded) and `problem` keeps the exit code only.
TestAUB_AFailingCommandLeaksNoOutput, red-proofed.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-14 16:58:20 +02:00
admin 8d862e4e69 v0.311.0: after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007)
gates / gates (push) Successful in 52s
New template block after_unclean_boot (decision 236): once per controller start that followed an
UNCLEAN host boot (crashboot's own verdict, after the 15-minute crash grace; a normal or unknown boot
never runs it), each app with the block rebuilds ONLY its zero-byte files (scan: regular files, no
symlinks, its own filesystem; ids appended to the app's own command in batches; one retry, then
reported) and its own lost-file check is read. The household sees lost files (name, date taken) on
the app page until dismissed; the operator gets app_repaired_after_crash with counts only. Skips,
with the reason, an unproven image, an unknown key, an app not running. MinAgent 0.155.0 unchanged.
Tests red-proofed (14 guards). One new parity fixture (the new card only).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-14 16:47:13 +02:00
admin acbbf4b305 REPORT: the machine came back by its power button; the hub ended the hold (R-998 live proof complete)
gates / gates (push) Successful in 49s
2026-10-14 00:05:58 +02:00
admin e24430cce5 REPORT: power-off (R-998) released and proven on the N100
gates / gates (push) Successful in 48s
2026-10-13 21:41:33 +02:00
admin 201f936e9a v0.310.0: „A gép kikapcsolása" — the household turns the whole machine off (R-998); honest restart text (R-999)
gates / gates (push) Successful in 48s
POST /api/server/poweroff (dashboard login, POST only, the typed word KIKAPCSOLÁS / TURN OFF)
→ agent POST /host/poweroff (MinAgent 0.155.0; hidden for an older agent). The controller refuses
during its own backup/restore/app update; the agent's reasons are shown as household sentences.
„Kiszolgáló újraindítása" becomes „A Felhom újraindítása" and says it restarts the Felhom part;
/api/server/reboot is POST only (R-983). Tests red-proofed; parity fixtures regenerated on purpose.
2026-10-13 20:17:30 +02:00
admin 1b2f74c52f v0.309.0: the default list completes groups 1 and 3 for households with no saved list (operator Q1); REPORT
gates / gates (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 17:25:12 +02:00
admin 7626a28de3 v0.309.0: the fill warning reaches the household in its own language; the root-filesystem disk event pinned end to end (R-521 F7)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 17:11:53 +02:00
admin 402fa41e45 v0.309.0: re-capture the two notification-page parity fixtures; capture settings_notifications_groups_custom (R-388 slice 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 16:50:42 +02:00
admin 462196e29b v0.309.0: the notification page shows three group switches; details and cooldown under Részletek (R-388 slice 1, decision 200)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 16:50:41 +02:00
admin 68294ecb6f REPORT: v0.308.0 (the operator's answers, 2026-10-12)
gates / gates (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:42:31 +02:00
admin faef8f8a91 v0.308.0 (R-554, held-back-able): recovery-info.txt points at the dashboard's Restore page and the recovery code; the first-boot setup wizard is deleted
Operator ruling 2026-10-12 (S27, 09 §3 decision 222). The operator approves the Hungarian text before
release. A box whose first ingestion failed now serves one bilingual waiting page and retries every
minute instead of the wizard. Own commit so the lead can hold it back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:38:28 +02:00
admin fd28d70bff v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)
Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged.
R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its
settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:13:59 +02:00
admin dcd89364a4 REPORT: v0.307.0 released and delivered (2026-10-10 afternoon)
gates / gates (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 15:24:38 +02:00
admin fc6466a50a v0.307.0: after a restart the Databases table lists each database once, not every pre-restore undo copy
gates / gates (push) Successful in 1m14s
Seen on demo-hp 2026-10-10 (adventurelog x4, bookstack x4). The synthesised run skips IsUndo files; only undo copies
-> no synthesised run. Two tests, both red-proved. v0.306.0 (R-570) was built but never delivered; 0.307.0 carries it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 15:19:43 +02:00
admin 9e5b88146e v0.306.0: R-570 — the off-site note decides on its kind only; the pre-0.251.0 text fallback is deleted
gates / gates (push) Successful in 1m14s
Fleet condition read on each box's settings.json 2026-10-10 (offbox.last_success ~02:16Z on 0.304.0).
Red-proof: the new no-kind row of TestR553_StaleNoteDecisionSurvivesWordingChange failed against the fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 15:08:42 +02:00
admin c255195e37 REPORT: v0.305.0 released and delivered (2026-10-10)
gates / gates (push) Successful in 1m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 11:06:55 +02:00
admin 1cb43a21a2 v0.305.0: release entry (R-921 pre-check, R-922 email_cleared); MinAgent 0.131.0
gates / gates (push) Successful in 1m4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 09:55:30 +02:00
admin b3a879b50e rules: "The system poster stays true" (section 6, all five copies identical)
gates / gates (push) Successful in 1m14s
The shared rule file carries the same wording in felhom.eu, felhom-controller,
felhom-agent, app-catalog-felhom.eu and the workspace root on DooPlex --
"change all five or none" -- so this is this repo's copy of a rule added in
felhom.eu a08bd3cbd5.

A session that changes a fact listed in
architecture/felhom-system-poster.facts.md (a machine, a role, a traffic path,
a backup tier, a time, a retention, a key, a known gap) updates that file in
the same commit; fixes the poster's text if the change is text only; or adds
"System poster needs a refresh: <what changed>" to STATUS. The report names
which of the three it did.

The poster is drawn in Claude Design and nothing in the repo renders it, so
scripts/poster_facts_gate.py only WARNS when the facts outrun the drawing --
it never fails a push, because a refresh needs the operator and another tool.

Verified identical to the other four copies by diff, before and after.
2026-10-09 18:10:12 +02:00
admin ec9b997151 controller (unreleased): R-921 check-first pre-check (no stop while another tier's job is in flight); R-922 email_cleared on a household's deliberate clear
gates / gates (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-09 12:57:34 +02:00
admin 13eeb484ab REPORT: v0.304.0 released, delivered, D1/D3/D4 proven live (2026-10-09)
gates / gates (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-09 08:36:57 +02:00
admin d7bfdccf57 v0.304.0: the decision sheet (D1, D3, D4, D8), R-899, R-304, three layout fixes — release entry
gates / gates (push) Successful in 1m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-09 06:56:22 +02:00
admin 3f84f82c3d R-893: hold the app after ANY failure once the definition or a volume moved; hold persisted before the stop; run_job done says it ran, not what it found (security review)
gates / gates (push) Successful in 56s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 15:13:04 +02:00
admin d3e17e9b2e CHANGELOG/README/REUSE/REPORT: the decision sheet D1, D3, D4, D8 (unreleased)
gates / gates (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin e06680b797 Operator actions in the report reply (D1, R-314/R-279/R-177, decision 185)
The hub may ask the running controller for a CLOSED list of actions,
carried in the report ACK (operator_actions) and answered on the next
report (operator_action_results): offsite_backup_now, abandon_stop,
abandon_extend (1-30 days), run_job (fill-watch, offsite-integrity,
offsite-proof, disk-health-check). Unknown action/job/argument -> refused,
nothing called. Once per id (in memory; every action is safe to repeat).

- internal/report/opactions.go: the executor; results re-sent until the
  hub stops listing the id.
- scheduler.RunNow: refuses unknown / already-running jobs; OnDemand(ctx)
  makes an operator's offsite-integrity run even when not due.
- ExtendAbandon never shortens the countdown and refuses in the hub phase;
  StopAbandon reports failure when the hub cancel failed (it said success).
- Report ACK read cap 4 KiB -> 64 KiB (an ACK over the cap dropped every
  field in it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin 63441f0a69 R-893 slice 1: a failed off-site replay after files/volumes/version moved holds the app
Decision 192 (D8, option C). After a failed off-site database replay whose
rollback worked, but where the snapshot's definition was written or a named
volume was replaced, the app is no longer started on the mixed state:
the live definition is written back, the database service stopped, and the
app held (HoldReasonRestoreMixed) for support. The sentence (hu+en) says the
app needs help and no longer claims the data is back as it was. The operator
gets a backup_run_failures mail (leg restore-hold-mixed).

The plain case (no version change, no volume replaced) keeps today's
behaviour (TestR379_ScenarioA). Red-proofed: r893_mixed_restore_test.go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin 740339567a R-35: an ended session cannot return after a failed save (password fingerprint in the file; a failed revoking save removes it) — security review
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin 1040cfe225 R-35 (D4): dashboard sign-ins survive the controller's own restart; disk holds only a fingerprint
Sessions are keyed by sha256(cookie) and persisted to dashboard-sessions.json
(0600, tmp+fsync+rename) in the data dir: fingerprint, expiry, CSRF token.
Loaded in NewServer; expired rows dropped at load and save. Logout and
invalidateAllSessions (password change, claim reset) write the file at once.
Corrupt/unreadable file = start with no sessions (never fatal).

Red-proof: with load/save as no-ops the restart test fails ('the old cookie
no longer signs in'); with the raw token as the key the file test fails
('the sessions file holds the cookie value').

Also: TestR650_NoBareDockerExec skips a non-.go file that vanished mid-walk
(a parallel stacks test's update-journal.json.tmp raced it in a full run).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin fcd9f09927 health check: the last six producers carry their dashboard sentence (R-79 option A, D3)
Docker unreachable, protected container down, and the four storage-path entries now carry a
bundle key beside their wire text; the wire text is unchanged byte for byte. Hungarian values of
the four storage keys equal the frozen formats (i18n_go_parity). Red-proven: a zero MsgRef on the
unavailable-path warning fails TestR79_RemainingProducersCarryTheirDashboardSentence and
TestR79_HealthBannersFollowTheHousehold (producer-driven, English household sees Hungarian).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin 423b6d3404 Dashboard banners on a phone wrap inside the banner; the banner link takes the banner's colour
gates / gates (push) Successful in 1m5s
Seen on scratch 9202 with the R-906 build: once the real banner warnings showed (instead of an empty
overflow line), the link ran 90 px past a 390 px screen and rendered browser-default blue. Phone block:
.alert-banner wraps, .alert-message breaks long paths, .alert-link on its own line; .alert-link color: inherit.
Pinned in TestR907_PageHeaderWrapsOnAPhone (red against d5f2e47's CSS). No Hungarian string changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-08 14:41:18 +02:00
admin e37e9b50c6 Parity fixtures: re-capture stacks_full, stacks_unhealthy_stop, stacks_install_interrupted for the Apps card layout (R-909)
gates / gates (push) Successful in 1m1s
Reason: a deliberate LAYOUT change, not a translation. Deleted and re-written with
`go test -run TestI18nParity -update-i18n-golden -i18n-golden-only '^stacks_'`.
Measured: each equals its predecessor with exactly the template change applied (div.stack-title-text,
the address span.subdomain-text + title, the div.stack-tags row) — byte-for-byte, 3 of 3; the visible
text is identical. No Hungarian string changed; no other fixture moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-08 14:32:06 +02:00
admin d5f2e47ea1 Dashboard layout: Apps card header (R-909), launcher header wraps on a phone (R-907), overflow counted after the page filter (R-906)
- Apps card: title row (logo top-aligned, name, address cut with an ellipsis and full in title) over a wrapping
  .stack-tags row; the tags no longer share a space-between row with the name.
- Phone (768px block): .page-header wraps; the share button drops to its own line, text whole.
- GetBannerAlerts(page, lang): the layout's filter before the cap; baseData and /monitoring use it.
- Tests: TestStackCardHeaderLayout, TestR907_PageHeaderWrapsOnAPhone, TestR906_* (all red against 05e12921).
- No Hungarian string changed. The three stacks parity fixtures are re-captured in the next commit
  (this commit alone fails TestI18nParity on them; both are pushed together).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-08 14:32:06 +02:00
admin 05e129267a REPORT/CONTEXT: 2026-10-08 afternoon (R-304 operator mail, R-298 side fix)
gates / gates (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 10:25:55 +02:00
admin a40729a698 R-304 option C: operator mail when a household's code opens or may open an older package (once a day); R-298 side fix (no eject/format on a backup-target drive); R-717 comments
gates / gates (push) Successful in 59s
Unreleased; ships with tomorrow's release (needs the hub of the same day).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 10:07:03 +02:00
admin a0370b4ed8 REPORT/CONTEXT: 2026-10-08 day (R-899, R-304 on main, unreleased)
gates / gates (push) Successful in 1m1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 08:08:44 +02:00
admin 75b3b39254 R-304: the recovery screen says 'we do not know' when earlier packages were not checked (hu + en)
gates / gates (push) Successful in 54s
Unreleased; ships with tomorrow's release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 07:53:09 +02:00
admin 6d07ca2be4 R-899: a daytime press never cancels the night's whole-guest backup (operator ruling 2026-10-08, option A); press sends trigger=manual
gates / gates (push) Successful in 1m3s
Unreleased; ships with tomorrow's release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 07:34:03 +02:00
admin 4d3a0246df REPORT: v0.303.0 (R-897) delivered
gates / gates (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 19:06:36 +02:00
admin 29bebbb937 v0.303.0 (code): after a restart, a backup capture waits for the drive's bind (R-897)
gates / gates (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 18:45:01 +02:00
admin 431da7ebbc REPORT/CHANGELOG: 2026-10-07 day
gates / gates (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 14:21:10 +02:00
admin 191836946b Shared rule file: no hub image build or deploy in a session the operator does not attend (09 §3 decision 162)
gates / gates (push) Successful in 55s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 09:54:53 +02:00
admin e2392935a3 REPORT: released and delivered (2026-10-07)
gates / gates (push) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 09:32:03 +02:00
admin a3e85e5a40 v0.302.0: R-542, R-516, R-330 (MinAgent 0.131.0)
gates / gates (push) Successful in 1m32s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 09:07:48 +02:00
admin 9acc3adb96 Shared rule file: rule 11 — every helper prompt carries the brief's fences in full (09 §3 decision 160)
gates / gates (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 08:52:24 +02:00
admin 0629c16a4b REPORT: the second burn-down night
gates / gates (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 00:14:57 +02:00
admin 1de22a166a R-330: the controller decodes SMART 187/188/199 from the agent (carried only, no verdict change)
gates / gates (push) Successful in 56s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 22:05:57 +02:00
admin 3d6ba2852f R-516: the formal forms in Go literals move into the bundle in the te-form
gates / gates (push) Successful in 50s
22 Go-literal messages (escrow handler, share password page, export
upload, network-storage uid/remove/attach failures) are bundle keys with
te-form Hungarian and English; a detached attach failure renders in the
reader's language. The NAS refusal says „Válassz". Setup wizard,
recovery-info.txt (R-554) and the SMART/fill-watch wire texts are left.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 21:40:25 +02:00
admin f65ace0ca6 R-542: the disk candidates no longer offer a registered, in-use drive
gates / gates (push) Successful in 1m2s
The format wizard rendered the agent's initialize list as-is, and the
agent deliberately allows re-initialising Felhom's own drives, so a
registered data drive was offered for formatting. The controller proxy
now drops every candidate that backs a registered storage path (joined
through the guest mount table) from both lists; an unreadable mount
table empties initialize.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 20:52:15 +02:00
admin 5e7522e023 REPORT: v0.301.0 (2026-10-06 evening)
gates / gates (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 16:10:36 +02:00
admin 0b1b8d3445 v0.301.0: R-518, R-638, R-717 (MinAgent 0.131.0)
gates / gates (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:49:58 +02:00
admin 66aee8939b Rules: the shared rule file's copies line names five copies (the agent repo has one now, 09 §3 decision 152)
gates / gates (push) Successful in 55s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:23:16 +02:00
admin ce2e31f4b2 R-717: name each test's red-proof file
The six red-proofs are saved under felhom.eu
documentation/audits/design-build-2026-10-06/E/red-*.txt; every test comment
now names its file and the reverted line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin 5b8656974b R-717: after_setup open_command — the family window reopens an app's DB-held sign-up switch
The command form ran only when the lock was SET, so a database switch closed by
`command` stayed closed through the household's 15-minute window. New twin
fields `open_command` + `open_success` (same service/user/args_env and the same
argv-safe expansion and success-marker rules as `command`/`success`).

- liftNativeLock (the window, via OpenSignupWindow → goNativeLock(false)): marks
  the gate record native_lock "opening" BEFORE anything opens, lifts the env,
  runs open_command; only full success records "lifted". A failed open closes
  the switch again at once and records after_setup {ok: false, step: open}; the
  app page shows its own line (app_info.signup_native_open_failed).
- The close: reconcileSignupBlocks (every 20 s and at controller start) runs
  `command` for any non-"applied" state once no window runs. A failed close
  after a window is logged ERROR ("may still be OPEN past the household's
  window") and retried every nativeLockOpenRetry (2 min) instead of 30.
- After a successful app update, verifyAndConclude → markNativeLockForReapply
  sets native_lock "" so the loop closes the switch again.
- A template with `command` but no `open_command` keeps today's window (env
  only) and logs once per app that its own switch cannot be reopened.

Tests: internal/stacks/after_setup_r717_test.go (7), web render + parity case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin 9c945688c0 R-638 option A: a replay meets the copy's own schema on the two side paths (09 §3 decision 154)
Slice 1 — the no-manifest fallback RestoreApp no longer starts the WHOLE stack
at the current definition before the replay (a newer app could migrate the
restored data underneath it). New order: resolve DB services from the live
compose -> stop -> volumes -> DB-only start (StartStackServices, the same
helper the unit restore uses, R-47) -> replay -> full start -> health wait.
A dump with no identifiable DB service is refused before any mutation (same
gate and message as the unit and off-site paths). A failed volume leg skips
the replay. restoreDockerVolumes now goes through the existing
volumeReplayFrom seam (nil in production) so the order is testable without
Docker.

Slice 2 — a unit restore whose volume leg failed no longer calls the
importer. Everything else on that failure path is unchanged: dataErr is
returned as "completed with data errors", the unit's definition is written
and the app is fully started.

No loader change, no new delete step. Red-proofs in felhom.eu
documentation/audits/design-build-2026-10-06/B/ (red-slice1-fallback-order.txt,
red-slice2-no-replay-after-volume-failure.txt).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin 9d2d62831a R-518: a press with the local storage absent backs up nothing (never the off-site tier as a stand-in); an agent that flags no primary keeps the first tier
09 §3 decision 156 says the button makes the local copy only.
Test: TestManualRunTiers_NoPrimaryAvailable_BacksUpNothing (red-proved on the previous version).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin d720495cd5 R-518: backup button copy says local copy, one short stop, off-site at night (hu+en)
The 'Mentes most' hint and its confirm stated the v0.296.0 measurements
(about 6 min / 9 apps, about 8 min / 12 apps) and that the off-site copy in
the same run makes it longer. Since decision 156 the press makes the local
copy only and resumes the apps at its snapshot, so the copy now says: a
short stop of about 1-1.5 minutes (longer with more apps), the apps run
again while the copy finishes, the off-site copy follows with the next
nightly backup. Parity fixtures updated in place;
TestR518_BackupButtonStatesTheMeasuredDowntime ->
TestR518_BackupButtonStatesTheShortLocalOnlyStop (new copy on page AND in
confirm, old minutes gone, ASCII fragments).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin d8c2634472 R-518 option A: one stop per backup tier (09 §3 decision 156)
Each quiesce window now backs up ONLY the first due tier that starts and
resumes the apps at its snapshotted; the upload finishes with the apps
running. Any other due tier stays due and a later cycle (the next poll)
takes it in its own short window - never straight after the first.
This reverses R-82's 'ONE quiesce window for both due tiers'.

A manual press (TriggerNow) backs up the primary (local) tier only, picked
by the agent's Primary flag; the off-site tier follows at the next
scheduled night run. If no available tier is flagged primary, the first
available tier is backed up rather than nothing.

Unchanged: marker written before any stop, one unquiesce per window, the
max-quiesce bound, BUSY/start-error handling (the next tier is still tried
in the same window when a tier does not start), breaker and contention.

Tests: TestBothTiersDue_ExactlyOneQuiesceWindow ->
TestBothTiersDue_FirstCycleRunsOnlyFirstTier; TestNonLastTierSnapshot_
DoesNotResumeApp -> TestFirstTierSnapshot_ResumesAppWhileUploadContinues
(red on old code); new TestManualPress_RunsOnlyLocalTier (red on old
code), TestLeftoverTier_RunsInNextCycleInItsOwnWindow,
TestManualRunTiers_NoPrimaryAvailable_BacksUpFirstAvailable;
TestNotify_BothFailingTiersAreReported now runs two cycles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin 31242786af Instruction files kept true (09 §3 decision 150): stale gate lists, paths and facts corrected; no code change
gates / gates (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 13:44:01 +02:00
admin 36088fd82e REPORT: the operator's ten answers (2026-10-06)
gates / gates (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 12:28:46 +02:00
admin 871d05b84d v0.300.0: R-645, R-856 (MinAgent 0.131.0)
gates / gates (push) Successful in 48s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 11:57:06 +02:00
admin c393d8529a R-856: after a crash boot of the host, app mails wait ~15 minutes; a normal boot keeps 90 s (09 decision 143)
The dead-app check (source of app_start_failed and app_stopped_unhealthy) now gates on a crash-aware
boot grace (internal/crashboot): 15 min when the host crash guard's last boot was UNCLEAN and within
30 min of the controller start, otherwise 90 s. The fact is read from the agent's local API
(GET /host/crash-guard, agentapi.Client.CrashGuard). UNKNOWN - no agent, an older agent's 404, no
crash-guard state - is a normal boot. The decision is logged once ("boot grace ...: ... (R-856)").

NEEDS AN AGENT CHANGE to take effect: GET /host/crash-guard serving the guard's state.json fields
(present, last_boot_at, last_boot_unclean, tripped). Until then every box keeps 90 s.

Tests: TestR856_CrashBootHoldsTheMailsForTheLongGrace, TestR856_NormalBootKeeps90s,
TestR856_FactReadLateInTheNormalGraceStillCounts, TestR856_AgentProbeReadsTheCrashGuardState,
TestR856_CrashGuardDecodesAndAnOlderAgentIs404, TestR856_DeadAppCheckWaitsOnTheCrashAwareGrace,
TestR856_NormalGraceIsTheDeadAppBootGrace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 11:39:25 +02:00
admin 2d63714eca R-645: the night backup skips an app whose pinned version is not the one it runs (09 decision 142)
Every night leg (DB dump, volume dump, recovery-unit capture, Tier-2 mirror) now leaves alone an app
whose app.yaml pin (pinned_images) differs from its running record (installed_images) - the state a
failed update leaves behind. A hold lifted by hand (--clear-restore-hold + restart) no longer lets the
capture write the just-failed definition over the good unit. Unknown (no pin, no record, a service not
observed) never skips. The log says it per leg; the backups page shows one amber line, hu + en
(backup.status.version_skip). Seam: backup.Manager.SetVersionCheck <- stacks.Manager.PinNotRunning.

Tests: TestR645_HandLiftedHoldKeepsTheGoodUnit (whole night run + Tier 2, unit tree fingerprint),
TestR645_VersionSkipSentence, TestR645_PinNotRunning_*, TestR645_BackupRowSaysTheNightBackupSkipsIt,
TestR645_VersionCheckIsWiredAtStartup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 11:39:25 +02:00
admin 13bda270c3 REPORT: the morning after (2026-10-06)
gates / gates (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 08:50:42 +02:00
admin ff4a99a0ab v0.299.0: R-889 (df's disk percent), R-585, R-621, R-516 (MinAgent 0.131.0)
gates / gates (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 08:03:46 +02:00
admin bee2c2d815 R-889: every disk percent is df's (used / (used + available)); the tile says Rendszer, not Rendszer (/) (09 decision 138)
The box divided used by the whole filesystem, so the 5 % reserved for root made a full disk read ~5 points low
(61.8 of 68.7 GB = 90 % where df said 95 %) and the fill alarms fire late. One function, DFUsedPercent, now serves
GetDiskUsage, readDiskUsage, the recovery-unit headroom projection and the deploy page's free percent. Tests use
numbers measured on demo-hp 9201 (/mnt/sys_drive: old 21.5 %, df 23 %); a source scan refuses a percent divided by
the whole filesystem (red-proved by putting the old line back). The label measured the docker data volume, not /.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 08:03:21 +02:00
admin c67b26be28 CHANGELOG: unreleased — R-516 the bundle's last formal forms
gates / gates (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:44:42 +02:00
admin 4c3c2037fd R-516: the formal forms the gate did not count are in the te-form; the gate's stems widened
49 Hungarian values on the debug (RESET prompt), storage, network-storage, security, system,
deploy, restore and remote-backup copy moved from the formal („ön") to the product's te-form
(„írd be", „add meg", „hozz létre", „biztosan eltávolítod", „engedélyezd", „próbáld", ...).
i18n_missing_gate.py's stem list widened by 33 stems (+ one listed third-person exception,
„sora adja meg"); it counted 61 on the previous bundle and counts 0 now, ceiling stays 0.
Decoys: a widened-stem form convicts, its te-form twin and the third-person phrase pass.
38 parity fixtures changed by exactly those bytes. Seven Go-side keys listed as REWORDED in the
go-parity map. Pinned by TestR516_WidenedFormalFormsAreGone; two tests that quoted the old
words follow them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:37:34 +02:00
admin 2fc314d491 REPORT: the burn-down night (2026-10-06)
gates / gates (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:13:03 +02:00
admin 0a960ba58d CHANGELOG: unreleased — burn-down night ctrl-d lane (R-585, R-621, R-516, R-426 controller decoys)
gates / gates (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:08:50 +02:00
admin 7044b11804 R-426: decoys for docker-v and the shared reuse-refs, instructions and observations gates
docker-v: an unallowlisted `-v /etc:/x` in a NEW .go file two directories down
under internal/ and under cmd/ convicts; controls: the clean tree and the same
line in a _test.go pass. The three shared felhom.eu scripts run against a
scratch clone of THIS repo in a scratch workspace (siblings symlinked), the
felhom-agent b78a0ff pattern: a missing cited .go/.md path, a version literal
in CLAUDE.md effective text and R-419's prose-only Observations note convict;
the real files, the version inside an HTML comment and both genuine markers
pass. DECOY_SHARED_DIR judges a mutated copy for the red-proof. All four in
COVERS, so their EXEMPT entries in decoy_coverage_gate.py can go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:13 +02:00
admin 2e5ef7e410 R-516 item 12: the last eleven formal forms in the dashboard copy use the te-form
launcher, layout remove-dialog, deploy, debug, import, remote-backup, shared
backups, system settings and the password-changed flash. Formal-form ceiling
13 -> 0 (the ratchet now refuses any new one the gate's stems see). 119 parity
fixtures changed by exactly those bytes. flash.login.password_changed listed
as REWORDED in the go-parity map; pinned by TestR516_FormalFormsAreGone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:12 +02:00
admin e4774e6a06 R-516 items 7-10: one banner per drive, local times, te-form, health banners in the household's language
- item 7: the storage page shows the disconnect time in local time (fmtTimeStr), not the raw RFC3339 UTC.
- item 8: a disconnected drive no longer has two banners - the health check's warning for it is dropped
  when the dedicated alert.storage.disconnected banner was built (it stays on the wire).
- item 9: alert.deadapp.group says "nezd meg" (te-form); formal ceiling 14 -> 13.
- item 10: the disk/memory/CPU/temperature health banners show the dashboard's own sentence
  (health.* keys, hu + en) via HealthReport.WarningMsgs/IssueMsgs; the wire text is unchanged.
  checkResources split out of RunHealthCheck as the test seam.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:12 +02:00
admin 0f2eab7a3e R-621: the hold panel points at the log the hold kept
A held app's page now says, inside the hold panel, that the app's own log from
before it was stopped was kept, with a Napló link to the logs page that serves
it (0b93e1a). Shown only for an update hold with a non-empty kept log
(Manager.HoldLogKept). New copy through the bundle (hu + en), parity case
app_info_hold_log captured when born.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:12 +02:00
admin ff1758a21c R-585: the last customer-facing producers follow the household's language
backup_integrity_ok / backup_integrity_failed now take facts and push bundle
keys (Hungarian bytes unchanged - go-parity, pinned verbatim by
TestR585_IntegrityHungarianIsUnchanged). The interrupted-operation alert
(backup_failed, customer-enabled by default) used to send the operator's
ENGLISH sentence to every household; NotifyInterruptedOperation composes it
per language, the English byte-identical to the operator's log line. The
now-callerless NotifyBackupFailed is removed. local_api_endpoint_drift is
operator-only (no customer toggle) and already English by design - not
changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:12 +02:00
admin 84d6ef706d v0.298.0: the burn-down night (MinAgent 0.131.0)
gates / gates (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 00:30:36 +02:00
admin ef199c5705 CHANGELOG: unreleased — burn-down night ctrl-a lane (R-126, R-270, R-616, R-498, R-682, R-757, R-522, R-575, R-585 part, R-516 part, R-718)
gates / gates (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:20:21 +02:00
admin c4f4019721 R-718: the close-sign-up and setup-gate cards say the app restarts once
Where the app has its own switch (after_setup.env), both presses restart
it; the cards now say so (app_info.restarts_once, hu+en). Two parity
cases born with the line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:13:00 +02:00
admin d7efa7b54e R-516 items 1+4: the menu says Hibakeresés; the storage pages use the te-form
Item 1: the always-visible 'Debug' menu item reads 'Hibakeresés' in
Hungarian (key layout.hibakereses; English stays 'Debug'). Item 4: four
formal forms on the storage pages (Adjon hozzá, Csatlakoztasson,
Válassza ki x2, Önnek) are now te-form. The parity fixtures change by
exactly these bytes (a deliberate copy change, not a regeneration);
HU_FORMAL_CEILING 17 -> 14. Item 2 was already fixed (slice 1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:13:00 +02:00
admin ca89e70d53 R-585 (part): three producers follow the household's language
db_dump_failed, the off-box backup_failed and offbox_enlarge_blocked
(whose sentence IS the household's mail - no hub customerMessages entry)
now take facts and push a bundle key, so an English household reads
English. Hungarian bytes unchanged (go-parity). Added to
convertedProducers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin b05be0ac11 R-575: the overcommit warning follows the reader's language
memoryVerdict returns the warning's bundle KEY (KeyMemoryOvercommitWarning)
and DeployStack hands it on; the API renders it per request
(deployWarningData). msgHU, whose only use this was, is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin b36a85ac04 R-522: a disconnected tunnel is told on the dashboard row
Since v0.292.0 (R-841) cloudflared's health check asks /ready, so a lost
connection reads unhealthy; the cloudflared row now also carries the tag
"Kivulrol nem erheto el" with a plain title, in hu+en. New parity case
dashboard_tunnel_offline (born as a key).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 1991c742a3 R-757: a secret only after_install reads is not invented for an installed app
InjectMissingFields skips a new generated secret that is in after_install's
env list and named by no compose definition: after_install does not run
again, so the app never received the value and the reveal would answer
a login the app does not have (calibre-web ADMIN_USER, 2026-10-01).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 4347983a72 R-682: a Remove cut off by a controller restart is finished at boot
RemoveStack journals itself before compose down and clears on every
return; at start a found journal finishes the remove through the same
RemoveStack (once, before the boot reconciler), keeping drive data and
backups even if the household had asked to delete them (no unattended
deletion at boot; logged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 05a447ef02 R-498: the app page's first steps name the household's address, not wiki.DOMAIN
AppInfo.WithAddress fills the catalog placeholder (<label>.DOMAIN -> the
effective subdomain + customer domain, bare DOMAIN -> the domain) on a
copy; appDetailHandler applies it. Pinned per catalog template (both
languages) and on the rendered page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 28a5203d01 R-616 (on top of R-615): the catalog clone stores no credentials
Replaces 365eff6 for main, which now carries R-615 (0b349e2). The plain
URL is cloned; the Basic credentials ride per git command as
http.<origin>.extraHeader via GIT_CONFIG_COUNT. R-615's origin compare
now compares credential-free forms against the configured URL, so a set
token never re-clones; a stored origin with user:token@ is rewritten
without it. Pinned by TestR616_* incl. TestR616_TokenSetSameRepoNoRecloneOriginClean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 6fac245d24 R-270: the local_api drift detector also names a TOKEN-only divergence
A rotation that reached bootstrap.json but not controller.yaml left the
agent channel at 401 across restarts while the endpoint-only detector
stayed silent. The tokens are now compared (constant time, boolean only);
the token case gets its own banner key and operator message. Still
detection only - nothing is reconciled (R-78).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 33c397380e R-126: refuse a .fab export without a password to a network drive (09 decision 128)
An export to a registered network drive (Kind=network) with no bundle
password now answers 400 with a household sentence (hu+en); with a
password it runs; a local drive is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 034a2b7363 CHANGELOG: unreleased — burn-down night ctrl-c lane (R-578, R-569, R-492, R-576, R-574, R-488, R-325)
gates / gates (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:30:45 +02:00
admin daeed27223 R-325 (controller half): retrieval_promise_gate imports the shared stem list
STEMS now comes from felhom.eu/scripts/customer_copy_vocab.py (RETRIEVAL_STEMS), read the
way controller_gates.py reads the other shared checkers (CI clones felhom.eu beside this repo);
an absent or empty vocabulary is INCONCLUSIVE (exit 2), never a pass. Decoys: vocabulary
absent -> rc 2; an invented stem in a doctored shared list convicts a template using it.
hub_copy_gate.check_drift on this file now reads 'ok — single source'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 8ce496f124 R-488: backup tests read ONE health-wait clock; package 444 s -> ~2 s
waitForHealthy's 3 s settle / 5 s poll / 90 s timeout become package vars (production values
unchanged, pinned by TestR488_HealthWaitDefaultsAreProduction); TestMain shortens them once.
Measured before: 51 of 599 tests held 442 of 444 s, two not-running fixtures 279 s alone.
newOffboxManager now installs a failing SSH fake: TestOffbox_ConfirmedReset was spawning a
real 'ssh felhom@nas.local' and waiting out its 10 s ConnectTimeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 391967bcf1 R-574: handler_debug.go literals classified; the 38 page-copy ones move to the bundle
39 Hungarian literals: 38 are debug-page copy (every writeDebugJSON message/error, rendered
into the result line by debug.html, plus the pre-0.83 agent notice rendered in the log
viewer) -> 30 debug.api.* keys (hu byte-equal, parity gate green; en added). One is PAYLOAD:
the 'Teszt esemény' text sent to the hub as the test event's message stays a literal. The
diagnostic dump carries no Hungarian. TestR574_DebugPageCopyFollowsTheLanguage pins hu bytes
and en rendering.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 074ddda352 R-576: go-parity gate also judges the CALL — arity vs printf verbs, and no key glued with +
Checks 4 (ARITY) and 5 (NO-CONCAT) in i18n_go_parity.py: at every message-helper call
(MsgError/MsgErrorf/Text/Msgf/msg/msgLang/msgHU/note) whose key is a literal hu.json knows,
the argument count must equal the Hungarian value's printf verbs; a known key literal may
never be an operand of +. 485 calls judged, 0 defects today. Decoys: a dropped argument and
a key+suffix concatenation on a real producer convict; a nested-paren argument is accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 7b8216abac R-492: delete the always-empty cfg.Paths.HDDPath (field, env binding, every fallback)
Readers (report builder, health check, /api/system, web primaryHDDPath, metrics collector,
AutoDiscoverStoragePaths' fallback parameter) now use the storage registry only. An old
controller.yaml still carrying paths.hdd_path keeps loading (non-strict YAML), pinned by
TestR492_OldConfigWithHDDPathStillLoads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:22 +02:00
admin d15ad105be R-569: stack-lifecycle handlers pick their status by error KIND, not English words
stop/start/restart/update, remove and delete matched "protected", "not found", "not deployed",
"still running", "not orphaned" in err.Error(). New sentinels in internal/stacks
(stack_errors.go) carried by the producers in manager.go/delete.go via util.KindErrorf (message
bytes unchanged); api.stackOpStatusFor maps them. Tests: reworded-message table per family,
wiring check over all three handlers, producers keep kind + words.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:22 +02:00
admin 9271f33359 R-578: settings-callback deadlock gate over every controller package
TestR578NothingTakesTheSettingsLockInsideASettingsCallback (internal/settings) derives from
source the *Settings methods that take s.mu, the callback runners among them, and per package
every helper that transitively reaches one; a call to any of those inside a func literal
passed to a runner (or a non-literal callback) fails with file:line. Decoy:
TestR578GateConvictsAHelperChain. REUSE.md lookalike row added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:22 +02:00
admin f7c39c1700 CHANGELOG: unreleased — burn-down night ctrl-b lane (R-729/R-545, R-621, R-839, R-271, R-521, R-607/R-615, R-25)
gates / gates (push) Successful in 51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:03:52 +02:00
admin 1355391c78 R-25 (controller half): drive init mounts the filesystem UUID the agent verified
agentapi FormatResult / FormatStatusResult gain fs_uuid (agent R-25 half). runStorageInit mounts
it — from the synchronous answer or the polled status — instead of re-resolving the UUID from the
/dev path, so a node that moved between format and mount cannot redirect the mount. An agent that
sends no field keeps the old resolve, logged as a WARN each time (the window is open then).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 0b349e2415 R-607 + R-615: the catalog sync follows a changed repo_url and rescans when the catalog moved
R-615: a changed git.repo_url was inert (the clone's stored origin was fetched for ever). The sync
now compares the configured URL with the clone's origin: another repository -> drop the cache and
clone it; the same repository with new credentials -> set-url.
R-607: a catalog move that changed no stack directory (a deployed, pinned app keeps its stored
definition) skipped the rescan, so CatalogImages and the update badge stayed stale, and the sync
said 'nincs változás'. The sync now notes the cache commit before and after, rescans when it moved,
says so, and returns catalog_moved.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 800b32ceec R-521: an app a lost drive stopped no longer mails app_start_failed one by one
One unplugged drive sent storage_disconnected plus an app_start_failed per app on it (five operator
mails). The dead-app job now hands the notifier the apps of a disconnected drive (its recorded
StoppedStacks plus apps whose HDD_PATH is that drive) as not down; the storage event already names
them. The dashboard's dead list is unchanged. The hub-side cooldown (F6/F7) and a household mail
for a lost drive are not in this change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin f885100d29 R-271: the channel recovery after a controller restart is reported
The agent_channel_unauthorized alert's remedy is a re-bootstrap (a restart), and an unseeded->up
first observation was silent, so following the instruction guaranteed no recovery event. A down
alert now leaves a marker in the data dir; the first UP after a restart sends the recovery and
clears it. A restart with no alert outstanding stays silent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 4867ec1804 R-839: the deploy API refuses an HDD_PATH that is a folder inside a registered drive
Diagnosed: 9202's paperless-ngx app.yaml named <drive>/userdata/paperless-ngx, written by the
2026-09-22 drill harness through POST /api/stacks/<n>/deploy, which accepted it (RefuseAsAppNamespace
matches by prefix). The boot sweep then asked a folder whether it was a mountpoint and HELD the app.
The deploy page offers drive roots only, so a household could not reach this; every other caller
is now refused with a sentence naming the drive (settings.DriveOfSubPath).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 0b93e1ad1e R-621: a held app's logs show the log the hold kept
GetLogs (logs page, /api/stacks/<n>/logs, the operator's remote diagnostics) returned "" for a held
app — no containers — while captureHoldLogs had kept the app's own log under
hold-logs/<ts>/compose-logs.txt. It now serves the newest non-empty kept log, under a header naming
the file, when the live log is empty; a running app's own output always wins. GetLogs gains a test
seam (logsComposeFn) so the test never reaches docker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 27b373b93c R-729 + R-545: the household can remove its own off-site target („Távoli mentési cél törlése")
POST /backup/offbox/clear (reveal-then-confirm) forgets the target and deletes ssh_key + known_hosts;
nothing on the target is touched. repo_password is kept whenever anything could depend on it (hub
sealed package — the R-241 rule; escrowed; a successful run; snapshots) and deleted only otherwise.
Refused for the Felhom tier (rclone-pinned), while the single-flight is held, and while an
abandonment countdown runs. i18n parity fixtures of backups_remote gain the additive block only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 6f1ba1fe43 gofmt gate: NOT CHECKED (out loud) on the Go-less CI runner, INCONCLUSIVE elsewhere; decoys for both (CI run 1371 was red)
gates / gates (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 20:15:54 +02:00
admin 1453cfc69b v0.297.0: burn-down round 2 — 24 small rows (R-591 R-568 R-567 R-363 R-547 R-10 R-552 R-251 R-104 R-619 R-362 R-675 R-256 R-257 R-240 R-365 R-425 R-565 R-564 R-603 R-454 R-208 R-457-swept) + the banner countdown and deepCopyStack twins; MinAgent 0.131.0
gates / gates (push) Failing after 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 20:12:56 +02:00
admin 114ff2761a R-263 writer invariant pinned by an AST test; R-368 IsDefault comment exact (no binary change; burn-down)
gates / gates (push) Successful in 32s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 17:05:24 +02:00
admin e563733282 CHANGELOG: R-818 correction notes under v0.224.0 and v0.225.0 (mislabelled R-330/R-331)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 16:53:22 +02:00
admin df85a07c4c the size rule: a small finding is fixed in the session, not filed; reports state four register numbers (burn-down Part D)
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 16:43:23 +02:00
admin 7690c27f86 controller v0.296.0 REPORT
gates / gates (push) Successful in 32s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 13:00:18 +02:00
admin ff69074514 controller v0.296.0: a backup run cut off by a power cut or restart is said on the backup pages (R-519); the whole-system backup text states today's measurement (R-518)
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 12:14:54 +02:00
admin 477e2548db REPORT: the 2026-10-05 catch-up session
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 10:29:08 +02:00
admin 635c33d381 v0.295.0: a box that was off at its backup time catches up once (R-871, decision 109); the missed-backup banner (decision 110); a late daily timer after a host suspend is skipped
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 09:27:16 +02:00
admin e730a629fd REPORT: the 2026-10-05 night-fixes session
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 08:25:44 +02:00
admin 7861bf9dde v0.294.0: off-site clean-up guard follows the policy's own constants (R-867); no image clean-up while compose pulls (R-863); stderr tail (R-864); move-aside destination logged (R-869)
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 07:13:02 +02:00
admin 69e914534f REPORT + CONTEXT: 2026-10-04 night (R-840 / R-860)
gates / gates (push) Successful in 33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 20:39:49 +02:00
admin 2a7f6c6c42 v0.293.0: the controller heals itself after the guest's Docker socket is re-created (R-860)
gates / gates (push) Successful in 29s
internal/sockheal: 60 s of refusals (never a timeout, only after Docker answered once) → exit 75 so
Docker's restart policy brings the controller back on the current socket; every 5 min it restarts any
other socket user (traefik) holding an older inode. Measured on 9202: only a docker.socket restart
re-creates the file; dockerd crash / docker.service restart keep it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 19:48:29 +02:00
admin 09e634de31 v0.292.0: cloudflared readiness health check (R-841) — tunnel --metrics localhost:20241 run + Docker healthcheck on cloudflared's own /ready; the host agent reports running/not_running/unknown from State.Health
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 13:16:26 +02:00
admin badc9c2a0c CHANGELOG + comment: correct the file-browser claim (the start-up mount sync already moved it); 9202 gaps measured
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 11:00:54 +02:00
admin fc796dd95e v0.291.0: decision 78 (R-726) — a returning household's first night sets the orphaned copy aside; R-838 — traefik v3.7.13, cloudflared 2026.9.3, filebrowser 1.5.6-stable, a release now moves a running file browser, check-infra-pins.py
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 10:56:19 +02:00
admin 99a1497560 CONTEXT + REPORT: v0.290.0 (floored, golden 0.290.0 vouched)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 07:39:45 +02:00
admin c1a73b24b3 v0.290.0: the clean-up guard skips same-day superseded young snapshots instead of refusing (R-824), refuses above the weekly cap; a due set-aside deletion is handed to the hub's 7-day wait (decision 74, R-823)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 07:27:38 +02:00
admin c4bf730637 CONTEXT + REPORT: v0.289.0/v0.289.1 off-site lock (floored 0.289.1, golden 0.289.1 vouched)
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-03 21:29:10 +02:00
admin 2a2af2018d v0.289.1: strip the provider rclone notice; an unreadable snapshot count is never a measured zero (found live on demo-felhom)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-03 17:15:10 +02:00
admin 55bb6c3d32 v0.289.0: off-site key cannot delete — append-only rclone transport, box sends only its public key (hub registrar), retention only inside a hub window behind the fake-snapshot guard (decisions 68-69, R-820, R-822)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-03 17:01:36 +02:00
admin 09453325d1 v0.288.0: REPORT + CONTEXT (R-800 live on 9202, floored, golden 0.288.0 vouched)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 16:54:36 +02:00
admin 580b656914 v0.288.0: the remove dialog and result name the household's userdata folders, which stay (decision 67, R-800)
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 14:03:44 +02:00
admin 9821690980 v0.287.0: REPORT + CONTEXT (the family gate live on 9202, floored, golden 0.287.0 vouched)
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 09:03:51 +02:00
admin c8d5ee204d v0.287.0: CHANGELOG, README, REUSE (the family gate)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 07:43:49 +02:00
admin 977665d8c0 The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
gates / gates (push) Successful in 27s
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 07:42:47 +02:00
admin a4a753b9e2 CONTEXT + REPORT: v0.286.1 (R-753 visitors apart, R-772, R-773), floored + golden
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 22:06:02 +02:00
admin a28155f858 v0.286.1: CHANGELOG (R-772 order fix found live; 0.286.0 never floored)
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:41:23 +02:00
admin 79ef715277 R-772 (found live on 9202 with v0.286.0): a stopped probe container is seen on the next tick, not when the last healthy record's 5-minute interval runs out
Finding the container costs no network call, so it now runs before the interval check. Red-proof RP-D1b.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:11:22 +02:00
admin d4167420d1 v0.286.0: CHANGELOG + README (R-753 visitors apart, R-772, R-773)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:04:26 +02:00
admin 1e216d3468 R-773: a removed app restored from its backup gets its sign-up lock back (record opened_by restore + block, before anything starts)
gates / gates (push) Successful in 29s
An installed app the household never closed keeps what it had (decision 49). Red-proof RP-D2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:01:42 +02:00
admin b53721db34 R-772: a health probe that could not run records not_checked + NOT healthy, is looked at again on the 10 s cycle, and the app page says so
The state stays the containers' (probeSaysUnhealthy skips a not-checked record), so R-630 holds.
Red-proof RP-D1 — felhom.eu audits/visitors-2026-10-01/D.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:01:42 +02:00
admin 1e8d045815 R-753: the box tells visitors apart — cloudflared at a fixed address, traefik trusts only it, the controller reads the hop traefik saw
- felhom-tunnel network 172.16.253.0/29 (ip-range .4/30): cloudflared alone at .2, traefik at .3; traefik's websecure
  trusts forwarded headers from 172.16.253.2/32 only, and every request passes felhom-forwarded@file, which removes
  the client-writable host/path/address headers (X-Forwarded-Host/-Uri/-Method/-Prefix, Forwarded, True-Client-Ip, …)
  and fixes X-Forwarded-Port to 443 (measured: Cloudflare passes a client's X-Forwarded-Host/-Port).
- EnsureBaseStack reconciles a RUNNING traefik/cloudflared whose rendered files changed (recreate), refuses a rewrite
  that would drop a certificate resolver, and moves cloudflared only once traefik is on the tunnel network.
- clientIP: believed only when the TCP peer is traefik; the rightmost X-Forwarded-For entry (the hop traefik saw);
  the tunnel hop → CF-Connecting-IP (the edge refuses a client-sent one, measured 403). rateKey: IPv6 per /64.
  Dashboard login, claim, share and escrow counters key on it; the setup gate logs it.
- Dashboard login messages: keys, informal voice, both languages.
Red-proofs: RP-A1 (leftmost hop), RP-A2 (shared tunnel key), RP-A3 (no reconcile) — felhom.eu audits/visitors-2026-10-01/A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:01:42 +02:00
admin c1b123c649 REPORT: v0.285.0 — two controller versions per box, R-751, live results
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 08:39:51 +02:00
admin ac8ea72025 v0.285.0 — a box keeps two controller versions (decision 56, R-745); the update clean-up's nil-stack crash (R-751)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 07:36:26 +02:00
admin a70c398dee v0.284.2 — the image clean-up sees digest-pulled (untagged) images (decision 53, R-736)
gates / gates (push) Successful in 26s
Found live on 9202: the product pins tag@digest, and such images are stored untagged (repo:<none>);
`docker image ls` without -a did not list them, so the retention saw almost no app image. Now `image ls -a`;
an anonymous <none>:<none> entry is never a candidate; the one-time marker is v2 so the corrected sweep runs
once everywhere. Test TestImageRetention_SeesUntaggedDigestPulledImages, red-proofed. 0.284.0/0.284.1 were
never floored.

MinAgent: 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 23:03:32 +02:00
admin 00fddd3816 v0.284.1 — the image clean-up runs on the household's Remove button too; one summary line per pass (decision 53, R-736)
gates / gates (push) Successful in 26s
Found live on 9202: v0.284.0 wired the remove half into DeleteStack only; the app page's Remove runs RemoveStack.
Now RemoveStack reads the app's image repositories before its compose down and runs the retention after. Every
retention pass logs one line (images seen, candidates, deleted), so a pass that kept everything is visible.
Tests TestImageRetention_TheRemoveButtonRunsIt / ADoneUpdateRunsItWithThePrevious, red-proofed. v0.284.0 was never
floored (scratch 9202 only).

MinAgent: 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 22:54:15 +02:00
admin 5a3437669f v0.284.0 — a box deletes old app images (decision 53, R-736); an after_install app is held until its known login is replaced (R-741)
gates / gates (push) Successful in 27s
Image retention: after a done/undone guarded Update and at remove, an app's images older than its running
and previous one are deleted — never an image any container, installed compose or installed/previous record
names (box-wide keep set read at delete time); exact id, never forced or pruned; paused while any update runs;
a one-time sweep of catalog app images at the first start. Install hold: an after_install app is installed
behind the setup gate's door and opens when after_install succeeds or the household says it changed the login.
Tests TestImageRetention_* and TestInstallHold_* with red-proofs; parity fixture for the held card.

MinAgent: 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 22:29:33 +02:00
admin d48da6c7f3 CONTEXT + REPORT: v0.283.0/v0.283.1
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 11:00:41 +02:00
admin 29d3c533aa v0.283.1: a Stop holds during the volume dump in production and at the crash recovery (R-721, found live on v0.283.0)
gates / gates (push) Successful in 28s
Red-proofs RP43, RP44. MinAgent 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 10:52:19 +02:00
admin 6be6c53e29 v0.283.0: apps go off-site by themselves (decision 50) with a size warning; a Stop holds during a backup (R-721); page slips (R-724/R-725)
gates / gates (push) Successful in 25s
Red-proofs RP31-RP38. MinAgent 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 10:31:55 +02:00
admin 1cfb1244d6 REPORT: v0.282.0
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 17:19:37 +02:00
admin 4110da50e9 v0.282.0: after_setup (the app's own sign-up switch), close sign-up now (decision 49), probes read lists + done_status (R-715)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 16:19:12 +02:00
admin 6fc3059167 REPORT: v0.281.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 11:24:58 +02:00
admin 149467c795 v0.281.0: 'Done' asks the probe first; sign-up closed after the first admin (decision 47); R-713 code-bound values refused, ${NAME|base64}
gates / gates (push) Successful in 23s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 09:57:58 +02:00
admin 3a12c7341a REPORT: v0.280.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 09:22:53 +02:00
admin 7fa8768cfd v0.280.0: the setup gate (decision 46); R-710 'I changed it' + absent-record window; R-709 password fields off the page; password:N:special generator
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 08:51:46 +02:00
admin 2548b4c924 REPORT: v0.279.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 19:02:26 +02:00
admin 0c702f834a v0.279.0: after_install (decision 45), known default logins on the page, Part D empty-backup alarm, night chain (R-705), R-706
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 18:38:30 +02:00
admin 2e9a948cbd REPORT: v0.277.0 + v0.278.0, live off-site proof
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 15:56:46 +02:00
admin 7cba0bfca7 v0.278.0: a hold left by an earlier install no longer holds the new one (R-704)
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 15:34:57 +02:00
admin 1ee6dbe184 REPORT: v0.277.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 11:51:52 +02:00
admin 6479933e8e v0.277.0: kept data loads from the off-site copy too (R-691 (2))
gates / gates (push) Successful in 25s
Use my kept data / Load consider the off-site snapshot when it is newer than every local copy or
the only one; the unit is downloaded alone, judged (drive, data, recorded data version) and only
then restored. The page names the copy and its date.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 09:57:11 +02:00
admin 0c1023e503 REPORT + CONTEXT: v0.276.0 released, floor 0.276.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-27 18:04:03 +02:00
admin 820e8efde1 v0.276.0: a restore and a drive move keep the app's records (R-697, R-700)
gates / gates (push) Successful in 26s
A drive move persisted through the restore's fresh app.yaml write and dropped the pin: the syncer
then copied the catalog verbatim and the next start jumped the app past its ladder (R-700).
persistDriveFlip now changes HDD_PATH and nothing else. The restore's write carries the life
records (conversion copies, desired_state, update history) from the app.yaml it replaces, and a
second conversion no longer overwrites the first kept copy's record (R-697).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-27 17:59:04 +02:00
admin 54bb4da343 REPORT + CONTEXT: v0.275.0 released, floor 0.275.0
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-27 13:02:06 +02:00
admin 7fcda8f1a4 R-699: a unit that holds no data is never an update-precondition copy (found live on 9202)
gates / gates (push) Successful in 25s
A just-installed app's unit, captured by the status refresh before any backup, satisfied
the precondition on its manifest time; tandoor's PostgreSQL was converted with no backup
of its database. Listed still; never a copy on Tier 1 or Tier 2. Red-proof RP6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-27 12:04:03 +02:00
admin b6810f14ff v0.275.0: a backup's data and its version travel together (R-696, 07 §6.6, D4 option A); R-695, R-691, R-694
gates / gates (push) Successful in 23s
The unit's data files are stamped with the versions that wrote them; the capture keeps the
definition the data belongs to; a restore never starts data under another version's
definition (unit restores refuse a mismatch; the off-site restore writes the snapshot's
definition); every tier's time is its data's; the conversion-copy release needs a dump on
the new engine. File-browser sync single-flight + no empty kept folder (R-695); the kept
view joins the folder's owning group, language switch resyncs (R-691); a restore-generated
login is not shown as the password (R-694). Red-proofs in
felhom.eu/documentation/audits/version-travel-2026-09-26/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-26 10:35:22 +02:00
admin fb2bcdd5d6 REPORT: v0.273.0 + v0.274.0
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 14:39:12 +02:00
admin 64cbefe317 CHANGELOG: v0.274.0
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 14:28:57 +02:00
admin 064f23896a kept data: the list names a leftover by the app that binds it through HDD_PATH, never the file browser (R-692, found live on 9202)
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:51:26 +02:00
admin 43e99d160c kept data: the choice at reinstall, the list, the read-only view, the load (09 decision 36); R-690 fixed
gates / gates (push) Successful in 26s
An install over an app's kept drive folder (appdata/<app> non-empty) asks the household:
"use my kept data" (a load from the newest copy of THIS drive's install, own unit or
second-drive mirror, then the template's after_load) or "start fresh" (the folder is
renamed into <drive>/kept/<app>/<date>/ with the removed app's unit; nothing deleted).
The install API answers 409 kept_data_choice until one is chosen; DeployStack refuses
too. New page Megorzott adatok / Kept data (/kept-data): Load / Look / Delete (typed
confirmation, the only deletion of kept data). FileBrowser gets a read-only source.
The drive-full warning names the kept folders. <drive>/kept is protected and outside
every backup leg.

R-690: the removed-app restore (R-487) never found a unit on a DATA drive — it asked
GetStackComposePath (true for every catalog app) and restored nextcloud with no env.
Now isStackDeployed; pinned with a production-shaped provider.

Red-proofs: audits/night-2026-09-26/E/redproofs/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:32:30 +02:00
admin 5a731b45b9 CHANGELOG: v0.273.0
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:30:50 +02:00
admin 7162f489c9 README + REUSE: the PostgreSQL conversion (v0.273.0)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:30:08 +02:00
admin 1c5dd07604 stacks: R-687 — an empty leg reports steps [], and a taken files_may_change step names its whole copy
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:24:19 +02:00
admin b6399fdf0b stacks: the recovery line names CONVERTING, not UNDOING, for a restart during the conversion (found live on 9202)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:18:56 +02:00
admin 2caae38a71 stacks: the box converts a PostgreSQL major as a guarded-update step (09 6.4 part 10, decisions 35/37/38)
gates / gates (push) Successful in 25s
A step whose ladder entry carries engine_conversion {service, engine, from, to}
converts the database: the old engine alone, the check (owners, roles,
extensions, per-table row counts), pg_dumpall validated by its completion line,
the volume emptied only after the undo copy's marker is validated again, the new
engine alone, the load with ON_ERROR_STOP, the check again + PG_VERSION. Any
failure goes to the existing undo; a restart during converting is undone.
A PostgreSQL major move without the mark is refused before anything moves.
The old datadir's copy is kept until a backup is proven after the conversion.
17 tests, 9 red-proofs (audits/night-2026-09-26/B/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 12:57:03 +02:00
admin 3d49df1e5a REPORT + CONTEXT: v0.272.0, floor 0.272.0
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 11:33:46 +02:00
admin 44ae4dea70 controller v0.272.0: the backup page says when a whole-box backup does not fit (R-685); R-671, R-670, R-677
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 11:19:27 +02:00
admin 71accca427 rules: Peti's box retired 2026-09-25 — the fence is DooPlex and ep0
gates / gates (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 11:04:55 +02:00
admin cdfc817d5e REPORT + CONTEXT: v0.271.0 (automatic app updates), floor 0.271.0, the demo boxes' first real night
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 04:55:09 +02:00
admin 9cf13a3add controller v0.271.0: automatic app updates — the update leg after the off-site copy, the backup gate waits, the switch (09 6.4 part 7; R-680, R-678, R-643)
gates / gates (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 22:07:24 +02:00
admin 1495ca43fb REPORT + CONTEXT: v0.270.0 (floor 0.270.0, both demo boxes arrived)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 17:04:48 +02:00
admin 504eae018b v0.270.0: no update for a current app (R-679); an interrupted install is reported (R-681); a restore brings back the pinned version's health check (R-669); R-674
gates / gates (push) Successful in 24s
Five red-proofs. MinAgent 0.131.0 unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 16:49:43 +02:00
admin 7c6140d95b REPORT + CONTEXT: v0.269.0/v0.269.1 night (floor 0.269.1)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 14:53:45 +02:00
admin 5b1b191ffc v0.269.1: an installed app keeps the image digest it runs until a guarded Update moves it
gates / gates (push) Successful in 26s
Found live on 9202 (night 2026-09-24 Part B): the sync rendered the ladder's newest
tested digest into a RUNNING app's compose, so the next restart would pull a new image
with no backup and no undo. stacks.CarryDigests keeps the running digest for an
installed app; a fresh install still takes the tested digest. Red-proofed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 13:01:31 +02:00
admin 3c6b49b31c controller v0.269.0: whole restore from the second drive; crash loops stopped; exact image digests; steps judged by their own .felhom.yml (decisions 26-28, R-661 R-666 R-667 R-668 R-664 R-665 R-662, 09 6.4 part 6)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 12:18:39 +02:00
admin 7c3b3a9694 REPORT + CONTEXT: v0.268.0 (2026-09-24)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 08:52:55 +02:00
admin 206b0357d1 controller v0.268.0: the undo finds volumes by definition; a held app names only a whole copy; one press = one tested step (R-658, R-659, R-660, R-651; 09 §6.4 part 5)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 08:15:27 +02:00
admin 80e6ad8c47 controller v0.267.0: tests off DooPlex's Docker, cut-off copies refused, two pages true
gates / gates (push) Successful in 26s
R-650: internal/dockerexec — every docker exec routed through it; under
go test a real docker is refused (opt-in FELHOM_TEST_REAL_DOCKER=1; a stub
under the temp dir is allowed). api/stacks/web tests run under a silent
stub (TestMain). TestR650_NoBareDockerExec pins it repo-wide.
R-640: a dump without its engine's completion marker is refused before
the first mutation (unit + off-site restore) and again before any load.
R-499: the Tier-2 page's system-disk sentence has four true branches.
R-518: the backup button states the measured ~8 min stop.
R-626: measured on 9202, not reproduced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 20:25:28 +02:00
admin 15e630aa02 REPORT: v0.266.0 addendum (R-649)
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 18:14:42 +02:00
admin 964ae7538f controller v0.266.0: a failed install removes what it started (R-649, operator ruling)
gates / gates (push) Successful in 27s
compose down (volumes kept) before the record reads not deployed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 18:06:29 +02:00
admin 411a43f8ef REPORT + CONTEXT: v0.265.0 live proof and floor raise
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 17:39:48 +02:00
admin 0054d4bd69 controller v0.265.0: R-634 cause fixed, held apps say so, OOM storm alarm, R-647 leftovers
gates / gates (push) Successful in 27s
R-634: a whole-box backup no longer stops/restarts a DEPLOYING app (the
measured cause of containers running under 'not deployed'); StopStack
and StartStack refuse a deploying stack for every caller.
R-625: held badge 'Stopped - restore needed', no Update button.
R-636: kernel oom_kill counter; 20+ in 30 min -> one app_oom_storm.
R-647: held error per reader, copy_holds key, two log wordings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 17:16:57 +02:00
admin 0a3026180a REPORT + CONTEXT: v0.264.0 live proof and floor raise
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 14:39:12 +02:00
admin bc278944a3 controller v0.264.0: the household is told when an update is undone or held, in its language
gates / gates (push) Successful in 25s
app_update_undone / app_update_held events (09 decision 15), on by
default and seeded once on existing boxes; R-606 update sentences as
key+args rendered per reader; R-646 startup applied-meta backfill for
apps current with the catalog; R-620 a disabled notifier WARNs once per
event type. Needs hub v0.120.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 13:51:21 +02:00
admin c3a2aba0d2 docs: CONTEXT and REPORT for v0.263.0-v0.263.2 (the undo)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 12:25:44 +02:00
admin 2cd66663f3 v0.263.2: the undo keeps the probe of the pinned version (R-637)
gates / gates (push) Successful in 25s
Found live on 9202 (romm): .felhom.yml flows into the stack dir on every
catalog sync, so "the old .felhom.yml" saved at update time was already the
new one, and the serving old version was judged with the new probe.

New record applied-meta/.felhom.yml, written whenever a version is pinned
(deploy, adoption, pin advance) and put back by the undo, like
applied-compose.yml. The fixture now places the new file at sync time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 11:53:53 +02:00
admin 5d38573a1a v0.263.1: the undo asks the old probe even on an app marked unhealthy (R-637)
gates / gates (push) Successful in 27s
Found live on 9202: the periodic probe (current .felhom.yml, new port) flips
the app to unhealthy, and the update's health wait probed only 'running'
apps - so the undo's old probe was never asked and a serving old version was
judged "did not start". With the undo's override, an unhealthy app is probed
and the old check decides; never settled on container state.

New seam probeRunFn; the test drives the real wait loop and reproduces the
live message when the fix is switched off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 11:35:25 +02:00
admin 8fc2b4a1a9 v0.263.0: a failed update puts the app back by itself (09 decision 15, R-637)
gates / gates (push) Successful in 26s
The guarded update gains a folder copy of the app's named volumes, taken
after the pull where the app stops anyway (decision 19, chosen by the
2026-09-23 bake-off). On a failed health check the box undoes: every copy
validated by its finished-marker first, volumes refilled, definition and pin
from the job's own pre-update copies, the old version checked with the OLD
.felhom.yml probe. It holds only if the undo fails, and the hold sentence
says so and what state the data is in. Bind-mounted folders are never
touched.

- R-637 built; R-638/R-640/R-641 do not arise with a folder copy; R-639
  (pre-update copies incl. .felhom.yml kept until the undo is over).
- journal phases copying/undoing with power-cut recovery.
- app.yaml last_update_undone + one line on the app page (hu/en).
- R-642: start/restart never answer "completed".
- Removal deletes kept undo copies.

MinAgent unchanged (0.131.0). Nine red-proofs in REPORT.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 11:12:49 +02:00
admin b9deec1907 REPORT for v0.262.0 + v0.262.1
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-22 21:49:20 +02:00
admin 3d41758be0 v0.262.1: the busy refusal is a CONFLICT, not a server error (R-633)
gates / gates (push) Successful in 24s
Caught by the live proof on 9202, not by a test. The v0.262.0 guard fired exactly right and
answered HTTP 500: router.go maps remove errors by grepping the error TEXT for "not deployed" /
"still running" / "not found" / "protected", and the busy sentence contains none of them.

A 500 tells the UI something broke; this is "wait a moment". Now a typed *stacks.RemoveBusyError
matched with errors.As and answered 409, carrying both the Hungarian bytes and the bundle key.

Its test asserts the sentence contains none of the words the text mapping greps for, so the type is
load-bearing rather than decorative. Red-proof seen failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-22 21:48:01 +02:00
admin b793638484 v0.262.0: six defects two drill nights found in the update, remove and hold paths
gates / gates (push) Successful in 26s
R-630 (P1): waitUpdateHealthy kept the probe inside `if hc != nil && len(hc.Checks) > 0`, and when
findProbeContainer returned "" its else set last="no probe container" and LOOPED - the settle path
sat in the outer else, unreachable. So verifying could only time out and failAndHold then stopped a
working app. Measured on paperless-ngx: three containers healthy, failed at +313.0s, front door 404
after. It now falls through to the same settle path with a WARN naming the candidates.

The probe target is decidable now: HealthCheckConfig.Container plus findProbeContainerMeta resolve
by exact stack name -> explicit container -> a UNIQUE prefix -> nothing with the candidates
returned. The old rule took the FIRST prefix match. A skipped stack records why instead of silence.

R-634 (half): RemoveStack refused on the !Deployed FLAG while the machine had containers, a compose
file and an app.yaml. It now asks whether anything EXISTS. The mechanism producing the bad record is
still not diagnosed and R-634 stays open for it.

R-633/R-626: RemoveStack consults UpdateGuards.Busy and IsUpdating and refuses with the app's own
sentence - the product already refused this clash for update and for restore. And because `down`
returning 0 is a request not a result, the project is watched for 25s afterwards, anything carrying
its label is removed by name with its labels logged, and the answer carries `verified`.

R-621: failAndHold writes compose logs --tail 400 into <stackdir>/hold-logs/<ts>/ BEFORE the down
that destroys them. Two existing tests pin the compose sequence and correctly caught the new step;
their expectations are updated with the reason that the ORDER is the assertion.

R-614: RemoveStack calls ClearUpdateState.

NOT in this release: R-625 (a held app still renders an Update button). Named, not half-done.

Three new sentences, each born as a key in both bundles. Four red-proofs seen failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-22 20:46:50 +02:00
admin 93cee16843 CONTEXT + REPORT for v0.261.0 (R-608, R-609)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 15:00:29 +02:00
admin 811f75736e v0.261.0 — the controller no longer swaps itself out from under an app update (R-608, R-609)
gates / gates (push) Successful in 23s
The controller self-updates daily at 04:30 by default, and after any hub report
once a floor sits above the box. That swap restarts the controller container.
The window proposed for automatic app updates is 02:30-05:00. It contains 04:30.

R-608 — a two-way lock, wired in main.go (stacks never imports selfupdate):
- stacks.Manager.AnyUpdating() -> Updater.SetAppUpdatingCheck, consulted in the
  same three places as the existing backupRunning gate.
- Updater.IsUpdateRunning -> Manager.SetSelfUpdatingCheck; UpdatePreflight
  refuses `self_updating`.
- MEASURED: the gap was narrower than assumed. The update's `backing-up` phase
  already takes the backup single-flight, so that one phase was covered. The
  other six were not, and `starting`/`verifying` are where data may have moved.
- The lock must NOT latch: a held app does not block the controller's own
  updates, including the release that might fix the hold.

R-609 — the 409 carries `data.reason`, additively. transient (busy, updating,
deploying, migrating, self_updating) vs terminal (held, downgrade). Found while
writing the test: the router refuses a HELD app on its own line before the
preflight, so `held` would have been the one reason missing.

Five red-proofs, each seen to fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 14:23:59 +02:00
admin d0d431b42b Correct a stale count repeated in four places: 10 floating pins, not 23
gates / gates (push) Successful in 25s
Recounted at catalog 18a6d2d8: 66 unique pins — 48 full X.Y.Z, 6 two-part
lines, 4 major lines (10 float), 8 exact versions wearing a variant suffix.
The '23' carried since v0.233.0 matches no definition the catalog supports.
Definition written down beside the number so it can be rechecked.

Also: CONTEXT said the fleet floor was 0.257.0; the hub says 0.259.0.

Comment and doc only — no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 13:02:48 +02:00
admin bdeb1c0d8e REPORT + CONTEXT for v0.260.0 (R-524)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 12:59:26 +02:00
admin 8f8a64cad7 v0.260.0 — a box ahead of the catalog reads „Naprakész", and the pin never moves backwards (R-524)
gates / gates (push) Successful in 24s
MEASURED 2026-09-15 (BIGNIGHT Phase 6): privatebin updated 2.0.5 -> 2.0.6, catalog
reverted to 2.0.5, and the box read „Frissítés elérhető — ma" over an Update that
would have moved the pin BACKWARDS onto a possibly-migrated datadir.

- stacks.CatalogOrder: the comparison gains a fourth answer (Ahead) and moves out of
  web, so the badge and UpdatePreflight cannot drift apart.
- The badge: ahead reads „Naprakész"/"Up to date", tag-ok, with a title saying why.
- The refusal: UpdatePreflight returns `downgrade` (409), born as a bundle key; the
  API now renders update refusals through errText so it reaches English households.
- Ahead is narrow: every differing service must be orderable AND newer, else Behind.
- Ordering is util.Version.Compare behind a tag normaliser — no second comparator.
- Three red-proofs, each seen to fail.

R-589 was already fixed in v0.258.0; only its register row was stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 12:48:55 +02:00
admin 19ef0329ab CONTEXT + README: v0.259.0, and the two probe traps that cost the most
gates / gates (push) Successful in 25s
The composed-sentence-into-page-data class is now five instances and has its
own warning block in the README's i18n section, with the two things that make
a live probe lie: the felhom_lang cookie is ignored behind auth (so an English
probe of /backups returns Hungarian and reads as unfixed), and an apostrophe in
an English value is escaped so no assertion on the rendered page ever matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 08:07:27 +02:00
admin 0fe04bb6d5 v0.259.0 — the claim page and the backup warnings answer in the reader's language (R-596, R-598)
gates / gates (push) Successful in 28s
The 2026-09-20 English drill ended one screen short: the claim page was English
and its answers were Hungarian, so a household who mistyped the code from their
e-mail could not tell a typo from a dead code. Fourteen call sites carrying nine
messages now go through s.msg; the backup page's two protection warnings — which
are promises about whether the customer's files are safe — follow the same route.

Hungarian is byte-identical, proved structurally by the go-parity gate against the
frozen base capture and red-proofed on a single added full stop.

data["Title"] was DEAD (claim.html is standalone; .Title is layout.html's) and is
deleted rather than translated — a translated dead field is a permanent false
signal about where the page's title comes from.

Six existing copy-contract tests were kept, not weakened: each now resolves its key
through the real bundle, so it still convicts on a reworded Hungarian sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 07:45:56 +02:00
admin f6909492cc docs: v0.258.0's report, and what the walk then found
gates / gates (push) Successful in 27s
The release fixed four Go-side leftovers. The walk that judged it on a box installed
from scratch that day found two more of the SAME SHAPE — a composed sentence handed
to a renderer as page data — on the claim page (R-596, P1) and the Backup page
(R-598). That shape now has three instances on file with R-573, which is worth naming
as a class rather than fixing one at a time.

Verdict recorded: not yet ready for an English-speaking tester.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 19:27:33 +02:00
admin 7c4a33b6d1 v0.258.0: the last four Hungarian things an English household met
gates / gates (push) Successful in 24s
R-589 the update badge, R-590 the data-folder backup sentence, R-573 the two channel
banners, R-572 two dead helpers. All four were built in Go, which is why neither the
template parity fixtures nor TestI18nEnglishPages could see them; slice 5's LIVE proof
is what found them.

R-590 is the one that matters most: it is a promise about the customer's files. It
said, in Hungarian and under an already-English folder label, that a drop-zone is
temporary and unbacked. The test now asserts the CONSEQUENCE in both languages — and
that the two languages do not produce the same string, which would mean the English
fell back.

R-572 is not what its row said. The row claimed a template renders "vasárnap" on an
English page; measured, NO template and no Go file called pruneLabel or
nextPruneLabel. They were dead func-map entries returning Hungarian, so they are
deleted rather than translated — translating dead code would add machinery with no
reader and a test pinning a fiction. Deletion is fail-loud and that was proven: a
template naming the removed function panics loadTemplates at startup.

Five red-proofs. One of them says something about the gate rather than the code: the
Go-parity gate does not measure localeFuncs keys against the base capture, so the
citation to TestLocaleFuncsHungarianBundleMatchesFuncMap is what carries them — the
test was extended to make that citation true.

MinAgent: 0.131.0 (unchanged). Hungarian byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 18:10:53 +02:00
admin ecefdf2dc5 docs: the floor is at 0.257.0 and it delivered the feature (R-560)
gates / gates (push) Successful in 24s
The operator asked for the floor at the end of the translation work. Raised the same
session with the declared MinAgent 0.131.0 — above the vouched golden, so the
declaration carries it. demo-felhom went 0.255.0 -> 0.257.0 by itself in under 12
seconds and THEN rendered the English tagline, which is the observable worth having:
the floor delivered the feature, not a version string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 16:40:07 +02:00
admin eb806f5780 docs: v0.257.0 — README, REPORT, CONTEXT for the catalog read path (R-560)
gates / gates (push) Successful in 25s
README gains "Catalog copy in a second language" under §18: the format, the
field-by-field fallback, whole-list replacement vs key matching, the no-write-through
rule, the four templates that carry copy, and why an older controller is unaffected.

REPORT records the nine red-proofs — including the two that convicted a hollow TEST
rather than the code — and the live proof on both demo boxes, naming the method
(endpoint level, no browser on DooPlex) rather than implying a click-through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 14:43:52 +02:00
admin 60f0a86bd4 v0.257.0: the app catalog can speak English (R-560, slice 5 Part A)
gates / gates (push) Successful in 25s
The READ PATH for a second language in `.felhom.yml`. An `i18n: {en: …}` sibling
block inside the same file; `Metadata.For(lang)` merges it FIELD BY FIELD over the
Hungarian, so a missing or blank English field shows the Hungarian one and a
half-translated app is a legal, shippable state.

`For("hu")` is the parsed struct with `I18n` cleared and nothing else — measured
against all 53 real catalog files, copied into `internal/stacks/testdata/catalog/`.
Lists replace whole; every other list is matched by its own key, never by position.
`For` never writes through the receiver: the metadata is the stack manager's, shared
by concurrent requests, and an in-place merge would leak one household's language
into another household's page.

Pages reach catalog copy only through `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`,
and `TestNoDirectMetaCopyReadOnPages` keeps a named, reasoned allow-list of every
direct `.Meta.<copy>` read in `internal/web` so the NEXT page to read one fails the
suite instead of quietly rendering Hungarian to an English household.

Eight red-proofs. Two of them convicted a hollow TEST rather than the code: a struct
copy shares its slices' backing arrays, so the obvious DeepEqual mutation check
passed a deliberately broken merge; and a one-entry fixture cannot tell key matching
from position matching. Both rewritten, both then seen to fail.

MinAgent: 0.131.0 (unchanged). Older controllers are unaffected — `LoadMetadata`
uses non-strict `yaml.Unmarshal`, so a pre-0.257.0 box drops the whole block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 14:31:52 +02:00
admin e81ad613ae v0.256.1: the push log says whether a household sentence was attached (R-558)
gates / gates (push) Successful in 25s
Found while trying to PROVE v0.256.0 on a live box: there was nothing to look
at. The box logs the Hungarian message only, the hub logs the Hungarian message
only, and the payload is inside TLS - so "did the box attach a second
sentence?" was unanswerable from either end. That is the fork in the diagnosis
when an English household reports a Hungarian mail.

  [INFO] Event pushed: app_deployed (info) [+household(en)] — Alkalmazás ...
  [INFO] Event pushed: app_deployed (info) [hu-only] — Alkalmazás ...

The sentence itself is not logged: it is the same sentence twice and one copy
is already on the line. Pinned in both languages.

A feature whose only failure mode is "the wrong language arrived" needs an
observable that says which branch was taken. Without one, every diagnosis is a
guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 17:06:20 +02:00
admin bef39598d0 v0.256.0: the box sends its own sentence in the household's language (R-558 Part B)
gates / gates (push) Successful in 24s
MinAgent: 0.131.0 (unchanged). Needs hub v0.118.0+, which shipped first and
tolerates a box that sends none of this - every box in the fleet is that box
until this release reaches it.

The hub writes a household's e-mails in their language now, but about a third
of those mails carry a sentence the BOX composed, naming a drive, an app or a
number. The hub cannot translate one. So the box sends it twice.

- message_customer on POST /api/v1/event, omitempty. A HUNGARIAN household
  sends nothing extra at all, so its payload stays byte-for-byte what every box
  sends today and the hub's fallback path keeps being the one production
  exercises rather than a branch nobody takes.
- 19 producers render both sentences from ONE bundle key. `message` stays
  Hungarian always: it is what the operator is mailed and what the hub logs.
- customer.language bootstraps a new box - stored choice, then config, then
  Hungarian. The config value is NEVER written into settings.json: that would
  record a choice the household never made.

The Hungarian did not move, measured twice: the wire golden from the slice-2
base commit, and the Go parity gate over all 19 new keys.

Three guards had to learn the change and one caught me: the test seam now
carries the new field; the R-329 severity register reported two dynamic sites
as no longer existing the moment they moved off PushEvent (the walk now checks
36 severity literals, up from 20); and TestConfigLanguageIsWiredInMain reads
main.go, because cmd/ is gitignored and ripgrep does not.

A mistake, named: the first pass dropped displayName from three producers,
which would have mailed customers "Alkalmazás telepítve: %!s(MISSING)". Caught
reading the diff; now pinned by a test that refuses %!/MISSING/%s/%d in either
language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 16:54:20 +02:00
admin 8fb2f9ef9d v0.255.0 — the globe on the sign-in-flow pages: styled, and inside the card
gates / gates (push) Successful in 23s
Two defects in v0.254.0's globe, both plain on a browser and neither catchable by anything
that existed — every test read the MARKUP, and the fault was in which CSS file the browser
fetched.

The shells requested /static/style.css with NO ?v=, while layout.html has carried one since
v0.166.0. A browser holding a copy from before v0.254.0 kept serving CSS with no .lang-globe
rules, so the globe came out as a bare unstyled <details> — a stray triangle and two plain
words at the edge of the window. It was FIVE shells, not the three named: both guest share
pages have the same fault for any CSS change, and their visitor is the likeliest of all to be
holding an old copy. And .Version was missing from three of those five data maps, which is
exactly how the next one would be forgotten — it is now filled at the one choke point every
shell renders through.

The globe also floated outside the card, pinned to the corner of the VIEWPORT, reading as part
of the browser rather than the page. It now sits inside the card, centred under the footer, with
the menu opening upward via the shared rule — so the dashboard and the shells cannot drift.

AND A THIRD, caught by a test that already existed: putting the version on the guest share pages
would have printed the controller build onto a page a stranger with a capability URL can open.
TestShareGuest_HeadersTilesNoAdminChrome refused it. Those two now take an opaque per-build tag
— same cache-busting, no disclosure. The fill is ONE function shared with the parity harness,
because a fixture rendered through a different data path is a picture of a page nobody serves,
which the previous release got wrong twice.

15 shell fixtures re-captured; 91 identical, every dashboard page among them.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 15:07:49 +02:00
admin 2e9d40255b docs: record the fleet floor raise to 0.254.0 (operator asked)
gates / gates (push) Successful in 25s
0.253.0 -> 0.254.0 with min_agent 0.131.0 declared — the floor sits above the vouched golden,
so the declaration is what carries it (R-472), and the agent versions were checked first
because a floor is HELD for a box whose agent is below the requirement.

demo-felhom took it by itself in ~40 s. The box's own log is the observable that counts
("settle-gate: GO — at/above floor 0.254.0"), not the hub's claim, and the release's visible
change is there too: one globe on the sign-in page, zero of the old text links.

The two DOWN customers did not get it and will take it unattended when they return, from
0.115.0 and 0.245.0 — said plainly rather than left to be inferred from a green dashboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:42:13 +02:00
admin 0b1486df8f fix: the recovery page's globe must write the HOUSEHOLD's setting, not a cookie nothing reads
gates / gates (push) Successful in 24s
/recovery is in the AUTHENTICATED route table — its reader is the household, not a visitor. The
first draft of v0.254.0 gave it the anonymous form, which sets the felhom_lang cookie that
langFor deliberately ignores once there is a session: the button would have appeared to work
and done nothing. Found by the live probe on demo-hp reporting no globe on /recovery (it 302s
to /login without a session) and then reading the route table.

executeTemplateLang now branches on hasSession: household form with its session CSRF, or the
visitor form without. The parity harness and TestI18nDirectRenderPagesFollowLanguage carry the
same branch, so the fixture is the form the real page serves — the trap this release already
walked into once with the shells.

A per-session CSRF token cannot be a fixture value, so it is blanked on both sides of every
parity comparison, exactly as relative ages already were. What stays pinned is that the field is
THERE and WHICH form it sits in — the half that says whether the globe writes the household's
setting or the visitor's cookie.

Evidence regenerated: 3 change shapes across 106 fixtures, 5 byte-identical (both guest share
pages and the catch-all — the three that must not change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:28:24 +02:00
admin 48f3336956 v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
gates / gates (push) Successful in 23s
The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:19:31 +02:00
admin eb6aa58aa7 docs: record the fleet floor raise to 0.253.0 (operator asked)
gates / gates (push) Successful in 24s
min_controller_version 0.250.0 -> 0.253.0 with min_agent 0.131.0 declared — the floor sits
above the vouched golden 0.246.0, so the declaration is what carries it (R-472).

demo-felhom took it by itself in ~20 s and is healthy; its own log is the observable that
matters ("settle-gate: GO — at/above floor 0.253.0"), not the hub's claim alone. The two DOWN
customers did not get it and will take it unattended when they return, on versions nobody has
carried forward from — said plainly rather than left to be inferred from a green dashboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 12:22:53 +02:00
admin 7c05b59708 v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by
whoever caught them: too late to translate where they are shown, too early where they are
made. Every one now carries its key across that gap. ZERO Hungarian error literals remain.

util.MsgError does three things at once, each earned:
  - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged;
  - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause);
  - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole.
A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours.

76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do
not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the
household's language come from one value; UpdateRefusal gained a Cause to carry it.

Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a
per-call-site flag — the producer somebody forgot would read "3 app is not running". The
guard caught a real key collision (alert.deadapp.one) the day the rule landed.

TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter
silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could
not see it, because every surviving fragment WAS a real base literal while the CALL had lost
text; two behaviour tests caught it. And the counting script was case-sensitive, so it said
"0 left" while five remained.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 11:44:30 +02:00
admin 5270bad76e v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
gates / gates (push) Successful in 23s
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 10:15:56 +02:00
admin 736f54b496 REPORT: v0.251.0 — R-553 and R-563, the five sites, red-proofs, live evidence
gates / gates (push) Successful in 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 21:12:29 +02:00
admin 0194342264 CHANGELOG v0.251.0 + CONTEXT: R-553 and R-563 — decisions read a kind, not a word
gates / gates (push) Successful in 20s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 21:04:02 +02:00
admin f806baf4c7 R-563: the remote-backup page polls from a status attribute, and „Fut…" is finally translatable
- backups_remote.html: the status stat carries data-status="{{.Offbox.LastStatus}}"; the poll reads
  v.dataset.status === 'running' instead of v.textContent.indexOf('Fut'). The English page could never
  see a running backup before this (slice 1 shipped the English page with that word left Hungarian).
- The running label becomes {{T "backups_remote.fut"}} — hu „Fut…" unchanged, en „Running…".
- 12 backups_remote parity fixtures re-captured. Proven (audit switch/fixture diff): each differs from
  its predecessor ONLY by the data-status attribute and that one poll line; the other 94 are
  byte-identical. The displayed Hungarian is unchanged.
- Tests: TestR563_PollStartsFromAttribute (hu and en), TestR563_AttributeFollowsTheStatus.
  Red-proofed twice: poll back on textContent → both languages fail; word back inline → the English
  page shows Hungarian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 21:02:47 +02:00
admin c00fed6db3 R-553: four decisions stop reading their own Hungarian words (sites 1-4)
Every Hungarian sentence is byte-identical; each decision now reads a signal set where the message is
made. util.KindErrorf builds the same bytes fmt.Errorf did while carrying a sentinel for errors.Is.

- Deploy status (api/router.go): deployStatusFor() by kind — stacks.ErrAlreadyDeployed (409),
  ErrRequiredField / ErrPathMissing / ErrNotEnoughMemory (400). The „kötelező" / „memória" /
  "does not exist" / "already deployed" text chain is gone.
- Off-site failure class (backup/offbox.go): ErrOffsiteQuota replaces the „tárhelykeretet" match. The
  restic/ssh signatures stay text matches on purpose — that output is not ours and is not translated.
- Alert placement (web/alerts.go): monitor.HealthReport carries WarningKinds parallel to Warnings;
  the "not on a separate drive" warning is inline by KIND. The hub report is untouched (builder.go
  copies Status/Issues/Warnings only) — pinned by a wire test.
- Stale off-site note (web/handlers.go): settings LastWarningKind + backup.OffboxWarnNoAppsSelected.
  The text test survives ONLY for kind == "" (a box whose last run predates 0.251.0) and is removed
  when R-570 closes; slice 2 must not translate that producer before then.

Tests (all red-proofed by restoring the pre-fix predicate — see the audit's redproofs.txt):
TestR553_Deploy_DecisionSurvivesWordingChange, TestR553_DeployHandlerUsesTheKind,
TestR553_DeployProducersCarryKindAndKeepTheirWords (through the real DeployStack),
TestR553_OffsiteQuota_{Decision,HeadLine}SurvivesWordingChange, TestR553_OffboxRunRecordsTheKind,
TestR553_StorageWarningsCarryKindsAndKeepTheirWords, TestR553_DiskWarningPlacementSurvivesWordingChange,
TestR553_HubReportWarningsAreUnchangedOnTheWire, TestR553_StaleNote*, TestR553_WarningKindIsPersistedAndCopied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 20:58:45 +02:00
admin e236dca486 REPORT: localisation slice 1 (R-556) — releases A, B, C, per-page table, red-proofs, live evidence
gates / gates (push) Successful in 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 18:50:59 +02:00
916 changed files with 135990 additions and 14859 deletions
+10 -10
View File
@@ -6,17 +6,17 @@ paths: ["controller/**/*.go", "controller/**/*.html", "controller/**/*.css", "co
## The ONE entry point
**Run `python3 controller/scripts/controller_gates.py` (from `controller/`) after ANY change in this
repo.** It runs the local gates — `template_id_gate`, `emoji_gate`, `native_confirm_gate`,
`offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`, `docker_run_volume_path_gate`,
`secret_in_markup_gate`, `retrieval_promise_gate`, `debug_route_gate` — plus `reuse_refs_check`,
`instructions_gate` and `observations_gate` on the repo root, streaming each gate's own output and
exiting non-zero if any fails. **The runner's `GATES` table is the list; this sentence is a pointer to
it, not a second copy** — it has already drifted once (it said "seven" while nine were registered).
**Run `python3 scripts/controller_gates.py` from `controller/` (or `python3
controller/scripts/controller_gates.py` from the repo root) after ANY change in this repo.** It runs
every gate in the runner's `GATES` table, the shared `reuse_refs_check`, `instructions_gate` and
`observations_gate` among them, streaming each gate's own output and exiting non-zero if any fails;
one, `golden-notice`, is ADVISORY and never refuses. **The `GATES` table is the list; this sentence is a
pointer to it, not a second copy** — it drifted twice (it said "seven" while nine were registered, then
named ten local gates while fourteen were).
- `--fast` selects the gates that touch no network and no container runtime; today that is all of them.
- **A missing gate script is a FAILURE, never a skip.**
- **The shared `reuse_refs_check.py` and `instructions_gate.py` live in `felhom.eu/scripts/` and are
- **The shared `reuse_refs_check.py`, `instructions_gate.py` and `observations_gate.py` live in `felhom.eu/scripts/` and are
never copied here** — a copy would recreate the drift they detect; an absent sibling clone FAILS.
- **The pre-push hook** (`.githooks/pre-push`) runs it with `--fast` and refuses a failing push. It is
per-clone — switch it on once with `git config core.hooksPath .githooks`, and a manual run WARNS
@@ -59,7 +59,7 @@ fast, and wrong.
**A decoy is the LABEL without the FACT** — a directory with the right name and no bake log, a
handler case that exists only in a comment, a note whose prose mentions the marker it lacks. Write
one for every new gate, run it, and watch it convict. `scripts/decoy_coverage_gate.py` refuses a gate
one for every new gate, run it, and watch it convict. `felhom.eu/scripts/decoy_coverage_gate.py` (run by felhom.eu's `repo_gates.py`, for all four repos) refuses a gate
registered without one, or without a named exemption carrying its row.
**Earned by five instances, every one found by accident:** R-410, R-400, R-378, R-419, R-94. The
@@ -75,4 +75,4 @@ green and correct today, blind the moment anyone adds a subdirectory. Prefer `os
glob over a hand-maintained list of files.
**A decoy nobody would write proves nothing** — say the gate is sound and move on. Five of mine were
withdrawn as illegitimate and are named in `documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
withdrawn as illegitimate and are named in `felhom.eu/documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
+52 -7
View File
@@ -6,22 +6,25 @@ unconditional: true
> Goal sessions, nightly sessions, "work the register" sessions. **A session that starts from
> `/goal` or a standing brief inherits these rules exactly as it inherits the gates.** They are the
> part of `PROMPT-TEMPLATE.md` that a task file used to carry and a goal does not. Same wording lives
> in all three repos' `.claude/rules/`; change it in all three or in none.
> in `felhom.eu`, `felhom-controller`, `felhom-agent` and `app-catalog-felhom.eu` `.claude/rules/`, and in the workspace
> root's unversioned `.claude/rules/`; change all five or none.
## 1. What you may pick up on your own
- A register row **you or another CC session filed**, with owner CC, at P3 or a bounded P2, that
needs **no operator decision**, touches **no customer data by design**, and introduces **no
mechanism nobody has measured**. Smallest first.
- A defect you find while exercising the product, filed as a row **before** you fix it.
- A defect you find while exercising the product, filed as a row **before** you fix it — **unless it is small**:
a small finding is fixed in the session and never filed (the size rule, `OPEN-ITEMS.md` „How a row is filed").
- Hygiene: register compression, stale citations, rows with no owner, documents that contradict
live source.
**Not yours, ever, without a task file or an operator word:** money; anything that changes risk to
customer data; anything that changes a promise the product makes to a customer; anything that
reverses a documented design decision (`documentation/architecture/` — a design decision is not a
defect, R-370); anything on DooPlex, Peti's box or ep0; baking or vouching a golden; promoting a
catalog version; a new external dependency.
defect, R-370); anything on DooPlex or ep0; baking or vouching a golden; promoting a
catalog version; a new external dependency; **a hub image build or hub deploy in a session the operator does not
attend** (operator ruling 2026-10-07, `09` §3 decision 162).
## 2. When you may decide instead of ask (operator grant, 2026-09-14)
@@ -41,12 +44,28 @@ it **first** in the morning note. A decision you cannot write in that shape is o
Throwaway apps only; the standing apps and `bentopdf` stay.
5. **Evidence off the machine at the end of each phase**, before any revert (R-320).
6. **One release per repo per session**, with a CHANGELOG entry (controller: with its `MinAgent`
line), REPORT overwritten, floor raised to deliver it. **No golden unless a drill or fresh install
needs one** (the waiver, R-468). **No `--no-verify`.**
7. **An enumerated gap becomes a row in the same session.** Prose is not a record.
line) and REPORT overwritten. **A controller release needs no golden** (operator ruling 2026-10-15
11:26, `09` §3 decision 250; decision 7 stands): push the image only when it is ready for EVERY box (the
household's update button offers the highest tag); raise the floor, MinAgent declared, only after it ran
on Tester 1, demo-hp and demo-felhom through the normal update path and the box walk's checks are green
on all three — not at night unless the night brief says so. **Agent releases still stop for the
operator's vouch.** The golden is baked weekly and vouched by the operator; CC never vouches. **No `--no-verify`.**
**A version number is pushed once** — `felhom-controller/controller/build.sh` now refuses a second
push of a tag that exists, and a registry it cannot reach is a refusal too. This is a FACT about
what the build does, not a new rule: a fix for something already pushed is the next patch version,
and a box on a bad image is moved by the normal update path, never by re-tagging. Earned on
2026-10-15, when 0.315.0 was pushed three times from three different commits and a cache kept
serving the first one (`09-update-architecture.md` §0).
7. **An enumerated gap becomes a row in the same session — or, if it is small, is fixed in it** (the size rule).
Prose is not a record.
8. **Hungarian text is searched with ASCII fragments**, with a positive and a negative control.
9. **Never leave a half-state.** If time runs out, revert to clean and say what was reverted.
10. **Teardown, three layers, stated** — machine, host, hub — or "provisioned nothing".
11. **Every helper prompt carries the brief's fences in full** (operator ruling 2026-10-07). A helper session (a
subagent, a fork, a workflow agent) gets the brief's fence list word for word — every protected machine, every
„no", every delivery and Docker limit — not a summary and not „the usual fences". Earned on 2026-10-06 night: two
helpers whose prompts carried only part of the fences ran `docker volume prune` on the bench and a Docker-using
gate on DooPlex.
## 4. The morning note
@@ -54,3 +73,29 @@ One screen, plain language, in this order: **decisions you took** (§2) first; w
what broke and whether you fixed it; rows opened and closed with the register size before and after;
what needs the operator, each with what happens if they do nothing. No file paths, no function
names, no row numbers as the subject of a sentence.
## 5. Instruction files
**Instruction files (`CLAUDE.md`, `.claude/rules/*`) are kept true by the session that finds them wrong**
(operator ruling 2026-10-06, `09` §3 decision 150). A session MAY, without asking: correct a stale fact (a command, a
count, a version, a path, a description of what a gate does), add a fact it proved, and remove a reference to something
that no longer exists. Each edit is named in the report (file, line, before, after, why). A session MAY NOT, without the
operator's word: loosen a safety rule, a fence, a „never", a protected machine, a secret rule, or a review step; or
remove a rule. When in doubt, it is a rule change, and it goes to the operator. If Claude Code's own permission check
asks before such an edit, wait for the operator's click; if it refuses, record that and file the exact line.
## 6. The system poster stays true
**A session that changes a fact listed in `architecture/felhom-system-poster.facts.md` (a machine, a
role, a traffic path, a backup tier, a time, a retention, a key, a known gap) updates that file in
the same commit.** If the change is text only, it also edits the matching text in
`felhom-system-poster.html`. If it needs a new drawing, it adds the line
**"System poster needs a refresh: <what changed>"** to `STATUS.md`'s "waiting on the operator" list.
**The session report names which of the three it did.**
Why the three-way split: the poster is a drawing made in Claude Design, and **nothing in this
repository renders it** — unlike `where-felhom-stands.html`, which has `render_stands.py`. So the
facts file is the source of truth and the drawing trails it. `scripts/poster_facts_gate.py` warns
when the facts file has a newer commit than the poster; it **never fails a push**, because a refresh
needs the operator and another tool, and a gate nobody can clear is a gate people learn to route
around.
+2606
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -27,7 +27,7 @@ integrations, git-sync, notifications. Disk/host/Proxmox concerns are delegated
| which box may I break | `felhom.eu/documentation/runbooks/target-selection.md` |
| host addresses, break-glass, node facts | `felhom.eu/documentation/operations/nodes.md` |
| what version is live anywhere | ask the hub (`/hosts`, `/configs`) or the box — **never a doc** |
| the authoritative design | `felhom.eu/documentation/architecture/01/02/03-*.md` |
| the authoritative design | `felhom.eu/documentation/architecture/NN-*.md` (01–03 for this repo; 07 backup, 09 updates, 10 language) |
## Session-critical invariants
@@ -37,8 +37,8 @@ The rest live in `REUSE.md`. These cost incidents to learn:
- Docker's `.State` says "running" even for unhealthy containers — the `.Status` parse is the truth.
- In-memory `Deployed` is set BEFORE `compose up -d` (slow-pull race); reverted on failure.
- `compose up -d` exits 0 on crash-loops — the post-start status check is the detection.
- Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller)
cannot be stopped from the UI.
- Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller,
and always samba) cannot be stopped from the UI.
- Verify a container image HAS the healthcheck tool before using it (BusyBox wget / python3 / curl —
the catalog `REUSE.md` maps the families).
- `IsRunning()` is CONCURRENCY, false during a verification restore — display MUST use
@@ -61,7 +61,7 @@ credentials) — they load when you touch a template or stylesheet.
| Surface | Command |
|---|---|
| Gates (after ANY change) | `python3 controller/scripts/controller_gates.py` — from `controller/` |
| Gates (after ANY change) | `python3 scripts/controller_gates.py` — from `controller/` |
| Green gate | `go build ./... && go vet ./... && go test ./...` |
| Build + deploy | the **`felhom-build-deploy`** skill — do not hand-roll it |
+340 -1
View File
@@ -7,7 +7,346 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-09-17 (v0.247.0 — i18n spike: the dashboard can speak English, Hungarian byte-identical)
Last updated: 2026-10-16 (v0.323.0 — Debian 13, restic 0.18.1 pinned outside the OS and re-proven, felhom-samba 1.2.0)
> **2026-10-16 (evening) — v0.323.0 RELEASED to the three test boxes ONLY (per-box floors); the GLOBAL floor stays
> 0.322.0 until each test box has made one real night's off-site backup on 0.323.0** (the brief's rule; the check and
> the floor command are in felhom.eu `STATUS.md`). R-1105: runtime `debian:13.7-slim` (digest-pinned), Docker repo
> codename from `/etc/os-release`; restic is the official 0.18.1 release binary from a `restic` build stage (signed
> `SHA256SUMS` + vendored key in `third_party/restic/`, published sum, pinned per-arch sum). Every restic assumption
> re-proven with REAL outputs of 0.14.0 and 0.18.1 (`internal/backup/testdata/restic/`, `TestR1105_*`); the one that
> moved is the forget plan — 0.17+ keeps each group's oldest snapshot while monthly slots are unused (only ever keeps
> more). Removed packages the binary never runs (postgres/mysql clients, sqlite3, fdisk, e2fsprogs, parted, sshpass).
> felhom-samba 1.2.0 (Alpine 3.24.2), and `build-samba-image.sh` refuses a reused tag. Live on demo-hp 9201: both
> restore directions byte-identical on the real store; the drill's data came back; the full restore after a reinstall
> leaves a DB app unusable (old volume, new password — R-1106, not restic). Open: R-1107 (restic 0.18.1's binary adds
> 50 fixable scanner findings; 0.19.1 has 19 — the operator's choice), R-1108 (a share added before the first password
> is not exported). **Decision (CC, inside the brief, reversible):** the packages above were removed though the brief
> named only the two database clients — none is run by the binary (every exec call read), and the image carries fewer
> known holes. Full report: felhom.eu `REPORT-debian13-restic-2026-10-16.md`.
> **2026-10-16 — v0.322.0 RELEASED by CC; the global floor 0.322.0 / MinAgent 0.158.1.** R-970: `/api/config`,
> `/api/config/apply`, `/api/config/hash` are gone; the self-update and geo routes no longer take the box's hub key
> (`selfUpdateAuthMiddleware` removed), and the CSRF Bearer exemption is gone. The route table is `buildMux` in `main.go`
> so `TestR970_HubKeyOpensNoDoor` drives the real table. R-972: Go 1.27.2, x/crypto v0.58.0, runtime
> `debian:12.13-slim` pinned by digest + `apt-get upgrade`. **Decision (CC, inside the brief, reversible): the runtime
> stays on Debian 12** — Debian 13 brings restic 0.18.0 and the off-site code relies on measured 0.14.0 behaviour; the
> move is R-1105. Data tab: the export field is `fab_encryption_key`, `autocomplete="new-password"`; the 2nd-copy
> status rows are gone. R-1103: the installed-only branches of `app_fields.html` removed; their rules live in the
> app-page and settings-tab tests. Full report: felhom.eu `REPORT-burndown2-2026-10-16.md`.
> **2026-10-16 — v0.321.0 RELEASED by CC; the global floor 0.321.0 / MinAgent 0.158.1.** R-1101: in `generated` the page asks
> FileBrowser whether the stored password still logs in (`stacks.FileBrowserStoredPasswordRefused`, only a 401/403 counts)
> and, refused, offers the reset; no „change it" line for FileBrowser; its config switches off its update banner and
> „Install app" prompt. The Data tab's backup part is one section in plain words. R-1095: `/apps/{slug}/install` of an
> installed app → 302 settings; the installed-only `app_fields.html` branches stay (R-1103). Catalog the same day: RomM's
> saves in a named volume (R-1097), `default_creds` off eight apps (D10 = A). Next: R-1103; R-1067 waits on Tester-2 (0.292.0).
> **2026-10-16 — v0.320.0 RELEASED by CC; the global floor 0.320.0 / MinAgent 0.158.1.** A protected infra stack is
> installed by definition on the app page (`Deployed || Protected`; v0.315.0 had shown FileBrowser „not installed" with
> Install and no login); the deploy API and `DeployStack` refuse a protected stack. FileBrowser `operator` state →
> „Új jelszó beállítása" (`stacks.ResetFileBrowserAdminPassword`: its CLI with the server stopped, verify, encrypt,
> `generated`) — pressed on demo-hp; **the N100 waits for the operator's yes**. Every installed app has ONE login answer
> (`web/app_login.go` `loginAnswerFor`; catalog `app_info.login_kind`). „Fájlok ↗" + per-drive media folders; a folder with
> no FileBrowser source is text. Decisions inside the brief (felhom.eu `REPORT-logins-folders-2026-10-16.md` §0): no backup
> sentence on media rows; no data tab for infra; „none" behind the family gate says the family sign-in. Next: R-1100
> (one sign-in), R-1101 (stale FileBrowser password), R-1102 (eight „known password" lines, decision D10).
> **2026-10-16 — v0.319.0 RELEASED by CC; the global floor 0.319.0 / MinAgent 0.158.1.** App page style A (operator
> 2026-10-16): the hero, the tab bar and every section are panels on `--bg-1`; the install page's form groups use the same
> rule. „Belépés az alkalmazásba" pairs a `type: password` field with its `*_USER/_USERNAME/_EMAIL` name
> (`web/app_login.go`); a field the box holds no value for is a sentence, never an eye — page and reveal endpoint share
> `storedValuePresent`. Measured cause on demo-hp: Calibre-Web installed 2026-08-21, `ADMIN_PASSWORD` entered its template
> 2026-09-29, and `InjectMissingFields` never fills a `type: password` field; its stored user name is real (renamed in the
> app on 2026-10-01). The settings tab no longer renders `app_deploy_fields`; `/apps/{slug}/install` for an installed app
> still does (R-1095). Evidence: felhom.eu `audits/app-page-tuning-2026-10-16/`.
> **2026-10-16 — v0.318.0 RELEASED; the global floor 0.318.0 / MinAgent 0.158.1 (set by the operator).** The internal data
> place („Belső tárhely", agent ≥ 0.158.1 reports kind `internal`): bound on one drive-gate tick, registered on the next as
> the default drive (web/internal_place.go); eject/decommission/wipe of it refused. Partitions in the format/attach wizards
> (agent `formattable`: only while nothing on the disk is mounted); the whole disk under a registered part refused
> server-side (`deviceBacksRegistered`). R-1084: the whole-box backup offer asks the same-disk question (`offerRelationFor`).
> R-1082: `/mnt/sys_drive` never offered (`withoutSystemVolume`, by device). Live on a fresh one-disk scratch box: felhom.eu
> `audits/storage-identity-buildB-2026-10-15/`. Next: R-1094 (the whole-disk refusal sentence), with the next release.
> **2026-10-15 (late evening) — v0.316.0/.1/.2 built and on demo-hp; .3 committed, NOT built.**
> **Measured before fixing:** the tabs rendered unstyled through the public address because of a
> CACHE, not the box — the stylesheet URL was `?v={{.Version}}`, identical across three different
> 0.315.0 builds; the origin sends NO ETag and NO Last-Modified, so a copy can only expire; and
> Cloudflare caches it while rewriting the browser TTL to four hours. Evidence and its caveat:
> `audits/app-page-2026-10-15b/P0/VERDICT.md`.
> **Two mechanisms now:** `build.sh` refuses a second push of a version (a registry it cannot reach
> is a refusal too), and every shell's stylesheet address is a per-build tag.
> **The app page is the approved one-column mockup**: one width, hairline sections, underlined tabs,
> and a box only for „the app is waiting for you" (blue) or „this is risky" (amber). The
> auto-update SUCCESS banner is now meta text.
> **THE LESSON OF THE EVENING, and it cost a bad push:** I put the guard in `controller/build.sh`,
> but the release runbook runs `build/felhom-controller/build.sh` — a SEPARATE COPY from
> 2026-07-18. Proving the refusal against a real tag therefore re-pushed 0.315.0 a fourth time
> (R-1081). The build directory's script is a symlink now. A guard in a script nobody runs is not a
> guard. The same shape bit twice more in one evening: the asset tag shipped dead because only one
> of TWO render paths set it, and both times a BROWSER found what the suite could not.
> Not yet validated: the operator's own look; 0.316.3's two cosmetic fixes.
> **2026-10-15 (evening) — v0.315.0, ONE APP PAGE WITH FOUR TABS.** An app was six pages and every „Vissza" went to the
> app LIST; it is now `/apps/{slug}/{settings,data,guide,logs}` plus `/apps/{slug}/install`, one shared header and one
> set of banners (`internal/web/app_page.go`, `templates/app_shell.html`). Operator decisions recorded for this release:
> **D1** — Export lives on „Adatok és mentés", not under Beállítások. **D2** — an after-start field's CARD is rendered
> generically from the catalog's `after_start: true`, and its ACTION is Go code in a registry keyed by slug + env var
> (`internal/web/after_start.go`), so customer text can never reach a catalog-defined command. **The FAQ is a section of
> the guide, not a tab**, and **the guide tab replaces the details page**. **302, never 301**, for every old
> `/stacks/{name}/…` GET — a cached 301 cannot be withdrawn; every POST on those paths is untouched.
> New catalog block `app_info.guide` (sections + faq + `checked_version`), rendered only when `checked_version` names the
> image tag its author checked it against; no template carries one yet (the content is the companion task). The Pi and
> „csak x86" badges are gone; `PiCompatible` and the catalog key STAY this release, or a box on 0.314.0 would read
> „csak x86" on every app.
> **Found by a red-proof, and worth remembering:** the after-start handler's „the catalog must have DECLARED this field"
> check could be deleted with the whole suite green — and a POST for an undeclared field then reached Plex and opened the
> setup gate. Two guards that look like one are two tests.
> **Two defects the whole test suite could not see, both found in Chrome on the workstation** (this is
> the case for CC running on Windows): the tab bar had no CSS at all and the hero logo was unsized,
> because the shell invented class names beside ones the design system already had rules for; and the
> log tab's `<script>` was never closed, so the page rendered, every marker matched, and the live log
> poller was dead ("SyntaxError: Unexpected token '<'"). A marker proves a string is present, not that
> the browser will run it. `TestAppPage_ScriptTagsAreBalanced` now pins the second on all seven URLs.
> Live on demo-hp 9201; the floor stays 0.314.0 until all three test boxes have taken it.
> Not yet validated: the operator's own look at the page, and a real Plex claim through the new card.
> **2026-10-15 (evening) — v0.314.0, the floor since 13:11 UTC (raised by CC's token after the three test boxes; no golden).**
> A restore puts the setup gate back as it was (`internal/stacks/restore_gate.go`, felhom.eu `09` §3 decision 253, R-1057);
> `util.ParseVersionPre` puts `X.Y.Z-rc.N` below `X.Y.Z` for the floor ONLY — never for the tags offered (R-1053);
> Prowlarr's start runs the media wiring (`linkOnlyApps`, R-1058); every Plex call goes to the container's address
> (`mediawire.PlexURL`, R-1060 — Plex refuses the name `plex` as non-local). Still open: the real Plex claim on demo-hp
> 9201 (the operator's code), R-1052's new-box proof (a throwaway customer is session only).
> **2026-10-15 (afternoon) — release rule `09` §3 decision 250:** a release needs no golden; the floor carries it after
> the three test boxes; the golden is weekly. Test images use `-rc.N` tags — the household button skips them, but the
> floor never moves a box running one (`maybeAutoUpdate: not parseable — skip`, R-1053): write the release tag back.
> **2026-10-15 — v0.313.0, the media build (R-1023 and the rows under it; `09` §3 decisions 245, 247, 248).** Media apps bind
> every PRESENT drive through an owned `docker-compose.override.yml` (`media_library:` marker in the template); a real
> restart (force-recreate + start times); the drive wiring (`internal/mediawire`, keys read at use — Plex's own token,
> decision 248; Jellyfin's key named „felhom"); drive events with the notice, „restart now/later" and a stream check; safe
> removal keeps the drive; Plex's code asked after the start (`after_start`). Floor 0.313.0 and golden 0.313.0 vouched by the operator 2026-10-15 11:29. Traps found on Tester 1 and fixed: qBittorrent 5 logs in with 204; the agent's disk
> `state` stays „attached" for a pulled registered disk (presence = backing device); the drive gate raced a safe removal.
> A Plex status probe (`/library/sections`, 401) OPENED an unclaimed Plex while Plex started — removed within the hour;
> never gate on a status a starting app also returns.
> **2026-10-14 (night) — v0.312.0, https only (R-1033, P1; `09` §3 decision 244).** DECIDED BY CC UNATTENDED — operator
> may reverse: the redirect lives in traefik on the box (`felhom-https-only` in the `felhom-forwarded` chain), not in
> Cloudflare, because every box's `cf_api_token` lacks „Zone Settings" (403, measured on all three zones). Cloudflare's
> „Always Use HTTPS" stays the operator's defence-in-depth option once the keys carry the permission. HSTS one day.
> **2026-10-08 (day) — UNRELEASED on main, ships with tomorrow's release (no release today: kernel night 8→9, `09` §3
> decision 178).** R-899 (decision 177): a daytime press never cancels the night's whole-guest backup —
> `internal/quiesce/nightowed.go` ledger, owed tiers never fire the valve; a press reaches the agent as `trigger=manual`.
> R-304: the agent's new 424 `older_unchecked` → `RecoveryOlderUnchecked`, bundle key `recovery.older_unchecked` (hu+en).
> MinAgent unchanged (0.131.0): both changes degrade safely against an older agent. Afternoon (`a40729a`): the
> operator-only `recovery_older_package` event (decision 183; needs the hub of the same day), no eject/format on a
> backup-target drive (R-298 side fix), R-717 comments.
> **2026-10-04 night — v0.293.0 (R-860; floor 0.293.0 on the demo customers only, golden 0.293.0 vouched):**
> `internal/sockheal` — the controller exits after 60 s of socket REFUSALS (never timeouts, only after Docker answered
> once) so Docker restarts it on the current socket, then restarts any other socket user on an older inode (traefik).
> Measured: only a `docker.socket` restart re-creates the file. Live: 9202 104 s, demo-hp 9201 120 s, ids unchanged.
> The global floor stays 0.292.0 until Tester 2 may move (operator ruling 97 limited Tester 2 to three acts).
> **2026-10-04 — v0.290.0 (floored, golden 0.290.0 vouched, needs hub v0.128.0):** `offsiteGuard` EXCLUDES a young
> snapshot superseded the same day in its group (R-824) and REFUSES above the hub's `max_remove` (half); a due
> abandonment on the pinned tier is handed to the hub (`OffsiteAbandonClient`), which deletes after 7 days unless
> cancelled (decision 74); a recovery cancels at the hub. Weekly windows ON fleet-wide since 2026-10-04.
> **2026-10-03 (evening) — v0.289.0 + v0.289.1 (floored 0.289.1, golden 0.289.1 vouched, needs hub v0.127.0):** the
> hub-provisioned off-site tier is reached through an APPEND-ONLY key the hub's registrar pins
> (`offsiteapply.HubRegistrar`, `PinnedProber`); the box never receives the Storage Box password; transport `rclone:`
> over ssh 23 (`OffboxTarget.Transport = "rclone-pinned"`; the household NAS stays sftp). Retention only inside a hub
> window behind the fake-snapshot guard (`backup/offbox_window.go`) — weekly windows are OFF; the guard is too strict
> after manual runs (R-824, next). Move-aside is the hub's; abandonment deferred to the operator (R-823). v0.289.1: the
> provider rclone NOTICE line is stripped from restic output, and an unreadable snapshot count is never a measured 0
> (it caused one false `offsite_snapshots_dropped` mail, R-825). Both demo boxes migrated live with history kept.
> Evidence `felhom.eu/documentation/audits/offsite-lock-build-2026-10-03/`.
> **2026-10-02 (afternoon) — v0.288.0 (floored, golden 0.288.0 vouched):** `userdata_kept` in hdd-data and both remove
> results; the dialog says the household's files stay and names them (decision 67). Rule for later work: a remove NEVER
> deletes a `${USERDATA_PATH}` folder — only the app's `${HDD_PATH}` binds, volumes and backups.
> **2026-10-02 — v0.287.0 (floored, golden 0.287.0 vouched): the family gate.** `internal/family` (members, sessions asked
> on every request), `stacks/family_gate.go` (door before the first start; anchored exceptions; record at install and at a
> removed app's restore; `min_controller`), `web/family_gate.go` (forwardAuth, `/__family/*`, the Család card). The family
> cookie is never accepted by RequireAuth. Rules for later work: a family door is never removed while the app is
> installed; an exception is a literal prefix, anchored; never an exception on an app with no login. Live proof
> `felhom.eu/documentation/audits/family-gate-2026-10-02/A/items.txt`. Catalog: Grimmory + MeTube published behind it.
> **2026-10-01 night — v0.286.0 (9202 only) → v0.286.1 (floored, golden 0.286.1 vouched).** Operator ruling `09` §3
> decision 63: extend the box's own gate; first the box tells visitors apart (R-753). `internal/infra`: `felhom-tunnel`
> network `172.16.253.0/29` (ip-range `.4/30`), cloudflared ALONE at `.2`, traefik at `.3`; traefik's websecure trusts
> forwarded headers from `172.16.253.2/32` only and passes `felhom-forwarded@file` (removes client-writable
> host/path/address headers, fixes `X-Forwarded-Port: 443`). `EnsureBaseStack` now RECONCILES a running traefik/cloudflared
> whose rendered files changed (recreate; refuses dropping a certificate resolver). `internal/web/clientaddr.go`: `clientIP`
> = the hop traefik saw (rightmost XFF, only when the peer is traefik), the tunnel hop → `CF-Connecting-IP`; `rateKey`
> IPv6 per /64. **Readers take the visitor from the RIGHT, never the leftmost** — catalog apps that read the leftmost have
> `<router>-xff` (19, catalog). R-772: `HealthProbeResult.NotChecked`, seen on the next tick (0.286.1). R-773: a removed
> app restored gets its sign-up lock back (`SetupGateByRestore`). Open: R-776..R-779 (felhom.eu register). The permanent
> family gate (R-780) was SPIKED and passed — not built; the operator decides.
> **2026-10-01 — v0.285.0.** Operator ruling `09` §3 decision 56 (R-745): `stacks/controller_image_retention.go` keeps
> the running controller image + the previous (swap record `update-state.json` via `UpdateState.RecordedPrevious`; version
> order as fallback) + anything above the running version + non-version tags; deletes older/untagged by exact name/ID,
> 3 min after every start (main.go, after the one-time app sweep). Measured: the agent's swap rolls back to the RUNNING
> image (what `/etc/felhom-controller-image` named), never to a controller-supplied previous. R-751: nil-stack panic in
> `RetainImagesAfterUpdate` fixed; both retention seams are no-ops in the stacks tests (`TestMain`).
> **2026-09-30 late — v0.284.0.** Operator rulings: `09` §3 decision 53 (R-736 A: keep running + previous image per
> service, delete older, never an image a container/compose/record names) → `stacks/image_retention.go`, with a
> one-time sweep at start; R-741 → `stacks/install_hold.go` (after_install apps held behind the gate's door until the
> login is replaced). Decision 52 (same-tag re-tests) needed NO controller change (measured by a unit walk).
> **2026-09-30 — v0.283.0 / v0.283.1.** Decision 50: `settings.DefaultOffboxOnForNewApp` from the deploy-done hook
> (an earlier per-app choice wins); one press for older apps; `backup/offbox_fit.go` size card (estimate at each run
> + background from the page, never `du` in a request). R-721: `stacks.Manager.WantsStopped` asked by the quiesce
> resume, the volume dump (through `stackAdapter` — the v0.283.0 miss), the update leg and `AppStopGuard.Recover`.
> R-724/R-725 page slips. Floor 0.283.1, MinAgent 0.131.0. No golden (the operator's choice before Tester-2).
> **2026-09-28 evening — v0.279.0.** `stacks/after_install.go` (decision 45: a fresh install replaces a known default
> login; the deploy-done hook now set on every box), `web/known_login.go` (the page's default-login rule),
> `backup/hollow_watch.go` (Part D: a running app's empty copy → digest + page), `web/night_chain.go` + `RunUpdateLegNow`
> (R-705), `Router.removeVerificationCopy` (R-706). Also: v0.278.0 (R-704) earlier the same day.
> **2026-09-28 — v0.277.0 (MinAgent 0.131.0).** `backup/kept_load.go`: `KeptBestCopy` (local newest vs off-site —
> off-site only when NEWER or the only copy), `KeptOffsiteCopies` (ONE `snapshots --json`, 20 s bound; `offsiteNewest`
> now carries `paths`), `LoadKeptOffsite` (unit-only download via `restoreUnitReadOnly` into the PROOF scratch → judge:
> `KeptCopyAt` + `data` block required + `unitVersionCheck` → `prepare` → `keptUnitRestore` (seam) → scratch removed
> before the outcome). `KeptCopyKey` names a copy on both pages. Golden 0.276.0 baked + vouched with agent 0.137.0 the
> same day.
> **2026-09-27 — v0.276.0, floor 0.276.0 (MinAgent 0.131.0).** `stacks/life_records.go` `carryLifeRecords` — the restore's fresh `app.yaml` keeps `desired_state`, `conversion_copy`, `earlier_conversion_copies`, `failed_update_step`, `last_update_undone`, `last_auto_update` (NOT the pin, NOT `installed_images`); `recordConversionCopy` moves a superseded copy to `earlier_conversion_copies`; `ReleaseConversionCopies` → `releaseOneConversionCopy` per kept copy. Drive move: `persistDriveFlip` (load-then-save, `HDD_PATH` only) + `upFromAppConfig`; `flipEnv` removed. **A new `app.yaml` writer that is not a new install must load-then-save** (R-697, R-700). Tests `r700_records_carried_test.go`.
>
> **2026-09-27 — v0.275.0, floor 0.275.0 (MinAgent 0.131.0).** `backup/data_versions.go`: `stampDataFile` (called by the
> DB leg, the volume leg and `RunAppBackupNow` — a new writer of a unit data file MUST call it), `foldUnitData`,
> `RecoveryManifest.Data`; `captureRecoveryUnit(stack, dataRun)` keeps `compose/` when the pins moved since the data
> (frozen); `unitVersionCheck`/`ErrUnitVersionMismatch` in the unit restore and the off-site reconstitute (which now
> writes the snapshot's definition when versions differ); `unitDataTime` = THE unit data time (+ proven flag, R-699);
> Tier 2 `DataDate`/`DataUnproven`; Tier 3 `offsiteDataTime`; `stacks.DumpStampSource` (optional guards interface) +
> `ConversionCopy.Service`; `stacks.RestoredVersionPosition` + the restore pages' first sentence; R-695 single-flight
> FB sync; R-691 `keptReadGroups`/`group_add` (CC-unattended decision, `07` §6.5); R-694 `restored_logins`.
> D4 (open) option A is the built behaviour — `07` §6.6.
> **2026-09-25 midday — v0.272.0, floor 0.272.0.** `web.noSpaceLine` (agent prefix `skipped: not enough space: `,
> key `backup.tier.no_space`); `backup.SetUndoCopyRemover` → `stacks.RemoveUndoCopies` from `clearUpdateHoldAfterRestore`
> (R-671); `stacks.LoadProbeMetadata` (R-670); `stacks.BehindSinceAge` in both badge producers (R-677). Rulings: Peti's
> box retired; `09` decision 34 — no resume of the update leg after a restart.
> **2026-09-25 night — v0.271.0, floor 0.271.0 (MinAgent 0.131.0).** `stacks/unattended.go`: `RunUpdateLeg` — one
> app at a time, ONE step per app per night (decision 33), only `proven` entries, never `needs_person`,
> `files_may_change` only with `backup.FreshWholeCopy` (the hold's truth table), never the step in `app.yaml`
> `failed_update_step` while the ladder print is unchanged (R-680), transient refusals retried once, no step at or
> after W+5h (`backupwindow.UpdateLegStopOffsetMin`). Chained by `chainUpdateLeg` inside the `offbox-backup` job
> (every path incl. panic). `quiesce.Options.UpdateLegFn` = decision 20 + 31 (the gate waits until W+5h, then only
> for a step in flight, cap W+5h30m). Self-update waits for the whole leg (decision 32). Switch `settings.json`
> `app_update.unattended`, absent = ON, card on /settings (`POST /settings/app-update`). Page line
> `last_auto_update` („Automatikus frissítés %s-kor — sikeres."), report `update_leg`. R-678: `ScanStacks` before
> `finishUpdate` on done/undone. `stacks.update_window` REMOVED. Decisions 31–33 taken unattended (operator may
> reverse). Open: R-686 (no resume after a restart), R-687 (live-proof gaps), R-685 controller half (the backup
> page line for a space skip). Record: `felhom.eu/documentation/audits/DRILL-night-2026-09-25.md`.
> **2026-09-24 evening — v0.270.0, floor 0.270.0.** `UpdatePreflight` refuses `already_current` (R-679).
> `stacks/install_interrupted.go`: `.felhom-install-pending` marker + `RecoverInterruptedInstalls` at start (after the
> deploy-done hook) → compose down, stale pins cleared, `app_deploy_failed`, `.felhom-install-interrupted` → the
> apps page sentence (R-681). The recovery unit captures `stacks.AppliedMetaFile`; a restore calls
> `RecordRestoredAppliedMeta` (R-669). Next: part 7 (the update leg, `09` §6.4.2), R-678, R-680, R-682.
> **2026-09-24 night — v0.269.0 + v0.269.1. Fleet floor 0.269.1 (MinAgent 0.131.0; needs hub v0.123.0).**
> `backup.RestoreTier2Whole` (`tier2_whole.go`, decision 26): `mergeRestoreFiles` by the four file rules
> (never delete; never an older file over a newer one; bring back missing; an older differing live file kept
> beside as `.felhom-<UTC>`), then `RestoreFromRecoveryUnitAtWith(AcceptMissingFiles)` from the MIRROR; refuses
> before anything moves (not a file app / no proven openable mirror / < 2 GB). `sameDevice` fails CLOSED (R-668).
> Decision 27: `RemoveKeepsDataOnly` → 409 `err.stacks.remove_keep_data_while_support`; `keep_data_only` on
> hdd-data. Decision 28/29: `stacks.ObserveUnhealthy` (RestartCount delta ≥ 6 in 10 min, OOM ≥ 20 in 30 min)
> → `stopUnhealthyApps` → `HoldUnhealthy` (`unhealthy_stop`, trip 2 within 24 h) → `app_stopped_unhealthy`;
> Start lifts it. R-664/R-665: `entry.NewMeta` = the step's own `.felhom.yml`. §6.4 part 6 box half:
> `stacks/digest.go` — `RenderWithLadderDigests` for update + fresh install; **`CarryDigests` for an installed
> app's sync (v0.269.1, decision 30)**. Open next: R-669 (applied-meta after hold + restore), R-679/R-680 +
> part 7 (the update leg, `09` §6.4.2), R-681/R-682 (interrupted install/remove). Record:
> `felhom.eu/documentation/audits/DRILL-night-2026-09-24.md`.
> **2026-09-24 — v0.268.0 (R-658, R-659, R-660, R-651; `09` §6.4 part 5). Fleet floor 0.268.0.** The undo
> selects volumes with `DeclaredVolumeNames` (the compose definition), never the project label; the restore
> labels volumes (project/volume/version, no config-hash). The hold names the newest WHOLE copy
> (`backup.WholeOnTier` = the restores' refusal predicate `DeclaredDriveFileLegs`; off-site only for file apps)
> or, with none, `hold.update.no_whole_copy` + no Mentések button + `app_hold_no_whole_copy` (needs hub
> v0.122.0). `UpdateHeldStacks` is the fourth app-down suppression. Remove deletes `applied-compose.yml` +
> `applied-meta/`. The ladder: `stacks/ladder.go` — `nextLadderStep` picks the NEWEST entry whose `from` is
> the pin; a non-head step pins `catalog-cache/templates/<app>/steps/<StepKey(to)>.yml`; missing/wrong →
> `update.error.pin_failed` before anything moves; no match → template, logged. `LadderStepsLeft` on the
> page. Open next to it: R-661 (Tier-2 no whole action), R-664 (a step has no .felhom.yml), R-665, R-666,
> R-667 (crash-loop clock resets on a flapping container).
> **2026-09-23 (night) — v0.267.0 (R-650, R-640, R-499, R-518).** Every docker exec goes through
> `internal/dockerexec`; under `go test` a real docker is refused (opt-in `FELHOM_TEST_REAL_DOCKER=1`; a
> stub under `os.TempDir()` passes). `api`/`stacks`/`web` tests run under `RunWithStub` (TestMain). Restore:
> `appbackup.CheckDumpComplete` — a dump without its engine's end marker is refused before the first
> mutation (unit + off-site) and again before any load. Tier-2 page: `systemBackupFact` → four sentences.
> Backup button: the measured ~8 min. R-626 not reproduced (closed by measurement).
> **2026-09-23 (late evening) — v0.265.0 (R-634, R-625, R-636, R-647).** A whole-box backup no longer
> stops/restarts a DEPLOYING app (`ListDeployedStacks` skips `Deploying`; the volume leg re-asks via the
> optional `deployingReporter`; `StopStack`/`StartStack` → `ErrStackDeploying`). Held apps: badge
> `badge.update.held`, no Update button. OOM: the kernel `oom_kill` counter (the `OOMKilled` flag is sticky);
> ≥20 kills / 30 min per container run → one `app_oom_storm`. Web `readerFuncs` render hold/error per reader
> in EVERY language. Needs hub v0.121.0. Floor 0.265.0. Open: R-649 (operator), R-650 (tests on real docker).
> **2026-09-23 (evening) — v0.264.0 (`09` §6.4 parts 2–3; R-606, R-620, R-646).** Two events,
> `app_update_undone` / `app_update_held`, one per app per outcome, via `Manager.SetUpdateEventSink`
> (main.go) → `NotifyAppUpdateUndone` / `NotifyAppUpdateHeld`; on by default and seeded once on existing
> boxes (`app_update_events_seeded`), with two toggles on the notifications page (the page pushes the
> list to the hub on save). Update sentences are stored as key + args (`UpdateErrorKey/Args`,
> `update.error.*`, `update.refusal.*`, `update.phase.*`, `hold.*`) and rendered per reader
> (`updatePhaseText` / `updateErrorText` / `holdText`, `RestoreHoldForLang`, `UpdateErrorIn`); stored
> Hungarian unchanged. Startup `BackfillAppliedMeta` (R-646). Disabled notifier WARNs once per type
> (R-620). Needs hub v0.120.0. Floor 0.264.0. Leftovers: R-647. Evidence:
> `felhom.eu/documentation/audits/undo-fleet-2026-09-23/`.
> **2026-09-23 — v0.263.0 → v0.263.2 (R-637, `09` §3 decisions 15 + 19).** A failed health check after
> an update is now UNDONE: phase `copying` (after the pull, the app stopped anyway) copies every NAMED
> volume `cp -a` into `<vol>.pre-update-<stamp>` (label `felhom.undo-copy-of`, finished-marker last);
> on failure `undoing` validates every copy first, refills the volumes, puts back definition + pin +
> the pinned version's `.felhom.yml` from the job's OWN copies, and checks the old version with ITS
> probe → `undone` (`app.yaml` `last_update_undone`, one page line) or a HOLD prefixed with the undo's
> failure and the data state. Bind folders are never touched. Journal recovery: `copying` → old version
> back; `undoing` → resumed. **Two live-only lessons, both now in code:** (1) the periodic probe with
> the CURRENT `.felhom.yml` flips the app `unhealthy`, so the undo's health wait must probe an
> `unhealthy` app with its own override (`waitUpdateHealthyMeta`, v0.263.1); (2) `.felhom.yml` flows
> into the stack dir on every catalog SYNC, so the pinned version's file must be recorded AT PIN TIME
> — `<stack>/applied-meta/.felhom.yml`, written at deploy, adoption and pin advance (v0.263.2). Apps
> pinned earlier have no record until their next pin (R-646). R-642: start/restart never say
> "completed". **The fleet floor is still 0.262.1** — raising it is the operator's decision. Evidence:
> `felhom.eu/documentation/audits/undo-bakeoff-2026-09-23/`, `…/undo-live-2026-09-23/`.
> **2026-09-21 (evening) — v0.261.0 (R-608 + R-609).** The controller self-updates daily at **04:30** by default and after ANY hub report once a floor sits above the box; that swap restarts the controller, which is the supervisor of a running app update. The window `09` §3b Q1 proposes for automatic app updates is 02:30-05:00. **It contains 04:30.** **A two-way lock, wired in `main.go` — `stacks` never imports `selfupdate`:** `Manager.AnyUpdating()` -> `Updater.SetAppUpdatingCheck` (a sibling of the existing `SetBackupRunningCheck`, consulted in the SAME three places), and `Updater.IsUpdateRunning` -> `Manager.SetSelfUpdatingCheck` with `UpdatePreflight` refusing `self_updating`. **MEASURED: the gap was NARROWER than assumed** — the update's `backing-up` phase already took the backup single-flight, so only the other six phases were exposed. The live probe landed in `safety-dump`, i.e. in the real gap. **THE PROPERTY THAT MATTERS: the lock must NOT latch** — `Stack.Updating` clears on done, failed AND held, so a held app does not block the controller's own updates for ever. **R-609:** the 409 now carries `data.reason` — transient (`busy`,`updating`,`deploying`,`migrating`,`self_updating`) vs terminal (`held`,`downgrade`). **Found while writing the test: the router refuses a HELD app on its OWN line BEFORE `UpdatePreflight`**, so `held` — the reason an unattended caller needs most — would have been the one missing. Five red-proofs, each seen to fail. **Proven live on guest 9202 with a negative control:** with no app update the manual swap gives the AGENT refusal; with one in flight it gives OURS. The sentence changing IS the proof. Deployed to **9202 only**; the fleet floor is 0.260.0 and 0.261.0 is a separate operator ask. Measurements: `felhom.eu/documentation/audits/update-arc-gaps-2026-09-21/`.
> **2026-09-21 — v0.260.0 (R-524, update arc).** A box that updated before the catalog was reverted under it read „Frissítés elérhető" over an Update that would have moved its pin BACKWARDS (measured BIGNIGHT Phase 6, privatebin 2.0.6 vs catalog 2.0.5). **The comparison gains a fourth verdict and MOVES OUT OF `web`:** `stacks.CatalogOrder` — Unknown/Current/Behind/**Ahead** — is read by BOTH the badge and `UpdatePreflight`, because a comparison implemented twice drifts. Ahead reads „Naprakész"/"Up to date" (`tag-ok`, same word and class as level — nothing for the household to do) with a title saying why; the Update is refused `downgrade` 409. **The API now renders update refusals through `errText`** — otherwise the new key would be a seam built and never wired. **Ahead is NARROW:** every differing service must be orderable AND newer, else Behind — this gate can BLOCK an update, so it errs towards letting one run. **THE TRAP, and the fixture caught it, not the design:** the first tag rule accepted only bare `X.Y.Z`, so every REAL catalog tag was unorderable and the refusal test failed with `got nil`. The rule now takes the version at the FRONT and requires the trailing suffix to be IDENTICAL on both sides — `31.0.14-apache → 31.0.15-apache` orders; `26.05.2-ls310 → -ls311`, `postgres:16-alpine`, `apache-2.57.0`, a date stamp and a digest pin do not. Ordering is `util.Version.Compare` and nothing else (one comparator, house rule). Three red-proofs, each seen to fail. **R-589 was NOT open** — it shipped in v0.258.0 and only its row was stale; a reviewer who reads ONE producer cannot see a SECOND that overrides it. **Floor NOT raised — the operator's step; and CONTEXT's own 0.257.0 was STALE — the hub says 0.259.0 (read live from `/configs`, never from a doc).** Deployed on demo-felhom 9201, demo-hp 9201 and demo-hp 9202 (scratch, upgraded from 0.245.0 for the live proof). Seven questions for Slices 6 and 7 are in `09` §3b; the state of the whole arc with drift numbers is `audits/UPDATE-ARC-STATE-2026-09-21.md`.
> **2026-09-21 — v0.259.0 (R-596 P1 + R-598, the drill's blockers).** The claim page's answers and the Backup page's protection warnings follow the reader's language. **Fourteen** live sites carrying **nine** messages, not the sixteen literals R-596 counted — and the sixteenth, `data["Title"]`, was **DEAD** (`claim.html` is standalone; `.Title` belongs to `layout.html`) and was DELETED rather than translated. `backup_handlers.go` had **nine** code literals, not twelve; three were inside comments. `degradedMessageFor` now returns a **KEY**, so the decision stays language-free in one place while the words are chosen by whoever knows the reader; `buildTierViews`/`backupTargetLabel`/`loadGuestBackup` take `lang` the `buildDataPathCards` way. **The anonymous cookie-less claim page's language chain** (`langFor` → `settings.GetLanguage` → `configLanguage` ← `cfg.Customer.Language`) **was an unpinned assumption and is now a test.** Six existing copy-contract tests were kept rather than weakened — each resolves its key through the real bundle, so they still convict on a reworded Hungarian sentence. **Two things the tests caught and review did not:** an apostrophe in an English value is escaped to `&#39;` and NEVER matches on the page (the failure reads exactly like an unwired handler — R-603), and the i18n gate convicted two of my English sentences for saying "please". **Proven live on guest 9201 through the `felhom_lang` cookie** — and the lockout proved itself unasked: Hungarian attempts locked out the English request from the same source, so the counter is per SOURCE, not per language. **The instrument trap that nearly cost a second fix (R-602): the cookie works only on ANONYMOUS pages** — `langFor` step 2 means a signed-in request reads the household's setting and ignores the cookie, so `/backups?felhom_lang=en` returns HUNGARIAN and reads like an unfixed defect. Use `?lang=` behind auth. **Floor NOT raised — the operator's step**; it still stands at 0.257.0. Guest 9201 is on **felhom-pve**, and **demo-hp answers on no route** (R-601).
> **2026-09-20 — v0.258.0 (R-589/R-590/R-573/R-572, slice 6 Part 0).** The four leftovers slice 5's LIVE proof found, all Go-side: the update badge and the lifecycle badge through `localeFuncs` (reusing `compareInstalledToTemplate`/`EffectiveLifecycle`, so the DECISION cannot drift between languages — only the words do); the data-folder card's backup sentence through `s.msgLang`, with the test asserting the CONSEQUENCE in both languages AND that the two differ; the two channel banners keyed by the checker's own CLASSIFICATION with the composed sentence as a FAIL-OPEN fallback. **R-572 was not what its row said** — nothing called `pruneLabel`/`nextPruneLabel`, so the two dead Hungarian-producing func-map entries were DELETED; deletion is fail-loud and that was proven (a template naming a removed func panics `loadTemplates`). Five red-proofs; one of them convicted the GATE rather than the code — `localeFuncs` keys sit under `_preexisting` and are not measured against the base capture, so the citation to `TestLocaleFuncsHungarianBundleMatchesFuncMap` is what carries them, and that test was extended to make the citation true.
> **THE WALK (slice 6 Part B) then judged it**, on a box installed from scratch that day by an English speaker: `felhom.eu/documentation/audits/DRILL-first-hour-en-0258-2026-09-20.md`. Verdict **NOT YET ready for an English-speaking tester** — `internal/web/claim.go` has **16 raw Hungarian literals**, every one a message on the one screen between a household and their box (**R-596, P1**), and `backup_handlers.go` + `backup_target_offer.go` carry the Backup page's two protection warnings (**R-598**). Both are the *composed-sentence-into-page-data* shape — **three instances on file now** (R-573, R-596, R-598), worth naming as a class. Everything else held with ZERO Hungarian: the download page, the bilingual console, all three customer mails, the bind page, the dashboard's first language from `customer.language` alone, both app pages, the catalog, the language switch. **R-214 closed as a side effect.** **Floor NOT raised — the operator's step.**
> **2026-09-20 — v0.257.0 (R-560 slice 5 Part A).** The catalog's `.felhom.yml` may now carry an `i18n: {en: …}` sibling block, and the dashboard reads it. `Metadata.For(lang)` merges FIELD BY FIELD (absent or blank English shows Hungarian, so a half-translated app ships); `For("hu")` is the parsed struct with `I18n` cleared and nothing else, deep-compared over all **53 real catalog files** copied into `internal/stacks/testdata/catalog/`; the three prose lists replace WHOLE and every other list is matched by its own key (`env_var`, option `value`, `match_group`, `target`, `path`) — **never by position**; `For` never writes through the manager's shared metadata. Pages reach copy only via `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`, and `TestNoDirectMetaCopyReadOnPages` AST-parses `internal/web` against a named allow-list so the NEXT page to read catalog copy fails the suite instead of rendering Hungarian to an English household. **Two of the nine red-proofs convicted a hollow TEST, not the code:** a struct copy shares its slices' backing arrays, so the obvious `DeepEqual` mutation check passed a deliberately broken merge; and a ONE-entry fixture cannot tell key matching from position matching. Both rewritten. Proven live on demo-hp (English pages English, seven Hungarian pages byte-identical apart from the CSRF token) and on demo-felhom **0.255.0** (block synced, pages identical, no parse warning in a 93-line log window). Gaps: R-589 (update badge still Hungarian in English), R-590 (the data-folder promise sentence), R-591 (`Stack.Copy()` does not deep-copy the new `Meta.I18n`), R-592 (three defects inside the new catalog gate, each found by its own decoy). **FLEET FLOOR RAISED to 0.257.0** the same session (operator asked), `min_agent` 0.131.0 declared — above the vouched golden 0.246.0, so the declaration carries it (R-472). Hub: `managed floor SERVED for demo-felhom … from declared`; **demo-felhom went 0.255.0 → 0.257.0 by itself in under 12 s**, healthy, `settle-gate: GO — at/above floor 0.257.0`, **and then rendered the English tagline** — the floor delivered the FEATURE, not a version string. Peti's box and tester-1 are DOWN and take it unattended when they return. The catalog was fully translated the same day (50 more apps in three pushes, 1 031 of 1 032 strings): **the English Apps list shows ZERO Hungarian app descriptions across all 53**. Gap added: R-594 (the catalog gate can convict a retrieval promise but cannot REGISTER a true one).
> **2026-09-18 — v0.255.0 (fixes the v0.254.0 globe).** The sign-in-flow pages requested `style.css` with **no `?v=`**, so a browser holding a pre-0.254.0 copy served CSS with no `.lang-globe` rules and the globe rendered as a bare unstyled `<details>` — **invisible to every test, because they all read markup and the fault was in which CSS file the browser fetched**. It was FIVE shells, not three (both guest share pages too), and `.Version` was missing from three of their data maps — now set once in `executeTemplateLang`. The globe also moved INSIDE the card, centred under the footer, menu opening upward via the shared rule. 15 shell fixtures re-captured, **zero dashboard ones**. R-579.
> **2026-09-18 — FLEET FLOOR RAISED to 0.254.0** (operator asked), `min_agent` 0.131.0 declared — above the vouched golden 0.246.0, so the declaration carries it (R-472). Hub: `managed floor SERVED … from declared`; demo-felhom went 0.253.0 -> 0.254.0 **by itself in ~40 s**, healthy, four other containers up, own log `settle-gate: GO — at/above floor 0.254.0`, and its sign-in page shows **one globe, zero old text links**. **Peti Proxmox (DOWN 65 d, 0.115.0) and Tester 1 (DOWN 1 d, 0.245.0) did NOT get it** and take it unattended when they return — untested on this version. Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/floor-raise-0.254.0.md`.
> **2026-09-18 — v0.254.0 (R-557 slice 2 release C — SLICE 2 CLOSED).** Saved notes follow the box language at WRITE time (§16 option 1): ~70 producers, and `EndRestoreOp` no longer takes a Hungarian literal from anywhere. The language switch is a **globe** (`<details>`, no script) in the sidebar footer and on the sign-in/claim/recovery pages; a visitor's choice lives in a display-only `felhom_lang` cookie that `langFor` reads ONLY when there is no session, and a claim carries it into the household setting on success. `POST /lang` is CSRF-exempt for a narrow reason written at the exemption; `safeBackPath` refuses `//evil.example` too. **Two parity exceptions, measured with a real diff: exactly two change shapes across 106 fixtures, 5 byte-identical.** **A DEADLOCK was introduced and caught by the suite hanging** — a note rendered inside `UpdateOffboxStatus`'s callback takes the settings read lock while the write lock is held; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` guards it now. 629 literals left, 0 errors, 0 saved notes. New row R-577.
> **2026-09-18 — FLEET FLOOR RAISED to 0.253.0** (operator asked), `min_agent` 0.131.0 declared — the floor is above the vouched golden 0.246.0, so the declaration is what carries it (R-472). Hub: `managed floor SERVED … from declared`; demo-felhom went 0.250.0 -> 0.253.0 **by itself in ~20 s**, healthy, its other four containers up, and its own log reads `settle-gate: GO — at/above floor 0.253.0`. Both live boxes run agent 0.132.0 (above the requirement). **Peti Proxmox (DOWN 65 d, 0.115.0) and Tester 1 (DOWN 1 d, 0.245.0) did NOT get it** and will take it unattended when they return — untested on this version. Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/floor-raise-0.253.0.md`.
> **2026-09-18 — v0.253.0 (R-557 slice 2 release B).** All **179** Hungarian error messages carry a key (`util.MsgError`): `Error()` is still the Hungarian byte for byte, `errors.Is` answers for the kind AND the wrapped cause, and an inner error argument renders recursively. 76 display sites go through `errText`, pinned by `TestNoErrErrorInPageOutput`. `memoryVerdict` returns an error, `UpdateRefusal` gained a `Cause`. Plurals: a key with `.one`/`.other` takes its count FIRST (bundle rule, not a call-site flag) — the guard caught a real key collision. **Two tooling defects found and recorded:** the bulk converter dropped multi-line concatenations (7 producers; the parity gate could not see it, two behaviour tests could), and my counter was case-sensitive and under-reported. 705 literals left, 0 of them errors.
> **2026-09-18 — v0.252.0 (R-557 slice 2 release A).** The sentences the program BUILDS now follow the language: 226 Go literals converted (flash lines, page data, API answers, alert banners, 237 country names, the four app-named page titles — R-566 closed). Flash travels as a KEY in the redirect URL with `fa` parameters; an old link's prose is still shown verbatim. New gate `i18n_go_parity.py` refuses any key whose Hungarian is not byte-identical to the base commit (7 467 literals frozen; 3 decoys). Wire goldens freeze the report warnings and the event messages the hub MAILS — those stay Hungarian until slice 3 (R-558). Remaining: 894 literals, 176 of them `fmt.Errorf` (release B); persisted text (release C, §16 option 1). New rows R-572/R-573/R-574.
> **2026-09-17 night — v0.251.0 (R-553 + R-563 CLOSED).** Five decisions that read their own Hungarian words now read a kind: deploy status sentinels (`internal/stacks/deploy_errors.go`), `backup.ErrOffsiteQuota`, `monitor.WarningKinds`, `settings.LastWarningKind` (legacy text fallback until R-570), and `data-status` on the remote-backup status. `util.KindErrorf` keeps message bytes identical. Slice 2 (R-557) is unblocked except the one producer named in R-570.
> **2026-09-17 night — v0.250.0 (R-556 release C, slice 1 CLOSED).** All templates converted; the language switch is on every dashboard page (decision 6 superseded); login/claim/guest/catch-all through `executeTemplateLang`; Go-side messages and three app-name titles stay Hungarian (slice 2).
+218
View File
@@ -0,0 +1,218 @@
# REPORT — the app page in one column, and one image per version number (2026-10-15)
**A non-overwritten sibling of `REPORT.md`.** Two sessions released this repo on the same evening;
`REPORT.md` is per-session and overwritten, and it now holds v0.317.0's (the storage work). This
file holds the app-page arc so it stops being clobberable — the convention `felhom.eu/CLAUDE.md`
prescribes for parallel sessions, applied here.
## 1. Baselines
| Repo | `main` at start | Version at start |
|---|---|---|
| felhom-controller | `8339206` | v0.315.0 |
| felhom.eu | `340804f9` | docs only |
Architecture read before any claim: `02-controller-module-map.md` „The app page",
`09-update-architecture.md`, the `felhom-ui-design` skill, `CLAUDE.md` + `.claude/rules/*`.
Edited on the Windows workstation; built and tested on DooPlex in an isolated clone; the browser
work is on the workstation, which is why CC runs here.
## 2. PART 0 — the diagnosis, before any fix
**Verdict: (a) a cache.** Evidence: `felhom.eu/documentation/audits/app-page-2026-10-15b/P0/VERDICT.md`.
**(b) is ruled out.** Exactly one guest ran 0.315.0 — demo-hp 9201 — on digest `8887343011d6`,
identical to the registry's 0.315.0 at the time of measuring. The **global floor was 0.314.0** (read
from the hub's `/configuration`), so no box could have taken 0.315.0 by itself. demo-hp 9202 and
felhom-pve (N100) 9201 were both on 0.314.0; Tester 1 (`c11-appliance`) was unreachable and, by the
floor, cannot have had it.
**The mechanism, in four measured facts:**
1. `layout.html` asked for `/static/style.css?v={{.Version}}` → `?v=0.315.0` for **all three**
different 0.315.0 builds (`e66d466`, `fe0228c`, `db52f5a`); the first had no `.app-tabs` rules.
2. The origin sends **no validator**: on the box, `ETag present = 0`, `Last-Modified present = 0`.
A cached copy cannot be revalidated — only expire.
3. Cloudflare caches that URL (`cf-cache-status: HIT`, `age: 99 → 100` over three requests) and
**rewrites the browser TTL from the origin's 3600 to 14400** — four hours.
4. The session's first request returned **`cf-cache-status: EXPIRED`**, which only happens when the
edge holds an entry that has aged past its TTL.
**The caveat, stated rather than glossed:** origin, edge and box now agree byte-for-byte
(`sha256 c1774f63…`) and all carry the rules. The stale object itself is **no longer observable** —
that first probe forced its revalidation, which is what `EXPIRED` records — so no `HIT` with a bad
body can be produced. What is shown is every precondition, the proof an aged entry existed, and the
elimination of the only other candidate. **No cache was purged by hand.**
## 3. What shipped
| Area | What |
|---|---|
| Push-once | `build.sh` refuses `--push`/`--multiarch` for a tag already in the registry, before building. A registry it cannot reach is a refusal too. Local builds untouched. |
| Clean tree | `build.sh` also refuses a tree that is dirty, not at `origin/main`, or not a git checkout. Added after it was needed — see §6. |
| Asset tag | `assetTag()` hashes version + `GitCommit` + `BuildTime`; **all nine shells** use it (seven asked for `?v={{.Version}}`). Opaque 8 hex, because the stranger-facing share pages use the same value. `Cache-Control` unchanged — the new address is the fix. |
| Layout | The approved one-column mockup: 820px column, hairline sections, underlined tabs, no hero box, 200px label grid. Two boxes only: blue „the app is waiting for you", amber „this is risky". |
| Banner | The auto-update SUCCESS banner is meta text in the hero. Every banner that needs a decision is untouched above the tabs (R-571). |
| Leftovers | „2. mentés beállítása — <app>" and „<app> — Exportálás" removed; „Cím és kulcsok" with one address row replaces the duplicate disabled subdomain field; the „already installed, read-only" banner is now the hint under the fields; the install wizard gets the same back link, hero and column. |
## 4. Commits on `main`
`9cc7ffc` push-once + per-build asset tag · `7f3e58f` the one-column layout · `48d8674` v0.316.0
CHANGELOG + the rule-file fact · `006ceab` v0.316.1 (the tag reaches the dashboard) · `f908eee`
settings tab de-boxed · `9e68e52` two data-tab leftovers · `8c7aa10` v0.316.3 CHANGELOG · `f9151f0`
**the padding fix** · `4261a38` `FELHOM_REPO_DIR` + the build names its tree · `c6a5671` v0.316.4
the clean-tree gate · `e7b64af` `workspace/` gitignored.
felhom.eu: `b6638ac4` (design docs + the audit), `22cd7c51` (R-1080 closed, R-1081 filed).
## 5. Tests and red-proofs — every break seen failing, then reverted
| # | Break | Result |
|---|---|---|
| B1 | the registry check deleted | 5 of 6 cases fail; a present tag builds AND pushes |
| B2 | an unreachable registry treated as absent | the two unanswered-registry cases fail |
| B3 | the clean-tree gate deleted | all four of its cases fail |
| C1 | `assetTag` from the version only | `TestAssetTag_ChangesPerBuild` fails on both same-version cases |
| C2 | one shell back on `?v={{.Version}}` | `TestAssetTag_EveryShellUsesIt` fails naming layout.html |
| C3 | `addShellAssetData` removed from `executeTemplate` | `TestAssetTag_ReachesTheRenderedPage`: empty buster on four pages |
| D1 | the success banner restored | `TestAppPage_AutoUpdateSuccessIsMetaNotBanner` fails |
| D2 | one tab loses its column | `TestAppPage_OneColumnLayout` fails naming the tab |
| D3 | a section drawn as a card again | same test fails on two tabs |
| E | an informational section drawn as a blue callout | `TestAppPage_CalloutsOnlyForAskAndWarn` fails on both counts |
New: `scripts/test_build_push_once.sh` (**ten** cases, driving the real `build.sh` with a fake
`docker` on PATH — nothing is ever pushed),
`TestAssetTag_{ChangesPerBuild,EveryShellUsesIt,ReachesTheRenderedPage}`,
`TestAppPage_{OneColumnLayout,AutoUpdateSuccessIsMetaNotBanner,CalloutsOnlyForAskAndWarn}`.
Full gate: `go build ./... && go vet ./... && go test ./...` → **exit 0, 37 packages ok, zero
failures**, read from `go test`'s own exit code, not a pipeline's. All controller gates OK; 53
decoys behaved.
## 6. THREE INCIDENTS, one shape
All three are the same defect: **a guard that is not on the path the work actually takes.** Two of
them I caused while fixing the first.
**(i) I re-pushed 0.315.0 a fourth time.** The push-once guard went into
`controller/build.sh` in the repo; the documented release command runs
`$FELHOM_ROOT/build/felhom-controller/build.sh`, which was a **separate copy taken 2026-07-18** with
no guard. The first thing I did with the new refusal — run it against a real existing tag to prove
it fires — therefore rebuilt and re-pushed 0.315.0 with v0.316.0's code under it
(`8887343011d6` → `b809201841f7`). Root cause fixed: the build directory's script is a symlink to
the repo's, the stale copy kept as `build.sh.stale-copy-2026-07-18`, and the refusal then proven
from the documented path. **R-1081.**
**(ii) The asset tag shipped dead in 0.316.0.** Every shell asked for `?v={{.AssetTag}}` and the
template test passed, but `addShellAssetData` was only ever called by `executeTemplateLang` — the
stranger-facing pages. The dashboard path had never needed it, because `layout.html` used to ask for
`{{.Version}}`, which `baseData` supplies. The first live load rendered `/static/style.css?v=` —
**empty, which is a constant, and a constant busts nothing.** Found in a browser. Fixed in 0.316.1;
`TestAssetTag_ReachesTheRenderedPage` now asserts the rendered value, not the markup.
**(iii) Building 0.316.3 published another session's unreleased work.** The symlink from (i) points
into the shared clone's **working copy**, which predated the `FELHOM_REPO_DIR` override — so the
override was silently ignored and the build copied the shared tree, which held 31 uncommitted files
of the physical-disk storage work. The banner said `Commit: 0225255` with no `Repo:` line; the
evidence was on screen and the image was already pushed. **Nobody deployed it — that was luck, not a
control.** The clean-tree rule had existed only as a sentence in a runbook. It is now two questions
the build asks and refuses on, plus a refusal for a source that is not a git checkout at all; it
immediately caught its own next build (the scratch `workspace/` beside the script, now gitignored).
**`0.316.3` must never be deployed.** It is neither re-pushed nor deleted: a version number is
pushed once, and that applies to a number I spoiled.
## 7. Deployed, and what happened to 0.316.4
**demo-hp guest 9201 runs `0.317.0`, healthy.** That is the other session's release, built from
`main` at a commit that contains all of this work — verified: `f9151f0` (the padding fix) and
`c6a5671` (the clean-tree gate) are both ancestors of it.
**`0.316.4` was built but never published, and was then dropped.** Its first build attempt was
refused by its own new clean-tree gate (the `workspace/` directory); by the time that was fixed,
`0.317.0` was already in the registry. Publishing 0.316.4 would have put a *lower* number in the
registry carrying nothing 0.317.0 does not already have. The household's button offers the highest
tag, so it would have been invisible anyway.
**The floor stays 0.314.0** — raising it needs all three test boxes and a green box walk, and this
evening two sessions released the same component.
| tag | state |
|---|---|
| 0.316.0 / .1 / .2 | published, superseded |
| **0.316.3** | published, **CONTAMINATED — never deploy** |
| 0.316.4 | never published (superseded) |
| **0.317.0** | published, deployed on demo-hp, carries this work |
## 8. The browser check, through the PUBLIC address
Chrome on the workstation, `https://felhom.enkisfelhom.hu`, **normal loads — no hard refresh**:
- The stylesheet URL is a real 8-hex per-build tag and the loaded sheet carries the `.app-tabs`
rules. **This is the fix working:** before it, the same page needed a hand-forced refetch.
- `.app-tab-active` computes `border-bottom: 2px rgb(46,168,245)` (`--blue-bright`); column
`max-width: 820px`; hero `border-top-width: 0px`.
- Across settings / data / guide / logs / plex / grocy / install: **1 column each, 1 `aria-current`
on an installed app and 0 on a not-installed one, 0 `.app-info-card`, 0 success banners.**
- Data tab: exactly **1 amber callout** (the same-disk warning). Settings, guide, grocy: none —
correct, nothing is asked and nothing is risky.
- **After the operator's report:** `.app-page` padding `0px 0px 32px`, `.content` padding `32px`,
measured gap **32px from the sidebar** (was 80) and **36px from the top** (was 64+).
- **Console clean on a fresh load**, on 0.316.2 and again on 0.317.0.
Screenshots: `felhom.eu/documentation/audits/app-page-2026-10-15b/browser/` —
`01-settings-boxes-still-there`, `02-settings-one-column-0.316.2`, `03-data-tab-amber-callout`,
`04-guide-tab-padding-fixed-0.317.0`.
**Scenario E's blue callout was NOT exercised live.** Plex's setup gate is open (the operator's real
claim, R-1060), so `Needed()` is false and the card is correctly hidden. Showing it live would mean
un-claiming the operator's real Plex server; not done. The branch is covered by
`TestAppPage_CalloutsOnlyForAskAndWarn` against a harness Plex with a closed gate.
## 9. Parity fixtures
Recaptured (the pages this task restyles): `app_settings_*` ×21, `app_data_*` ×9, `app_guide_*` ×4,
`app_logs`, `deploy_new{,_blocked,_memory_blocked}`. **All other fixtures are byte-identical** — and
now permanently independent of build identity: the harness normalises the asset tag on **both sides**
of the comparison, as it already did for the per-session CSRF token, so a build can no longer churn
148 files. The same normalisation was applied at the second comparison site in `i18n_wiring_test.go`,
which had the same assumption.
## 10. NOT yet validated
1. **The operator's own look** at AdventureLog and Plex on demo-hp through the public address. The
layout judgement is theirs. (The padding complaint from the first look is fixed and live.)
2. **A blue callout in a real browser** (§8).
3. **A catalog guide rendered from a real template** — no template carries `app_info.guide` yet.
## 11. Teardown — three layers
- **Machine:** nothing provisioned, no app installed, no throwaway Plex. Probe scripts, fetched
pages and the password file removed from guest 9201, demo-hp and DooPlex `/tmp`; the dashboard
password went file→file, was never printed, and no copy reached Windows or git.
- **Host:** both scratch clones and the `felhom.eu` sibling symlink deleted. The shared DooPlex
checkout was never written to by me — which is how the other session's work was distinguishable
from mine. `build.sh` in the build directory is now a symlink to the repo's (deliberate), with the
stale July copy kept beside it.
- **Hub:** nothing created; no floor change.
## 12. Observations
- **FILED: R-1081** — the registry's `0.315.0`, and now also `0.316.3`, hold content that is not
what their numbers say. Both caused by this session; both fixed at the root; no box affected.
- **NOT-A-FINDING (fixed in session):** the asset tag shipped dead — incident (ii).
- **NOT-A-FINDING (fixed in session):** the clean-tree rule had no mechanism — incident (iii).
- **NOT-A-FINDING (fixed in session):** `app_row_dedup_gate` convicted my `.app-row`, correctly —
that class already means „a row in the app list". Renamed `.app-field-row`; an over-broad rename
briefly took the list's own rule with it and was restored.
- **NOT-A-FINDING (fixed in session):** the one-column padding doubled against `.content`'s own
2rem. The mockup's numbers were the total the page should show, not an amount to add.
- **NOT-A-FINDING:** `build.sh`'s `workspace/` dirtied any clone it was run from; gitignored.
- **Register: 169 before → 170 after; 1 opened (R-1081), 1 opened-and-closed (R-1080).**
- **System poster:** no fact in `felhom-system-poster.facts.md` changed. Neither file edited.
- **Instruction files:** `.claude/rules/unprompted-work.md` item 6 in **all four** versioned copies
gains „a version number is pushed once", stated as a fact about what the build now enforces, not
as a new rule. The workspace-root copy is unversioned and not on this machine.
- **Worth the operator's attention:** two sessions released this repo within an hour, and the
second overwrote the first's `REPORT.md`. This file is the workaround; whether `REPORT.md` should
stay a single overwritten file in a repo two sessions can release is a question for the operator,
not a change I should make unasked.
+30 -100
View File
@@ -1,109 +1,39 @@
# REPORT — controller v0.247.0: the i18n spike — English on three pages, Hungarian byte-identical
# REPORT — v0.323.0: Debian 13, restic 0.18.1 pinned outside the OS and re-proven, felhom-samba 1.2.0 (2026-10-16)
**Date:** 2026-09-17 · **Task:** localisation starter (spike + design), Phase 1 · **Baseline:** `main`
`89dd3e94b1de` (v0.246.0, MinAgent 0.131.0) · **Released:** `612c417` → image
`gitea.dooplex.hu/admin/felhom-controller:0.247.0` · **MinAgent:** 0.131.0 (unchanged).
**Architecture read before any claim:** `felhom.eu/documentation/architecture/02-controller-module-map.md`
(templates, setup wizard obsolete) and `05-hub-architecture.md` (report ingestion, config push).
**Design written after the spike ran:** `felhom.eu/documentation/architecture/10-localisation.md`.
**Inventory:** `felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
Brief: „the controller on Debian 13 with a pinned restic, re-proven; the Samba image rebuilt". Full report, in plain
words: felhom.eu `REPORT-debian13-restic-2026-10-16.md`. Evidence: felhom.eu `documentation/audits/debian13-restic-2026-10-16/`.
## Claims in the brief that turned out wrong — named first
## 1. What changed
The brief named four already. Further ones (details and methods in the inventory §0):
- `controller/Dockerfile`: a `restic` stage fetches the official restic 0.18.1 binary and checks the signed `SHA256SUMS`
(vendored key, `third_party/restic/`), the published sum and the per-arch sum pinned in the file. Runtime
`debian:13.7-slim` (digest); Docker repo codename from `/etc/os-release`. Removed packages the binary never runs:
postgres and mysql clients, sqlite3, fdisk, e2fsprogs, parted, sshpass.
- `infra-images/samba/Dockerfile`: Alpine 3.24.2 (digest). `SambaImage` → `felhom-samba:1.2.0` (pushed once).
`scripts/build-samba-image.sh` refuses a reused tag, an unanswerable registry and a dirty/unpushed tree.
- Comments in `internal/backup/offbox_{capture,progress,proof,window}.go` corrected to what was measured on both versions.
1. **„`fmtTime`, `fmtBytes`, `timeAgo` do not exist; `fmtMB` is the one size helper"** — all three
exist (`internal/web/funcmap.go` L265/L318/L403), plus nine more copy- or locale-producing helpers
and four size helpers. `timeAgo` builds „%d perce" — the plural problem lives there.
2. **„`fmtMB` used 10× in templates"** — 8×.
3. **„~13 `.Format(` layout calls in `internal/web`"** — 10 at the baseline (plus 8 `time.RFC3339`).
4. **Template lines 1 613 / 1 838** — 1 553, by two methods; neither figure reproduced by four variants.
5. **Go literals 1 234 in 99 files** — 1 141 in 84 (a lexer, comments skipped); comments explain most of
the difference.
6. **Banner 33 lines** — 34 once ASCII-only Hungarian words count (method difference).
## 2. Tests (each red-proved)
## What shipped
- `TestR1105_ResticIsPinnedOutsideTheOS` (`cmd/controller`): fails on the old Dockerfile and on four single mutations.
- `TestR1105_EachResticFailureKeepsItsClass`, `_InitRaceIsStillSuccess`, `_TheJSONTheCodeReadsStillParses`,
`_TheForgetPlanOnlyEverKeepsMore` (`internal/backup`): REAL outputs of 0.14.0 and 0.18.1 in `testdata/restic/`, through
the code's own parsers and classifiers. Six mutations, each convicted.
- `scripts/test_samba_build_push_once.sh`: 6 cases; 4 fail on the old script.
- Full suite green; `controller_gates.py --fast` green.
- **Mechanism (b), expanded at template LOAD:** `internal/i18n` (`Bundle`, `Expand`, `Msg`, `Plural`,
embedded `locales/hu.json` + `en.json`). `loadTemplates` parses one template set per language via
`parseTemplateSet` (= `ParseFS` + `Expand`); `s.tmpl` stays Hungarian. Why not a runtime `T` func: the
escaper changes `+ ' "` bytes and 618 of 1 867 template strings are inside `<script>`. Why not
`x/text/message`: no gender, only count plurals — a dependency for nothing.
- **Converted:** `launcher.html`, `backups.html`, `app_info.html`, `layout.html`, `backups_shared.html`
(`backups_empty`) — 252 markers, 235 hu keys, all translated (238 en keys incl. plural forms).
Page titles via `TitleKey`; English overrides for `stateLabel`, `timeAgo`, `timeAgoStr`,
`nextRunLabel`, `statusText`; `<html lang>` follows.
- **Setting / switch / report:** `settings.json` `language`; `POST /settings/language`; `?lang=`;
switch in the sidebar footer only when not Hungarian or `?lang=` is present; `"language"` in the hub
report at all four build sites in `cmd/controller/main.go`.
- **Gates:** new `i18n_missing_gate.py` (registered in `controller_gates.py`). `emoji`,
`native_confirm`, `secret_in_markup`, `retrieval_promise` now read templates expanded
(`scripts/i18n_bundle.py`); `mojibake` scans `.json`. **Found while converting:** retrieval-promise
went STALE (red) the moment copy moved; the other three went silently BLIND — the HEAD emoji gate
passed a planted emoji in `en.json`, and the HEAD retrieval gate never saw a planted promise in
`hu.json`.
- **Tool:** `scripts/i18n_extract.py`. First pass pulled layout `<span>`s and a button-link pair into
messages and mis-split a JS string that starts inside a tag — fixed and re-run from clean templates.
It also **missed eight ASCII-only Hungarian strings** (Megszakadt, Elavult, Csak x86, Pi kompatibilis,
Rendszermonitor, Most nem, sikertelen, ismeretlen) and one JS fragment („adatait ide:"), found by a
by-eye review of what it left; the word list was extended and the fragment converted by hand.
- Two existing tests (`TestTemplatesParse`, `TestTemplatesParseWithFuncmap`) parsed raw templates with
`ParseFS`; they now parse through `parseTemplateSet` for every language.
## 3. Live
## Red-proofs — each seen failing, then passing (`felhom.eu/documentation/audits/i18n-2026-09-17/redproofs/`)
demo-hp 9201 on rc.1 (code identical to the release): both restore directions byte-identical on the real store; the
off-site run (dumps + push, live progress) ok; the drill's row and file came back from the off-site copy; Samba 1.1.0 →
1.2.0 with the same household password, every discovery daemon answering. Released by per-box floors to Tester 1,
demo-hp, demo-felhom (19:25 UTC), box walk green. **The global floor waits for each box's first night on 0.323.0.**
| test | mutation | failure seen |
|---|---|---|
| `TestI18nParity` | fixture file absent | `no parity fixture` (red before the fixture existed) |
| `TestI18nParity` | „Megosztási link" → „…linK" in hu.json | `launcher_full: Hungarian page CHANGED at line 261` (+ launcher_nopassword) |
| `TestI18nEnglishPages` | `layout.inditopult` deleted from en.json | `Hungarian template copy on the English page, line 111 … Indítópult` (5 cases) |
| `TestLanguageSwitch_EndToEnd` | `s.addLanguageData` removed from `executeTemplate` | `?lang=en must render English and show the switch`; title and switch missing |
| `TestI18nJSContextValuesAreSafe` | en `launcher.masolva` = `Copied'` | `contains '\'', which breaks its context` |
| `TestBundleParametersMatchAcrossLanguages` | en banner drops `{{.RecoveryAbandonDate}}` | `template fields ".RecoveryAbandonDays", Hungarian has ".RecoveryAbandonDate,.RecoveryAbandonDays"` |
| `TestLocaleFuncsHungarianBundleMatchesFuncMap` | hu `func.state.running` = „Futás" | `hu.json "Futás", funcmap "Fut"` |
| gate decoys (`test_gate_decoys.py`) | undefined key, „Please", gap grew, new formal form; emoji / confirm( / secret / retrieval promise planted in a bundle | all 18 decoys rejected; HEAD emoji gate passed the bundle emoji, HEAD retrieval gate did not see the bundle promise |
## 4. Observations
Fixtures: captured by running the parity test with `-update-i18n-golden` in a clean `git worktree` of
`origin/main` 89dd3e94b1de (the test file adapted to `s.tmpl`), then copied in; the current tree passed
them before AND after conversion. All files restored after each mutation (`cmp`).
## Gates
- Green gate: `go build ./... && go vet ./... && go test ./...` → rc 0, 29 packages ok (first run found
the two raw-`ParseFS` tests; fixed; re-run green).
- `controller_gates.py --fast` → all OK incl. `i18n` (English missing 0 / ceiling 0; formal forms 6 /
ceiling 6). Pre-push hook ran them on the push.
## Live validation (endpoint-level; no browser on DooPlex) — demo-hp guest 9201
Deployed by hand (`/etc/felhom-controller-image`, bootstrap restart); **fleet floor NOT raised; no
golden.** Startup: `loadTemplates: lang=hu … 252 markers expanded` and `lang=en … 252`.
- **Hungarian unchanged:** launcher, `/backups`, `/apps/privatebin` fetched on 0.246.0 and on 0.247.0 —
identical after replacing version string and CSRF token; raw sizes equal (44 462 / 46 250 / 42 081).
A fetch 7 s after restart differed only in app state tags („Nem egészséges" while health warmed up).
- **English:** `?lang=en` and, after `POST /settings/language lang=en` (302 → `/launcher`, settings
`"language": "en"`, INFO `language switch: household language set to en`), identical renders. No
accented Hungarian from template copy. Remaining Hungarian, all Go view models or catalog: backup-target
banner/offer and whole-guest tier labels (`/backups`); the „Naprakész" update badge and the catalog
tagline / use cases / first steps (`/apps/privatebin`). Launcher: none.
- **Hub report:** stored reports read no field (0.246.0), `hu`, `en` (12:54:43Z), `hu` (12:55:22Z).
Negative control: demo-felhom (0.246.0) no field.
- **Refusals:** no `_csrf` → 403; no session → 302 to login; setting unchanged.
- Evidence (CSRF redacted): `felhom.eu/documentation/audits/i18n-2026-09-17/live/`.
## Teardown
Machine: guest temp scripts, password file, session file shredded/removed; box left on `hu`, 0.247.0.
Host: the tarball removed from demo-hp. Hub: nothing written; the DB copy read for reports shredded on
DooPlex. Nothing provisioned. The scratch worktree of `origin/main` is removed.
## Observations
1. The wire-contract gate passes an unread field whose name appears in a hub Go comment (`language`). **FILED: R-555**
2. Four places decide behaviour by matching Hungarian wording (HTTP status, quota class, alert placement, a persisted warning marker). **FILED: R-553**
3. The extractor and the inventory detect ASCII-only Hungarian only through a word list; eight strings slipped past on three pages. **FILED: R-556**
4. `TestI18nEnglishPages` subtracts fixture data strings, so a template word equal to a data string (the launcher title „Indítópult") is masked on the launcher cases. **FILED: R-556**
5. Six formal „ön" forms in the converted Hungarian copy, now counted by a ratchet. **FILED: R-516**
6. Dates and sizes are not locale-formatted; the two template date layouts disagree; sizes use a decimal point. **FILED: R-562**
7. `storage_handlers_test.go` imported `text/template` for its template-parse check. **NOT-A-FINDING: a test-only parse check with no escaping assertion; it now parses through the production `parseTemplateSet`, which uses `html/template`.**
1. The forget plan of restic 0.17+ keeps each group's oldest snapshot while monthly slots are unused. NOT-A-FINDING: it only ever keeps more; pinned by `TestR1105_TheForgetPlanOnlyEverKeepsMore`, written into felhom.eu `07` §6.8.
2. A reinstalled database app restored from the off-site copy is unusable (old volume, new password). FILED: R-1106
3. The official restic 0.18.1 binary adds 50 fixable scanner findings. FILED: R-1107
4. A share added before the first household password is not exported. FILED: R-1108
5. A plain `restic restore` writes a lock on both versions; a comment said it did not. NOT-A-FINDING: comment corrected; the nightly proof uses `--no-lock`.
6. `build.sh --push` also pushes `:latest` for an rc tag. NOT-A-FINDING: nothing pulls `:latest`; boxes run a named tag and the update button reads X.Y.Z tags.
+86 -13
View File
@@ -23,7 +23,23 @@
| `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) |
| `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/<n>` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert |
| `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) |
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, legacy felhom-data) | Consult before ANY recursive delete under a drive |
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, kept, legacy felhom-data) | Consult before ANY recursive delete under a drive |
| `stacks.OldAppDataPaths` / `Manager.ListKept` / `KeepAside` / `DeleteKept` / `FindKept` | controller/internal/stacks/kept.go | `(composePath, hdd)` / `(drives)` / … | Kept data (`09` §3 decision 36): what counts as an app's old data (ONLY `<hdd>/appdata/…` binds), the list, start-fresh, the household's delete | **An action names a kept item by path only through `FindKept`** — `DeleteKept` refuses anything not listed. `KeepAside` is a rename on one drive; never copy, never `RemoveAll` in a rollback (`removeEmptyDirs`) |
| `stacks.RunAfterInstall` / `expandAfterInstall` / `web.defaultLoginInEffect` (v0.279.0, decision 45) | controller/internal/stacks/after_install.go · controller/internal/web/known_login.go | `(name, wait)` / `(cmd, allowed, env)` / `(meta, cfg, installed)` | A fresh install replaces a known default login; the page says when a default is still in effect | **Only from the deploy-done hook** — never after a restore/kept load (R-694). A `success:` marker is required (exit 0 lies). Never log the expanded command |
| `stacks.OpenSetupGate` / `SetupGateTick` / `SetupGateHost` · `web.ServeGateAuth` / `ServeGateStart` (v0.280.0, decision 46) | controller/internal/stacks/setup_gate.go · controller/internal/web/setup_gate.go | `(name, by)` / `()` / `(host)` · handlers | The setup gate: a `setup_gate: true` install is closed to everyone but the household until its probe or the household's press opens it | **Write the gate BEFORE the first start** (spike F2). Open = record first, then remove the file. Never widen the dashboard cookie — the handshake mints a host-bound one-use token |
| `stacks.OpenSignupWindow` / `SignupBlocked` / `SetupGateProbe` · `web.ServeSignupClosed` (v0.281.0, decision 47) | controller/internal/stacks/signup_block.go · controller/internal/web/setup_gate.go | `(name)` | Sign-up closed at the app's own address once the gate opens; the household's 15-minute window; the press asks the probe | **The block goes up BEFORE the gate comes down** (a failed write keeps the gate closed). Never on an app this box did not gate |
| `family.Store` · `stacks.FamilyGateHost` / `familyGateTick` / `FamilyExceptRegexp` · `web.ServeFamilyGateAuth` / `ServeFamilyStart` / `ServeFamilyLogin` / `ServeFamilyLogout` (v0.287.0, decisions 63/64) | controller/internal/family/family.go · controller/internal/stacks/family_gate.go · controller/internal/web/family_gate.go | store `Add/Reset/Remove/Verify/NewSession/Valid/EndSession` · `(host)` / `()` / `(prefix)` · handlers | The PERMANENT family gate: family members with their own logins in front of a `family_gate: true` app | **A family cookie never opens the dashboard** (RequireAuth reads only `felhom_session`); the app cookie names a STORE session, so the store is asked on every request (reset/remove/logout end access at once); **every exception goes through `FamilyExceptRegexp`** (anchored — never a hand-written PathPrefix); door written before the first start, like the setup gate. `familyStoreOverride` is the test seam. Fifth atomic-write helper (family.json, fsync) — see §6 |
| `stacks.OpenInstallHold` / `installHoldTick` (v0.284.0, R-741) | controller/internal/stacks/install_hold.go | `(name, by)` / `()` | An `after_install` app held behind the setup gate's door until its known login is replaced | **Written before the first start**, like the gate; opens on `after_install` success or the household's "I changed it"; the door (`SetupGateHost`) reads holds first |
| `stacks.RetainImagesAfterUpdate` / `RetainImagesAfterRemove` / `RunImageRetentionOnce` (→ `(deleted, done)` since v0.294.0) / `RunImageRetentionOnceUntilDone` · seam `imageDocker` (v0.284.0, decision 53) | controller/internal/stacks/image_retention.go | `(name, previous)` / `(name, repos)` / `()` | Deletes an app's images older than its running + previous one | **The keep set is box-wide and read at delete time** (containers, installed composes, installed/previous records); exact id, never forced or pruned; skipped while any update runs AND while any image-pulling compose command runs (R-863, `dockerexec.TryImageCleanup`); the one-time pass writes its marker only after a pass that RAN; tests use the `imageDocker` seam, never Docker |
| `dockerexec.BeginImageWork(args)` / `TryImageCleanup()` / `ImagePulling(args)` (v0.294.0, R-863) | controller/internal/dockerexec/imagework.go | `defer dockerexec.BeginImageWork(args)()` | **Every NEW place that runs `compose up/pull/create/run` must hold it** (the stacks compose helpers, appexport's restore and the FileBrowser sync already do) | An image pulled by compose is named by no container until compose creates one; a clean-up pass in that window deleted it (measured, R-863). The pass takes the lock exclusively without waiting; pulling commands wait for a running pass (seconds) |
| `nightchain.Ledger` / `CatchUp` / `ResumeWatch` / `ComputeBanner` (v0.295.0, R-871) | controller/internal/nightchain/ | `Open(path, now)`, `MarkEnded(leg, t)`, `Missed(now, W, loc)`, `Evaluate(ctx, why)` | "was a night missed?" and making it up; the missed-backup banner's rule | The ledger is an ATTEMPT record (ran to its end) — never evidence a backup exists; a NEW nightly backup leg must be wrapped by `withLeg` in main.go and listed in `nightchain.Order`, or the catch-up never makes it up; never add the update leg to `catchUpLegs` |
| `scheduler.DailyLateLimit` (v0.295.0) | controller/internal/scheduler/scheduler.go | — | a daily timer firing > 60 min late (host suspend) is skipped | do not "fix" a late fire by running it: the app-update leg would run at noon |
| `stacks.RetainControllerImages(ControllerImageRecord)` · `selfupdate.UpdateState.RecordedPrevious` (v0.285.0, decision 56) | controller/internal/stacks/controller_image_retention.go | `({Repo, Running, Previous})` | Deletes controller images older than the running + previous one | The previous comes from the SWAP RECORD (success onto the running version), version order only as the fallback; versions above the running one and non-version tags are kept; skipped while the controller swaps itself; same `imageDocker` seam |
| `stacks.CloseSignupNow` / `CloseSignupOffered` / `applyNativeLock` (v0.282.0, decisions 47/49) | controller/internal/stacks/after_setup.go | `(name)` | The app's own sign-up switch after the setup; "close sign-up now" for an app installed before the rule | **Check the installed compose reads the variable** (an old install carries the old compose until its next update) — never record a lock that is not there. One run per app at a time (`nativeLockBusy`) |
| `backup.judgeCopy` / `HollowCopies` / `SetHollowCopyNotify` (Part D, v0.279.0) | controller/internal/backup/hollow_watch.go | `(app, tier, unitDir)` | A RUNNING app whose newest copy holds no data → operator digest once/day + page sentence | Uses `unitCarriesData` (the manifest, never size); a stopped held app is never flagged |
| `web.nightChain` (R-705, v0.279.0) | controller/internal/web/night_chain.go | `POST /api/debug/backup/night-chain` | The night's four legs now, in order | Refuses while any op/update/chain runs; the leg uses `RunUpdateLegNow` |
| `Router.dropLeftoverHold` + `settings.ClearUpdateHold` (R-704, v0.278.0) | controller/internal/api/router.go · controller/internal/settings/settings.go | `(name, why)` / `(stack) (bool, error)` | A new install (plain or "use my kept data") and a removal clear the update / crash-loop hold of the app's install | **A hold belongs to an INSTALL; the name is all the next install shares with it.** Never clears an R-379 restore hold (operator-only) |
| `backup.KeptBestCopy` / `KeptDBCopy` / `KeptOffsiteCopies` / `KeptCopyAt` / `LoadKeptApp` / `LoadKeptOffsite` / `KeptCopyKey` | controller/internal/backup/kept_load.go | `(ctx, app, drive)` / `(app, drive)` / `(ctx, apps)` / `(unitDir, drive, tier)` / … | Which copy can load kept files (local tiers + since v0.277.0 the off-site copy, R-691 (2)), the load as a restore op, the copy's name for the page | A copy counts only with data (DB dump or volume tar) AND its app.yaml `HDD_PATH` = this drive. Installed apps are never offered. **The off-site copy is judged only after its unit is downloaded** — `LoadKeptOffsite` refuses an unversioned unit (`07` §6.6) BEFORE `prepare` (a dated folder's move-back); ask the repository once per page (`KeptOffsiteCopies`), never per row. Both pages name a copy through `KeptCopyKey` |
### Subprocess + timeout + exit-code discipline
@@ -37,6 +53,19 @@
| `parseWWWAuthenticate` + `fetchAnonymousToken` | controller/internal/selfupdate/updater.go | Bearer-challenge parse + anonymous Docker v2 token | Any credential-free registry API access | realm comes FROM THE HEADER (never hardcode a token URL); denial = errAnonymousDenied, never "credentials missing" |
| `Syncer.runGit` / `runGitInDir` | controller/internal/sync/sync.go | `(args...) error` | git CLI ops | Credentials masked in logs via `maskRepoURL` |
### Error kinds — never branch on a customer-facing sentence (R-553)
| Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---|
| `util.KindErrorf` / `util.KindError` | controller/internal/util/errkind.go | `(kind error, format string, a ...interface{}) error` | ANY refusal a caller must tell apart: build the message exactly as `fmt.Errorf` would AND carry a sentinel for `errors.Is` | The message bytes are unchanged (pinned by tests); never `fmt.Errorf("%w: …")`, which would prepend the sentinel's own text to the customer's sentence |
| `stacks.ErrAlreadyDeployed` / `ErrRequiredField` / `ErrPathMissing` / `ErrNotEnoughMemory` | controller/internal/stacks/deploy_errors.go | sentinels | the API's deploy status code (`api.deployStatusFor`) | 409 / 400 / 400 / 400. Do NOT add a text signature beside them |
| `stacks.ErrStackNotFound` / `ErrProtectedStack` / `ErrNotDeployed` / `ErrStillRunning` / `ErrNotOrphaned` | controller/internal/stacks/stack_errors.go | sentinels | the API's stop/start/restart/update, remove and delete status code (`api.stackOpStatusFor`, R-569) | 404 / 403 / 409 / 409 / 409. Only the producers in manager.go and delete.go carry them; a new refusal on those paths must carry one or it answers 500 |
| `backup.ErrOffsiteQuota` | controller/internal/backup/offbox.go | sentinel | `ClassifyOffsiteFailure` telling a quota over-run apart | The other arms of that switch stay TEXT matches on purpose — they are restic's and ssh's own English output, which we neither write nor translate |
| `monitor.WarnKind*` + `HealthReport.addWarning` / `WarningKindAt` | controller/internal/monitor/healthcheck.go | `(text, kind string)` | a health warning whose PLACEMENT the dashboard decides | Internal only: `internal/report/builder.go` copies Status/Issues/Warnings, so kinds never reach the hub (pinned) |
| `monitor.MsgRef` + `HealthReport.addWarningMsg` / `addIssue` / `WarningMsgAt` / `IssueMsgAt` | controller/internal/monitor/healthcheck.go | `MsgRef{Key, Args}` beside the wire text | a health warning/issue whose BANNER must follow the household's language (R-516 item 10) | The wire text (report health.*) stays byte-identical; `AlertManager.Refresh` renders the key and falls back to the text when Key is empty. Every issue goes through `addIssue` so the parallel slices cannot drift (pinned) |
| `AlertManager.GetBannerAlerts(page, lang)` + `bannerShows` (R-906, 2026-10-08) | controller/internal/web/alerts.go | the layout banner's list for ONE page, capped AFTER the page filter | any page that fills `data["Alerts"]` (`baseData` does it for you) | `GetAlerts` keeps Inline and other pages' alerts (debug dump, placement tests) and must not feed a banner: its cap counted alerts the layout then dropped, so a page showed „+ 5 további figyelmeztetés" and nothing above it. `bannerShows` mirrors the layout's own filter (pinned: `TestR906_*`) |
| `settings.OffboxTarget.LastWarningKind` + `backup.OffboxWarnNoAppsSelected` | controller/internal/settings/settings.go | persisted string | the Távoli mentés page's stale-note substitution | Written and cleared with `LastWarning`; the page decides on the kind ONLY — the legacy text fallback was removed when R-570 closed (2026-10-10) |
### HTTP/JSON envelopes + flash messages
| Symbol | File | Short signature | Use for | Gotchas |
@@ -64,6 +93,8 @@
| `Manager.SetOffboxLatestSnapshotFn` (R-357, v0.226.0) | controller/internal/backup/offbox_restore.go | `(fn func(ctx, stack) (id string, paths []string, err error))` INIT/TEST-ONLY | Overriding the restic snapshot lookup in tests | Exists because R-357's gate could not otherwise be tested at the level that matters: reaching it requires getting past `offboxLatestSnapshot`, which shells to restic. **The assertion the seam enables is `StopStack` call count == 0** — a gate placed after the stop returns the right sentence and still takes the outage. Nil in production |
| `CheckPlacement` + `PlacementMismatchMessage` | controller/internal/backup/offbox_placement.go | `(*RecoveryManifest, liveDrive, liveNS) PlacementCheck` / `(stack, PlacementCheck) string` | Comparing where a backup SAYS the data lived against where a restore is about to write | Pure and total — nil/empty/blank manifest all give the same honest "not known, no mismatch". **An UNKNOWN is never a mismatch** (refusing on an absence strands every pre-field unit). Compares the DRIVE only (the namespace root is derived from it), Cleaned, so a trailing slash is not a difference. The message names BOTH values on purpose |
| `RecordedUnitForStack` + `RecordedAddress` | controller/internal/backup/offbox_placement.go | `(stack) (RecordedPlacement, RecordedAddress, bool)` | Reading back the address + data folder a backup recorded, for a reinstall prefill | Local file reads over every readable namespace root — **no network, no restic, no restore**; it exists for the NOT-INSTALLED case where `GetStackHDDPath` is `""`. **`RecordedAddress.Known()` requires BOTH halves:** an absent `SUBDOMAIN` makes the live deploy path fall back to the CATALOG default (`stacks/deploy.go:88-90`), and offering that back as "what your backup says" is a fabricated fact |
| **`Metadata.For(lang)` + `LocalizeStacks` / `LocalizeStackPtr` / `Stack.MetaFor` (R-560, v0.257.0)** | controller/internal/stacks/metadata_i18n.go | `(lang string) Metadata`; `([]Stack, lang) []Stack`; `(*Stack, lang) *Stack` | THE only way a page may reach catalog COPY — the app description, tagline, use cases, first steps, prerequisites, deploy-field labels and descriptions, optional-config text, integration labels, data-path labels and the initial-credentials note | Reading `stack.Meta.<copy>` off the manager renders HUNGARIAN to an English household, with no error and nothing wrong-looking on the page — `TestNoDirectMetaCopyReadOnPages` keeps the named, reasoned allow-list of every direct read in `internal/web`. **`For("hu")` is the parsed struct with `I18n` cleared** and nothing else (pinned over all 53 real catalog files), so a translation cannot change the Hungarian product. **Fallback is FIELD BY FIELD** — absent or blank English shows Hungarian, which is what makes a half-translated app shippable. **Lists replace WHOLE; every other list is matched by its own key** (`env_var`, option `value`, `match_group`, `target`, `path`) — position matching mistranslates silently the first time a field is inserted. **It never writes through:** the metadata is shared by concurrent requests, so an in-place merge leaks one household's language into another's page |
| `AppInfo.WithAddress` (R-498) | controller/internal/stacks/appinfo_address.go | `(subdomain, domain string) AppInfo` | Filling the catalog copy's `<label>.DOMAIN` / bare `DOMAIN` placeholder with the household's real address (effective subdomain + customer domain) | Returns a COPY (the metadata is shared); `\bDOMAIN\b` leaves `SUBDOMAIN`/`MAIL_DOMAIN` alone; no domain → unchanged. Apply AFTER `LocalizeStackPtr`. The deploy page still fills its post-deploy steps in JS (`deploy.html`), `knownLoginLine` its own line |
| `Metadata.HasDeployField` | controller/internal/stacks/metadata.go | `(envVar string) bool` | "Does this app have somewhere to PUT a recorded value?" | **13 of 53 templates declare `HDD_PATH`; 40 do not** (measured 2026-08-21). For the 40 a recorded placement is a FACT TO STATE, never a value to write into a field that does not exist |
| `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention |
| `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
@@ -82,7 +113,6 @@
| `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback |
| `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) |
| `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) |
| `atomicWriteFile` | controller/internal/setup/handlers.go | `(path, data, perm) error` | setup-wizard writes | Same bind-mount fallback caveat |
| `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand |
| `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops |
| `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
@@ -94,7 +124,9 @@
| `infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount` | controller/internal/infra/samba.go | consts | single source of truth for the samba container identity | the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (`felhom-samba`) is NOT the stack name (`samba`) — `EffectiveProtected` needs the container one |
| `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 |
| `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot |
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE R-82 multi-tier backup schedule — several whole-guest tiers (local daily + PBS weekly) reconciled into ONE quiesce window | **Both tiers due ⇒ ONE stop/start pair**, never two (two = two app outages for one night). Tiers run SEQUENTIALLY (vzdump holds a guest lock) and the app stays down until the LAST tier snapshots — resuming earlier loses app-consistency on the DR tier. Order is fast-first (agent advertises primary first) or downtime blows up. `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE multi-tier whole-guest backup schedule (local daily + PBS weekly) | **One stop per tier** (`09` §3 decision 156, v0.301.0 — REVERSES R-82's one window for both tiers): a window backs up only the FIRST due tier and resumes the apps at its `snapshotted`; another due tier waits for a later cycle. A tier that refuses to start (BUSY or an error) lets the next tier try in the same window. Order is agent order (primary first). `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
| `quiesce.InFlightProber` + `Loop.anotherTierInFlight` · `agentapi.Client.BackupJobsInFlight` (R-921) | controller/internal/quiesce/precheck.go · controller/internal/agentapi/backup_tiers.go | `BackupJobsInFlight(ctx) ([]string, error)` | Check first, stop second: no app is stopped for a tier the agent will refuse because ANOTHER tier's job is in flight | OPTIONAL interface (fakes without it keep the old path). Fails toward backing up on any read error. **It cannot see the agent's host-wide heavy-op gate** (OS step, restore-test, fstrim — no endpoint serves it): that refusal is met by the BUSY path's immediate resume (`TestR921_BusyRefusalResumesAtOnce`). The window's OWN tier in flight is not skipped (the agent answers with the running job) |
| `quiesce` whole-guest ledger — `Loop.pressOwesNight` / `recordWholeGuestSuccess`, `WithManualTrigger` / `IsManualTrigger` (R-899) | controller/internal/quiesce/nightowed.go | `pressOwesNight(led, target) bool`; `WithManualTrigger(ctx) ctx` | **A household press never cancels the night's whole-guest backup** (operator ruling 2026-10-08) | The ledger (`whole-guest-ledger.json` beside the marker) records SUCCESSES only, per tier, press vs scheduled — it decides due-ness and is never evidence that a copy exists (the agent's storage answers that). An „owed" tier never fires the window gate's safety valve (`withoutOwed`/`gateAge`). The press mark rides the context to the agent adapter (`agentapi.StartBackupForTrigger` → `trigger=manual`), so the agent runs no OS leg after a press. |
| `quiesce.failureBreaker` + `Loop.dropBackedOffTiers` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `blocked/recordFailure/recordSuccess(target, now)`; `backoffFor(n) time.Duration` | **R-88** — a tier whose backups keep failing stops re-quiescing. Backoff 15m→30m→1h→2h→4h (cap), reset on success | **It gates the QUIESCE, not the backup** — the harm was never the failing backup, it was the app outage taken to attempt it, so backed-off tiers are dropped from the due set BEFORE any stack is stopped. **Per TARGET** — a broken offsite tier must never suppress a healthy local one (`TestBreaker_OneFailingTierDoesNotSuppressAHealthyOne`). **Never permanent** — the cap bounds the retry INTERVAL, it never stops retrying; a latched breaker is a silent backup outage, worse than the loop it replaces. **`TriggerNow` is never gated** (it already bypasses due-ness and the window gate), though a manual run still RECORDS its outcome. **`stillRunning` is NOT a failure** — a first full offsite snapshot legitimately runs for hours. State is **in-memory on purpose**: a restart forgets the backoff and re-attempts, which is the cheap direction to fail. Log the deferral ONCE when armed, never per tick. |
| `quiesce.TierNotifier` + `Loop.SetTierNotifier` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `BackupFailed(tier,msg,err)` / `BackupRecovered(tier,msg)`; `SetTierNotifier(n)` INIT-ONLY | **R-97a** — the whole-guest backup tier reports its outcome to the hub | A **seam, not an import** — quiesce keeps no dependency on `internal/notify` (same reason `windowStartFn` is injected). Wired by a setter because main.go builds the notifier AFTER the loop; `nil` = unprovisioned guest, not an error. **Edge-triggered:** failure fires only when the breaker ARMS (`n == 1`), never per retry — the cadence is 15m/30m/1h/2h/4h and an event per attempt is an inbox nobody reads. Recovery rides `recordSuccess`'s existing bool. **Event types are OPERATOR-ONLY** (`whole_guest_backup_failed`/`_recovered`, hub >= v0.78.0) — NOT `backup_failed`, which has a customerMessages entry AND sits in live `enabled_events`, so it would email the CUSTOMER about a backup they cannot act on. `WholeGuestBackupDetails.Tier` is load-bearing: the hub keys its per-tier cooldown on it. |
| `quiesce.Loop.SuppressedStacks` + `markQuiesced` / `markUnquiesced` | controller/internal/quiesce/suppress.go | `() map[string]bool` (nil-safe on a nil *Loop) | **R-97b** — an app THIS controller stopped for a backup is not a fault | Consumed at the SINGLE derivation point `classifyRunStates` (which computes both the banner dead-list and the notifier Down-set — keep it one place). **Cycle-keyed, not state-based:** v0.164.0's `!= StateStopped` filter cannot see an app caught MID-RESTART (`starting`/`unhealthy`), which is how BookStack alarmed on 2026-07-27. The window (`quiesceAlarmGrace` = 180 s, derived from the deploy flow's 120 s health timeout and Mealie's 60 s start_period) **EXPIRES** — permanent suppression turns a loud false alarm into a silent real one. Open-ended while the cycle runs (a first offsite snapshot legitimately takes hours). **This set alone is NOT the whole answer** — see `AppStopGuard.SuppressedStacks` (R-330) for the per-app operations; `classifyRunStates` consumes the union of both. |
@@ -105,6 +137,10 @@
| Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---|
| `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure |
| `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `<project>_<key>`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only |
| `renderDigests` / `RenderWithLadderDigests` / `CarryDigests` / `StripDigest` (v0.269.0/.1) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update; the sync for an app NOT yet installed) — an INSTALLED app's sync uses `CarryDigests` (v0.269.1), which keeps the digest the app runs | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin |
| `mergeRestoreFiles` / `RestoreTier2Whole` (v0.269.0) | controller/internal/backup/tier2_whole.go | mirror subtree → live subtree, four rules, counts | ANY restore that brings files back over live ones | Never `rsyncMirror` (--delete) in the restore direction; never overwrite a newer live file; a replaced file's old copy stays beside |
| `nextLadderStep` / `StepKey` / `StepFile` (v0.268.0) | controller/internal/stacks/ladder.go | `(templateDir, pinned) (LadderStep, error)` | which definition ONE guarded-update press pins (`09` §3 decision 14) | `StepKey` must equal the catalog's `ladder.step_key` (TestLadder_StepKeyMatchesTheCatalog). A missing/wrong step file is an ERROR, never a jump |
| `Manager.RedeployFromEnv` | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | Re-up with changed env (migration flip, config edits) | `compose up -d`, never `restart` (restart won't pick up images/env) |
| `Manager.PersistUnitRedeployConfig` (R-47, v0.153.0) | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | the PERSIST half of `RedeployFromEnv` — app.yaml + locked fields + in-memory flags, **starts nothing** | **TRAP: the restore paths must use THIS, never `RedeployFromEnv`.** RedeployFromEnv ends in a full `up -d`, which before the replay IS the H4 race. RedeployFromEnv is now literally this + the unchanged up-and-report tail |
| `Manager.StartStackServices` (R-47, v0.153.0) | controller/internal/stacks/manager.go | `(name string, services []string) error` | scoped `compose up -d <svc>...` — the DB-only window a dump is replayed in | **REFUSES an empty list** (argument-less `up -d` is a FULL start — the one silent fall-through that would reintroduce the race). No `logPostStartStatus`: the app containers are absent on purpose. Never `RestartStack` here — it is a full up in disguise |
@@ -117,7 +153,7 @@
| `backup.AppStopGuard` (`Begin`/`End`/`Recover`) (R-166, v0.189.0) | controller/internal/backup/appstop_marker.go | `(opID, reason, stacks) error` / `()` / `() *AppStopRecovery` | THE crash marker for stop→work→start windows (volume dump, offbox reconstitute, `.fab` export) | Its **own** file (`appstop-state.json`), never quiesce's — one file, one writer. **A `defer` is NOT the mechanism** (Campaign 8 fault 10: SIGKILL runs no defer); the marker is. Written BEFORE the stop, cleared ONLY after a restart that succeeded; a FAILED restart deliberately KEEPS it. `Recover` RETURNS its outcome rather than notifying, because it must complete before the boot reconciler while the notifier does not exist yet |
| `backup.AppStopGuard.SuppressedStacks` + `markStopped` / `releaseStarted` / `ReleaseFailed` (R-330, v0.224.0) | controller/internal/backup/appstop_suppress.go | `() map[string]bool` (nil-safe on a nil *AppStopGuard); `ReleaseFailed(stacks ...string)` | **R-330** — an app a PER-APP operation is holding stopped (nightly volume dump, offbox reconstitute, `.fab` export) is not a fault | The **twin** of `quiesce.Loop.SuppressedStacks` above, and the two are unioned by `unionSuppressed` in main.go before `classifyRunStates` — **consult BOTH or the bug comes back**: R-330 shipped because the alarm read only the quiesce set while the per-app legs stopped apps through a different path. Rides `Begin`/`End`, so all three call sites got it with no call-site change. Grace is `appStopAlarmGrace` = 180 s, deliberately the SAME constant and derivation as quiesce's — two windows over one alarm that disagreed would be a bug on whichever path used the shorter one. **It must never latch**, and unlike quiesce's loop `End()` runs ONLY on a restart that succeeded: (1) every failure path calls `ReleaseFailed`, which drops the entry IMMEDIATELY so the app alarms on the next scan; (2) `Begin` REPLACES the set (one marker file = one operation); (3) `appStopMaxHold` (6 h) caps an open-ended hold and logs at WARN. **Deliberately NOT persisted** — after a crash the guard holds nothing and a down app must alarm. `ReleaseFailed` drops the suppression and KEEPS the durable marker; the two are independent and a test pins that |
| `backup.ErrStartRefused` + `AppStopRecovery.Refused`/`Alarming()` (R-174, v0.191.0) | controller/internal/backup/appstop_marker.go | `errors.Is(err, ErrStartRefused)` / `() bool` | THE refusal-vs-failure split in the app-stop crash recovery | **A gated starter's refusal is NOT a restart failure.** `Recover`'s starter MUST be the gated `gatedAppStopStarter` (cmd/controller/main.go), never the raw `stacks.Manager` — that was the v0.189.0 defect, which started apps onto ABSENT drives at boot (R-171 one path over). A refusal goes to `Refused` (marker KEPT, silent), a real error to `Failed` (marker kept, ALARMS). Collapsing them routes a deliberate hold into `NotifyBackupFailed`, a customer-enabled type — the R-171 false alarm again. `main.go` must guard the notify with `Alarming()`, not `!= nil` |
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never `cfg.Paths.HDDPath`; a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** |
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never a global (`cfg.Paths.HDDPath` was deleted in R-492); a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** |
| `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix |
| `Manager.recordInstalledImages` (v0.233.0) | controller/internal/stacks/installed.go | `(name, stackDir string, env []string)` | writing down what each compose SERVICE is ACTUALLY running, into `app.yaml.installed_images` | Called after a successful compose up from `StartStack`/`RestartStack`/`UpdateStack`/`runComposeDeploy`. **Reads the CONTAINER, never `docker-compose.yml`** — that file is the value the syncer has already moved (spike §3: 25 minutes of disagreement). **A failed write NEVER refuses the action** — the deliberate OPPOSITE of `SetDesiredState`: intent refused, observation logged at ERROR. **NOT from `StartStackServices`** (the R-47 DB-only window would overwrite a complete record with a partial one). Skips the write when ref+digest are unchanged, and carries `at` forward so it means "running since". Its OWN seam (`installedExecFn`) with a **context + 30 s timeout** — the two existing exec helpers have neither |
| `Manager.SetPin` / `AdoptPins` / `RenderPlanFor` / `AppliedComposePath` (v0.235.0) | controller/internal/stacks/pin.go | `SetPin(name, stackDir, pin, composeSrc) error` | THE version freeze — `app.yaml.pinned_images` + the stored `applied-compose.yml` | **`PinnedImages` is INTENT, `InstalledImages` is an OBSERVATION — never feed one from the other** (the R-166 category error, one field over). Four writers only: deploy, the guarded update (via `advancePinToCatalog`, which advances the pin and re-renders BEFORE the pull, and REFUSES the update if the pin cannot be written; a failed pull puts the pin BACK via `SetPin`), the restore adapter (this is what closes R-441), and `AdoptPins`. `AdoptPins` reuses `observationCoversTemplate` — do NOT write a second completeness rule — and skips loudly rather than inventing a pin. Absent pin = pre-v0.235.0 behaviour |
@@ -127,13 +163,15 @@
| `backup.Manager.UpdateRestorePoints` + `CanBackUpApp` (v0.239.0, R-475) | controller/internal/backup/update_guard.go | `(ctx, stack, accept func(UpdateTierPoint) bool) (UpdateTierPoint, bool, []UpdateTierPoint)` | "which backup can this update lean on" — walks Tier 2, 1, 3 and returns the first copy `accept` admits | **The age rule lives in the caller's `accept`** (stacks' `freshRestorePoint`), so it is ONE rule for every tier. **The ORDER is `UpdateTierOrderFor` (v0.241.0, R-479): 2 → 3 → 1 for an app with classified binds (`DataOutsideUnit`), 2 → 1 → 3 otherwise; `UpdateCopyHolds` is the matching phrase for the hold.** Stops at the first accepted copy, so Tier 3 (restic, 15 s bound, unreachable = absent + WARN) is reached only when needed. Seams: `updateTier2PointFn`, `updateTier1PointsFn`, `updateOffsiteTimesFn` (v0.240.0: Tier 3 reads `OffsiteSnapshotTimes` — snapshots only, never the inventory's per-app `stats`). stacks adds R-478's rule: a copy older than `deployed_at` does not count (`usableRestorePoint`). Tier numbers are pinned equal across stacks/backup by `TestR475_TierConstantsAgree` |
| `backup.Manager.Tier2MirrorDirsForApp` / `RemoveTier2Mirrors` + `settings.DeleteAppBackupPrefs` (v0.240.0, R-474 / R-486) | controller/internal/backup/r474_remove_mirrors.go | `(stack) []string`; `(stack, dirs) []string` | deleting an app's backups on removal — the Tier-2 mirror lives on ANOTHER drive, outside RemoveStack's per-app base; the same dirs size the backup card (R-485) | Read the mirror dirs BEFORE the prefs are forgotten. Deletes only `<root>/backups/secondary/<stack>` for a known root; never `_shares`, never a path that merely cleans to it. **The Tier-2 RECORD goes only with `remove_backups`** (R-486) — a removal that keeps the backups must keep the record, or the mirror is unrestorable. Pinned by `TestR474_RemoveHandlerDeletesUnitMirrorAndPrefs` + `TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo` |
| `appbackup.dbTypeForImage` (R-484, v0.240.0) | controller/internal/appbackup/dbservices.go | `(image) (DBType, bool)` | the ONE place an image is judged a database — nightly dumps, pre-update safety dump, DB-only replay | Derived Postgres images (`postgis`, `pgvector`, `timescaledb`) are Postgres. A new engine image goes HERE and in `dbservices_test.go`'s table, never in a second matcher |
| `backup.Manager.HoldAfterFailedUpdate` / `RunAppBackupNow` / `WriteUpdateSafetyDump` / `UpdateBusy` (v0.237.0) | controller/internal/backup/update_guard.go | see file | the update's hold, per-app backup-now, safety dump, busy check | The hold is `settings.RestoreHold` with `Reason: update_failed` — SAME store and gate as R-379, never a second map. `RunAppBackupNow` composes the nightly legs for ONE app (admission, DB dump, volume dump, capture, Tier-2) — do not write a second backup orchestration. A successful unit restore lifts an UPDATE hold only. **`isHeld` is ALSO true while a guarded update is moving the app (`SetUpdatingCheck`, v0.238.1)** — found live: the periodic capture overwrote a primary unit during a health wait |
| `backup.Manager.HoldAfterFailedUpdate` / `RunAppBackupNow` / `WriteUpdateSafetyDump` / `UpdateBusy` (v0.237.0) | controller/internal/backup/update_guard.go | see file | the update's hold, per-app backup-now, safety dump, busy check | The hold is `settings.RestoreHold` with `Reason: update_failed` — SAME store and gate as R-379, never a second map. `RunAppBackupNow` composes the nightly legs for ONE app (admission, DB dump, volume dump, capture, Tier-2) — do not write a second backup orchestration. A successful unit restore lifts an UPDATE hold only. **`isHeld` is ALSO true while a guarded update is moving the app (`SetUpdatingCheck`, v0.238.1)** — found live: the periodic capture overwrote a primary unit during a health wait. **R-645: every night leg (DB dump, volume dump, capture, Tier-2) also skips an app whose pin is not what it runs (`versionSkip`, wired by `SetVersionCheck` → `stacks.Manager.PinNotRunning`); unknown never skips. The page line is `VersionSkipFor`** |
| `stacks.Manager.memoryVerdict` (v0.237.0) | controller/internal/stacks/deploy.go | `(newReq, newLimit, releasedReq, releasedLimit int) (refusal, warning string)` | the deploy's memory check, shared with the update | An update RELEASES the app's current request first. Deploy passes `0, 0` and is byte-identical in wording and log line |
| `Syncer.SetRenderPlanFn` + `renderSource` (v0.235.0) | controller/internal/sync/sync.go | `func(appName string) stacks.RenderPlan` | the catalog render table | **NIL-SAFE: no seam = copy verbatim = the old product.** Catalog images == pin → verbatim (fixes flow + self-healing, both deliberately kept); differ → the WHOLE stored definition, **never a ref substitution into a newer template** (`wger 2.6`). `.felhom.yml` always verbatim (R-458). The syncer must NEVER read app.yaml. Re-reads the applied file before writing it — a test caught it writing an empty compose over a live app |
| `Stack.CatalogImages` vs `Stack.TemplateImages` (v0.235.0) | controller/internal/stacks/manager.go | both `map[string]string` | badge input vs "what the next `up -d` gives this app" | **THE TRAP: same type, same shape, opposite meaning after the freeze.** `TemplateImages` reads the LIVE (possibly frozen) compose file; `CatalogImages` reads the syncer's clone. `web.compareInstalledToTemplate` MUST use `CatalogImages` or it answers „Naprakész" on exactly the apps that are behind, with every test green. Red-proved |
| `Manager.BackfillInstalledImages` (v0.234.0) | controller/internal/stacks/installed.go | `() int` | seeding `installed_images` for apps that have NO record — call ONCE at startup | Beside `BackfillDesiredState` in `cmd/controller/main.go`, after it and BEFORE the boot reconciler (pinned by an AST-walking test that asserts the ORDER). **READS only** — starts nothing, writes no compose file. **Never overwrites an existing record** (an app that has one is not even observed). **REFUSES a partial observation** (`observationCoversTemplate`): `web.compareInstalledToTemplate` reads a service-count mismatch as BEHIND, so seeding a degraded app from what is visible renders „Frissítés elérhető" over an app that is current. The bring-up paths may write a partial because they follow a SUCCESSFUL `up -d` where a gap is real news; a backfill meets any state and must be stricter |
| `stacks.ParseComposeImages` (v0.233.0) | controller/internal/stacks/installed.go | `(composePath string) (map[string]string, error)` | compose SERVICE name -> the image the FILE pins; feeds `Stack.TemplateImages` and the update badge | yaml.v3 `services:` MAP parse, never a line scan (same reason as `DBServiceNames`). An error means CANNOT-TELL — `ScanStacks` leaves `TemplateImages` nil and the badge renders NOTHING, never "current" |
| `web.updateBadge` / `updateBadgeAt` / `Metadata.CatalogSince` + `CatalogSinceAge` (v0.233.0) | controller/internal/web/updatebadge.go, controller/internal/stacks/metadata.go | `(stacks.Stack) *MetaBadge` | THE "is this app current?" label — „Naprakész" / „Frissítés elérhető — N napja" | The SECOND `*MetaBadge` user the type was built for: existing `meta_badge` partial, **no new markup or CSS**. **NO RECORD RENDERS NOTHING — absent means UNKNOWN, never current** (R-166 applied to an observation; red-proved). **No version number reaches the customer** and **no registry is queried**. `catalog_since` is tolerant in the `lifecycle` style — absent/empty/malformed/**future** all degrade to a badge with no age + one WARN. LIMITATION: for the 23 floating pins the ref can match while the image has moved, so those read „Naprakész" when they may not be |
| `stacks.Manager.AnyUpdating` / `SetSelfUpdatingCheck` + `selfupdate.Updater.SetAppUpdatingCheck` (v0.261.0, R-608) | controller/internal/stacks/update.go, internal/selfupdate/updater.go, wired in cmd/controller/main.go | `() bool` callbacks, both directions | THE two-way lock between the CONTROLLER's own swap and a guarded APP update | **Wire BOTH halves or neither** — they are wired together in `main.go`, the only place holding both objects; **`stacks` must never import `selfupdate`**. The app-update side is a sibling of `SetBackupRunningCheck` and is consulted in the SAME three places (dry run, `TriggerUpdate`, `maybeAutoUpdate`) — do not add a fourth pattern. **`AnyUpdating` MUST answer false for a HELD app** (`Stack.Updating` is cleared on done/failed/held): a latching gate would block the controller's own updates for ever, including the one that fixes the hold. Nil callbacks are SAFE and mean pre-v0.261.0 behaviour — never fail closed on an unwired gate |
| `stacks.CatalogOrder` / `CompareImageRefs` (v0.260.0, R-524) | controller/internal/stacks/updateorder.go | `(Stack) UpdateOrder` — Unknown/Current/Behind/**Ahead** | THE one "how does this app stand against the catalog?" verdict | **Both the badge AND `Manager.UpdatePreflight`'s `downgrade` refusal read it — never re-implement the comparison.** `web.compareInstalledToTemplate` is a thin wrapper. Ahead is NARROW: every differing service must be orderable AND newer, else Behind. Ordering is `util.Version.Compare` behind a tag normaliser (`X.Y`/`X.Y.Z`, optional `v`, suffix must be IDENTICAL on both sides) — **never add a second comparator**. Queries NO registry; absent record = Unknown, never „Naprakész" |
| `web.updateBadge` / `updateBadgeAt` / `Metadata.CatalogSince` + `CatalogSinceAge` (v0.233.0) | controller/internal/web/updatebadge.go, controller/internal/stacks/metadata.go | `(stacks.Stack) *MetaBadge` | THE "is this app current?" label — „Naprakész" / „Frissítés elérhető — N napja" | The SECOND `*MetaBadge` user the type was built for: existing `meta_badge` partial, **no new markup or CSS**. **NO RECORD RENDERS NOTHING — absent means UNKNOWN, never current** (R-166 applied to an observation; red-proved). **No version number reaches the customer** and **no registry is queried**. `catalog_since` is tolerant in the `lifecycle` style — absent/empty/malformed/**future** all degrade to a badge with no age + one WARN. LIMITATION: for the **10** floating pins (recounted 2026-09-21; the old "23" matched no definition the catalog supports) the ref can match while the image has moved — 6 of the 7 measurable ones HAVE moved — so those read „Naprakész" when they may not be |
| `Manager.logPostStartStatus` | controller/internal/stacks/manager.go | `(name, stackDir, env)` | Async post-start verification | compose up exits 0 on crash-loops; this is the detection. Goroutine + 3s, never blocks |
| `Manager.EnsureBaseStack` | controller/internal/stacks/infra.go | `() error` | Traefik/cloudflared/FileBrowser infra convergence | Renders from `internal/infra` templates |
| `appbackup.ClassifyBinds` / `ValidateBackupSpec` | controller/internal/appbackup/classify.go | `(spec, binds) ([]ClassifiedBind, bool)` / `(spec, binds) error` | Backup-classification (Task 2, referential coupling) — pure | Two-level default: explicit wins over `:ro`; unlisted writable→mandatory, unlisted `:ro`→excluded; nil spec→legacy/false. Validate REJECTS the WHOLE block on any defect (whole-block semantics). INERT — no tier consumes it yet |
@@ -142,9 +180,13 @@
| `Manager.ClassifiedBinds` + `StackDataProvider.GetStackClassifiedBinds` | controller/internal/stacks/metadata.go, appbackup/appdata.go | `(name) ([]appbackup.ClassifiedBind, bool)` | Per-stack classification through the REAL LoadMetadata validate path | The wired seam Task 3 consumes; LoadMetadata is the SINGLE validation choke point (bad block → nil + one ERROR → legacy) |
| `backup.Manager.DumpAppVolumesSafe` | controller/internal/backup/backup.go | `(stackName) error` | Volume tar of a live app | Stops → dumps → restarts; surfaces BOTH errors (app may be left stopped). Check `GetDockerVolumes()!=0` + `IsProtectedStack` BEFORE calling — it stops the stack before its own volume check (see `runVolumeDumps`) |
| `backup.Manager.ListRestorePoints` | controller/internal/backup/restore_points.go | `(stackName) ([]RestorePoint, bool)` | Restorable keep-side backups (the /api/backup/snapshots payload) | ONE point per app (the current unit); tier always 1 — never list Tier-2 (not restorable via /backup/restore) |
| `backup.Manager.stampDataFile` / `foldUnitData` / `unitNewestArtifact` / `RecoveryManifest.Data` (v0.275.0, R-696) | controller/internal/backup/data_versions.go, removed_units.go | `(stack, unitDir, rel)` / … / `(unitDir) (time.Time, bool)` | A backup's data and its version travel together: the leg stamps each file it writes, the capture folds the stamps into `data` and keeps the definition the data belongs to; `unitNewestArtifact` is THE unit data time for every tier and list | **A new writer of a unit's data file MUST call `stampDataFile` right after the file reaches its final name** — an unstamped file makes the whole unit's versions unknown (restores as before, WARN). Never date a unit by its manifest; never count `pre-restore-*` as data. `definitionPins` is the ONE source of "current pins" (the stack's compose parse) |
| `backup.unitVersionCheck` / `ErrUnitVersionMismatch` (v0.275.0) | controller/internal/backup/restore_unit.go | `(stack, *RecoveryManifest, composeDir) ([]string, error)` | A restore never starts data with a definition it does not belong to — the check every unit restore and the off-site restore make before the first mutation | Branch with `errors.Is`; the sentence is the bundle's (`err.backup.unit_version_mismatch` / `_versions_mixed`). Unknown (`nil, nil`) restores as before |
| `backup.Manager.RestoreTier2Files` | controller/internal/backup/tier2_restore.go | `(stackName) (filesRestored int, err error)` | In-place ADDITIVE-ONLY class-C file restore from the recorded Tier-2 copy (`POST /backup/tier2/restore`) | Never overwrites/deletes live files; refusals (Hungarian) before any stop; source = recorded `DestinationPath`, never re-selected. **C9-F1 (v0.183.0): reads `hdd/` + `userdata/` ONLY — never `recovery-unit/`.** For 43 of 53 catalog apps that is a guaranteed no-op, so it now refuses with `ErrTier2NoRestorableData` BEFORE stopping the app. Ask `Tier2RestoreCoverage` first |
| `backup.Manager.Tier2RestoreCoverage` | controller/internal/backup/tier2_restore.go | `(stackName) (Tier2Coverage{Legs, HasUnit}, error)` | Answers what a Tier-2 restore CAN and CANNOT return for an app, from the RECORDED copy on disk | **C9-F1.** `Legs` = subtrees the restore reads; `HasUnit` = the copy also holds DB dumps + volume tarballs it will NEVER read. Use it to refuse up front and to decide whether the success message must disclose uncovered data. Judged from the copy, not the catalog, so a retemplated app is judged by what it actually has |
| `Manager.acquireRunning`/`releaseRunning`, `acquireMigrating` | controller/internal/backup/backup.go, controller/internal/stacks/migrate.go | `() error` | Single-flight for long ops | Copy this mutex-flag pattern for any new long-running manager op |
| `stacks.Manager.WantsStopped` (v0.283.0, R-721) | controller/internal/stacks/desiredstate.go | `(name) bool` | The household's recorded intent is „stopped" | ASK IT before any machine restarts an app it stopped for its own purpose (quiesce, volume dump, update leg) — a Stop pressed meanwhile must hold. Never gate the household's own Start on it |
| `settings.DefaultOffboxOnForNewApp` / `AppsWithoutOffbox` / `EnableOffboxFor` (v0.283.0, decision 50) | controller/internal/settings/offbox_default.go | `(stack) (bool, why)` | A fresh install joins the off-site copy when the customer has off-site | Call only from the deploy-done hook; it never overrides an existing per-app choice |
### Secrets hygiene
@@ -162,6 +204,12 @@
| `maskRepoURL` | controller/internal/sync/sync.go | `(url) string` | Logging git URLs | Strips embedded credentials |
| `metrics.RedactLine` | controller/internal/metrics/redact.go | `(s string) string` | ANY log line shipped off-box (issue context, log tails) | Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative |
| `settingsRetrievalPasswordRevealHandler` | controller/internal/web/handlers.go | `POST /settings/retrieval-password/reveal` | **THE PATTERN for showing a secret in the UI** — an XHR that returns only the value | **Never template a secret into a page and hide it with CSS.** `display:none` / `hidden` / `type="password"` stop a browser DRAWING the value; the plaintext is still in the response body, so a `curl` of the page returns it, and it reaches caches, history and any screen-share of the source. R-249 shipped exactly that for two months and was found by it landing in a transcript. The page carries a **boolean** (`HasRetrievalPassword`); the value comes from a POST (CSRF-covered, uncacheable) and the reveal is **logged as an act**. `escrow_handlers.go` states the same rule for R. **Test on the RESPONSE BODY** — a test asserting what the customer *sees* cannot see this class at all. **Both R-254 sites are now FIXED the same way** — `POST /apps/<slug>/initial-credentials/reveal` (re-reads the container, never a cached copy) and `POST /stacks/<name>/auto-field/reveal` (authorised on the field being a `type: secret` auto-field of that stack). **Per-secret, never one generic reveal-any-named-secret endpoint.** The PRE-DEPLOY hidden input is deliberate and untouched — a form must carry what it submits (README §318). Enforced by `scripts/secret_in_markup_gate.py`, whose measured blind spot (a secret under a neutral page-data key) is in its docstring; runtime body-assertion covers 4 of 27 pages — R-255. |
| `storedValuePresent` / `appLoginFields` | controller/internal/web/app_login.go | the settings tab's login panel and „Cím és kulcsok" rows (v0.319.0) | **A field the box holds no value for is a sentence, never an eye.** `storedValuePresent` is the ONE definition of „stored", read by the page AND by `appAutoFieldRevealHandler`'s 404 branch — so the page cannot offer a reveal the endpoint refuses. The page gets a boolean per login field, never the value (R-254/R-709). Pinned by `TestAppPage_NoStoredValueShowsSentence`. |
| `loginAnswerFor` | controller/internal/web/app_login.go | the settings tab's ONE login answer (v0.320.0) | `fields` · `initial` · the catalog's `app_info.login_kind` via `AppInfo.EffectiveLoginKind` (`first_visit` default; an unknown value reads as it). An infra stack with no login gets no panel. Pinned by `TestAppPage_EveryInstalledAppHasOneLoginAnswer`. |
| `filesButtonLink` / `mediaFolderCards` / `drivePresent` | controller/internal/web/datapath_card.go | the „Fájlok ↗" target and the media folder rows (v0.320.0) | **A folder whose drive is not bound into FileBrowser now is TEXT, never a link** — `drivePresent` reads `stacks.PresentMediaDrives` (the same gates `buildFileBrowserPaths` applies); tests seam it with `Server.presentDrivesFn`. Links only through `fileBrowserLink`. Pinned by `TestAppPage_FilesButtonTarget`, `_UnknownSourceIsTextNotLink`. |
| `infraMetaIn` | controller/internal/web/inframeta.go | an infra stack's curated name/description in a language | The ONE place for both the `infraMeta` template func and the app page. |
| `Manager.FileBrowserStoredPasswordRefused` | controller/internal/stacks/filebrowser_password.go | is the stored FileBrowser password still valid (v0.321.0, R-1101) | TRUE only on FileBrowser's own 401/403 in the `generated` state; FileBrowser not answering is FALSE (an unproven refusal must not tell the household its password changed). 3 s bound. Web seam: `Server.fbStoredRefused`. |
| `Manager.ResetFileBrowserAdminPassword` | controller/internal/stacks/filebrowser_password.go | FileBrowser's admin password without the old one (v0.320.0) | Only in `operator`; holds `infraMu` (the base tick must not start the stopped server), and the web handler holds `fileBrowserMu` (the mount sync must not recreate it). Stop → `docker run --rm --volumes-from filebrowser … set -u admin,<pw>` → start (detached from the request's cancel) → verify → store encrypted. TRAP: the CLI opens the DB with no lock timeout — never run it against a running server. Seam: `fbSetPassword`. |
### Storage registry + mount detection
@@ -172,6 +220,10 @@
| `registerStoragePath` | controller/internal/web/storage_handlers.go | `(where, label, setDefault) error` | Post-enroll registration | The single funnel used by init/attach/manual-add |
| `system.IsMountPoint` / `IsWritable` / `PathsOverlap` | controller/internal/system/mounts_linux.go | `(path) bool` | Mount checks | `_other.go` stubs return permissive values — Linux behavior is the real one |
| `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) |
| `backup.Manager.diskRelation` | controller/internal/backup/backup.go | `(a, b) (DiskRelation, reason)` — DiskDifferent / DiskSame / DiskUnknown | EVERY Tier-2 or one-copy "same disk?" decision (v0.317.0, felhom.eu `16-storage-physical-identity.md` §4) | Asks the host agent (`SetDiskJudge`, FeatureSameDisk ≥ agent 0.157.0); an older agent or an `unmapped` path → the old `sameDevice`/st_dev rule. Never call `sameDevice` directly for a Tier-2 decision — st_dev calls two partitions of one disk "different". Pinned by diskrelation_test.go |
| `web.Server.ensureInternalPlace` / `refuseInternalLifecycle` | controller/internal/web/internal_place.go | drive-gate tick hook; `(w, r, where) bool` | the internal data place (v0.318.0, `16` §5): bind on one tick, register (kind internal, default when none) on the next; refuse eject/decommission/wipe | Only an agent row of kind `internal` is enrolled without a press — a household drive never is. Pinned by internal_place_test.go + stacks d4_needs_drive_test.go (a media app lands on it) |
| `web.Server.offerRelationFor` | controller/internal/web/backup_target_offer.go | `(ctx, drive) offerRelation` (sameDiskFn seam) | the whole-box backup offer's disk question (R-1084) | Older agent → today's offer; an error → no promise. Pinned by r1084_offer_identity_test.go |
| `web.withoutSystemVolume` / `deviceBacksRegistered` / `freePartitions` | controller/internal/web/attach_sources.go, agent_disk_handlers.go | pure filters | R-1082 (sys_drive never offered, by device) and partitions (`16` §6: free parts of an in-use disk stay, the whole disk never; server-side refusal) | Pinned by r1082_sysdrive_hidden_test.go + partitions_test.go |
| `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — |
| `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var |
| `stacks.ExportDataMounts` | controller/internal/stacks/delete.go | `(composePath, hddPath) []string` | THE .fab-export mount discovery (v0.130.0 C6B-F1) | Unions `${HDD_PATH}` binds + the `${USERDATA_PATH}` ROOT (single `userdata` entry — basename must round-trip the import's `<HDD_PATH>/<subdir>` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it |
@@ -205,26 +257,33 @@
|---|---|---|---|---|
| `Notifier.PushEvent` | controller/internal/notify/notifier.go | `(eventType, severity, message, details)` | Hub events | Async goroutine, 3 attempts/3s backoff. NEW event types MUST be added to hub `allowedEventTypes` or POST /event 400s. **SEVERITY IS AN EXACT WIRE CONTRACT: `{"info","warning","error","critical"}` and nothing else.** The hub silently COERCES any other string to `"info"` (`hub/internal/api/handler.go`, the ingest severity switch) and `severityNotifies` (`hub/internal/notify/dispatcher.go`) emails only warning/error/critical — so a typo'd severity is stored and delivered to NOBODY, with no error anywhere. **`"warn"` is not a severity.** It shipped on `disk_health_degraded` (fixed v0.215.0, R-328) and is STILL live on `app_start_failed` (R-329) |
| `notify.DiskAlert` / `DiskAlertKind` / `DiskAlertKind.Severity()` | controller/internal/notify/notifier.go | `NotifyDiskHealthDegraded(DiskAlert)` | The disk-health alert payload + its five Hungarian message shapes | The notifier owns customer copy — pass a `DiskAlert`, never a pre-formatted string, or Hungarian scatters across packages. `Severity()` is the ONE mapping kind→hub severity and is exported so any package can assert the contract instead of duplicating the literal |
| `Notifier.PushEventHousehold` (v0.309.0, R-521 F7) | controller/internal/notify/notifier.go | `(eventType, severity, message, household, details)` | A producer that renders BOTH languages itself because its sentence carries figures it computed (fillwatch) | Prefer `pushEventMsg` (one key, both renderings) inside notify; this is for a sentence built outside. `household` "" for a Hungarian household. Registered in the R-329 severity contract (`severityArg`) |
| `Notifier.Notify*` convenience methods | controller/internal/notify/notifier.go | typed wrappers (backup/DB/storage/channel/DR…) | Standard events | Add a typed wrapper rather than raw PushEvent calls |
| `report.BuildReport` / `Pusher.Push` | controller/internal/report/builder.go + pusher.go | periodic hub report | Box→hub reporting | ACK carries `config_version` → `ConfigRefresher.Reconcile` |
| `report.Trigger` (`NewTrigger`/`Fire`/`Run`) | controller/internal/report/trigger.go | `Fire()` after a hub-relevant user action | THE out-of-cycle report push (v0.139.0) — fire via `api.Router.reportPushNow` / `web.Server.reportTriggerNow`, both nil-safe | Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the `internal/sync` REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit |
| `report.SetPendingLogTails` + `buildLogTailsSection` | controller/internal/report/logtail.go | ACK `log_tail_requests` → next report `log_tails` | THE pull-based ACK-flag pattern (hub asks, controller pushes next cycle) — copy for any new hub→box request | Consume-once drain at BuildReport; failed push re-arms from the hub's still-pending request; NEVER add a hub→controller push channel |
| `metrics.FetchContainerLogTail` | controller/internal/metrics/logscanner.go | `(name, tailLines) (string, error)` | Raw per-container `docker logs --tail=N` | 15s timeout; caller caps/redacts (capTailLines) |
| `ConfigRefresher.Reconcile` | controller/internal/report/config_refresh.go | `(ackVersion int)` | Pull-based config refresh | Re-pulls controller.yaml (re-merging local_api), then graceful self-restart; first-run = baseline, no restart |
| `offsiteapply.HubRegistrar` / `HubWindowClient` / `PinnedProber` (v0.289.0, decisions 68–69) | controller/internal/offsiteapply/seams.go | `Register(ctx,pub)(fp,err)` · `Confirm` · `MoveAside` · `Open/Close` window · `Probe(ctx,host,user,port,kh,privPEM) bool` | EVERY off-site key install, the hub move-aside, the clean-up window | **The box never handles the sub-account password** — there is no consume path any more. `PinnedProber` is a POSITIVE observable (exit 0 + rclone output); "authenticates" is NOT enough — an unpinned key authenticates and can delete. |
| `Manager.offsiteWindowRetention` + `offsiteGuard` (v0.289.0, R-822) | controller/internal/backup/offbox_window.go | `(ctx, base, env, why)` · pure `(all, plan, now, newestAllowed, max) (ids, refuse)` | THE retention step for both callers (after a run, over quota) | Pinned tier: no window → nothing deleted; the guard runs BEFORE any forget; "young" = fewer than `keepDaily` CALENDAR days old (v0.294.0, R-867 — the line is built from the same constants as `retentionPolicy`; never an hour count); a young snapshot superseded the same day is EXCLUDED, any other young removal / future date / plan above `max_remove` REFUSES; tests run restic 0.14.0's policy itself (`restic0140Plan`, proven identical to the binary); forget is by explicit ids, oldest first. A due abandonment goes to `OffsiteAbandonClient` (hub, 7-day wait). NAS tier: the old SP-2 policy, unchanged. |
| `offsiteapply.SettleProvider` / `SettleFunc` / `Bridge.AwaitSettle` / `ReconcileWhenSettled` (R-71a, v0.162.0) | controller/internal/offsiteapply/offsiteapply.go + seams.go | `SettleState() (version, floor string, updateRunning, floorKnown bool)` | THE settle-gate: defers the offsite one-time-password consume past a managed day-0 floor-update (the F10 race). Wire the `SettleFunc` adapter over `updater.GetFloor()`/`IsUpdateRunning()` — **the updater's knowledge is the ONE floor source; never fetch the floor a second way**. Gate ONLY the bridge goroutine, and only when an updater exists (nil `Settle` = reconcile immediately). Bounds `settlePoll`/`settleFloorSubBound`/`settleOverallBound`; the floor is in-memory (report-ACK-derived, ~5–10 s), NOT persisted → unknown until the first ACK on any restart. Inject `Now`/`Sleep` in tests (no real sleeps). B′: at/above-floor GOes on the first poll, zero wait. Do NOT touch the consume/persist order or the 404 contract — ordering only |
| `bootstrap.MaybeIngest` / `RefreshConfig` | controller/internal/bootstrap/bootstrap.go | bootstrap.json → controller.yaml | Day-0 + refresh | Overwrites controller.yaml, NEVER settings.json |
| `api.GracefulSelfRestart` | controller/internal/api/selfrestart.go | `(logger)` | Controller self-restart | Detached exit; bootstrap unit re-runs the image |
| `settings.NotificationGroups` + `NotificationGroupState` / `NotificationPrefs.GroupState` (R-388 slice 1, v0.309.0) | controller/internal/settings/notif_groups.go | `(enabled []string, key) "on"\|"off"\|"custom"` | THE `08` §8.1 group membership of every customer-switchable detector — the notification page's three switches | A group is a VIEW over `enabled_events`, never a stored field. A new detector with a page switch MUST join exactly one group (`TestR388_EveryPageSwitchIsInExactlyOneGroup` fails otherwise). The save rule lives in `Server.applyNotificationGroupSwitches`: only a MOVED group switch overrides the per-detector switches — an untouched „egyedi" group is saved back unchanged |
| `Settings.AddPendingEvent/DrainPendingEvents` | controller/internal/settings/settings.go | offline event queue | Events while hub unreachable | — |
| `Manager.SetUnitNotify` + `UnitSpace` (R-158/R-167, v0.191.0) | controller/internal/backup/recovery_unit.go | `(func(stack string, err error, *UnitSpace))` | THE per-app Tier-1 recovery-unit capture failure alert — fires PER APP from `captureAllRecoveryUnits`, loop continues | **OPERATOR-TIER** (`recovery_unit_capture_failed`, in the hub's `operatorOnlyEvents`). **NEVER route it to `backup_failed`** — that type is in `DefaultEnabledEvents` and carries Hungarian copy, so it emails the CUSTOMER about a failure they cannot act on (D-c; R-158's own proposal said `backup_failed` and D-c overrides it). `UnitSpace` is **nil when the target filesystem is unreadable** and renders as *"unavailable"*, never as zeros — "0 GB free" and "we could not look" are opposite diagnoses. No controller-side cooldown: the hub owns it |
| `Manager.beginRunSummary` / `noteFailure` / `noteAttempted` / `emitRunSummary` / `SetRunSummaryNotify` (R-182, v0.194.0) | controller/internal/backup/runsummary.go | `(kind, runID) func()` / `(app, leg, reason)` / `(RunSummary)` | **THE per-run operator digest.** One `backup_run_failures` event at the end of a run listing every failed app, its leg and its reason — emitted ONLY when something failed | **The RECORD and the NOTIFICATION are different things and must stay so.** The per-app `recovery_unit_capture_failed` event is the record (hub routes it *record-only*, stored + logged every time); this digest is the notification. Before R-182 one event was both, and did neither: nine arrived, two were mailed, seven vanished before `LogNotification`. **Lifetime is `admissionSet`'s exactly** — absent collector means "no run in flight", never a stale answer. **A refusal is noted ONCE, inside `admitApp` where the verdict is taken**, not at the three legs that consult it: R-181's one-verdict-covers-all-three contract makes per-leg noting produce "2 of 1 apps failed". **Deliberate skips (disconnected / decommissioned) must NEVER be noted** — they have their own alert and a nightly digest about an unplugged drive is an ignored digest. **A clean run emits NOTHING**; silence is safe only because the hub's deadline check (`monitor/deadline.go:396,417`) raises a missed backup from report freshness independently — if that is ever weakened this design loses its footing. **`run_id` is unique per real run** (so the hub's 1-h cooldown cannot collapse a manual run into the nightly one) and **deliberately EMPTY on the periodic refresh sweep**, which must stay under that cooldown or a polled status page becomes a mail flood |
| `Manager.admitApp` / `beginAdmissionRun` / `decideAdmission` / `estimatedWriteBytes` (R-181, v0.193.0) | controller/internal/backup/admission.go | `(stackName) bool` / `() func()` | **THE reserve gate. Call it before ANY per-app backup write** — one verdict per app per run, covering the DB dump, the volume dump and the unit capture (all three write under one per-app root) | **Decided LAZILY at the app's first write, never once at run start** — app A's dump can put app B under the reserve, so a run-start verdict reads a disk that no longer exists. **Never re-decided between an app's own legs**: that is exactly the split R-181 closed (bulk written, capture refused). **Reset per run** via the closer `beginAdmissionRun` returns. **Must sit ahead of `DumpAppVolumesSafe`**, which stops the stack as its first act — a refusal decided inside it has already bounced the app. Fires **exactly one** `unitNotify` per refused app per run. Nil admission set (periodic status refresh) → decides fresh, which is still once per app per sweep. Wiring pinned by an **AST walk** in `TestAdmission_IsWiredIntoEveryProductionWriteLeg`, not `strings.Contains` |
| `Manager.floorVerdict` + `FloorUsedPercent`/`FloorFreeGiB` / `ErrCaptureFloor` / `floorReason` (R-165 B2 v0.192.0, size term R-181 v0.193.0) | controller/internal/backup/recovery_unit.go | `(*UnitSpace, estGiB float64) (*UnitSpace, floorReason)` | The pure two-question predicate behind `admitApp`: is the filesystem already below the reserve (`floorHeadroom`), and would THIS app's write take it below (`floorSize`)? | **Headroom is about the FILESYSTEM, never a per-unit cap** — a size cap is R-163 rebuilt inside one volume; the size term bounds the *delta*, not the unit. **REFUSES, never deletes:** nothing here is generational (a unit is one fixed path per app, a DB dump one fixed name), so pruning could only destroy a DIFFERENT app's only local copy — **never repurpose `pruneStalePrimaryDirs`**, which removes ORPHANED dirs from an app that moved drives and has no notion of age. Two terms (97% / 1 GiB) in `fillwatch`'s shape, deliberately BEYOND its critical band (95% / 2 GiB) so the customer is always warned first — pinned by `TestFloorSitsBelowTheCriticalWarningBand`. **`estGiB == 0` degrades to headroom-only on purpose** — refusing an app with no history makes the FIRST backup the one that can never happen. A nil reading neither refuses nor warns (§8.4). Inject `unitSpaceFn` in tests rather than manufacturing occupancy on a real disk |
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space |
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space. **Both languages (v0.309.0, R-521 F7):** the sentence is the bundle's `event.fill.*`, the household's rendering rides as `message_customer` via `Notifier.PushEventHousehold` (`SetLang`; `SetExtra` takes the language); a built-in area carries a `LabelKey`, a household's own drive label is used as written. Hungarian pinned byte-for-byte by `TestMessageHungarianWireText` (v0.309.0 dropped the formal „Kérjük," — R-516) |
### Scheduler / time / UI
| Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---|
| `Scheduler.RunNow` / `scheduler.OnDemand` | controller/internal/scheduler/scheduler.go | `RunNow(name) error`; `OnDemand(ctx) bool` | Run a registered job once now (operator action `run_job`, decision 185) | Refuses unknown and already-running jobs; a job that checks due-ness reads `OnDemand(ctx)` to skip that check on demand |
| `report.OperatorActions` | controller/internal/report/opactions.go | `Reconcile(reply)` → results in the next report | Hub-requested actions in the report reply — the `selftail.go` pattern plus results | The action table is CLOSED and pinned by `TestOpActions_ClosedList`; once per id in memory; every entry must be safe to repeat |
| `web.sessionFingerprint` / `loadSessions` / `saveSessionsLocked` / `saveSessionsRevokingLocked` | controller/internal/web/session_store.go | dashboard sessions on disk (R-35) | Any session lookup | The map is keyed by sha256(cookie), NEVER the cookie — hash with `sessionFingerprint` first; a path that ENDS sessions uses the revoking save |
| `Scheduler.Every` / `Daily` | controller/internal/scheduler/scheduler.go | `(name, interval/"HH:MM", fn)` | ALL background jobs | Daily is Europe/Budapest, DST-safe (`nextDailyRun` avoids Add(24h)); register in main.go block (§5) |
| `Scheduler.UpdateDaily` | controller/internal/scheduler/scheduler.go | `(name, "HH:MM") bool` | Retime a daily job at runtime (no restart) | Per-job buffered `resched` chan + select case in `runDailyJob`; false (WARN) on invalid time / unknown-or-non-daily name; read `Schedule` under the mutex in the loop |
| `backupwindow.*` (LegTimes / GateWindow / EffectiveWindow / ParseHHMM / FmtHHMM / Valid) | controller/internal/backupwindow/backupwindow.go | pure `string`↔`int` | Backup-window arithmetic (v0.168.0) | Offsets (W+60m/W+105m, gate W+2h..W+6h) are CONSTANTS — derived, never stored; wrap-safe modulo 1440; `EffectiveWindow(settings, yaml)` = settings>yaml>"02:30" |
@@ -233,9 +292,11 @@
| `i18n.Bundle` (`Shared` / `Msg` / `Plural` / `Expand`) | controller/internal/i18n/i18n.go | `Msg(lang, key) string` | ALL customer copy that has been converted (v0.247.0) | hu.json is authoritative; a key missing in en shows Hungarian (counted), never blank; `Expand` runs BEFORE template parse — never add a runtime `T` func (escaping would change Hungarian bytes). A template edit to converted copy goes in hu.json + en.json, then `TestI18nParity` must still pass |
| `huTemplateSource` (test) | controller/internal/web/i18n_wiring_test.go | `(name) ([]byte, error)` | A test that asserts a template's Hungarian WORDING | Reads the expansion, not the raw file — since slice 1 the wording lives in hu.json. Never use `templateFS.ReadFile` for a wording assertion |
| `Server.langFor` / `templatesFor` / `addLanguageData` | controller/internal/web/server.go + i18n_web.go | request → lang → template set | Rendering a page in the household's language | Only `executeTemplate` is language-aware; `s.tmpl` is the Hungarian set; Go-side titles via `data["TitleKey"]` |
| `bootstrap.Unconfigured` / `WaitForConfig` / `WaitingHandler` (v0.308.0, R-554) | controller/internal/bootstrap/waiting.go | `WaitForConfig(ctx, ln, configPath, cfg, logger, pull, every, version) *config.Config` | The ONLY unconfigured-box path: the bilingual waiting page + a retry of `MaybeIngest` every minute; returns the configured config (nil on stop) | Replaces the deleted setup wizard. `/api/health` stays 200 while waiting; a nil listener still retries |
| `i18n.FormatDateTime` / `FormatDate` / `FormatMonthDayTime` / `FormatDecimal` / `LocalizeDecimals` | controller/internal/i18n/format.go | `(lang, …) string` | EVERY date or decimal number shown on the dashboard (R-562, v0.308.0): hu „2026. 10. 10. 15:04" + decimal comma, en „2026-10-10 15:04" + point | Pass the time already in Budapest; `LocalizeDecimals` is only for a size another package already formatted („1.5 GB") — new code calls `FormatDecimal`. Templates use the bound names in `formatFuncs(lang)` (funcmap.go: `fmtTime`, `fmtTimeStr`, `fmtGB`, `fmtBytes`, `numText`, …); Go page data uses `fmtLocalTime(lang, t)` / `fmtRFC3339Local(lang, s)` / `fmtSizeBytes(lang, n)`; page scripts use `fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` (layout.html). Never a `"2006-01-02 15:04"` literal for display. CSS widths keep `printf "%.1f"` |
| `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) |
| `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` |
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input; setup wizard has its OWN csrf (§6) |
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input |
| `LogBuffer` + `Lines(maxBytes)` | controller/internal/web/logbuffer.go | ring buffer io.Writer | In-memory log capture for the debug UI + the report `controller_log_tail` source | v0.116.0: ALWAYS constructed (any logging.level) — the logger is `MultiWriter(LevelFilterWriter(stdout, level), ring)`; `Lines` drops OLDEST to honor the byte budget |
| `logx.Debugf/Infof/Warnf/Errorf` | controller/internal/logx/logx.go | `(l *log.Logger, format, args…)` | ALL NEW leveled log lines (the v0.116.0 sweep standard) | routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3) |
| `web.LevelFilterWriter` | controller/internal/web/levelfilter.go | `NewLevelFilterWriter(w, minLevel)` | stdout leveling under the always-on ring | untagged lines parse INFO; always reports full length written |
@@ -259,6 +320,7 @@
| Streaming subprocess progress | controller/internal/backup/offbox_progress.go | `offboxStreamRunner` seam (stdout scanned line-by-line, stderr buffered, output tail-bounded) + a PURE line parser + a mutex-guarded published snapshot. Traps it encodes: a source reporting nothing is **normal** (restic sends 0 bytes for a whole incremental run) and the progress source may only update on unit completion — degrade bytes → files → current item + elapsed, never fake a percentage |
| Post-start async verification | controller/internal/stacks/manager.go `logPostStartStatus` | goroutine + sleep, INFO log, never blocks/fails the operation |
| Startup wiring order | controller/cmd/controller/main.go | init-only setters (`SetStackProvider` M2 contract: exactly once, before scheduler/HTTP), scheduler registration block |
| Tabbed page (one subject, several URLs) | controller/internal/web/app_page.go + templates/app_shell.html | ONE data builder for what every tab shows (`appCommonData`), one shell partial (header + page-level banners + tab bar), one page template per tab calling `{{template "app_shell_start" .}}`. Tabs are `<a>` links with `aria-current`, never hidden panels — a link in an alert must land ON a tab and a poller must run on its tab only. The default tab is RENDERED at the bare URL, never redirected to. A key the tab owns (`Page`, `Title`, `TitleKey`, `TitleArgs`, `ActiveTab`, `AppTabs`) is listed in `appPageDataKeysOwnedByTab` so a merged data builder cannot overwrite it |
## 3. Dangerous lookalikes — do NOT reuse
@@ -270,9 +332,11 @@
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` |
| `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) |
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
| `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else |
| `config.LoadPermissive` | Skips validation — first boot only (customer.id/domain may be unset until `bootstrap.WaitForConfig` gets the configuration, R-554) | `config.Load` everywhere else |
| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
| any settings getter/setter or helper that ends in one (`m.note`, `boxLang`) INSIDE a `settings.UpdateOffboxStatus`/`UpdateCrossDriveStatus` callback (R-578) | The callback runs under the settings WRITE lock and `sync.RWMutex` is not reentrant — it DEADLOCKS holding the lock and wedges settings.json for the box | Resolve the value BEFORE the callback (`lang := m.boxLang()`); `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` (controller/internal/settings/r578_callback_lock_gate_test.go) convicts it in every package |
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
| `backup.WholeOnTier` vs `UpdateCopyHolds` (R-659) | `UpdateCopyHolds` says what a copy HOLDS; it does not say the restore will ACCEPT it — a file app's second-drive copy holds the files and its unit restore still refuses | `WholeOnTier` (asks `DeclaredDriveFileLegs`, the refusal's own predicate) before a sentence names a copy as a way back |
## 4. Seams & interfaces (testing + cross-repo)
@@ -280,10 +344,14 @@
|---|---|---|---|
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
| `healthSettle` / `healthInterval` / `healthTimeout` (package vars, R-488) | controller/internal/backup/restore.go | production 3 s / 5 s / 90 s — `waitForHealthy`'s clock, used by every restore path | shortened ONCE in controller/internal/backup/main_test.go `TestMain` (0 / 2 ms / 50 ms; the package went 444 s → ~2 s); `TestR488_HealthWaitDefaultsAreProduction` pins the production values. A new real-clock wait in this package goes behind the same kind of var, not a per-test sleep. Also: `newOffboxManager` installs a failing `SetOffboxSSH` fake, so no backup test spawns a real `ssh` |
| `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) |
| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated |
| `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) |
| `Manager.sambaUpFn` / `sambaPasswdFn` / `sambaRunFn` / `sambaAddrFn` (func seams) | controller/internal/stacks/manager.go (fields) + samba.go | nil → `composeUp` / `docker exec smbpasswd` (STDIN) / `containerRunning("felhom-samba")` / `docker exec felhom-samba ip -4 -o addr show eth0` | injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called **zero** times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker. **`sambaRunFn` has an EXPORTED setter (`SetSambaRunProbe`)** — internal/web's status-contract tests need a live-container world from another package. `sambaAddrFn` backs `SambaLANAddress()` (v0.151.0); its parse is separately pinned in samba_lanaddr_test.go and it returns "" on any failure — the page omits a line rather than printing a wrong address |
| `volumeCopier` + `Manager.undoCopier` / `updateUndoHealthFn` (v0.263.0) | controller/internal/stacks/undo.go | nil → `dockerVolumeCopier` (alpine helper: `cp -a` named volume → `<vol>.pre-update-<stamp>`, finished-marker LAST; restore re-checks the marker in the same shell) / nil → `waitUpdateHealthyMeta` with the OLD `.felhom.yml` | `fakeCopier` in controller/internal/stacks/undo_test.go — volume CONTENT as strings, so "the data came back" is a compare; a cut-off copy is a copy without its marker. **Judge a copy by the helper's own exit + the marker, never by the client** (killing `docker run` leaves the container copying — measured) |
| `pgConverter` + `Manager.pgConv` / `convertFreeFn` (v0.273.0) | controller/internal/stacks/pgconvert.go | nil → `dockerPGConverter` (docker exec psql/pg_dumpall over 127.0.0.1 — the entrypoint's temporary init server listens on the socket only; `Empty` re-checks the undo copy's marker in the same helper) / nil → statfs of the stack dir | `fakePG` in controller/internal/stacks/pgconvert_test.go — works on `fakeCopier`'s volume strings. **Never convert without the ladder's `engine_conversion` mark** (`planEngineConversion` refuses a PostgreSQL major move without it); `isPostgresImage` must match `appbackup.dbTypeForImage` (pinned by a source-reading test) |
| `Manager.composeExecFn` / `stacks.setupGateProbeGet` / `Server.gateClock` (v0.280.0) | controller/internal/stacks/manager.go + setup_gate.go · controller/internal/web/server.go | nil → the real compose call / an HTTP GET (5 s) / `time.Now` | controller/internal/stacks/setup_gate_test.go (with a docker STUB on PATH — R-650) · controller/internal/web/setup_gate_test.go |
| `Manager.SambaLANAddress()` | controller/internal/stacks/samba.go | `() string` — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) | Read from the SAMBA container's netns (`network_mode: host`), never `net.InterfaceAddrs()` — the controller is on a docker BRIDGE and would answer 172.x (the same trap `setup.DetectLocalIPs` needs `HOST_IP` for). **NEVER cache/persist it** — the guest holds it by DHCP (S-5); callers re-derive per render. `""` = omit the line |
| `Server.sambaAddrFn` (func seam) | controller/internal/web/server.go (field) + sharing_handlers.go `sambaLANAddress()` | nil → `stackMgr.SambaLANAddress()` | The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease |
| `Manager.guestNetExecFn` (func seam) + `GuestGateway()` / `GuestNetSnapshot()` | controller/internal/stacks/manager.go (field) + guestnet.go | nil → `docker exec felhom-samba <args>` — ONE seam for all R-66 guest-netns reads (route/link/addr/resolv.conf); tests script canned outputs per argv | guestnet_test.go. **The netns door rule:** the controller's OWN netns is the docker bridge, so any in-process read (`net.Interfaces`, `/proc/net/route`, its own `/etc/resolv.conf` = 127.0.0.11) is the S-2 wrong answer — guest-net reads MUST go through the samba (`network_mode: host`) exec door. Megosztás off ⇒ door closed ⇒ "" / per-item error strings; NEVER substitute an in-process value. Same S-5 law as SambaLANAddress: live per render, never cached/persisted. Parsers (`parseDefaultRoute`, `parseGuestInterfaces`, `parseResolvConf`) are pure + separately pinned |
@@ -305,9 +373,13 @@
| `Manager.execFn` (func seam) + `restartPolicyLookup` / `inspectRestartPolicyFn` (R-51, v0.156.0) | controller/internal/stacks/manager.go | nil → real `exec.Command` / `docker inspect -f {{.HostConfig.RestartPolicy.Name}}` | `scriptedDocker` in controller/internal/stacks/degraded_test.go drives the WHOLE production path (docker ps → aggregateState → docker inspect) — an aggregateState-only test proves the function, not the caller. Policy answers are cached per container+state and pruned to the live `docker ps` set; a FAILED inspect is deliberately never cached (a hiccup must not pin a container to "unknown") and reads as SUPERVISED, i.e. fail-closed — the opposite of `IsDownState`'s fail-open, because there the state is ambiguous while here a member is known dead |
| `bootrecon.StackProvider` (R-52, v0.156.0) | controller/internal/bootrecon/bootrecon.go | `*stacks.Manager` (GetStacks/StartStack/RefreshStatus) | `fakeStacks` counts StartStack per app; the load-bearing assertion is the NEGATIVE — a zero-container stack (a UI Stop = `compose down` = containers removed) must record **0** starts, while a boot orphan (containers present, Exited) records exactly 1. `Reconciler.sleep` is injected so the 30 s gap costs nothing |
| `bootReconcileFn` + `runBootReconcile` (package-main seam, v0.156.0) | controller/cmd/controller/main.go | `bootrecon.New(mgr, logger).Run` | controller/cmd/controller/bootrecon_wiring_test.go. **The wiring itself is asserted by an AST walk** over `func main()`, not a `strings.Contains` — the substring version passed its own red-proof because a commented-out call still contains the string. Comments are not callers |
| `crashboot.Probe` / `crashboot.Grace` (R-856) | controller/internal/crashboot/crashboot.go | `crashboot.AgentProbe(agentClient.CrashGuard)` (GET /host/crash-guard) | `fixed(Fact, err)` in crashboot_test.go. The dead-app check gates on `bootGrace.Within(now)` — 90 s normally, `CrashGrace` (15 min) when the HOST's last boot was unclean and within `BootWindow` of the controller start. **Unknown (no agent, 404, no state) is a normal boot** — never a longer silence on a guess. Asserted by AST walk in cmd/controller/r856_wiring_test.go |
| `classifyRunStates` (pure fix-3 derivation, v0.164.0) | controller/cmd/controller/main.go | `([]stacks.Stack, quiesced, failedRestart map[string]bool, now time.Time)` → `(dead []web.DeadApp, states []notify.AppRunState)` | classify_runstates_test.go. **THE single fix-3 rule: down = `(IsDownState(st.State) || st.CrashLooping(now)) && !userStopped && !quiesced`.** **`quiesced` is a UNION of TWO suppression sets** (R-330, v0.224.0): `quiesce.Loop.SuppressedStacks()` (whole-guest vzdump/PBS) and `backup.AppStopGuard.SuppressedStacks()` (per-app volume dump / offbox reconstitute / `.fab` export), merged by `unionSuppressed` in `scanDeployedAppRunStates`. **Adding a third way to stop an app means adding its set here** — R-330 was 61 false customer e-mails caused by exactly that omission, with a working suppressor sitting three lines away. C9-F2 (v0.183.0) added the crash-loop term: `restarting` is NOT in `IsDownState` and must not be — adding it alarms on every deploy and update fleet-wide — so a SUSTAINED restarting run (`stacks.crashLoopAfter` = 5 m, above the 120 s deploy timeout, Mealie's 60 s start_period AND R-97b's 180 s grace) becomes down instead. `now` is injected so the threshold is a testable contract. A deliberate UI stop (`compose down` → zero containers → StateStopped, I1) must not alarm — banner OR email — while faults (Exited/Degraded) alarm byte-identically; I2 (P2 census: all catalog services `unless-stopped`) is why a crash never rests at stopped. **Do NOT touch `IsDownState`** (other callers rely on stopped=down) and do NOT filter in `buildDeadAppAlerts`/`NotifyAppStartFailures` — one derivation point. If I1 or I2 changes, revisit the suppression |
| `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go |
| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
| `util.ParseVersionPre` | controller/internal/util/version.go | the same comparator, reading `X.Y.Z-rc.N` as BELOW `X.Y.Z` (v0.314.0, R-1053) — ONLY for „is this box below the floor?" | never use it to pick a tag to offer: the button must not see a test build |
| `mediawire.PlexURL` | controller/internal/mediawire/plexaddr.go | Plex's base URL BY ADDRESS, resolved at each call (v0.314.0, R-1060) — every controller call to Plex | Plex refuses the Host name `plex` as non-local (401); never cache the address |
| `stacks.GateCapture` / `stacks.ReadGateAtBackup` | controller/internal/stacks/restore_gate.go | the setup gate a per-app backup records, and the restore's read of it (v0.314.0, R-1057) | the unit `app.yaml` key is `setup_gate:` — the live file's keys, so either reads the same |
| `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line |
| `netProbeReadBack` (package var) | controller/internal/web/netprobe.go | `os.ReadFile` | overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production |
| `system.ClassifyPathFS(Timeout)` + `netProbeFSClass` / `Server.classifyFSPath` / `Router.classifyFSPath` | controller/internal/system/fsclass*.go (+ web/netprobe.go, web/server.go, api/router.go seams) | statfs f_type → network/autofs/stub/unknown in THIS namespace (RCA fix 2) | idle autofs = HEALTHY, never force-mount; unknown = fail OPEN; seams injected in netprobe_stub_test.go / networkstub_test.go / deploygate_test.go |
@@ -350,19 +422,20 @@ Cross-repo edges:
- **New startup self-check**: append check fn in `selftest.Run` (controller/internal/selftest/selftest.go).
- **Converting a template to the bundle (i18n, v0.247.0)**: `python3 controller/scripts/i18n_extract.py <template>` moves every Hungarian run into `hu.json` and leaves `{{T "key"}}`; then **review what it LEFT** (it matches Hungarian by accented letter plus an ASCII word list — eight ASCII-only strings were missed on the first three pages), add the English to `en.json`, add fixture states for the page to `i18n_parity_test.go` **captured from the unconverted template first**, and run `controller/scripts/i18n_missing_gate.py`. Copy gates judge the page through `controller/scripts/i18n_bundle.py` (`read_template(path, lang)`), never the raw template.
- **New settings field**: struct + accessor pair in controller/internal/settings/settings.go following the Lock→mutate→save pattern.
- **A value asked AFTER the app starts (v0.315.0)**: add a `DeployField` with `after_start: true` to the catalog template AND an entry to `afterStartRegistry` in `controller/internal/web/after_start.go`, keyed `slug + "/" + ENV_VAR`, with `Needed(stack) bool` and `Apply(s, r, stack, value) error`. **The catalog may never carry the action** — not a command, not a URL, not a container to exec: the household's text is an argument to a function in this repository, which is the whole point of the design (D2 = A). Both halves are required and each is checked: a declared field with no entry renders no card and 404s its POST; an entry whose field the catalog does not declare also 404s (`TestAfterStart_RegistryAloneIsNotAnInvitation` — that second half was green and unpinned until a red-proof showed a POST for an undeclared field reaching Plex). `Apply` returns `*afterStartError{Status, MsgKey}` to show a sentence of its own inline, and **never logs the value** (it is a credential).
- **A tab on the app page (v0.315.0)**: a `case` in `appPageHandler`'s switch, a `render<Tab>Tab` that adds only its own keys to `appCommonData`'s map, a template starting with `layout_start` + `app_shell_start`, and the tab's key in `appTabOrder` with a literal `app_page.tab_<key>` locale key resolved in `appTabLinksLang`. **Resolve the label in Go, not with a composed `{{T (printf …)}}`** — a composed key is invisible to `TestI18nParityCoversEveryMarker` and a missing translation would render the key to the household.
## 6. Known duplication (observed — NOT fixed)
| Duplication | Locations |
|---|---|
| Atomic write ×4 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/setup/handlers.go `atomicWriteFile`; controller/internal/api/router.go `writeConfig0600` |
| String truncation ×3 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based, widely used); controller/internal/backup/offbox.go `truncate` |
| Atomic write ×3 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/api/router.go `writeConfig0600` (the setup wizard's `atomicWriteFile` went with it, R-554) |
| String truncation ×4 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based HEAD, widely used) and `tailStr` (rune-safe TAIL, v0.294.0 R-864 — use it for a command's stderr: the error is printed last); controller/internal/backup/offbox.go `truncate` |
| humanizeBytes ×3 | controller/internal/appbackup/appdata.go `HumanizeBytes` (canonical) + private twin; controller/internal/appexport/estimate.go `humanizeBytes`; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge) |
| copyFile ×2 | controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go |
| dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` |
| timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) |
| CSRF ×2 | controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled |
| Budapest timezone loader ×3 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
| Budapest timezone loader ×4 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` vs controller/internal/api/kept_install.go `budapest` (v0.277.0, the kept choice's dates) (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
| JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` |
| Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) |
| DB wait/import ×2 | controller/internal/appbackup/dbdump.go `waitDBReady`/`ImportDump` vs controller/internal/appexport/restore.go `waitForDB`/`importDBDump` |
+5 -1
View File
@@ -41,4 +41,8 @@ restic-password
# App assets (synced from felhom.eu website at build time)
assets/*.svg
assets/*.png
assets/*.webp
assets/*.webp
# build.sh's scratch directory. It is created beside the script, so a build run from a CLONE of
# this repo (which is what the clean-tree gate pushes you to when the shared tree is busy) would
# otherwise dirty the very tree the gate is checking. Generated output, never committed.
workspace/
+65 -33
View File
@@ -5,12 +5,12 @@
# =============================================================================
# --- Build stage ---
FROM golang:1.24-bookworm AS builder
# R-972 (v0.322.0): a supported, patched Go, pinned by version AND digest (no moving tag). Go 1.24 had no security fixes
# left (go.dev/doc/devel/release#policy). The builder's Debian does not reach the runtime (a static CGO-free binary).
FROM golang:1.27.2-trixie@sha256:47667934874913a449b96faa7ef4777fa285d9ec29126ee80ed61c45b7cccf0e AS builder
ARG TARGETOS=linux
ARG TARGETARCH
ARG VERSION=dev
ARG GIT_COMMIT=unknown
WORKDIR /build
@@ -24,6 +24,13 @@ COPY . .
# Generate go.sum and ensure all deps are fetched
RUN go mod tidy
# R-208: the per-build values are declared HERE, just above the only step that reads them. A RUN's
# cache key includes the stage's ARG environment, so declared above `go mod download` a fresh VERSION
# re-ran the module download on every build (208 cache records, each used once, ~440 MB apiece).
# Pinned by TestR208_DockerfileVersionArgsSitBelowModuleDownload.
ARG VERSION=dev
ARG GIT_COMMIT=unknown
# Build static binary
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags="-s -w \
@@ -33,48 +40,73 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-o /build/felhom-controller \
./cmd/controller/
# --- Runtime stage ---
FROM debian:bookworm-slim
# --- restic: the official release binary, pinned by version AND checksum (R-1105, v0.323.0) ---
# Never again „whatever the OS ships": Debian 12 → 13 would have swapped restic 0.14.0 → 0.18.0 under the off-site code
# without anyone choosing it. The version below is a decision; moving it is a re-proof (07-backup-architecture.md §6.8).
# Three checks, each fails the build: restic's SHA256SUMS carries a good signature from restic's release key (vendored
# in third_party/restic/, fingerprint CF8F 18F2 8445 7597 3F79 D4E1 91A6 868B D3F7 A907 — no keyserver at build
# time); the download matches the published sum; and it matches the sum pinned HERE, so even a re-signed release
# cannot swap the binary. Pinned by TestR1105_ResticIsPinnedOutsideTheOS.
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f AS restic
ARG TARGETARCH
ARG RESTIC_VERSION=0.18.1
ARG RESTIC_SHA256_AMD64=680838f19d67151adba227e1570cdd8af12c19cf1735783ed1ba928bc41f363d
ARG RESTIC_SHA256_ARM64=87f53fddde38764095e9c058a3b31834052c37e5826d2acf34e18923c006bd45
COPY third_party/restic/restic-release-key.asc /tmp/restic-release-key.asc
RUN set -eu; \
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg gpgv bzip2; \
case "${TARGETARCH}" in \
amd64) want="${RESTIC_SHA256_AMD64}" ;; \
arm64) want="${RESTIC_SHA256_ARM64}" ;; \
*) echo "no pinned restic checksum for architecture '${TARGETARCH}'" >&2; exit 1 ;; \
esac; \
f="restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2"; \
url="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}"; \
cd /tmp; \
curl -fsSL -o "$f" "$url/$f"; curl -fsSL -o SHA256SUMS "$url/SHA256SUMS"; curl -fsSL -o SHA256SUMS.asc "$url/SHA256SUMS.asc"; \
gpg --dearmor < /tmp/restic-release-key.asc > /tmp/restic-release-key.gpg; \
gpgv --keyring /tmp/restic-release-key.gpg SHA256SUMS.asc SHA256SUMS; \
grep " ${f}\$" SHA256SUMS | sha256sum -c -; \
echo "${want} ${f}" | sha256sum -c -; \
bunzip2 -c "$f" > /usr/local/bin/restic; chmod 0755 /usr/local/bin/restic; \
/usr/local/bin/restic version | grep -q "^restic ${RESTIC_VERSION} "
# Install runtime dependencies:
# - docker-cli: for "docker compose" commands
# - ca-certificates: for HTTPS (healthchecks pings, git)
# - postgresql-client: for pg_dump
# - default-mysql-client: for mysqldump
# - sqlite3: for SQLite backup
# - git: for stack sync from Gitea
# - curl: for health pings and debugging
# - util-linux: lsblk, blkid, mount (storage init)
# - fdisk: sfdisk (partition table writing — separate from util-linux on Debian)
# - e2fsprogs: mkfs.ext4 (filesystem formatting)
# - rsync: for data migration between storage paths
# - parted: partprobe (partition table re-read after sfdisk)
# - restic: encrypted off-box (NAS) backup over SFTP (Part B; version pinned by the Debian release)
# - openssh-client: restic's sftp backend shells out to `ssh` (the SFTP transport); also ssh-copy-id/sftp
# for the SLICE-2 offsite apply-bridge key install
# - sshpass: feeds the one-time storage-box password to ssh-copy-id -s -f (offsite apply-bridge)
RUN apt-get update && apt-get install -y --no-install-recommends \
# --- Runtime stage ---
# Debian 13 (R-1105, v0.323.0), pinned by point release AND digest. It waited on Debian 12 until the off-site code's
# restic assumptions were re-proven on 0.18 (07-backup-architecture.md §6.8); restic itself no longer comes from here.
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f
# Install runtime dependencies — each one is a program the controller binary runs (R-1105 re-checked every exec):
# - docker-cli + compose plugin: every stack operation (below)
# - ca-certificates: for HTTPS (healthchecks pings, git, the hub)
# - curl: the image's HEALTHCHECK, and the docker repo key below
# - gnupg: dearmors docker's repo key below
# - git: catalog sync
# - util-linux: findmnt (mount reads)
# - rsync: data migration between storage paths
# - openssh-client: restic's sftp/rclone transports shell out to `ssh`; the off-site key probe
# v0.323.0 (R-1105) REMOVED, because the binary never runs them: postgresql-client and default-mysql-client (every
# dump and import runs INSIDE the app's own database container — `docker exec`, appbackup/dbdump.go), sqlite3,
# fdisk, e2fsprogs, parted (disk work moved to the host agent in v0.37.0), sshpass (the ssh-copy-id key install it
# fed was replaced by the hub's key registrar in v0.289.0). Fewer packages, fewer known holes.
RUN apt-get update && apt-get upgrade -y --no-install-recommends && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg \
git \
postgresql-client \
default-mysql-client \
sqlite3 \
util-linux \
fdisk \
e2fsprogs \
rsync \
parted \
restic \
openssh-client \
sshpass \
&& rm -rf /var/lib/apt/lists/*
# Install docker-cli (without daemon)
COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic
# Install docker-cli (without daemon). The repo's codename comes from the image's own /etc/os-release, never a
# hard-coded name (a hard-coded `bookworm` would have kept Debian 12's packages on a Debian 13 base).
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg \
&& . /etc/os-release \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] \
https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
+824 -177
View File
File diff suppressed because it is too large Load Diff
+84 -1
View File
@@ -16,7 +16,11 @@
set -euo pipefail
# --- Configuration (edit these if your paths differ) ---
REPO_DIR="/mnt/5_hdd/felhom.eu/git/felhom-controller"
# The default is the shared DooPlex clone, and the release runbook names it. It is overridable
# because that clone is shared: when another session has work in progress in it, the clean-tree gate
# rightly forbids building over them, and the way to ship is a clean clone at the pushed commit —
# not stashing somebody else's work. Set FELHOM_REPO_DIR to that clone.
REPO_DIR="${FELHOM_REPO_DIR:-/mnt/5_hdd/felhom.eu/git/felhom-controller}"
CONTROLLER_SRC="${REPO_DIR}/controller"
WEBSITE_ASSETS_DIR="/mnt/5_hdd/felhom.eu/git/felhom.eu/website/assets"
REGISTRY="gitea.dooplex.hu/admin"
@@ -61,11 +65,90 @@ info "║ felhom-controller image builder ║"
info "╚══════════════════════════════════════╝"
info "Version: ${VERSION}"
info "Commit: ${GIT_COMMIT}"
info "Repo: ${REPO_DIR}"
info "Source: ${CONTROLLER_SRC}"
info "Build dir: ${BUILD_DIR}"
info "Image: ${IMAGE}:${VERSION}"
echo ""
# =========================================================================
# A VERSION NUMBER IS PUSHED ONCE (v0.316.0)
# =========================================================================
# 0.315.0 was built and pushed THREE times from three different commits. A box that pulled between
# the first and the last runs different code under the same number — and will never fetch the fix,
# because to the box, and to the floor, that number is already current (`09-update-architecture.md`:
# a box compares VERSIONS, and re-pulls a tag it already has only when the tag changes). Nothing in
# the build stopped it. This does.
#
# The check runs BEFORE the build, so a refusal costs no minutes. A registry that cannot be reached
# is a REFUSAL too: the point is to prove the tag is new, and "I could not ask" is not that proof.
# Local builds (no --push) are unaffected — reusing a version while iterating is the normal case.
#
# `docker manifest inspect` is the probe, so a test can put a fake `docker` on PATH
# (scripts/test_build_push_once.sh).
if [[ "${ACTION}" == "--push" || "${ACTION}" == "--multiarch" ]]; then
step "0/4 — Checking the registry: is ${VERSION} already published?"
# `set -e` is on, and a failing command substitution would abort the script before the rc is
# ever read — which would turn "the registry said no such tag" into a silent exit. The `|| true`
# keeps the failure for us to classify; that classification is the whole check.
probe_rc=0
probe_out=$(docker manifest inspect "${IMAGE}:${VERSION}" 2>&1) || probe_rc=$?
if [[ ${probe_rc} -eq 0 ]]; then
error "${IMAGE}:${VERSION} already exists in the registry — bump the version."
error "A version number is pushed once. Two images under one number mean a box can run"
error "either of them and never learn the difference. If this is a fix for something already"
error "pushed, release it as the next patch version instead."
exit 1
fi
# Absent is the ONLY other acceptable answer. Anything that is not a clean "not found" (the
# registry is down, the login expired, DNS failed) leaves the question unanswered, and an
# unanswered question is a refusal.
if ! grep -qiE 'no such manifest|manifest unknown|not found|does not exist|NAME_UNKNOWN|MANIFEST_UNKNOWN' <<<"${probe_out}"; then
error "Could not establish whether ${IMAGE}:${VERSION} exists — refusing to build."
error "The registry answered: ${probe_out}"
error "Proving the tag is new is the point of this check; 'I could not ask' is not that proof."
exit 1
fi
info "${VERSION} is not present in the registry — proceeding."
# AND THE TREE IT BUILDS FROM MUST BE CLEAN AND PUSHED.
#
# Until v0.316.3 this was a sentence in a runbook, and a rule without a mechanism is a wish. It
# cost a bad push the same evening it was written down: the shared clone had 31 files of another
# session's work in progress, and a build from it put their unreleased code into a published
# image under my version number. Nobody deployed it; that was luck, not a control.
#
# Two questions, both of which must be YES:
# is every tracked file committed? — otherwise the image holds something git does not
# is HEAD what origin/main has? — otherwise the image holds something nobody can fetch
# „An unpushed change does not exist" (workspace CLAUDE.md), and an image built from one is a
# thing that exists and cannot be reproduced.
if [[ -d "${REPO_DIR}/.git" ]]; then
dirty=$(cd "${REPO_DIR}" && git status --porcelain 2>/dev/null | wc -l)
if [[ "${dirty}" -ne 0 ]]; then
error "${REPO_DIR} has ${dirty} uncommitted change(s) — refusing to push a build from it."
error "An image built from a dirty tree cannot be reproduced, and on a SHARED clone it"
error "may carry work that is not yours. Commit and push, or build from a clean clone"
error "with FELHOM_REPO_DIR pointing at it."
(cd "${REPO_DIR}" && git status --porcelain | head -10 | sed 's/^/ /')
exit 1
fi
head_sha=$(cd "${REPO_DIR}" && git rev-parse HEAD 2>/dev/null || echo unknown)
main_sha=$(cd "${REPO_DIR}" && git rev-parse origin/main 2>/dev/null || echo unknown)
if [[ "${head_sha}" != "${main_sha}" ]]; then
error "${REPO_DIR} is not at origin/main — refusing to push."
error " HEAD ${head_sha}"
error " origin/main ${main_sha}"
error "An unpushed change does not exist; an image built from one cannot be fetched again."
exit 1
fi
info "Source tree is clean and at origin/main."
else
error "${REPO_DIR} is not a git checkout — refusing to push a build whose source cannot be identified."
exit 1
fi
fi
# =========================================================================
# Step 1: Prepare clean build workspace
# =========================================================================
@@ -0,0 +1,61 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// v0.279.0 (decision 45): the deploy-done hook — the ONE place a fresh install ends — runs after_install, and
// the hook is set on every box, not only inside `if notifier != nil` (a box with no hub still installs apps).
// COMPANION RED-PROOF: move SetDeployDoneHook back inside `if notifier != nil` → the second assertion fails;
// drop the RunAfterInstall call → the first fails.
func TestAfterInstall_IsWiredToEveryFreshInstall(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var hook *ast.CallExpr
var insideNotifierIf bool
var walk func(n ast.Node, underNotifier bool)
walk = func(n ast.Node, underNotifier bool) {
ast.Inspect(n, func(x ast.Node) bool {
if ifs, ok := x.(*ast.IfStmt); ok {
if be, ok := ifs.Cond.(*ast.BinaryExpr); ok {
if id, ok := be.X.(*ast.Ident); ok && id.Name == "notifier" && be.Op == token.NEQ {
walk(ifs.Body, true)
if ifs.Else != nil {
walk(ifs.Else, underNotifier)
}
return false
}
}
}
if c, ok := x.(*ast.CallExpr); ok {
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && hook == nil {
hook, insideNotifierIf = c, underNotifier
}
}
return true
})
}
walk(f, false)
if hook == nil {
t.Fatal("SetDeployDoneHook is never called")
}
calls := false
ast.Inspect(hook, func(x ast.Node) bool {
if s, ok := x.(*ast.SelectorExpr); ok && s.Sel.Name == "RunAfterInstall" {
calls = true
}
return true
})
if !calls {
t.Fatal("the deploy-done hook does not run RunAfterInstall")
}
if insideNotifierIf {
t.Fatal("SetDeployDoneHook is set only when a notifier exists — a box with no hub would never run after_install")
}
}
@@ -135,7 +135,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
body := mainBody(t)
names := callsInMain(t, body)
if indexOfCall(names, "NotifyBackupFailed") < 0 {
if indexOfCall(names, "NotifyInterruptedOperation") < 0 {
t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " +
"controller died mid-backup and nobody is told (§2.4)")
}
@@ -144,7 +144,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
//
// R-174 STRENGTHENED THIS. `!= nil` alone is no longer sufficient, because Recover now returns a
// non-nil result for a recovery that merely REFUSED starts (an absent data drive) — the drive
// gate working as designed. `NotifyBackupFailed` sends `backup_failed`, which is customer-enabled
// gate working as designed. `NotifyInterruptedOperation` (R-585; was `NotifyBackupFailed`) sends `backup_failed`, which is customer-enabled
// by default (settings.DefaultEnabledEvents), so a nil-only guard would email the customer
// "A biztonsági mentés sikertelen!" about an app nothing is wrong with. The guard must consult
// Alarming().
@@ -156,7 +156,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
}
carries := false
for _, name := range callsInMain(t, ifst.Body) {
if name == "NotifyBackupFailed" {
if name == "NotifyInterruptedOperation" {
carries = true
}
}
@@ -188,7 +188,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
if !guardedByAlarming {
t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " +
"recovery that only REFUSED starts (drive absent) would be reported through " +
"NotifyBackupFailed, a customer-enabled event type, telling the customer their backup " +
"NotifyInterruptedOperation (backup_failed), a customer-enabled event type, telling the customer their backup " +
"failed when the drive gate was simply doing its job (R-174)")
}
}
@@ -0,0 +1,13 @@
package main
import "testing"
// TestConvert_ReleaseIsWiredAtStartup — v0.273.0 (`09` §6.4 part 10, B5): the kept pre-conversion datadir
// copy is released only by ReleaseConversionCopies, so main.go must CALL it (the seam-built-but-never-wired
// class). COMPANION RED-PROOF (REPORT.md): delete the `conversion-copy-release` job — this fails.
func TestConvert_ReleaseIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["ReleaseConversionCopies"]) == 0 {
t.Fatal("ReleaseConversionCopies is never called — a converted app's old datadir copy would stay on disk for ever")
}
}
@@ -0,0 +1,90 @@
package main
import (
"go/ast"
"io"
"log"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// `09` §3 decision 28 (v0.269.0) — the box stops a crash loop / OOM storm; never inside a backup's,
// a quiesce's or an update's own stop, never twice, never an app already held.
func TestD28_StopHoldTellInOrder_AndTheSkips(t *testing.T) {
var calls []string
d := unhealthyDeps{
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
return []stacks.UnhealthyVerdict{
{Stack: "gokapi", Kind: stacks.UnhealthyCrashLoop, Count: 7, Window: 10 * time.Minute},
{Stack: "quiesced", Kind: stacks.UnhealthyCrashLoop, Count: 7},
{Stack: "busy", Kind: stacks.UnhealthyCrashLoop, Count: 7},
{Stack: "held", Kind: stacks.UnhealthyOOMStorm, Count: 20},
}
},
suppressed: func() map[string]bool { return map[string]bool{"quiesced": true} },
busy: func(n string) (bool, string) { return n == "busy", "a restore is running" },
holdKind: func(n string) string {
if n == "held" {
return "update_failed"
}
return ""
},
stop: func(n string) error { calls = append(calls, "stop:"+n); return nil },
hold: func(n, k string, _ time.Time) (int, error) { calls = append(calls, "hold:"+n+":"+k); return 1, nil },
notify: func(v stacks.UnhealthyVerdict, trip int) { calls = append(calls, "notify:"+v.Stack) },
}
stopped := stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
if strings.Join(calls, ",") != "stop:gokapi,hold:gokapi:crash_loop,notify:gokapi" {
t.Fatalf("calls = %v — want gokapi stopped, held, told; the quiesced, busy and held apps untouched", calls)
}
if len(stopped) != 1 || stopped[0] != "gokapi" {
t.Fatalf("stopped = %v", stopped)
}
}
// A failed stop is neither held nor announced (the household would read "we stopped it" over a running app).
func TestD28_AFailedStopIsNotAnnounced(t *testing.T) {
var calls []string
d := unhealthyDeps{
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
return []stacks.UnhealthyVerdict{{Stack: "x", Kind: "crash_loop"}}
},
suppressed: func() map[string]bool { return nil },
busy: func(string) (bool, string) { return false, "" },
holdKind: func(string) string { return "" },
stop: func(string) error { return io.ErrUnexpectedEOF },
hold: func(string, string, time.Time) (int, error) { calls = append(calls, "hold"); return 1, nil },
notify: func(stacks.UnhealthyVerdict, int) { calls = append(calls, "notify") },
}
stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
if len(calls) != 0 {
t.Fatalf("calls = %v after a failed stop", calls)
}
}
// The wiring: main's dead-app loop calls stopUnhealthyApps with the detector.
func TestD28_TheLoopStopsUnhealthyApps(t *testing.T) {
found, withDetector := false, false
ast.Inspect(mainBody(t), func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "stopUnhealthyApps" {
found = true
ast.Inspect(call, func(m ast.Node) bool {
if sel, ok := m.(*ast.SelectorExpr); ok && sel.Sel.Name == "ObserveUnhealthy" {
withDetector = true
}
return true
})
}
return true
})
if !found || !withDetector {
t.Fatalf("main calls stopUnhealthyApps=%v with ObserveUnhealthy=%v — decision 28 is built and never run", found, withDetector)
}
}
@@ -0,0 +1,41 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// v0.317.0 (`16` §4): the agent's same-disk verdict reaches the backup side. A seam built and never wired
// has shipped in this repo seven times; this walks main.go for the call AND for the agent call inside it.
// COMPANION RED-PROOF: delete the SetDiskJudge block → "not wired"; replace ac.SameDisk with a constant →
// "never asks the agent".
func TestDiskJudge_IsWiredAtStartup(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var judge *ast.CallExpr
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDiskJudge" {
judge = c
}
}
return true
})
if judge == nil {
t.Fatal("backupMgr.SetDiskJudge is not wired in main.go — Tier-2 would keep the st_dev rule for ever")
}
asks := false
ast.Inspect(judge, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "SameDisk" {
asks = true
}
return true
})
if !asks {
t.Fatal("SetDiskJudge is wired but never asks the agent (no SameDisk call inside it)")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// v0.283.0 (decision 50, R-720): the deploy-done hook's SUCCESS branch — the one place a fresh install ends —
// calls sett.DefaultOffboxOnForNewApp. The rule itself is pinned in internal/settings/offbox_default_test.go;
// this pins that it is reached.
// COMPANION RED-PROOF: delete the call from the hook → this test fails.
func TestDecision50_DefaultIsWiredIntoTheDeployDoneHook(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := false
ast.Inspect(f, func(n ast.Node) bool {
c, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && len(c.Args) == 1 {
ast.Inspect(c.Args[0], func(m ast.Node) bool {
if cc, ok := m.(*ast.CallExpr); ok {
if ss, ok := cc.Fun.(*ast.SelectorExpr); ok && ss.Sel.Name == "DefaultOffboxOnForNewApp" {
found = true
}
}
return true
})
}
return true
})
if !found {
t.Fatal("the deploy-done hook does not call DefaultOffboxOnForNewApp — a new app would start with off-site OFF (R-720)")
}
}
@@ -0,0 +1,60 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"sync/atomic"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
)
// opActionReportFire holds the out-of-cycle report trigger once main has built it (func()). The
// executor is built earlier than the trigger, so it reads it through this holder.
var opActionReportFire atomic.Value
// operatorActionHandlers wires the four operator actions (R-314/R-279/R-177, `09` §3 decision 185)
// to the running process. ONE wiring site, called from main and from the wiring test, so the test
// exercises the doors production uses (the seam-built-but-never-wired trap).
//
// offsite_backup_now makes the SAME four checks as the household's „back up now"
// (offboxRunHandler) and then runs the same call, so an operator press can never do what the
// household's own press cannot.
func operatorActionHandlers(backupMgr *backup.Manager, sched *scheduler.Scheduler, resultReady func(), logger *log.Logger) report.OperatorActionHandlers {
h := report.OperatorActionHandlers{ResultReady: resultReady}
if sched != nil {
h.RunJob = sched.RunNow
}
if backupMgr == nil {
return h
}
h.OffsiteBackupNow = func() (string, func(ctx context.Context) error) {
switch {
case !backupMgr.OffboxConfigured():
return "no off-site target is set on this box", nil
case !backupMgr.OffboxRunnable():
return "the off-site key step (escrow) is not done yet — the run is refused until it is", nil
case backupMgr.OffboxOrphaned():
return "the off-site store is orphaned (written with an older key) — a run cannot write to it", nil
case backupMgr.IsRunning():
return "a backup run is already in flight", nil
}
return "", func(ctx context.Context) error {
if logger != nil {
logger.Printf("[INFO] [opaction] off-site backup started at the operator's request")
}
err := backupMgr.RunOffboxBackupWithProgress(ctx)
if errors.Is(err, backup.ErrOffboxRunInFlight) {
return fmt.Errorf("%w: %v", report.ErrActionRefused, err)
}
return err
}
}
h.AbandonStop = backupMgr.StopAbandon
h.AbandonExtend = func(days int) (time.Time, error) { return backupMgr.ExtendAbandon(days) }
return h
}
@@ -0,0 +1,169 @@
package main
import (
"context"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// `09` §3 decision 185 (D1), the design's red test (1): a reply carrying abandon_stop during a
// countdown stops it IN THE RUNNING MANAGER — and settings.json on disk agrees. This is the reason
// the action rides the report reply instead of the CLI lever: the CLI runs a SECOND process, whose
// write the running controller can overwrite (the lost update, main.go's R-241 block).
func TestD1_AbandonStopActsOnTheRunningManagerAndDisk(t *testing.T) {
logger := log.New(io.Discard, "", 0)
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.json")
sett, err := settings.Load(settingsPath, logger)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dir
cfg.Paths.SystemDataPath = filepath.Join(dir, "sys")
mgr := backup.NewManager(cfg, sett, logger)
// No test reaches a real ssh or restic (R-488 / R-650).
mgr.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
t.Fatal("abandon_stop must issue no remote command")
return nil, nil
})
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
t.Fatal("abandon_stop must run no restic")
return nil, nil
})
due := time.Now().Add(10 * 24 * time.Hour).UTC()
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
EscrowState: "escrowed",
AbandonRepoPath: "/srv/repo.orphaned-20261001",
AbandonStartedAt: time.Now().Add(-4 * 24 * time.Hour).UTC().Format(time.RFC3339),
AbandonAt: due.Format(time.RFC3339),
}); err != nil {
t.Fatal(err)
}
if !mgr.AbandonStatus().Active {
t.Fatal("fixture: a countdown must be running")
}
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpAbandonStop}})
if mgr.AbandonStatus().Active {
t.Fatal("the running manager still counts down after abandon_stop")
}
onDisk, err := settings.Load(settingsPath, logger)
if err != nil {
t.Fatal(err)
}
if tg := onDisk.GetOffboxTarget(); tg == nil || tg.AbandonAt != "" || tg.AbandonRepoPath == "" {
t.Fatalf("settings.json disagrees: %+v (want no due date, the set-aside path kept)", tg)
}
rs := o.Results()
if len(rs) != 1 || rs[0].Outcome != report.OutcomeDone {
t.Fatalf("result = %+v", rs)
}
// Control: the same countdown, a second press → nothing to stop, reported as such (not „done").
o.Reconcile([]report.OperatorAction{{ID: 2, Action: report.OpAbandonStop}})
if r := o.Results(); len(r) != 1 || r[0].ID != 2 || r[0].Outcome != report.OutcomeFailed {
t.Fatalf("second stop: %+v", r)
}
}
// offsite_backup_now refuses exactly where the household's „back up now" refuses: no target here.
func TestD1_OffsiteBackupNowRefusesWithoutATarget(t *testing.T) {
logger := log.New(io.Discard, "", 0)
dir := t.TempDir()
sett, err := settings.Load(filepath.Join(dir, "settings.json"), logger)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dir
mgr := backup.NewManager(cfg, sett, logger)
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
t.Fatal("no restic may run")
return nil, nil
})
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
o.Reconcile([]report.OperatorAction{{ID: 9, Action: report.OpOffsiteBackupNow}})
if r := o.Results(); len(r) != 1 || r[0].Outcome != report.OutcomeRefused {
t.Fatalf("result = %+v", r)
}
}
// run_job reaches the scheduler's RunNow: a registered job runs; a job not registered on this box is
// refused.
func TestD1_RunJobUsesTheScheduler(t *testing.T) {
logger := log.New(io.Discard, "", 0)
sched := scheduler.New(logger)
ran := make(chan bool, 1)
sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { ran <- scheduler.OnDemand(ctx); return nil })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sched.Start(ctx)
defer sched.Stop()
o := report.NewOperatorActions(ctx, operatorActionHandlers(nil, sched, nil, logger), logger)
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpRunJob, Arg: "fill-watch"}, {ID: 2, Action: report.OpRunJob, Arg: "offsite-proof"}})
select {
case od := <-ran:
if !od {
t.Fatal("the job did not see itself as on-demand")
}
case <-time.After(5 * time.Second):
t.Fatal("fill-watch never ran")
}
deadline := time.Now().Add(5 * time.Second)
for len(o.Results()) < 2 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
got := map[int64]string{}
for _, r := range o.Results() {
got[r.ID] = r.Outcome
}
if got[1] != report.OutcomeDone || got[2] != report.OutcomeRefused {
t.Fatalf("outcomes = %v, want 1:done 2:refused (offsite-proof not registered here)", got)
}
}
// main.go hands the reply's list to the executor and the trigger to its holder — one call each
// (the seam-built-but-never-wired trap: a green executor that main never calls proves nothing).
func TestD1_MainWiresTheExecutor(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := map[string]int{}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
switch fn := call.Fun.(type) {
case *ast.SelectorExpr:
if x, ok := fn.X.(*ast.Ident); ok {
found[x.Name+"."+fn.Sel.Name]++
}
case *ast.Ident:
found[fn.Name]++
}
return true
})
for _, want := range []string{"opActions.Reconcile", "report.SetOperatorActions", "operatorActionHandlers", "opActionReportFire.Store"} {
if found[want] != 1 {
t.Errorf("main.go calls %s %d time(s), want exactly 1", want, found[want])
}
}
}
@@ -0,0 +1,86 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
// R-1105: restic is pinned OUTSIDE the OS. Until v0.323.0 it came from the runtime's apt list, so the
// version was „whatever Debian ships" — a Debian 12 → 13 base move would have swapped restic 0.14.0 for
// 0.18.0 under the off-site code with nobody choosing it. The consequences pinned here:
// - the version is one explicit ARG, and each architecture's checksum is pinned beside it;
// - the fetch checks restic's signature (vendored key, no keyserver) AND both checksums;
// - the runtime takes restic ONLY from that stage — no apt package can bring a second one;
// - every base image is pinned by digest, and the Docker repo's codename is read from the image.
func TestR1105_ResticIsPinnedOutsideTheOS(t *testing.T) {
raw, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatal(err)
}
src := string(raw)
// Stages, split on FROM, with `\`-continued lines joined so an apt list reads as one command.
joined := strings.ReplaceAll(src, "\\\n", " ")
var stages []string
for _, part := range regexp.MustCompile(`(?m)^FROM `).Split(joined, -1)[1:] {
stages = append(stages, "FROM "+part)
}
if len(stages) < 3 {
t.Fatalf("expected a builder, a restic and a runtime stage, found %d stages", len(stages))
}
var fetch string
for _, s := range stages {
if regexp.MustCompile(`^FROM \S+ AS restic\b`).MatchString(s) {
fetch = s
}
}
if fetch == "" {
t.Fatal("no `AS restic` stage: restic is no longer fetched as a pinned release binary")
}
runtime := stages[len(stages)-1]
if m := regexp.MustCompile(`ARG RESTIC_VERSION=(\d+\.\d+\.\d+)\s`).FindStringSubmatch(fetch); m == nil {
t.Error("ARG RESTIC_VERSION=X.Y.Z is missing — the version must be an explicit decision")
}
for _, arch := range []string{"AMD64", "ARM64"} {
if !regexp.MustCompile(`ARG RESTIC_SHA256_` + arch + `=[0-9a-f]{64}\s`).MatchString(fetch) {
t.Errorf("no pinned sha256 for %s", arch)
}
}
for _, want := range []string{
"COPY third_party/restic/restic-release-key.asc",
"gpgv --keyring", // SHA256SUMS is restic's, signed
`SHA256SUMS | sha256sum -c -`, // the download matches the published sum
`echo "${want} ${f}" | sha256sum -c -`, // ...and the sum pinned in this file
} {
if !strings.Contains(fetch, want) {
t.Errorf("the restic stage no longer does %q", want)
}
}
if _, err := os.Stat("../../third_party/restic/restic-release-key.asc"); err != nil {
t.Errorf("the vendored release key is missing: %v", err)
}
if !strings.Contains(runtime, "COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic") {
t.Error("the runtime does not take restic from the pinned stage")
}
aptRe := regexp.MustCompile(`apt-get install[^&]*`)
for _, inst := range aptRe.FindAllString(runtime, -1) {
if regexp.MustCompile(`\srestic(\s|$)`).MatchString(inst) {
t.Errorf("the runtime's apt list installs restic — a second, OS-chosen restic: %q", strings.Join(strings.Fields(inst), " "))
}
}
for _, from := range regexp.MustCompile(`(?m)^FROM (\S+)`).FindAllStringSubmatch(src, -1) {
if !strings.Contains(from[1], "@sha256:") {
t.Errorf("base image %s is not pinned by digest", from[1])
}
}
if regexp.MustCompile(`download\.docker\.com/linux/debian (bookworm|trixie|forky)`).MatchString(src) {
t.Error("the Docker apt repo codename is hard-coded — it must come from /etc/os-release (VERSION_CODENAME)")
}
if !strings.Contains(runtime, "${VERSION_CODENAME}") {
t.Error("the Docker apt repo line does not read VERSION_CODENAME")
}
}
@@ -0,0 +1,56 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
// R-208: `ARG VERSION` / `ARG GIT_COMMIT` declared ABOVE `RUN go mod download` put a per-build value
// into that RUN's cache key, so every release re-downloaded the modules (measured: 208 cache records,
// every one with usage count 1). The consequence pinned here, in the builder stage: no ARG whose value
// changes per build is declared before the module-download step, and both are still declared before
// the `go build` that reads them (or the binary would report "dev"/"unknown").
func TestR208_DockerfileVersionArgsSitBelowModuleDownload(t *testing.T) {
src, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatal(err)
}
lines := strings.Split(string(src), "\n")
argRe := regexp.MustCompile(`^\s*ARG\s+(VERSION|GIT_COMMIT|BUILD_TIME)\b`)
download, build := -1, -1
args := map[string]int{}
for i, l := range lines {
if strings.HasPrefix(strings.TrimSpace(l), "FROM ") && i > 0 && download >= 0 && build >= 0 {
break // the runtime stage: only the builder matters
}
if m := argRe.FindStringSubmatch(l); m != nil {
if _, seen := args[m[1]]; !seen {
args[m[1]] = i // the FIRST declaration: any one above the download is the defect
}
}
if strings.Contains(l, "go mod download") && download < 0 {
download = i
}
if strings.Contains(l, "go build") && build < 0 {
build = i
}
}
if download < 0 || build < 0 {
t.Fatalf("the builder stage no longer has a `go mod download` (%d) or a `go build` (%d) — this test reads the wrong file", download, build)
}
for _, name := range []string{"VERSION", "GIT_COMMIT"} {
at, ok := args[name]
if !ok {
t.Errorf("ARG %s is not declared in the builder stage — the binary would be built without it", name)
continue
}
if at < download {
t.Errorf("R-208: ARG %s (line %d) is declared above `go mod download` (line %d), so every build with a new value re-downloads the modules", name, at+1, download+1)
}
if at > build {
t.Errorf("ARG %s (line %d) is declared after the `go build` that reads it (line %d)", name, at+1, build+1)
}
}
}
+28 -9
View File
@@ -5,6 +5,8 @@ import (
"go/parser"
"go/token"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// R-359 — the scheduled job must be PROVEN WIRED.
@@ -87,16 +89,33 @@ func TestR359_DebugCallbackIsWired(t *testing.T) {
func TestR359_OutcomeMessagesCarryNoMachineDetail(t *testing.T) {
// The customer gets a sentence; restic's words go to the log. R-379: 615 bytes of raw database
// text reached a customer once.
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
if contains(integrityFailedMsg, bad) {
t.Errorf("the failure sentence carries machine detail %q: %q", bad, integrityFailedMsg)
}
// text reached a customer once. R-585: the sentence is a bundle key now, so BOTH languages are
// held to it — an English household must be told what not to do just as plainly.
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
// It must still tell them what to do — and what NOT to do.
for _, want := range []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"} {
if !contains(integrityFailedMsg, want) {
t.Errorf("the failure sentence is a dead end; missing %q", want)
for _, tc := range []struct {
lang string
wants []string
}{
{"hu", []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"}},
{"en", []string{"Do not delete anything", "contact us"}},
} {
msg, fellBack, ok := b.Text(tc.lang, "event.backup_integrity_failed")
if !ok || fellBack {
t.Fatalf("%s: event.backup_integrity_failed is missing (ok=%v fellBack=%v)", tc.lang, ok, fellBack)
}
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
if contains(msg, bad) {
t.Errorf("%s: the failure sentence carries machine detail %q: %q", tc.lang, bad, msg)
}
}
// It must still tell them what to do — and what NOT to do.
for _, want := range tc.wants {
if !contains(msg, want) {
t.Errorf("%s: the failure sentence is a dead end; missing %q", tc.lang, want)
}
}
}
}
@@ -0,0 +1,58 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
"time"
)
// R-363 / R-547: the fill watcher ran once a day (plus once after start), so a filesystem that filled
// right after the 03:30 run went unannounced for ~24 h, and a disk held at 96 % for ten minutes raised
// nothing. The consequence pinned here: main.go registers the SAME fillWatcher.Check on a periodic
// job whose interval is no longer than the shortest measured silent window (10 min), so any such
// window contains a check. Edge-triggering (no repeat mails at a faster cadence) is pinned in
// internal/fillwatch (TestWarnsOnceThenIsSilent, TestEdgeStateSurvivesARestart).
func TestFillWatchRunsOnAnInterval(t *testing.T) {
const measuredSilentWindow = 10 * time.Minute // R-547, chaos night 2026-09-17
if fillWatchInterval <= 0 || fillWatchInterval > measuredSilentWindow {
t.Errorf("fillWatchInterval = %s; it must be >0 and <= %s, or a fill window like R-547's can pass unseen", fillWatchInterval, measuredSilentWindow)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
periodic := 0
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Every" || len(call.Args) != 3 {
return true
}
iv, ok := call.Args[1].(*ast.Ident)
if !ok || iv.Name != "fillWatchInterval" {
return true
}
// The job body must call fillWatcher.Check — the same edge-triggered check, not a new one.
ast.Inspect(call.Args[2], func(m ast.Node) bool {
if c, ok := m.(*ast.CallExpr); ok {
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "Check" {
if x, ok := s.X.(*ast.Ident); ok && x.Name == "fillWatcher" {
periodic++
}
}
}
return true
})
return true
})
if periodic != 1 {
t.Errorf("R-363: main.go registers fillWatcher.Check on a periodic (sched.Every, fillWatchInterval) job %d times, want 1 — without it the fill check is daily only", periodic)
}
}
@@ -65,12 +65,12 @@ func TestR475_AdapterReadsEveryTier(t *testing.T) {
if cb := adapterMethodSelectors(t, "CanBackUp"); !strings.Contains(cb, " CanBackUpApp ") {
t.Errorf("CanBackUp must ask backup.CanBackUpApp; selectors:%s", cb)
}
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " Tier ") {
t.Errorf("the hold must be told the chosen TIER, or it cannot name it; selectors:%s", h)
}
// R-479: and WHAT the copy holds, computed from the app's data layout at hold time.
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " UpdateCopyHolds ") || !strings.Contains(h, " HoldAfterFailedUpdateHolding ") {
t.Errorf("the hold must carry UpdateCopyHolds through HoldAfterFailedUpdateHolding (R-479); selectors:%s", h)
// R-659 (v0.268.0) CHANGED THIS ON PURPOSE: the hold no longer names the precondition copy the
// adapter is handed (round 11: that copy was one the restore refuses). The tier, and R-479's
// "what the copy holds", are now chosen inside backup.HoldAfterFailedUpdateWhole from every tier —
// pinned there by TestR659_TheHoldNamesOnlyAWholeCopy (which asserts the holds-phrase too).
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " HoldAfterFailedUpdateWhole ") {
t.Errorf("the hold must be recorded through HoldAfterFailedUpdateWhole (R-659); selectors:%s", h)
}
}
@@ -0,0 +1,94 @@
package main
import (
"go/ast"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
type fakeHDD struct {
sts []stacks.Stack
hdd map[string]string
}
func (f fakeHDD) GetStacks() []stacks.Stack { return f.sts }
func (f fakeHDD) LoadAppConfigByName(n string) *stacks.AppConfig {
return &stacks.AppConfig{Env: map[string]string{"HDD_PATH": f.hdd[n]}}
}
// R-521: one unplugged drive mailed the operator five times — storage_disconnected plus an
// app_start_failed per app on it. The consequence asserted: the NOTIFIER is told the drive's apps are
// not down (no per-app mail), while the dashboard's dead list still carries them, and an app on
// another drive that is genuinely down still alarms.
func TestR521_DriveLostAppsDoNotMailOneByOne(t *testing.T) {
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
for _, p := range []string{"/mnt/felhom-drives/usb", "/mnt/felhom-drives/other"} {
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: p, Schedulable: true}); err != nil {
t.Fatal(err)
}
}
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", true, []string{"immich"}); err != nil {
t.Fatal(err)
}
sts := []stacks.Stack{
stack("immich", stacks.StateExited, true, false), // recorded by the disconnect
stack("paperless", stacks.StateExited, true, false), // on the lost drive by HDD_PATH
stack("romm", stacks.StateExited, true, false), // another drive, genuinely broken
}
for i := range sts {
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
}
mgr := fakeHDD{sts: sts, hdd: map[string]string{"immich": "/mnt/felhom-drives/usb", "paperless": "/mnt/felhom-drives/usb", "romm": "/mnt/felhom-drives/other"}}
dead, states := classifyRunStates(sts, nil, nil, time.Now())
notified := downByName(maskDriveLost(states, driveLostApps(sett, mgr)))
if notified["immich"] || notified["paperless"] {
t.Fatalf("R-521: an app stopped by the lost drive is still reported down to the notifier: %v", notified)
}
if !notified["romm"] {
t.Fatal("a genuinely broken app on another drive stopped alarming — over-correction")
}
if dn := deadNames(dead); !dn["immich"] || !dn["paperless"] {
t.Fatalf("the dashboard's dead list must still carry the drive's apps: %v", dn)
}
// Reconnected: nothing is masked any more.
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", false, nil); err != nil {
t.Fatal(err)
}
if got := driveLostApps(sett, mgr); len(got) != 0 {
t.Fatalf("with no drive disconnected nothing may be masked, got %v", got)
}
}
// The wiring: the dead-app job hands the notifier the masked states.
func TestR521_DeadAppJobMasksDriveLost(t *testing.T) {
found := false
ast.Inspect(mainBody(t), func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "NotifyAppStartFailures" || len(call.Args) != 1 {
return true
}
if inner, ok := call.Args[0].(*ast.CallExpr); ok {
if id, ok := inner.Fun.(*ast.Ident); ok && id.Name == "maskDriveLost" {
found = true
}
}
return true
})
if !found {
t.Fatal("R-521: main calls NotifyAppStartFailures without maskDriveLost")
}
}
@@ -0,0 +1,68 @@
package main
import (
"io"
"log"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/fillwatch"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
type fillPushRecord struct {
eventType, severity, message, household string
details interface{}
}
type fakeFillPusher struct{ got []fillPushRecord }
func (f *fakeFillPusher) PushEventHousehold(eventType, severity, message, household string, details interface{}) {
f.got = append(f.got, fillPushRecord{eventType, severity, message, household, details})
}
// R-521 F7 (BIGNIGHT 2026-09-14): the guest's own system disk at 95 % produced only `health_degraded`,
// whose mail a cooldown swallowed — no disk-specific event reached the hub. The consequence pinned here,
// through the REAL target list (fillTargets), the REAL watcher and the REAL push closure (fillEventPush):
// the guest's root filesystem at F7's numbers sends the hub's existing `disk_critical` event (severity
// critical, a type the hub already allowlists), ONCE (edge-triggered), carrying the Hungarian sentence
// AND the household's own (English here).
func TestR521F7_RootFilesystemSendsTheDiskEventInBothLanguages(t *testing.T) {
cfg := &config.Config{}
cfg.Paths.SystemDataPath = "/mnt/sys_drive"
usage := func(p string) *fillwatch.Usage {
if p == system.DockerVolumePath { // the guest's root filesystem as the controller sees it
return &fillwatch.Usage{UsedPercent: 95, AvailGB: 3.5, UsedGB: 61.8, TotalGB: 68.7}
}
return &fillwatch.Usage{UsedPercent: 30, AvailGB: 50, UsedGB: 20, TotalGB: 70}
}
w := fillwatch.New(filepath.Join(t.TempDir(), "fill.json"), log.New(io.Discard, "", 0),
func() []fillwatch.Target { return fillTargets(cfg, nil) }, usage)
rec := &fakeFillPusher{}
w.SetNotify(fillEventPush(rec))
w.SetLang(func() string { return "en" })
for i := 0; i < 2; i++ { // the second check sees the same band: nothing new (edge-triggered)
if err := w.Check(); err != nil {
t.Fatal(err)
}
}
if len(rec.got) != 1 {
t.Fatalf("pushes = %d (%+v), want exactly 1 disk event for the root filesystem", len(rec.got), rec.got)
}
p := rec.got[0]
if p.eventType != "disk_critical" || p.severity != "critical" {
t.Errorf("pushed %s/%s, want disk_critical/critical", p.eventType, p.severity)
}
if d, _ := p.details.(map[string]any); d == nil || d["path"] != system.DockerVolumePath {
t.Errorf("details = %v, want path %q", p.details, system.DockerVolumePath)
}
if !strings.Contains(p.message, "Alkalmaz") || !strings.Contains(p.message, "kritikusan") {
t.Errorf("Hungarian message = %q", p.message)
}
if !strings.Contains(p.household, "App area") || !strings.Contains(p.household, "critically full") {
t.Errorf("household (English) message = %q", p.household)
}
}
@@ -0,0 +1,47 @@
package main
import (
"go/ast"
"strings"
"testing"
)
// R-659 (v0.268.0) — the pieces are wired: the adapter records the hold through the WHOLE-copy walk
// (never the precondition copy again), and the held-event sink sends app_hold_no_whole_copy.
// An AST walk, not a substring (a commented-out call satisfies strings.Contains).
func TestR659_HoldAndEventAreWired(t *testing.T) {
_, f, _ := slice4CallLines(t)
var calls []string
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "HoldAfterFailedUpdate" || fn.Recv == nil || fn.Body == nil {
continue
}
if st, ok := fn.Recv.List[0].Type.(*ast.StarExpr); !ok || st.X.(*ast.Ident).Name != "updateGuardsAdapter" {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok {
calls = append(calls, sel.Sel.Name)
}
return true
})
}
body := strings.Join(calls, " ")
if !strings.Contains(body, "HoldAfterFailedUpdateWhole") {
t.Fatal("the adapter does not record the hold through HoldAfterFailedUpdateWhole — the precondition copy would be named again (R-659)")
}
if strings.Contains(body, "HoldAfterFailedUpdateHolding") {
t.Fatal("the adapter still calls HoldAfterFailedUpdateHolding with the precondition copy")
}
sends := false
ast.Inspect(mainBody(t), func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "NotifyAppHoldNoWholeCopy" {
sends = true
}
return true
})
if !sends {
t.Fatal("main never calls NotifyAppHoldNoWholeCopy — support would never be told")
}
}
@@ -0,0 +1,87 @@
package main
import (
"go/ast"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-660 (v0.268.0) — an app HELD after a failed update is stopped by the product and has its own
// event. Measured on 9202 2026-09-23 night (chaos rounds 8 and 11): `app_update_held` at 20:56:04 and
// 21:42:39, then `app_start_failed` 11 s and 13 s later for the same moment — two alarms, one fact.
type fakeHeld map[string]bool
func (f fakeHeld) UpdateHeldStacks() map[string]bool { return f }
// TestR660_UpdateHeldAppIsNotReportedDown — the consequence, through the suppression the scan builds.
// A genuinely exited app beside it must still alarm (the over-correction guard).
//
// COMPANION RED-PROOF (REPORT.md): make updateHeldSet return nil. This test then fails at "a held app
// was reported DOWN".
func TestR660_UpdateHeldAppIsNotReportedDown(t *testing.T) {
sts := []stacks.Stack{
stack("nextcloud", stacks.StateStopped, true, false), // held after a failed update
stack("romm", stacks.StateExited, true, false), // genuinely broken
}
// A held app's intent is "running" — the household never asked for the stop.
for i := range sts {
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
}
dead, states := classifyRunStates(sts, unionSuppressed(nil, updateHeldSet(fakeHeld{"nextcloud": true})), nil, time.Now())
down := downByName(states)
if down["nextcloud"] || deadNames(dead)["nextcloud"] {
t.Fatal("a held app was reported DOWN — the second, false alarm after app_update_held (R-660)")
}
if !down["romm"] {
t.Fatal("a genuinely exited app stopped alarming — the fix silenced a real fault")
}
// Control: WITHOUT the held set the same app does alarm — so the suppression is what decides.
_, states = classifyRunStates(sts, nil, nil, time.Now())
if !downByName(states)["nextcloud"] {
t.Fatal("control failed: the fixture's app does not alarm even without the suppression")
}
}
// The wiring: main passes the backup manager, and the scan unions its held set.
func TestR660_ScanIsGivenTheHeldSet(t *testing.T) {
_, f, _ := slice4CallLines(t)
reads := false
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "scanDeployedAppRunStates" || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "updateHeldSet" {
reads = true
}
}
return true
})
}
if !reads {
t.Fatal("scanDeployedAppRunStates does not read the update-held set (R-660)")
}
withB := false
ast.Inspect(mainBody(t), func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "scanDeployedAppRunStates" {
for _, a := range call.Args {
if x, ok := a.(*ast.Ident); ok && x.Name == "backupMgr" {
withB = true
}
}
}
return true
})
if !withB {
t.Fatal("main calls scanDeployedAppRunStates without backupMgr — the held set is built and never read")
}
}
@@ -0,0 +1,31 @@
package main
import (
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/quiesce"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-721 (v0.283.1) — the seam, not the rule. The rule is pinned in internal/backup and internal/quiesce with
// fakes that DO answer WantsStopped; production hands the backup manager a *stackAdapter and the quiesce loop
// a *stacks.Manager, and a type that does not answer makes the check silently skip. Measured live on 9202
// 2026-09-30 with v0.283.0: the adapter lacked the method and the dump restarted a stopped app.
// COMPANION RED-PROOF: delete stackAdapter.WantsStopped → this test fails.
func TestR721_TheBackupsStackProviderAnswersWantsStopped(t *testing.T) {
var prov interface{} = &stackAdapter{}
if _, ok := prov.(interface{ WantsStopped(string) bool }); !ok {
t.Fatal("the backup manager's production stack provider does not answer WantsStopped — a household Stop is undone by the volume dump (R-721)")
}
// The quiesce loop is handed the stacks manager itself (main.go quiesce.Options{Stacks: stackMgr}).
// The startup crash recovery is handed gatedAppStopStarter{inner: stackMgr} (main.go SetStarter).
var gs interface{} = gatedAppStopStarter{inner: (*stacks.Manager)(nil)}
if _, ok := gs.(interface{ WantsStopped(string) bool }); !ok {
t.Fatal("the crash recovery's starter does not answer WantsStopped (R-721)")
}
var mgr *stacks.Manager
var _ quiesce.Stacks = mgr
if _, ok := interface{}(mgr).(interface{ WantsStopped(string) bool }); !ok {
t.Fatal("the quiesce loop's production Stacks does not answer WantsStopped (R-721)")
}
}
@@ -0,0 +1,64 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
)
// R-856 (`09` §3 decision 143): the dead-app check — the source of app_start_failed and
// app_stopped_unhealthy — waits on the crash-aware boot grace, not on the fixed 90 s constant.
//
// Red-proof: put `time.Since(startTime) < deadAppBootGrace` back as the check's gate — this test fails.
func TestR856_DeadAppCheckWaitsOnTheCrashAwareGrace(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var body *ast.BlockStmt
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) < 3 {
return true
}
if sel, ok := call.Fun.(*ast.SelectorExpr); !ok || sel.Sel.Name != "Every" {
return true
}
if lit, ok := call.Args[0].(*ast.BasicLit); ok && lit.Value == `"deadapp-check"` {
if fl, ok := call.Args[2].(*ast.FuncLit); ok {
body = fl.Body
}
}
return true
})
if body == nil {
t.Fatal("the deadapp-check schedule is gone from main.go")
}
within, constant := false, false
ast.Inspect(body, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.SelectorExpr:
if id, ok := x.X.(*ast.Ident); ok && id.Name == "bootGrace" && x.Sel.Name == "Within" {
within = true
}
case *ast.Ident:
if x.Name == "deadAppBootGrace" {
constant = true
}
}
return true
})
if !within || constant {
t.Errorf("the dead-app check must gate on bootGrace.Within (found=%v) and not on deadAppBootGrace (found=%v)", within, constant)
}
}
// The normal grace is today's 90 s: a normal boot must keep it exactly (decision 143's other half).
func TestR856_NormalGraceIsTheDeadAppBootGrace(t *testing.T) {
if crashboot.NormalGrace != deadAppBootGrace {
t.Errorf("crashboot.NormalGrace = %s, deadAppBootGrace = %s — a normal boot must keep today's grace", crashboot.NormalGrace, deadAppBootGrace)
}
}
@@ -0,0 +1,63 @@
package main
import (
"context"
"go/ast"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
)
// TestR871_CatchUpWiring — v0.295.0 (`09` decision 109): the catch-up is built, triggered at start and on a host
// resume, and holds the whole-guest cycle — the seam-built-but-never-wired class (four earlier instances).
// COMPANION RED-PROOF: delete the `catchUp.Evaluate(ctx, "controller start")` line → this fails.
func TestR871_CatchUpWiring(t *testing.T) {
lines, _, _ := slice4CallLines(t)
for _, call := range []string{"ResumeWatch", "SetCatchUpFn", "catchUpLegs", "NotifyBackupCatchUp"} {
if len(lines[call]) == 0 {
t.Errorf("%s is never called in main.go — the catch-up is built but not wired", call)
}
}
// Both triggers, by their reason argument: the start (a literal) and the resume (inside ResumeWatch's callback).
_, f, _ := slice4CallLines(t)
start := 0
ast.Inspect(f, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Evaluate" && len(call.Args) == 2 {
if lit, ok := call.Args[1].(*ast.BasicLit); ok && lit.Value == `"controller start"` {
start++
}
}
}
return true
})
if start != 1 {
t.Errorf("the catch-up's START trigger (Evaluate(ctx, \"controller start\")) is called %d times, want 1", start)
}
}
// The catch-up's leg table holds the three BACKUP legs and nothing else, and the off-site body it shares with the
// night never calls the update leg (that is chained only around the SCHEDULED off-site job).
// COMPANION RED-PROOF: put `stackMgr.RunUpdateLeg` inside offsiteOnly's body → "the update leg is inside".
func TestR871_CatchUpRunsNoUpdateLeg(t *testing.T) {
noop := func(context.Context) error { return nil }
legs := catchUpLegs(noop, noop, noop)
if len(legs) != 3 || legs[nightchain.LegDBDump] == nil || legs[nightchain.LegTier2] == nil || legs[nightchain.LegOffsite] == nil {
t.Fatalf("catchUpLegs = %v, want exactly db-dump, tier2, offsite", legs)
}
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "offsiteOnly := withLeg(nightchain.LegOffsite,")
j := strings.Index(s[i:], "sched.Daily(\"offbox-backup\"")
if i < 0 || j < 0 {
t.Fatal("the shared off-site leg body was not found in main.go")
}
if body := s[i : i+j]; strings.Contains(body, "RunUpdateLeg") || strings.Contains(body, "chainUpdateLeg") {
t.Fatal("the update leg is inside the off-site body the catch-up runs")
}
}
@@ -0,0 +1,84 @@
package main
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
)
// R-970 (v0.322.0) — the box's own hub API key opens NO route on the box.
//
// Until v0.322.0 a Bearer equal to the hub key reached /api/config/ (read the whole controller.yaml with every secret,
// and REPLACE it), /api/selfupdate/ and /api/geo/ from the internet, unsigned — against `01` §4 („no inbound control
// endpoint") and §10. The test drives the REAL route table (buildMux) with real web.Server and api.Router: a password
// is set (so auth is on), the request carries the key and no session, and the planted controller.yaml must be neither
// served nor changed.
//
// RED-PROOF (2026-10-16, REPORT): with an /api/geo/ route mounted without the session check, this failed at
// „GET /api/geo/countries with the hub key answered 200".
func TestR970_HubKeyOpensNoDoor(t *testing.T) {
const hubKey = "TESTONLY-hub-key-5d1b"
const secret = "TESTONLY-tunnel-token-q9"
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
cfgPath := filepath.Join(dir, "controller.yaml")
planted := "customer:\n id: t\n domain: example.hu\ninfrastructure:\n cf_tunnel_token: " + secret + "\n"
if err := os.WriteFile(cfgPath, []byte(planted), 0o600); err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Customer.Domain = "example.hu"
cfg.Hub.APIKey = hubKey
cfg.Paths.DataDir = filepath.Join(dir, "data")
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if err := sett.SetPasswordHash("$2a$10$abcdefghijklmnopqrstuuJ1A9pDq6s9F2x1H3bQb0v8Y3wq6z2Om"); err != nil {
t.Fatal(err)
}
ws := web.NewServer(cfg, nil, nil, nil, nil, sett, nil, nil, nil, lg, "test")
ar := api.NewRouter(cfg, cfgPath, sett, nil, nil, nil, nil, nil, nil, nil, lg)
mux := buildMux(ar, ws)
// Control: auth is on — a request with no key and no session is refused, so a refusal below means something.
ctl := httptest.NewRequest(http.MethodGet, "/api/stacks", nil)
ctl.Host = "felhom.example.hu"
cw := httptest.NewRecorder()
mux.ServeHTTP(cw, ctl)
if cw.Code != http.StatusUnauthorized {
t.Fatalf("control: an anonymous /api/stacks answered %d, want 401 — auth is not on in this harness", cw.Code)
}
paths := []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash",
"/api/selfupdate/check", "/api/selfupdate/update", "/api/geo/settings", "/api/geo/countries", "/api/geo/sync"}
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
for _, p := range paths {
for _, m := range methods {
req := httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: evil.example\n"))
req.Host = "felhom.example.hu"
req.Header.Set("Authorization", "Bearer "+hubKey)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code/100 == 2 {
t.Errorf("%s %s with the hub key answered %d — the key opens a door", m, p, w.Code)
}
if strings.Contains(w.Body.String(), secret) {
t.Fatalf("%s %s with the hub key served the settings file", m, p)
}
}
}
if got, _ := os.ReadFile(cfgPath); string(got) != planted {
t.Fatal("controller.yaml was replaced through a hub-key request")
}
}
@@ -0,0 +1,33 @@
package main
import (
"log"
"path/filepath"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-519 (v0.296.0) — an app-data backup run cut off by a power cut or a restart is said on the backup pages until
// the next complete run (internal/backup/run_record.go). main() calls this right after the restore record, before
// any page or any backup run can read the manager. Pinned by TestMainWiresRunRecord.
// runRecordFileName lives in DataDir, beside settings.json and the restore record.
const runRecordFileName = "appdata-run.json"
func loadRunRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger) time.Time {
if mgr == nil {
return time.Time{}
}
path := filepath.Join(dataDir, runRecordFileName)
mgr.SetRunRecordPath(path)
cut := mgr.LoadRunRecord()
if logger != nil {
if !cut.IsZero() {
logger.Printf("[WARN] [backup] the app-data backup run started %s was cut off by the stop — the backup pages say so until the next complete run (R-519)", cut.UTC().Format(time.RFC3339))
} else {
logger.Printf("[INFO] [backup] app-data run record wired: %s (an earlier cut still shown: %t)", path, !mgr.InterruptedRunAt().IsZero())
}
}
return cut
}
@@ -0,0 +1,59 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"os"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-519: the function main() calls finds a cut run.
func TestRunRecordAtStartup_FindsACutRun(t *testing.T) {
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
before := new(backup.Manager)
before.SetRunRecordPath(dir + "/" + runRecordFileName)
// the record a run leaves when the power goes mid-run (markRunStarted is unexported — write its file shape)
writeRunning(t, dir+"/"+runRecordFileName)
after := new(backup.Manager)
if cut := loadRunRecordAtStartup(after, dir, lg); cut.IsZero() || after.InterruptedRunAt().IsZero() {
t.Fatal("a cut run was not found at startup")
}
}
// The seam must be CALLED from main() — an AST call, not a string (a comment would match a string).
// RED-PROOF: delete the call in main() → this fails.
func TestMainWiresRunRecord(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := false
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "loadRunRecordAtStartup" {
found = true
}
}
return true
})
if !found {
t.Fatal("main() never calls loadRunRecordAtStartup — a cut run is never said on the page")
}
}
func writeRunning(t *testing.T, path string) {
t.Helper()
b := []byte(`{"running":true,"started_at":"` + time.Date(2026, 9, 14, 19, 40, 3, 0, time.UTC).Format(time.RFC3339) + `"}`)
if err := writeFileForTest(path, b); err != nil {
t.Fatal(err)
}
}
func writeFileForTest(path string, b []byte) error { return os.WriteFile(path, b, 0o600) }
@@ -111,3 +111,12 @@ func TestSlice4_UpdatingCheckIsWiredAtStartup(t *testing.T) {
t.Fatal("backupMgr.SetUpdatingCheck is never called — the nightly legs cannot see an update in progress")
}
}
// R-645 (09 §3 decision 142): the backup manager must be told which apps are not running their pinned
// version, or the night backup writes a just-failed definition over the good unit after a hand-lifted hold.
func TestR645_VersionCheckIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetVersionCheck"]) == 0 {
t.Fatal("backupMgr.SetVersionCheck is never called — the night backup cannot see an app off its pin")
}
}
@@ -0,0 +1,40 @@
package main
import (
"os"
"strings"
"testing"
)
// v0.264.0. The update-event sink and the R-646 backfill are both CALLED from main.go (an AST walk —
// a comment naming them would not count). A sink built and never wired would fail silently: no event,
// no mail, no error — this project's commonest defect, four recorded instances.
//
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateEventSink call in main.go — this fails.
func TestUpdateEventSinkIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetUpdateEventSink"]) == 0 {
t.Fatal("SetUpdateEventSink is never called — an undone or held update would tell nobody")
}
for _, callee := range []string{"NotifyAppUpdateUndone", "NotifyAppUpdateHeld", "RestoreHoldForLang"} {
if len(lines[callee]) == 0 {
t.Errorf("main.go must call %s inside the sink", callee)
}
}
if len(lines["BackfillAppliedMeta"]) == 0 || len(lines["AdoptPins"]) == 0 || lines["BackfillAppliedMeta"][0] < lines["AdoptPins"][0] {
t.Error("BackfillAppliedMeta (R-646) must be called, after AdoptPins")
}
}
// R-647 (2) — the held event carries the copy_holds KEY, never the Hungarian phrase: the hub prints the
// raw details as the household mail's `Note:` line. COMPANION RED-PROOF (REPORT.md): wire
// UpdateCopyHolds back — this fails.
func TestR647_HeldEventCarriesTheCopyHoldsKey(t *testing.T) {
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), "d.CopyHolds = backupMgr.UpdateCopyHoldsKey(") {
t.Fatal("main.go must fill AppUpdateDetails.CopyHolds with UpdateCopyHoldsKey (the key), not the Hungarian phrase")
}
}
@@ -0,0 +1,82 @@
package main
import (
"context"
"errors"
"os"
"strings"
"testing"
)
// v0.271.0 — the automatic update leg (`09` §6.4 part 7).
// TestChainUpdateLeg_EveryPath — the leg runs after the off-site half on EVERY path it can take:
// nothing to do (no target), ran ok, returned an error (incl. every RunOffboxBackup early return, which
// returns to here), and panicked. The off-site error reaches the scheduler unchanged; a panic becomes one.
//
// COMPANION RED-PROOF (REPORT.md): call the leg after `return offsite(ctx)` instead of in the defer —
// the error and panic cases fail at "the leg did not run after the off-site half".
func TestChainUpdateLeg_EveryPath(t *testing.T) {
boom := errors.New("sftp: connection refused")
cases := []struct {
name string
offsite func(context.Context) error
wantErr string
}{
{"no off-site target (nothing to do)", func(context.Context) error { return nil }, ""},
{"off-site ran ok", func(context.Context) error { return nil }, ""},
{"off-site failed / an early return", func(context.Context) error { return boom }, "connection refused"},
{"off-site panicked", func(context.Context) error { panic("nil map") }, "panicked"},
}
for _, c := range cases {
ran := 0
err := chainUpdateLeg(context.Background(), c.offsite, func(context.Context) { ran++ })
if ran != 1 {
t.Errorf("%s: the leg did not run after the off-site half (ran %d times)", c.name, ran)
}
if c.wantErr == "" && err != nil {
t.Errorf("%s: unexpected error %v", c.name, err)
}
if c.wantErr != "" && (err == nil || !strings.Contains(err.Error(), c.wantErr)) {
t.Errorf("%s: error %v, want one containing %q", c.name, err, c.wantErr)
}
}
}
// TestUpdateLegIsWiredAtStartup — the leg, its chain and the gate's interlock are CALLED from main.go (an
// AST walk; a comment naming them would not count). A seam built and never wired is this project's
// commonest defect.
//
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateLeg call — this fails.
func TestUpdateLegIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
for _, callee := range []string{"SetUpdateLeg", "chainUpdateLeg", "RunUpdateLeg", "FreshWholeCopy"} {
if len(lines[callee]) == 0 {
t.Errorf("main.go never calls %s — the automatic update leg is not wired", callee)
}
}
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), "Enabled: sett.GetAppUpdateUnattended,") {
t.Error("the leg's switch must be the household's app_update.unattended (sett.GetAppUpdateUnattended)")
}
if !strings.Contains(string(src), "UpdateLegFn: stackMgr.UpdateLegState") {
t.Error("the quiesce loop must be given stackMgr.UpdateLegState (decision 20's interlock)")
}
// the chain must sit INSIDE the offbox-backup job, not beside it
i := strings.Index(string(src), `sched.Daily("offbox-backup"`)
if i < 0 || !strings.Contains(string(src)[i:i+600], "chainUpdateLeg(") {
t.Error("the offbox-backup job must run through chainUpdateLeg")
}
}
// TestUndoCopyRemoverIsWiredAtStartup — R-671's seam is CALLED from main.go (AST walk).
// COMPANION RED-PROOF (REPORT.md): drop the SetUndoCopyRemover call — this fails.
func TestUndoCopyRemoverIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetUndoCopyRemover"]) == 0 {
t.Fatal("main.go never calls SetUndoCopyRemover — a lifted hold's undo copies would stay for ever")
}
}
@@ -57,7 +57,6 @@ stacks:
- "cloudflared"
- "felhom-controller"
- "filebrowser"
update_window: "03:00-05:00"
compose_command: ""
# --- Backup ---
+3 -3
View File
@@ -1,12 +1,12 @@
module gitea.dooplex.hu/admin/felhom-controller
go 1.24.0
go 1.27.2
require (
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.58.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.45.0
)
@@ -18,7 +18,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/sys v0.49.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+6 -6
View File
@@ -18,8 +18,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
@@ -27,10 +27,10 @@ golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+3 -1
View File
@@ -22,7 +22,9 @@
# is a Finder Settings -> Sidebar toggle). Treat sidebar discovery as an OPEN
# question, not a shipped feature.
# Evidence: felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md.
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
# 1.2.0 (R-972): Alpine 3.24.2, pinned by digest — the same base as the hub. 3.21 carried 48 fixable high findings
# (audits/security-review-2026-10-12/scanners/). Same packages; scanner before → after in the controller's v0.323.0 CHANGELOG.
FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# samba = smbd + nmbd + smbpasswd/testparm (meta-package proven installable in the spike);
# wsdd = WS-Discovery daemon; tini = a proper PID1 to reap nmbd/wsdd/avahi and forward signals;
@@ -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...)
}
}
@@ -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)
}
}
}
+50 -1
View File
@@ -64,6 +64,21 @@ func targetQuery(target string) string {
return "?target=" + url.QueryEscape(target)
}
// backupStartQuery is targetQuery plus `trigger=manual` for a household press (R-899).
func backupStartQuery(target string, manual bool) string {
q := url.Values{}
if target != "" {
q.Set("target", target)
}
if manual {
q.Set("trigger", "manual")
}
if len(q) == 0 {
return ""
}
return "?" + q.Encode()
}
// BackupDueFor reports whether THIS TIER is due. A fresh backup on another tier must not satisfy it
// — that filtering happens agent-side (latestSuccessfulBackupForTarget); this just asks per tier.
func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse, error) {
@@ -80,8 +95,15 @@ func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse,
// StartBackupFor enqueues a backup of this guest ON THE GIVEN TIER.
func (c *Client) StartBackupFor(ctx context.Context, target string) (BackupResponse, error) {
return c.StartBackupForTrigger(ctx, target, false)
}
// StartBackupForTrigger is StartBackupFor that also says whether this is a household press (R-899): a press
// carries `trigger=manual`, and an agent that knows it does not start the night's OS leg after it. An older
// agent ignores the parameter (it reads only `target`), so the request is safe against any agent.
func (c *Client) StartBackupForTrigger(ctx context.Context, target string, manual bool) (BackupResponse, error) {
var out BackupResponse
body, err := c.post(ctx, "/backup"+targetQuery(target), struct{}{})
body, err := c.post(ctx, "/backup"+backupStartQuery(target, manual), struct{}{})
if err != nil {
return out, err
}
@@ -105,6 +127,33 @@ func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResp
return out, nil
}
// BackupJobsInFlight (R-921) lists the tiers whose backup job the agent holds in flight right now — phase
// running or snapshotted, the agent's own definition (localapi backupInFlight). A job in flight on another
// tier is the agent's first reason to refuse POST /backup (HTTP 409), so the controller asks this BEFORE it
// stops any app. It reads only GET /backup/tiers and GET /backup/status?target= (both agent >= v0.97.0); a
// pre-R-82 agent (no /backup/tiers) answers nil, nil. Any other error is returned — the caller fails toward
// backing up. It does NOT see the agent's host-wide heavy-operation gate: no endpoint serves it.
func (c *Client) BackupJobsInFlight(ctx context.Context) ([]string, error) {
tiers, err := c.BackupTiers(ctx)
if errors.Is(err, ErrTiersUnsupported) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []string
for _, t := range tiers.Tiers {
st, err := c.BackupStatusFor(ctx, t.Target)
if err != nil {
return nil, err
}
if st.Phase == PhaseRunning || st.Phase == PhaseSnapshotted {
out = append(out, t.Target)
}
}
return out, nil
}
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
type SetBackupTargetResponse struct {
Target string `json:"target"`
+79 -9
View File
@@ -255,6 +255,9 @@ const (
PhaseRunning = "running"
PhaseDone = "done"
PhaseFailed = "failed"
// PhaseSnapshotted (8B.2): the storage snapshot is taken, the upload still runs and still holds the
// guest — the agent counts it as IN FLIGHT (felhom-agent localapi backupInFlight).
PhaseSnapshotted = "snapshotted"
)
// BackupDue reports whether a policy-scheduled backup is due for this guest (the quiesce trigger).
@@ -272,15 +275,7 @@ func (c *Client) BackupDue(ctx context.Context) (DueResponse, error) {
// StartBackup enqueues a backup of this guest (the agent vzdump) and returns the job to poll.
func (c *Client) StartBackup(ctx context.Context) (BackupResponse, error) {
var out BackupResponse
body, err := c.post(ctx, "/backup", struct{}{})
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode POST /backup: %w", err)
}
return out, nil
return c.StartBackupForTrigger(ctx, "", false)
}
// BackupStatus reports the current/last backup job phase for this guest.
@@ -360,8 +355,18 @@ type DiskInfo struct {
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
Smart *SmartSummary `json:"smart,omitempty"`
// Physical-disk identity (agent v0.157.0, `16` §3): kind, the keys of the physical disks under the place,
// proven|unknown + reason. Empty from an older agent or for an absent drive.
Kind string `json:"kind,omitempty"` // system | internal | disk | partition | network (felhom.eu `16` §3)
PhysicalDisks []string `json:"physical_disks,omitempty"`
Identity string `json:"identity,omitempty"`
IdentityReason string `json:"identity_reason,omitempty"`
}
// PlaceKindInternal is the agent's kind for the one-disk box's internal data place („Belső tárhely", agent ≥ 0.158.0,
// felhom.eu architecture/16 §5): a host mount on the system disk, bound at /mnt/felhom-drives/belso.
const PlaceKindInternal = "internal"
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
// is not a filesystem UUID (network/lvm targets — not assignable as a host mount).
func (d DiskInfo) FSUUID() string {
@@ -395,6 +400,10 @@ type FormatResult struct {
DurableID string `json:"durable_id,omitempty"`
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
PendingOp *PendingOp `json:"pending_op,omitempty"`
// FSUUID (R-25, agent >= the R-25 agent half) is the UUID of the filesystem the agent just made,
// sent only while the format's durable id still names the formatted device. "" = not verified, or
// an agent too old to send it. The init flow mounts THIS rather than re-resolving from the /dev path.
FSUUID string `json:"fs_uuid,omitempty"`
}
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
@@ -420,6 +429,29 @@ var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup de
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
// SameDiskResult is the agent's answer to POST /disks/same-disk (v0.157.0). Act on Proven first:
// Proven+Different = a counted second copy; Proven+!Different = the same disk; !Proven = unknown
// (Reason vm|network|usb_bridge|weak_wwn|no_serial|unmapped).
type SameDiskResult struct {
Different bool `json:"different"`
Proven bool `json:"proven"`
Reason string `json:"reason,omitempty"`
}
// SameDisk asks the agent whether two guest paths sit on the same physical disk. Gate it with
// Supports(FeatureSameDisk) — an older agent answers 404.
func (c *Client) SameDisk(ctx context.Context, a, b string) (SameDiskResult, error) {
var out SameDiskResult
raw, err := c.post(ctx, "/disks/same-disk", map[string]string{"a": a, "b": b})
if err != nil {
return out, err
}
if err := json.Unmarshal(raw, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /disks/same-disk: %w", err)
}
return out, nil
}
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
var out DisksResponse
@@ -449,8 +481,37 @@ type DiskCandidate struct {
// mountpoint — sending it down the device-attach path would try to mount an in-guest path as if
// it were a raw device. See web/attach_sources.go for why the agent's scan cannot supply these.
AlreadyMounted bool `json:"already_mounted,omitempty"`
// Partitions (agent ≥ 0.158.0, felhom.eu architecture/16 §6): each partition of this disk as its own place —
// state empty („format this part"), filesystem („use this part" when mountable; format through the confirmed
// path), in_use (already a Felhom drive: shown, never offered). Absent from an older agent.
Partitions []CandidatePartition `json:"partitions,omitempty"`
// WholeDiskInUse is set by the CONTROLLER (never the agent) when a partition of this disk is a registered drive
// (R-542): the disk stays listed for its FREE parts only, and no whole-disk action is offered.
WholeDiskInUse bool `json:"whole_disk_in_use,omitempty"`
}
// CandidatePartition mirrors the agent's partition entry (felhom-agent internal/storage/partitions.go).
type CandidatePartition struct {
Device string `json:"device"`
Number int `json:"number,omitempty"`
SizeBytes int64 `json:"size_bytes"`
FSType string `json:"fstype,omitempty"`
State string `json:"state"` // empty | filesystem | in_use
Mountable bool `json:"mountable"`
DurableID string `json:"durable_id,omitempty"` // uuid:<fs-uuid> — the attach key
MountedAt string `json:"mounted_at,omitempty"`
// Formattable (agent ≥ 0.158.1): false for every part of a disk with a mounted node — the root mkfs wrapper refuses
// those and trusts no „it is Felhom's own" claim. Only formattable parts are offered for formatting.
Formattable bool `json:"formattable"`
}
// Partition states (agent ≥ 0.158.0).
const (
PartEmpty = "empty"
PartFilesystem = "filesystem"
PartInUse = "in_use"
)
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
// unclaimed) and attach (the mountable-FS subset).
type CandidatesResult struct {
@@ -808,6 +869,8 @@ type FormatStatusResult struct {
Device string `json:"device"`
FSType string `json:"fstype"`
Error string `json:"error"`
// FSUUID (R-25): see FormatResult.FSUUID — "" until the job is done and verified.
FSUUID string `json:"fs_uuid,omitempty"`
}
// FormatStatus fetches the agent's most-recent format-job record.
@@ -1059,6 +1122,13 @@ type SmartSummary struct {
ReallocatedSectors *int `json:"reallocated_sectors"`
PendingSectors *int `json:"pending_sectors"`
OfflineUncorrectable *int `json:"offline_uncorrectable"`
// R-330 (agent v0.150.0+): three more SATA raw counters — 187 Reported_Uncorrect, 188
// Command_Timeout (vendor-packed on some drives: carried as reported), 199 UDMA_CRC_Error_Count.
// nil = unknown (an older agent, an NVMe/USB device, or not reported), never zero. CARRIED ONLY:
// no verdict, banner or mail reads them yet — that would change what a household is told.
ReportedUncorrect *int64 `json:"reported_uncorrect,omitempty"`
CommandTimeout *int64 `json:"command_timeout,omitempty"`
UDMACRCErrors *int64 `json:"udma_crc_errors,omitempty"`
// NVMe attributes.
CriticalWarning *int `json:"critical_warning"`
MediaErrors *int `json:"media_errors"`
@@ -0,0 +1,33 @@
package agentapi
import (
"context"
"encoding/json"
"fmt"
)
// CrashGuardState mirrors the agent's GET /host/crash-guard (R-856, `09` §3 decision 143): what the
// host's crash guard (`felhom-crash-guard`, `11` §5.9) recorded about the most recent HOST boot. The
// agent reads /var/lib/felhom-crash-guard/state.json (0644) and passes these fields through.
//
// Present=false: the host has no crash guard or no state yet. An agent that predates the route answers
// 404 (a *StatusError) — both mean UNKNOWN, and the controller then keeps its normal boot grace.
type CrashGuardState struct {
Present bool `json:"present"`
LastBootAt string `json:"last_boot_at,omitempty"` // RFC3339 UTC ("2006-01-02T15:04:05Z")
LastBootUnclean bool `json:"last_boot_unclean"`
Tripped bool `json:"tripped"`
}
// CrashGuard calls GET /host/crash-guard.
func (c *Client) CrashGuard(ctx context.Context) (CrashGuardState, error) {
var out CrashGuardState
body, err := c.get(ctx, "/host/crash-guard")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /host/crash-guard: %w", err)
}
return out, nil
}
@@ -0,0 +1,37 @@
package agentapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-856: GET /host/crash-guard decodes the crash guard's fields, and an agent that predates the route
// surfaces as a typed 404 (the caller reads it as unknown → the normal boot grace).
func TestR856_CrashGuardDecodesAndAnOlderAgentIs404(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /host/crash-guard", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":true,"last_boot_at":"2026-10-04T12:00:00Z","last_boot_unclean":true,"tripped":false}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
st, err := c.CrashGuard(context.Background())
if err != nil {
t.Fatal(err)
}
if !st.Present || !st.LastBootUnclean || st.LastBootAt != "2026-10-04T12:00:00Z" || st.Tripped {
t.Fatalf("state = %+v", st)
}
old := httptest.NewTLSServer(http.NewServeMux())
defer old.Close()
_, err = clientFor(t, old, strings.TrimPrefix(old.URL, "https://")).CrashGuard(context.Background())
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Fatalf("an older agent must answer a typed 404, got %v", err)
}
}
+34 -18
View File
@@ -57,20 +57,20 @@ type DiskPrior struct {
// DiskVerdictFor maps a SmartSummary plus the previous observation to a verdict. Rules are evaluated
// TOP-DOWN and the FIRST match wins (v0.215.0):
//
// 1. nil / "" / UNKNOWN -> Nincs adat
// 2. Health == FAILING -> Hiba (drive self-reports)
// 3. temperature_c >= 60 -> Hiba
// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba
// 5. percentage_used >= 100 -> Hiba
// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED)
// 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping)
// 8. unreadable >= 64 -> Hiba (too large to be a blip)
// 9. unreadable > 0 -> Figyelmeztetés (first sighting)
// 10. reallocated > 0 -> Figyelmeztetés
// 11. media_errors > 0 -> Figyelmeztetés
// 12. percentage_used >= 90 -> Figyelmeztetés
// 13. temperature_c >= 55 -> Figyelmeztetés
// 14. otherwise -> Rendben
// 1. nil / "" / UNKNOWN -> Nincs adat
// 2. Health == FAILING -> Hiba (drive self-reports)
// 3. temperature_c >= 60 (NOT for NVMe — R-333) -> Hiba
// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba
// 5. percentage_used >= 100 -> Hiba
// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED)
// 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping)
// 8. unreadable >= 64 -> Hiba (too large to be a blip)
// 9. unreadable > 0 -> Figyelmeztetés (first sighting)
// 10. reallocated > 0 -> Figyelmeztetés
// 11. media_errors > 0 -> Figyelmeztetés
// 12. percentage_used >= 90 -> Figyelmeztetés
// 13. temperature_c >= 55 (NOT for NVMe — R-333) -> Figyelmeztetés
// 14. otherwise -> Rendben
//
// WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187,
// 197 and 198 all carry thresh 0, and a normalized SMART value floors at 1, so it can never drop to
@@ -82,6 +82,12 @@ type DiskPrior struct {
// drive sustain fires a full day earlier (12 Aug) than the count threshold (13 Aug). Row 8 exists for
// a box that was powered off or restarted across the sustain window and so has no prior.
//
// WHY rows 3 and 13 skip an NVMe drive (R-333, `09` §3 decision 206, v0.308.0): the 55/60 °C bands are
// SPINNING-DISK bands. A healthy NVMe idles at 53 °C (measured on demo-hp) and routinely passes 60 °C
// under load with no fault, so the bands would tell a household "Hiba" about a working drive. An NVMe
// drive judges its own heat: critical_warning bit 1 is the device's temperature alarm, and row 4 still
// turns any critical_warning bit into Hiba. Pinned by TestDiskVerdict_NVMeHasNoHeatBand.
//
// Pure: no clock, no I/O, no logging. Everything it needs arrives as an argument.
func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
// 1 — no data. Never alarms.
@@ -94,8 +100,9 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
}
// Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters,
// because the overall verdict is structurally unable to report this class of fault.
nvme := IsNVMe(s)
switch {
case atLeast(s.TemperatureC, TemperatureFailC): // 3
case !nvme && atLeast(s.TemperatureC, TemperatureFailC): // 3
return DiskVerdictFail
case positive(s.CriticalWarning): // 4
return DiskVerdictFail
@@ -118,7 +125,7 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
return DiskVerdictWarn
case atLeast(s.PercentageUsed, percentageUsedWarn): // 12
return DiskVerdictWarn
case atLeast(s.TemperatureC, temperatureWarnC): // 13
case !nvme && atLeast(s.TemperatureC, temperatureWarnC): // 13
return DiskVerdictWarn
}
return DiskVerdictOK // 14
@@ -190,12 +197,21 @@ func DegradedAttributes(s *SmartSummary) []string {
if atLeast(s.PercentageUsed, percentageUsedWarn) {
out = append(out, "elhasználódás")
}
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable.
if atLeast(s.TemperatureC, temperatureWarnC) {
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. Not for an
// NVMe drive (R-333): its heat never triggers a verdict, so naming it would give a wrong reason.
if !IsNVMe(s) && atLeast(s.TemperatureC, temperatureWarnC) {
out = append(out, "hőmérséklet")
}
return out
}
// IsNVMe reports whether the summary came from an NVMe drive. The agent fills critical_warning,
// percentage_used and media_errors ONLY from smartctl's nvme_smart_health_information_log
// (felhom-agent internal/storage/smart.go), so any of them present means NVMe; a SATA or USB drive
// carries none of them.
func IsNVMe(s *SmartSummary) bool {
return s != nil && (s.CriticalWarning != nil || s.PercentageUsed != nil || s.MediaErrors != nil)
}
func positive(p *int) bool { return p != nil && *p > 0 }
func atLeast(p *int, n int) bool { return p != nil && *p >= n }
@@ -0,0 +1,36 @@
package agentapi
import "testing"
// R-333 (`09` §3 decision 206): no heat band for an NVMe drive. Both directions, because "fixed" means
// one kind of drive stopped alarming on heat and the other did not, and the drive's own alarm stays.
func TestDiskVerdict_NVMeHasNoHeatBand(t *testing.T) {
noPrior := DiskPrior{}
cases := []struct {
name string
in *SmartSummary
want DiskVerdict
}{
// A healthy NVMe under load: 72 °C, the device raises no flag. No heat alarm.
{"hot NVMe, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(0), PercentageUsed: ip(3), MediaErrors: ip(0)}, DiskVerdictOK},
{"warm NVMe 56 °C, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), CriticalWarning: ip(0)}, DiskVerdictOK},
// The same drive with its own temperature bit set (critical_warning bit 1 = 0x02): Hiba.
{"hot NVMe with critical_warning temperature bit → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}, DiskVerdictFail},
{"cool NVMe with critical_warning → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(40), CriticalWarning: ip(1)}, DiskVerdictFail},
// Spinning/SATA drives keep their 55/60 °C bands, unchanged.
{"SATA 56 °C → warn", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), ReallocatedSectors: ip(0)}, DiskVerdictWarn},
{"SATA 60 °C → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(60), ReallocatedSectors: ip(0)}, DiskVerdictFail},
{"SATA 54 °C → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(54)}, DiskVerdictOK},
}
for _, c := range cases {
if got := DiskVerdictFor(c.in, noPrior); got != c.want {
t.Errorf("%s: DiskVerdictFor = %s, want %s", c.name, got.Label(), c.want.Label())
}
}
// The alert body must not name heat as the reason for an NVMe drive.
for _, a := range DegradedAttributes(&SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}) {
if a == "hőmérséklet" {
t.Error("an NVMe alert names temperature as an attribute; the verdict fired on the drive's own warning")
}
}
}
+12
View File
@@ -210,6 +210,10 @@ const (
// out loud — a true sentence about our own incuriosity that a customer reads as a statement about
// their code.
RecoveryCodeOpensRetained
// RecoveryOlderUnchecked — the code did not open the current package, no earlier package opened it, and
// NOT every earlier package the hub holds was tried (R-304, agent 424). So whether the code is wrong is
// NOT known. The customer must not be told to check their typing as if it were.
RecoveryOlderUnchecked
)
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
@@ -251,6 +255,12 @@ func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained bool) Recove
return RecoveryCodeOpensRetained
}
return RecoveryUnknown
case http.StatusFailedDependency:
// R-304. NOT version-gated, deliberately — unlike 400 and 422 above. Those two make a claim about the
// customer's code (wrong / correct), so a status we did not design must not be read as one. This class
// claims only „not everything was checked; contact support", which is the same direction as
// RecoveryUnknown: misreading a stray 424 here can never accuse anyone.
return RecoveryOlderUnchecked
case http.StatusBadRequest:
if trustRefusal {
return RecoveryAskedAndRefused
@@ -276,6 +286,8 @@ func (f RecoveryFailure) String() string {
return "bundle-too-old"
case RecoveryCodeOpensRetained:
return "code-opens-retained"
case RecoveryOlderUnchecked:
return "older-unchecked"
default:
return "unknown"
}
+22
View File
@@ -83,6 +83,16 @@ const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
// for two months, and it heals itself when the agent updates.
const FeatureRetainedRecoveryClass Feature = "retained_recovery_class"
// FeatureHostPowerOff is the household power-off (R-998, agent v0.155.0): POST /host/poweroff. A POST that switches
// the machine off cannot be probed, so the VERSION decides. The settings card hides only on a definite SupportNo; on
// Unknown it stays, and an old agent's 404 is answered with the update sentence.
const FeatureHostPowerOff Feature = "host_poweroff"
// FeatureSameDisk — POST /disks/same-disk (agent v0.157.0, felhom.eu architecture/16-storage-physical-identity.md
// §4): the agent's physical-disk verdict for two guest paths. Below it the Tier-2 runner keeps the old st_dev
// rule (backup.Manager.diskRelation), logged once.
const FeatureSameDisk Feature = "same_disk"
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
// refuses — the existing agent-error paths speak honestly when the agent is down).
type SupportState int
@@ -159,6 +169,13 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
FeatureRetainedRecoveryClass: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
// R-998: probing would switch the machine off. The row must exist (table-gap rule above).
FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
FeatureSameDisk: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe // not probeable: decided by the version header alone
},
}
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
@@ -192,6 +209,11 @@ var featureMinAgent = map[Feature]string{
// screen must keep saying it cannot tell the two apart — which was true, and is what this gate
// preserves for boxes that have not updated yet.
FeatureRetainedRecoveryClass: "0.129.0",
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
FeatureHostPowerOff: "0.155.0",
FeatureSameDisk: "0.157.0",
}
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
@@ -0,0 +1,90 @@
package agentapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
)
// ── POST /host/poweroff (R-998, agent >= 0.155.0, felhom.eu documentation/architecture/14-host-power-off.md) ──
//
// The household's „A gép kikapcsolása". The agent answers 202 (it shuts this guest down, then the host) or 409
// with a machine-readable reason. Unlike post(), the 409 BODY matters here: the dashboard tells the household
// WHY (a backup runs, too soon after start, already asked…), so this call reads it.
// Agent refusal reasons (stable wire values; mirror felhom-agent internal/localapi/hostpoweroff.go).
const (
PowerOffTooSoon = "too_soon_after_boot"
PowerOffAlreadyAsked = "already_requested_this_boot"
PowerOffBackup = "backup_running"
PowerOffRestoreTest = "restore_test_running"
PowerOffOSUpdate = "os_update_running"
PowerOffCtrlUpdate = "controller_update_running"
PowerOffDrive = "drive_operation_running"
PowerOffEscrow = "escrow_ceremony_running"
PowerOffBusy = "host_busy"
PowerOffOtherGuests = "other_guests_running"
PowerOffUnknown = "state_unknown"
)
// PowerOffRefused is the agent's 409: Reason is one of the constants above (or a newer one), Detail is for the log.
type PowerOffRefused struct {
Reason string
Detail string
}
func (e *PowerOffRefused) Error() string {
return "agent refused the power-off: " + e.Reason + " (" + e.Detail + ")"
}
// HostPowerOff asks the agent to shut this guest down and power the host off. nil = accepted (the box goes down
// within minutes). A *PowerOffRefused = the agent said no, with its reason. A *StatusError 404 = an agent that
// predates the route.
func (c *Client) HostPowerOff(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/host/poweroff", bytes.NewReader([]byte("{}")))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("agentapi: POST /host/poweroff: %w", err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp)
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
logx.Debugf(c.logger, "[agentapi] POST /host/poweroff -> %d (%dms)", resp.StatusCode, time.Since(start).Milliseconds())
switch resp.StatusCode {
case http.StatusAccepted, http.StatusOK:
return nil
case http.StatusConflict:
var env struct {
Data struct {
Reason string `json:"reason"`
Detail string `json:"detail"`
} `json:"data"`
}
if json.Unmarshal(raw, &env) != nil || env.Data.Reason == "" {
return &PowerOffRefused{Reason: PowerOffUnknown, Detail: "unreadable 409 body"}
}
return &PowerOffRefused{Reason: env.Data.Reason, Detail: env.Data.Detail}
default:
return &StatusError{Method: http.MethodPost, Path: "/host/poweroff", Code: resp.StatusCode}
}
}
// IsPowerOffRefused returns the refusal, if err is one.
func IsPowerOffRefused(err error) (*PowerOffRefused, bool) {
var r *PowerOffRefused
ok := errors.As(err, &r)
return r, ok
}
@@ -0,0 +1,61 @@
package agentapi
import (
"encoding/json"
"reflect"
"testing"
)
// R-330 (disk health Phase 2, the wire only). The agent (v0.150.0+) sends three more SATA raw
// counters; the controller must DECODE them (a field the struct lacks is silently dropped by
// encoding/json) and must not yet ACT on them.
// The 2026-08-14 failing drive as the agent now reports it (values from
// felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json).
const r330AgentJSON = `{"health":"PASSED","temperature_c":40,"power_on_hours":null,
"reallocated_sectors":0,"pending_sectors":352,"offline_uncorrectable":352,
"reported_uncorrect":1001,"command_timeout":0,"udma_crc_errors":0,
"critical_warning":null,"media_errors":null,"percentage_used":null}`
// COMPANION RED-PROOF (observed): rename the controller tag `reported_uncorrect` → `reported_uncorrectable`
// → this fails with "187 must decode from the agent's JSON (raw 1001); got <nil>". Restored.
func TestR330_DecodesTheThreeCounters(t *testing.T) {
var s SmartSummary
if err := json.Unmarshal([]byte(r330AgentJSON), &s); err != nil {
t.Fatal(err)
}
if s.ReportedUncorrect == nil || *s.ReportedUncorrect != 1001 {
t.Fatalf("187 must decode from the agent's JSON (raw 1001); got %v", s.ReportedUncorrect)
}
// A measured ZERO stays a zero (not nil): 188 and 199 were reported as 0 by this drive.
if s.CommandTimeout == nil || *s.CommandTimeout != 0 || s.UDMACRCErrors == nil || *s.UDMACRCErrors != 0 {
t.Fatalf("188/199 reported as 0 must decode as a measured 0; got %v %v", s.CommandTimeout, s.UDMACRCErrors)
}
// An older agent omits the keys → nil (unknown), never 0.
var old SmartSummary
if err := json.Unmarshal([]byte(`{"health":"PASSED","pending_sectors":0}`), &old); err != nil {
t.Fatal(err)
}
if old.ReportedUncorrect != nil || old.CommandTimeout != nil || old.UDMACRCErrors != nil {
t.Fatalf("an older agent's report must leave the counters nil (unknown); got %v %v %v",
old.ReportedUncorrect, old.CommandTimeout, old.UDMACRCErrors)
}
}
// CARRIED ONLY: tonight the counters change no verdict, label or degraded-attribute list — reading
// them is a change to what a household is told and is not taken here (R-330's next slice).
func TestR330_CountersChangeNoVerdictYet(t *testing.T) {
with := realDrive()
big := int64(5000)
with.ReportedUncorrect, with.CommandTimeout, with.UDMACRCErrors = &big, &big, &big
without := realDrive()
for _, prior := range []DiskPrior{{}, {SawUncorrectable: true}} {
if a, b := DiskVerdictFor(with, prior), DiskVerdictFor(without, prior); a != b {
t.Fatalf("prior %+v: the R-330 counters changed the verdict (%v vs %v) — that is a household-facing change", prior, a, b)
}
}
if a, b := DegradedAttributes(with), DegradedAttributes(without); !reflect.DeepEqual(a, b) {
t.Fatalf("the R-330 counters changed the degraded-attribute list: %v vs %v", a, b)
}
}
@@ -0,0 +1,47 @@
package agentapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-899: a household press tells the agent so (`trigger=manual`), and nothing else does — the agent runs the
// night's OS leg only after a backup that is not a press. The request the agent RECEIVES is asserted.
func TestR899_PressCarriesTriggerManual(t *testing.T) {
var got []string
mux := http.NewServeMux()
mux.HandleFunc("POST /backup", func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.URL.RawQuery)
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"job_id":"backup-9201-2","phase":"running"}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
ctx := context.Background()
if _, err := c.StartBackup(ctx); err != nil {
t.Fatal(err)
}
if _, err := c.StartBackupFor(ctx, "local"); err != nil {
t.Fatal(err)
}
if _, err := c.StartBackupForTrigger(ctx, "local", true); err != nil {
t.Fatal(err)
}
if _, err := c.StartBackupForTrigger(ctx, "", true); err != nil {
t.Fatal(err)
}
want := []string{"", "target=local", "target=local&trigger=manual", "trigger=manual"}
if len(got) != len(want) {
t.Fatalf("queries = %q, want %q", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("query %d = %q, want %q (all: %q)", i, got[i], want[i], got)
}
}
}
@@ -0,0 +1,75 @@
package agentapi
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
// R-921: the pre-check reads the agent's real wire shapes — GET /backup/tiers, then GET /backup/status per
// target — and reports exactly the tiers whose job is running or snapshotted (the agent's in-flight set).
func TestBackupJobsInFlight_ReportsRunningAndSnapshottedTiers(t *testing.T) {
phases := map[string]string{"local": "done", "felhom-pbs": "snapshotted", "extra": "running"}
mux := http.NewServeMux()
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[` +
`{"target":"local","cadence_seconds":86400,"primary":true,"storage":"present"},` +
`{"target":"felhom-pbs","cadence_seconds":604800,"primary":false,"storage":"present"},` +
`{"target":"extra","cadence_seconds":604800,"primary":false,"storage":"present"}]}}`))
})
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
tg := r.URL.Query().Get("target")
ph, ok := phases[tg]
if !ok {
http.Error(w, "unexpected target "+tg, http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"phase":"` + ph + `","target":"` + tg + `"}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
got, err := c.BackupJobsInFlight(context.Background())
if err != nil {
t.Fatalf("BackupJobsInFlight: %v", err)
}
if want := []string{"felhom-pbs", "extra"}; !reflect.DeepEqual(got, want) {
t.Fatalf("in-flight tiers = %v, want %v (done must not count; running and snapshotted must)", got, want)
}
}
// A pre-R-82 agent (no /backup/tiers → 404) has no per-tier jobs: nil, nil — never an error that would be
// read as "cannot tell", and never a busy verdict.
func TestBackupJobsInFlight_PreR82AgentIsNotBusy(t *testing.T) {
mux := http.NewServeMux()
s := httptest.NewTLSServer(mux) // every route 404s
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
got, err := c.BackupJobsInFlight(context.Background())
if err != nil || got != nil {
t.Fatalf("pre-R-82 agent: got %v, %v — want nil, nil", got, err)
}
}
// A status read that fails is returned, so the loop can fail toward backing up and say so.
func TestBackupJobsInFlight_StatusErrorIsReturned(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[{"target":"local","primary":true}]}}`))
})
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
if _, err := c.BackupJobsInFlight(context.Background()); err == nil {
t.Fatal("a failed status read was swallowed — the caller could not tell it from 'nothing in flight'")
}
}
@@ -1,39 +0,0 @@
package api
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestWriteConfig0600 asserts F8: controller.yaml is persisted 0600 (it holds infra secrets), even when
// the target file already existed with looser (0644) permissions. POSIX modes only — skipped on Windows.
func TestWriteConfig0600(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX file modes not represented on Windows")
}
dir := t.TempDir()
path := filepath.Join(dir, "controller.yaml")
// Pre-create with world-readable 0644 to prove the helper tightens an existing file.
if err := os.WriteFile(path, []byte("old: true\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := writeConfig0600(path, []byte("hub:\n api_key: redacted\n")); err != nil {
t.Fatalf("writeConfig0600: %v", err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if mode := fi.Mode().Perm(); mode != 0o600 {
t.Fatalf("config mode = %o, want 0600", mode)
}
// No leftover temp file.
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("temp file not cleaned up")
}
}
@@ -0,0 +1,17 @@
package api
import (
"net/http"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// D4 (`16` §5): the no-drive refusal is a 400 the household can act on — not a 500.
func TestD4_NeedsDriveIs400(t *testing.T) {
err := util.MsgErrorf(stacks.ErrNeedsDrive, "err.stacks.needs_drive")
if got := deployStatusFor(err); got != http.StatusBadRequest {
t.Fatalf("ErrNeedsDrive → %d, want 400", got)
}
}
+6 -2
View File
@@ -57,11 +57,15 @@ func TestRefuseNetworkStubDeploy_Table(t *testing.T) {
}
for _, c := range cases {
r := newDeployGateRouter(t, c.class)
msg := r.refuseNetworkStubDeploy(c.hdd)
// v0.252.0 (R-557): the gate returns a bundle KEY, so the customer reads it in their own
// language. Rendered here rather than compared as a key, because what this test is about is
// the SENTENCE the customer is refused with -- a key renamed with its message intact is not
// a regression, and a message reworded is.
msg := r.msgLang("hu", r.refuseNetworkStubDeploy(c.hdd))
if c.refuse && !strings.Contains(msg, "a telepítés nem indítható") {
t.Errorf("%s: want the refusal message, got %q", c.name, msg)
}
if !c.refuse && msg != "" {
if !c.refuse && r.refuseNetworkStubDeploy(c.hdd) != "" {
t.Errorf("%s: must proceed, got refusal %q", c.name, msg)
}
}
+25 -12
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"sort"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
@@ -67,7 +68,7 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
if err := r.sett.SetGeoRestriction(geo); err != nil {
r.logger.Printf("[ERROR] [api] Failed to save geo settings: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -86,12 +87,12 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
// away instead of after the next ~15-min cycle.
r.reportPushNow()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Geo-korlátozás beállítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.set")})
}
func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
func (r *Router) geoTriggerSync(w http.ResponseWriter, req *http.Request) {
if r.geoSync == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Cloudflare API nincs konfigurálva"})
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: r.msg(req, "api.geo.no_cloudflare")})
return
}
@@ -105,11 +106,23 @@ func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
r.reportPushNow()
}()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Szinkronizálás elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.sync_started")})
}
func (r *Router) geoCountries(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: cf.AllCountries()})
// geoCountries serves the picker's country list in the request's language.
//
// The TABLE in internal/cloudflare is untouched and stays Hungarian: it is also the validator, and
// internal/report puts country CODES on the wire. Only the NAME shown to the customer is translated,
// and the list is re-sorted afterwards -- alphabetical order is per language, and a list sorted by
// the Hungarian names would read as shuffled in English.
func (r *Router) geoCountries(w http.ResponseWriter, req *http.Request) {
lang := r.langFor(req)
list := cf.AllCountries()
for i := range list {
list[i].Name = r.countryName(lang, list[i].Code, list[i].Name)
}
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list})
}
func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
@@ -144,7 +157,7 @@ func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, app
override := &settings.AppGeoOverride{AllowedCountries: body.AllowedCountries}
if err := r.sett.SetGeoAppOverride(appName, override); err != nil {
r.logger.Printf("[ERROR] [api] Failed to save geo override for %s: %v", appName, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -159,10 +172,10 @@ func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, app
}()
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás beállítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_set")})
}
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, appName string) {
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
if appName == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
return
@@ -170,7 +183,7 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, ap
if err := r.sett.RemoveGeoAppOverride(appName); err != nil {
r.logger.Printf("[ERROR] [api] Failed to remove geo override for %s: %v", appName, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -185,5 +198,5 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, ap
}()
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás eltávolítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_removed")})
}
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"net/http"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// ── The JSON answers follow the language too (v0.252.0, slice 2 — R-557) ───────────────────────
//
// Design: felhom.eu/documentation/architecture/10-localisation.md. The dashboard's page markup has
// followed the household's language since slice 1, but a large part of what a customer READS on
// those pages never passes through a template: the page's own script fetches /api/… and shows the
// `message` or `error` the answer carries. Until now those were Hungarian literals, so an English
// dashboard answered in Hungarian the moment anything was clicked.
//
// These helpers mirror internal/web's (i18n_web.go) exactly, and for the same reason: the Hungarian
// text is the SAME BYTES the literal carried, pinned by scripts/i18n_go_parity.py against a frozen
// capture of the base commit.
//
// NOT everything here is display. `internal/report` and `internal/notify` put Hungarian on the wire,
// where it is read by the hub and mailed to the household — those stay Hungarian in every language
// until slice 3 (R-558) teaches the hub which language to compose in, and their bytes are pinned by
// the wire goldens in those packages.
// langFor decides the language of one API request: `?lang=hu|en` (a testing override, never
// persisted), else the household's saved setting, else Hungarian. Identical in meaning to
// web.Server.langFor, deliberately — the page and the calls its script makes must never disagree.
func (r *Router) langFor(req *http.Request) string {
if req != nil {
if q := req.URL.Query().Get("lang"); i18n.IsSupported(q) {
return q
}
}
if r.sett != nil {
return r.sett.GetLanguage()
}
return i18n.Default
}
// bundle returns the process-wide bundle, or nil if it somehow failed to load. It is embedded in the
// binary, so a running box cannot reach the nil case; the callers still degrade to the key rather
// than panicking.
func bundle() *i18n.Bundle {
b, err := i18n.Shared()
if err != nil {
return nil
}
return b
}
// msg returns a message in the request's language, with the message's own printf verbs filled in.
func (r *Router) msg(req *http.Request, key string, a ...interface{}) string {
return r.msgLang(r.langFor(req), key, a...)
}
// msgLang is msg for a language that is already known.
func (r *Router) msgLang(lang, key string, a ...interface{}) string {
b := bundle()
if b == nil {
return key
}
if len(a) == 0 {
return b.Msg(lang, key)
}
return b.Msgf(lang, key, a...)
}
// errText renders an error in the request's language: its bundle message when it carries one, its own
// text otherwise (restic, docker, ssh and the Go stdlib print verbatim — that text is not ours).
func (r *Router) errText(req *http.Request, err error) string {
return util.ErrText(r.langFor(req), err)
}
// countryName returns a country's name in lang, falling back to the name the cloudflare table
// carries when the bundle has no entry for the code.
//
// The TABLE is not translated and must not be: internal/report puts country CODES on the wire, and
// cloudflare/countries.go is also what validates them. Only the NAME shown in the geo picker follows
// the language, which is why this lives here and not in that package.
func (r *Router) countryName(lang, code, fallback string) string {
b := bundle()
if b == nil {
return fallback
}
if v, _, ok := b.Text(lang, "country."+code); ok {
return v
}
return fallback
}
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Localisation slice 2 (R-557), scenario S2 — the JSON the page's own script reads follows the
// household's language, and the Hungarian answer is byte-identical to what it was.
//
// This is the half of the dashboard a template test cannot see. Since slice 1 the PAGE has been
// English, but every sentence that arrives by fetch — a refused deploy, a start blocked for memory,
// "the backup has started" — was a Hungarian literal in internal/api. An English household clicked an
// English button and was answered in Hungarian.
//
// The Hungarian side is a parity measurement, not a translation check: the `want` strings below are
// the literals that stood at the call sites at 736f54b49610.
func langRouter(t *testing.T, lang string) *Router {
t.Helper()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if lang != "" {
if err := sett.SetLanguage(lang); err != nil {
t.Fatal(err)
}
}
return &Router{sett: sett, logger: lg}
}
func TestAPIMessagesFollowTheHouseholdLanguage(t *testing.T) {
cases := []struct {
key, hu, en string
args []interface{}
}{
{key: "api.deploy.started",
hu: "Telepítés elindítva – az állapot a kártyán követhető",
en: "The install has started – the card shows how it is going"},
{key: "api.backup.already_running",
hu: "Mentés már folyamatban", en: "A backup is already running"},
{key: "api.start.drive_missing", args: []interface{}{"Kulso HDD"},
hu: "A(z) Kulso HDD tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.",
en: "Storage Kulso HDD cannot be reached right now — the app cannot start until the drive is back."},
{key: "api.start.not_enough_memory", args: []interface{}{512, 128, 1400, 1528},
hu: "Nincs elég memória az indításhoz. Szükséges: 512 MB, elérhető: 128 MB (használt: 1400 MB / használható: 1528 MB)",
en: "There is not enough memory to start. Needs 512 MB, 128 MB is free (1400 MB used of 1528 MB usable)"},
}
for _, c := range cases {
t.Run(c.key, func(t *testing.T) {
// hu: the saved setting, no query override — the ordinary household.
r := langRouter(t, "hu")
req := httptest.NewRequest("POST", "/api/stacks/x/start", nil)
if got := r.msg(req, c.key, c.args...); got != c.hu {
t.Errorf("Hungarian answer moved (parity)\n got %q\n want %q", got, c.hu)
}
// en: the same request shape on a box switched to English.
r = langRouter(t, "en")
if got := r.msg(req, c.key, c.args...); got != c.en {
t.Errorf("English answer\n got %q\n want %q", got, c.en)
}
})
}
}
// TestAPILangForPrecedence — `?lang=` overrides the saved setting (the testing door slice 0 built),
// an unsupported value is ignored rather than honoured, and a box with no setting answers Hungarian.
func TestAPILangForPrecedence(t *testing.T) {
cases := []struct{ saved, query, want string }{
{"hu", "", "hu"},
{"en", "", "en"},
{"hu", "en", "en"},
{"en", "hu", "hu"},
{"en", "de", "en"}, // unsupported: ignored, the saved setting stands
{"", "", i18n.Default},
}
for _, c := range cases {
r := langRouter(t, c.saved)
url := "/api/stacks"
if c.query != "" {
url += "?lang=" + c.query
}
if got := r.langFor(httptest.NewRequest("GET", url, nil)); got != c.want {
t.Errorf("saved=%q query=%q: got %q, want %q", c.saved, c.query, got, c.want)
}
}
}
// TestGeoCountryNamesFollowTheLanguage — the picker's 237 country names. The cloudflare TABLE is not
// translated (it validates codes, and internal/report puts codes on the wire); only the displayed
// name is, and the list is re-sorted afterwards because alphabetical order is per language.
func TestGeoCountryNamesFollowTheLanguage(t *testing.T) {
r := langRouter(t, "hu")
for _, c := range []struct{ lang, code, want string }{
{"hu", "DE", "Németország"},
{"en", "DE", "Germany"},
{"hu", "US", "Egyesült Államok"},
{"en", "US", "United States"},
{"hu", "AO", "Angola"}, // identical in both languages — must still resolve, not fall through
{"en", "AO", "Angola"},
} {
if got := r.countryName(c.lang, c.code, "FALLBACK"); got != c.want {
t.Errorf("%s/%s: got %q, want %q", c.lang, c.code, got, c.want)
}
}
// A code the bundle does not carry keeps the table's own name rather than showing a key.
if got := r.countryName("en", "ZZ", "Ismeretlen"); got != "Ismeretlen" {
t.Errorf("an unknown code must keep the table name, got %q", got)
}
// And the English list really is sorted by the English names: „Németország" sorts under N in
// Hungarian and Germany under G, so the two orders must differ.
if strings.EqualFold(r.countryName("hu", "DE", ""), r.countryName("en", "DE", "")) {
t.Error("the country names did not change with the language at all")
}
}
+164
View File
@@ -0,0 +1,164 @@
package api
import (
"fmt"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// keptChoiceData is what the install page needs to ask the household (`09` §3 decision 36). The
// sentences come rendered in the request's language; the page shows them as they are.
type keptChoiceData struct {
Code string `json:"code"` // "kept_data_choice"
Title string `json:"title"`
Body string `json:"body"`
UseLabel string `json:"use_label"`
UseDesc string `json:"use_desc"` // "" when use is off
UseOff string `json:"use_off"` // "" when use is offered
UseOffered bool `json:"use_offered"` // a database copy exists for these files
FreshLabel string `json:"fresh_label"`
FreshDesc string `json:"fresh_desc"`
NotBacked string `json:"not_backed_up"`
}
// keptDataAtInstall answers the install when the app's private drive folder already holds data:
//
// - no choice → 409 kept_data_choice with the sentences (nothing moved, nothing installed);
// - "use" → a LOAD from the newest usable copy (backup.KeptBestCopy: own unit, second drive, or —
// R-691 (2) — the off-site copy, downloaded unit-only at load time), then the app's after_load; 202.
// The page names the copy and its date. Refused 409 with use_off when no copy exists;
// - "fresh" → not handled here: DeployStack moves the old data aside and installs.
//
// Returns true when it wrote the response.
func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, name, hdd, choice string) bool {
old := r.stackMgr.OldAppData(name, hdd)
if len(old) == 0 || choice == stacks.KeptChoiceFresh {
return false
}
display := name
if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" {
display = st.Meta.DisplayName
}
var size int64
var newest time.Time
for _, p := range old {
size += stacks.DirSizeBytes(p)
if t := stacks.DirModTime(p); t.After(newest) {
newest = t
}
}
var cp struct {
ok bool
c backup.KeptCopy
}
if r.backupMgr != nil {
cp.c, cp.ok = r.backupMgr.KeptBestCopy(req.Context(), name, hdd)
}
lang := r.langFor(req)
data := keptChoiceData{
Code: "kept_data_choice",
Title: r.msgLang(lang, "kept.choice.title"),
Body: r.msgLang(lang, "kept.choice.body", display, appbackup.HumanizeBytes(size), localDay(newest)),
UseLabel: r.msgLang(lang, "kept.choice.use.label"),
UseOffered: cp.ok,
FreshLabel: r.msgLang(lang, "kept.choice.fresh.label"),
FreshDesc: r.msgLang(lang, "kept.choice.fresh.desc"),
NotBacked: r.msgLang(lang, "kept.not_backed_up"),
}
if cp.ok {
data.UseDesc = r.msgLang(lang, "kept.choice.use.desc_from", r.keptCopyName(lang, cp.c))
} else {
data.UseOff = r.msgLang(lang, "kept.choice.use_off")
}
switch choice {
case "":
r.logger.Printf("[INFO] [api] Deploy %s: the drive already holds its old data %v (%d bytes) — asking the household (use offered: %v)", name, old, size, cp.ok)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.Title, Data: data})
return true
case stacks.KeptChoiceUse:
if !cp.ok {
r.logger.Printf("[WARN] [api] Deploy %s: use my kept data REFUSED — no database copy for %s", name, hdd)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.UseOff, Data: data})
return true
}
if st := r.backupMgr.RestoreStatus(); st.Running || r.backupMgr.IsRunning() {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msgLang(lang, "api.kept.busy")})
return true
}
src := cp.c.UnitDir
if cp.c.Tier == backup.KeptTierOffsite {
src = "off-site snapshot " + cp.c.SnapshotID
}
r.logger.Printf("[INFO] [api] Deploy %s: USE MY KEPT DATA — loading from %s (tier %d, %s) under the kept files %v", name, src, cp.c.Tier, cp.c.Time.UTC().Format(time.RFC3339), old)
r.dropLeftoverHold(name, "use-kept-data") // R-704: this is a new install of an app that is not installed
r.startKeptLoad(name, hdd, cp.c, lang, nil)
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msgLang(lang, "kept.load.started", display)})
return true
default:
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("kept_data %q is not a choice", choice)})
return true
}
}
// keptCopyName names a copy for the household: which copy and its date („saját mentés, 2026-09-28 04:15").
func (r *Router) keptCopyName(lang string, c backup.KeptCopy) string {
return r.msgLang(lang, backup.KeptCopyKey(c.Tier), localMinute(c.Time))
}
// startKeptLoad runs the load and, when it succeeded, the app's after_load. then(ok) runs last. The
// off-site copy goes through LoadKeptOffsite (download the unit alone, judge it, then the same restore).
func (r *Router) startKeptLoad(name, drive string, c backup.KeptCopy, lang string, then func(ok bool)) {
display := name
if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" {
display = st.Meta.DisplayName
}
okMsg := func(error) string { return r.msgLang(lang, "kept.load.done", display) }
failMsg := func(err error) string { return r.msgLang(lang, "kept.load.failed", display, err) }
if c.Tier == backup.KeptTierOffsite {
r.backupMgr.LoadKeptOffsite(name, drive, c, nil, okMsg, failMsg, r.keptAfterLoad(name, then))
return
}
r.backupMgr.LoadKeptApp(name, c.UnitDir, okMsg, failMsg, r.keptAfterLoad(name, then))
}
// keptAfterLoad runs the app's after_load when the load succeeded, then then(ok).
func (r *Router) keptAfterLoad(name string, then func(ok bool)) func(ok bool) {
return func(ok bool) {
if ok {
if ran, err := r.stackMgr.RunAfterLoad(name, 10*time.Minute); ran && err != nil {
r.logger.Printf("[WARN] [api] kept load %s: after_load failed: %v", name, err)
}
}
if then != nil {
then(ok)
}
}
}
func localDay(t time.Time) string {
if t.IsZero() {
return "?"
}
return t.In(budapest()).Format("2006-01-02")
}
// localMinute is localDay with the time of day — the kept list's format, so both pages print one date.
func localMinute(t time.Time) string {
if t.IsZero() {
return "?"
}
return t.In(budapest()).Format("2006-01-02 15:04")
}
func budapest() *time.Location {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
return time.UTC
}
return loc
}
@@ -0,0 +1,289 @@
package api
import (
"context"
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// `09` §3 decision 36 — the install API answers an install over old data with the CHOICE (409,
// code kept_data_choice, both sentences, "use" OFF when no database copy exists) and installs nothing;
// "use" without a copy is refused; "fresh" is left to DeployStack. Nothing here reaches Docker.
// COMPANION RED-PROOF: make keptDataAtInstall return false at once → the no-choice case writes nothing
// and the first assertion fails (the install would go on to DeployStack).
func TestKept_InstallAPIAsksAndInstallsNothing(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
r := &Router{stackMgr: m, logger: log.New(io.Discard, "", 0)}
call := func(choice string) (bool, int, map[string]interface{}) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang=en", nil)
handled := r.keptDataAtInstall(w, req, "cloudapp", drive, choice)
var body map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &body)
return handled, w.Code, body
}
handled, code, body := call("")
data, _ := body["data"].(map[string]interface{})
if !handled || code != http.StatusConflict || data["code"] != "kept_data_choice" {
t.Fatalf("no choice: handled=%v code=%d body=%v", handled, code, body)
}
if data["title"] != "This app's old data is still here" || data["use_offered"] != false ||
data["use_off"] != "There is no backup of the database, so the app cannot load the old files. You can look at the files under Kept data." ||
data["fresh_label"] != "Start fresh" || data["not_backed_up"] != "This is not backed up." {
t.Fatalf("the choice's sentences: %v", data)
}
if handled, code, _ := call("use"); !handled || code != http.StatusConflict {
t.Fatalf("use with no copy: handled=%v code=%d", handled, code)
}
if handled, _, _ := call("fresh"); handled {
t.Fatal("fresh must be left to DeployStack (which moves the old data aside)")
}
if st, _ := m.GetStack("cloudapp"); st.Deployed || st.Deploying {
t.Fatal("something was installed")
}
// No old data → the API does not interfere.
_ = os.RemoveAll(filepath.Join(drive, "appdata/cloudapp"))
if handled, _, _ := call(""); handled {
t.Fatal("an install with no old data was intercepted")
}
}
// R-691 (2) — when the only database copy of kept files is OFF-SITE, the install choice offers „use" and
// names that copy and its date; before v0.277.0 it said "no backup". The repository is the restic runner
// seam; nothing reaches restic, ssh or Docker.
// COMPANION RED-PROOF: keptDataAtInstall back on KeptDBCopy (0.276.0) → use_offered=false and this fails.
func TestR691_InstallChoiceNamesTheOffsiteCopy(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp"), cfg.Paths.DataDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{Enabled: true, Host: "nas.local", Port: 22, User: "felhom",
RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed"}); err != nil {
t.Fatal(err)
}
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
if err := bm.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil {
t.Fatal(err)
}
snapAt := time.Date(2026, 9, 28, 2, 15, 0, 0, time.UTC) // 04:15 in Budapest
bm.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
for _, a := range args {
if a == "snapshots" {
return json.Marshal([]map[string]interface{}{{"short_id": "ab12cd34", "time": snapAt,
"tags": []string{"cloudapp"}, "paths": []string{drive + "/backups/primary/cloudapp", drive + "/appdata/cloudapp"}}})
}
}
return nil, nil
})
r := &Router{stackMgr: m, backupMgr: bm, logger: log.New(io.Discard, "", 0)}
for lang, want := range map[string]string{
"en": "We load the database from the off-site copy, 2026-09-28 04:15 and start the app with the old files. Changes made after that time can be missing.",
"hu": "Az adatbázist innen töltjük vissza: távoli mentés, 2026-09-28 04:15. A régi fájlokkal indítjuk az alkalmazást. Ami ezután változott, hiányozhat.",
} {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang="+lang, nil)
if !r.keptDataAtInstall(w, req, "cloudapp", drive, "") || w.Code != http.StatusConflict {
t.Fatalf("%s: the choice was not asked (code %d)", lang, w.Code)
}
var body map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &body)
data, _ := body["data"].(map[string]interface{})
if data["use_offered"] != true || data["use_desc"] != want || data["use_off"] != "" {
t.Fatalf("%s: use_offered=%v use_desc=%q use_off=%q\nwant use_desc=%q", lang, data["use_offered"], data["use_desc"], data["use_off"], want)
}
}
if st, _ := m.GetStack("cloudapp"); st.Deployed || st.Deploying {
t.Fatal("something was installed")
}
}
// R-704 (v0.278.0) — a hold an EARLIER install left under the app's name is dropped when a new install
// begins; the consequence measured on demo-hp was a fresh nextcloud whose backups skipped its volumes and
// unit because of a 2026-09-13 update hold. Driven through the real „use my kept data" path (the off-site
// copy, restic and the unit restore behind their seams — no Docker).
// COMPANION RED-PROOF: remove the dropLeftoverHold call from keptDataAtInstall → the hold is still there
// after the 202 and the first assertion fails; remove it from deployStack → the AST check fails.
func TestR704_AFreshInstallDropsALeftoverHold(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp"), cfg.Paths.DataDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox:1\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
_ = sett.SetOffboxTarget(&settings.OffboxTarget{Enabled: true, Host: "nas.local", Port: 22, User: "felhom",
RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed"})
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
if err := bm.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil {
t.Fatal(err)
}
bm.SetOffboxFreeFn(func(string) int64 { return 100 << 30 })
unitPath := drive + "/backups/primary/cloudapp"
bm.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
var target string
isRestore := false
for i, a := range args {
if a == "snapshots" {
return json.Marshal([]map[string]interface{}{{"short_id": "ab12cd34", "time": time.Now().Add(-time.Hour),
"tags": []string{"cloudapp"}, "paths": []string{unitPath}}})
}
if a == "restore" {
isRestore = true
}
if a == "--target" && i+1 < len(args) {
target = args[i+1]
}
}
if isRestore && target != "" {
u := filepath.Join(target, strings.TrimPrefix(unitPath, "/"))
_ = os.MkdirAll(filepath.Join(u, "compose"), 0o755)
_ = os.MkdirAll(filepath.Join(u, "db-dumps"), 0o755)
_ = os.WriteFile(filepath.Join(u, "db-dumps", "cloudapp.sql"), []byte("x"), 0o644)
_ = os.WriteFile(filepath.Join(u, "compose", "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox:1\n"), 0o644)
_ = os.WriteFile(filepath.Join(u, "compose", "app.yaml"), []byte("env:\n HDD_PATH: "+drive+"\n"), 0o600)
man, _ := json.Marshal(map[string]interface{}{"schema_version": 2, "app_name": "cloudapp", "db_dumps": []string{"cloudapp.sql"},
"data": map[string]interface{}{"at": time.Now().UTC().Format(time.RFC3339), "image_pins": []string{"busybox:1"}}})
_ = os.WriteFile(filepath.Join(u, "manifest.json"), man, 0o644)
}
return nil, nil
})
done := make(chan struct{}, 1)
bm.SetKeptUnitRestoreFn(func(app, unitDir string) (backup.UnitRestoreResult, error) {
done <- struct{}{}
return backup.UnitRestoreResult{}, nil
})
r := &Router{stackMgr: m, backupMgr: bm, sett: sett, logger: log.New(io.Discard, "", 0)}
// A leftover update hold from an earlier install (the demo-hp shape): the new install drops it.
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "cloudapp", At: "2026-09-13T19:56:32Z", Reason: settings.HoldReasonUpdateFailed})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang=en", nil)
if !r.keptDataAtInstall(w, req, "cloudapp", drive, "use") || w.Code != http.StatusAccepted {
t.Fatalf("use: code %d body %s", w.Code, w.Body.String())
}
if h, held := sett.GetRestoreHold("cloudapp"); held {
t.Fatalf("the earlier install's hold survived the new install: %+v", h)
}
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the load never reached the unit restore")
}
// A restore hold (R-379) is NEVER dropped by an install.
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "cloudapp", At: "2026-09-13T19:56:32Z", Reason: settings.HoldReasonRestoreFailed})
r.dropLeftoverHold("cloudapp", "install")
if _, held := sett.GetRestoreHold("cloudapp"); !held {
t.Fatal("an install dropped an R-379 restore hold")
}
_, _ = sett.ClearRestoreHold("cloudapp")
// The plain install path drops it too — before DeployStack (AST: the call precedes it in deployStack).
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var dropAt, deployAt token.Pos
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "deployStack" || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok {
switch s.Sel.Name {
case "dropLeftoverHold":
dropAt = s.Pos()
case "DeployStack":
deployAt = s.Pos()
}
}
return true
})
}
if dropAt == token.NoPos || deployAt == token.NoPos || dropAt > deployAt {
t.Fatalf("deployStack must call dropLeftoverHold before DeployStack (drop=%v deploy=%v)", dropAt, deployAt)
}
}
@@ -1,6 +1,7 @@
package api
import (
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"go/ast"
"go/parser"
"go/token"
@@ -77,21 +78,38 @@ func TestDeployStackWiresTheLifecycleGate(t *testing.T) {
// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via
// showAlert(), so it must be the sentence the spec ruled, not a Go error string.
//
// v0.252.0 (R-557) moved the sentence into the message bundle, so router.go now names a KEY. The
// ruling is unchanged and still checkable in one step more: router.go must name that key, and the
// key must still carry the ruled sentence, byte for byte. Both halves matter — a key with no handler
// is dead copy, and a handler naming a key whose text was reworded is the drift this test exists to
// catch. scripts/i18n_go_parity.py proves the same equality against the base commit independently.
func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
const key = "api.deploy.not_installable"
const want = "Ez az alkalmazás jelenleg nem telepíthető."
found := false
ast.Inspect(f, func(n ast.Node) bool {
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, want) {
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, key) {
found = true
}
return true
})
if !found {
t.Fatalf("the ruled refusal message %q is not present in router.go", want)
t.Fatalf("router.go no longer names %q -- the ruled refusal has no handler", key)
}
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
if got := b.Msg(i18n.Default, key); got != want {
t.Fatalf("the ruled refusal was reworded\n hu.json %s = %q\n ruled %q", key, got, want)
}
if en := b.Msg("en", key); en == "" || en == want {
t.Errorf("%s has no English of its own: %q", key, en)
}
}
@@ -0,0 +1,89 @@
package api
import (
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-552: an interrupted-restore notice was cleared only by a NEW restore of the same app, so a
// household that answered it by REMOVING the app kept a „Megszakadt visszaállítás" card about an app
// that no longer exists — for ever, because the notice is persisted. The consequence asserted: after
// the removal path's clear, the notice is gone from the page's list AND from the persisted record (a
// fresh Manager loading the file sees none), while another app's notice stays.
// COMPANION RED-PROOF: drop the clearInterruptedRestoreNotice call from removeStack → the AST check
// fails; make ClearInterruptedRestore a no-op → the notice is still listed.
func TestR552_RemoveClearsTheInterruptedRestoreNotice(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.DataDir = dir
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
recPath := filepath.Join(dir, "restore-status.json")
// The record two stops left behind: "otherapp" already carries a notice, "homebox" was running.
seed := map[string]any{
"running": true, "op": "restore", "stack": "homebox",
"interrupted": map[string]any{"otherapp": map[string]any{"op": "restore", "stack": "otherapp", "interrupted": true}},
}
b, _ := json.Marshal(seed)
if err := os.WriteFile(recPath, b, 0o600); err != nil {
t.Fatal(err)
}
load := func() *backup.Manager {
bm := backup.NewManager(cfg, sett, lg)
bm.SetRestoreRecordPath(recPath)
bm.LoadRestoreRecord()
return bm
}
bm := load()
if _, ok := bm.InterruptedRestore("homebox"); !ok {
t.Fatal("setup: no interrupted notice for homebox")
}
r := &Router{backupMgr: bm, logger: lg}
r.clearInterruptedRestoreNotice("homebox")
if _, ok := bm.InterruptedRestore("homebox"); ok {
t.Errorf("R-552: the removed app's interrupted-restore notice is still listed")
}
if _, ok := bm.InterruptedRestore("otherapp"); !ok {
t.Errorf("another app's notice was cleared by this app's removal")
}
again := load()
if _, ok := again.InterruptedRestore("homebox"); ok {
t.Errorf("R-552: the notice comes back after a restart — the clear was not persisted")
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
called := false
for _, d := range f.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil {
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "clearInterruptedRestoreNotice" {
called = true
}
return true
})
}
}
if !called {
t.Errorf("R-552: removeStack does not call clearInterruptedRestoreNotice")
}
}
@@ -0,0 +1,83 @@
package api
import (
"errors"
"net/http"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-553 — the deploy endpoint's status code must survive the day its Hungarian words are translated.
//
// Until v0.251.0 the handler read the refusal's TEXT: „kötelező", „memória", "does not exist",
// "already deployed". Localisation slice 2 translates exactly those words, and every empty required
// field would then answer 500 instead of 400 — a server error for something the customer can fix.
//
// The table below therefore passes messages that are ALREADY translated: the words are gone, only the
// kind remains. RED-PROOF (REPORT): restore the strings.Contains chain in deployStack and this fails
// on every translated row.
func TestR553_Deploy_DecisionSurvivesWordingChange(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"required field, Hungarian as shipped", util.KindErrorf(stacks.ErrRequiredField,
"a(z) %q (%s) mező kitöltése kötelező", "Jelszó", "PASSWORD"), http.StatusBadRequest},
{"required field, TRANSLATED", util.KindErrorf(stacks.ErrRequiredField,
"the %q (%s) field is required", "Password", "PASSWORD"), http.StatusBadRequest},
{"password field, TRANSLATED", util.KindErrorf(stacks.ErrRequiredField,
"fill in the %q field — use the Generate button", "Password"), http.StatusBadRequest},
{"path missing, TRANSLATED", util.KindErrorf(stacks.ErrPathMissing,
"a mappa %q nem létezik (%q mező)", "/mnt/nope", "Adatok"), http.StatusBadRequest},
{"not enough memory, Hungarian as shipped", util.KindError(stacks.ErrNotEnoughMemory,
"Nincs elég memória az alkalmazás telepítéséhez. Szükséges: 512 MB, Elérhető: 100 MB"), http.StatusBadRequest},
{"not enough memory, TRANSLATED", util.KindError(stacks.ErrNotEnoughMemory,
"Not enough memory to install this app. Needed: 512 MB, available: 100 MB"), http.StatusBadRequest},
{"already deployed, TRANSLATED", util.KindErrorf(stacks.ErrAlreadyDeployed,
"a(z) %q alkalmazás már telepítve van", "privatebin"), http.StatusConflict},
{"anything else is a server error", errors.New("compose exploded"), http.StatusInternalServerError},
{"a message that merely CONTAINS the old words is not a refusal kind",
errors.New("docker: kötelező memória does not exist"), http.StatusInternalServerError},
}
for _, c := range cases {
if got := deployStatusFor(c.err); got != c.want {
t.Errorf("%s: deployStatusFor(%q) = %d, want %d", c.name, c.err.Error(), got, c.want)
}
}
}
// The seam has to be WIRED, not merely present: the handler must call deployStatusFor, and no
// decision in the deploy handler may read the error's text again (seam-built-but-never-wired, four
// instances in this repo).
func TestR553_DeployHandlerUsesTheKind(t *testing.T) {
src, err := os.ReadFile("router.go")
if err != nil {
t.Fatal(err)
}
body := string(src)
i := strings.Index(body, "func (r *Router) deployStack(")
if i < 0 {
t.Fatal("deployStack handler not found — this test no longer reads what it thinks it reads")
}
handler := body[i:]
if j := strings.Index(handler[10:], "\nfunc "); j > 0 {
handler = handler[:j+10]
}
if !strings.Contains(handler, "deployStatusFor(err)") {
t.Error("the deploy handler no longer asks deployStatusFor for the status code")
}
// Comments may still NAME those words (this one does); a DECISION may not read them.
for _, line := range strings.Split(handler, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue
}
if strings.Contains(line, "strings.Contains(err.Error()") {
t.Errorf("the deploy handler decides by reading the error text again (R-553): %s", strings.TrimSpace(line))
}
}
}
@@ -0,0 +1,80 @@
package api
import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-569 — the stop/start/restart/update, remove and delete handlers pick their status by the
// refusal's KIND. Every row passes a REWORDED message: the words the handlers used to match
// ("protected", "not found", "not deployed", "still running", "not orphaned") are gone, only the
// kind remains. RED-PROOF (REPORT): restore any strings.Contains chain and its family's reworded rows
// answer 500.
func TestR569_StackOpStatus_SurvivesRewording(t *testing.T) {
cases := []struct {
family, name string
err error
want int
}{
// action family (start/stop/restart/update)
{"action", "unknown app, reworded", util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x"), http.StatusNotFound},
{"action", "unknown app, wrapped by the caller", fmt.Errorf("restarting: %w",
util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x")), http.StatusNotFound},
{"action", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "%q is infrastructure", "traefik"), http.StatusForbidden},
{"action", "update refusal", &stacks.UpdateRefusal{Reason: "busy", Message: "foglalt"}, http.StatusConflict},
{"action", "update refusal: unknown app", &stacks.UpdateRefusal{Reason: "not_found", Message: "nincs ilyen"}, http.StatusNotFound},
// remove family
{"remove", "not deployed, reworded", util.KindErrorf(stacks.ErrNotDeployed, "%q has nothing installed", "x"), http.StatusConflict},
{"remove", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up — stop it first", "x"), http.StatusConflict},
{"remove", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "cannot take %q away", "traefik"), http.StatusForbidden},
// delete family
{"delete", "not orphaned, reworded", util.KindErrorf(stacks.ErrNotOrphaned, "%q still belongs to the catalog", "x"), http.StatusConflict},
{"delete", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up", "x"), http.StatusConflict},
{"delete", "unknown, reworded", util.KindErrorf(stacks.ErrStackNotFound, "nothing named %q", "x"), http.StatusNotFound},
// the negative half: the old WORDS without a kind are not a refusal
{"any", "text that merely contains the old words", errors.New("docker: image not found; protected; still running"), http.StatusInternalServerError},
{"any", "anything else", errors.New("compose exploded"), http.StatusInternalServerError},
}
for _, c := range cases {
if got := stackOpStatusFor(c.err); got != c.want {
t.Errorf("%s / %s: stackOpStatusFor(%q) = %d, want %d", c.family, c.name, c.err.Error(), got, c.want)
}
}
}
// The seam must be WIRED in all three handlers, and none may decide by reading the error text again.
func TestR569_HandlersUseTheKind(t *testing.T) {
src, err := os.ReadFile("router.go")
if err != nil {
t.Fatal(err)
}
body := string(src)
for _, fn := range []string{"func (r *Router) actionStack(", "func (r *Router) removeStack(", "func (r *Router) deleteStack("} {
i := strings.Index(body, fn)
if i < 0 {
t.Fatalf("%s not found — this test no longer reads what it thinks it reads", fn)
}
h := body[i:]
if j := strings.Index(h[10:], "\nfunc "); j > 0 {
h = h[:j+10]
}
if !strings.Contains(h, "stackOpStatusFor(err)") {
t.Errorf("%s no longer asks stackOpStatusFor for the status code", fn)
}
for _, line := range strings.Split(h, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue
}
if strings.Contains(line, "strings.Contains(err.Error()") {
t.Errorf("%s decides by reading the error text again (R-569): %s", fn, strings.TrimSpace(line))
}
}
}
}
@@ -0,0 +1,32 @@
package api
import (
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-575: the deploy answer renders the overcommit warning in the reader's language — English for an
// English request (it used to be Hungarian on every page), Hungarian by default.
func TestR575_OvercommitWarningFollowsTheLanguage(t *testing.T) {
r := &Router{}
en := r.deployWarningData(httptest.NewRequest("POST", "/api/stacks/x/deploy?lang=en", nil), stacks.KeyMemoryOvercommitWarning)
hu := r.deployWarningData(httptest.NewRequest("POST", "/api/stacks/x/deploy?lang=hu", nil), stacks.KeyMemoryOvercommitWarning)
if en == nil || hu == nil {
t.Fatal("a warning key must produce a warning")
}
if !strings.Contains(en["warning"], "memory") || strings.Contains(en["warning"], "memóri") {
t.Errorf("English page got %q", en["warning"])
}
if !strings.Contains(hu["warning"], "memóri") {
t.Errorf("Hungarian page got %q", hu["warning"])
}
if strings.Contains(en["warning"], "err.stacks.") || strings.Contains(hu["warning"], "err.stacks.") {
t.Error("a raw key reached the page")
}
if d := r.deployWarningData(httptest.NewRequest("POST", "/", nil), ""); d != nil {
t.Errorf("no warning -> no data, got %v", d)
}
}
@@ -0,0 +1,89 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-619: GET /api/stacks/<n>/deploy-fields served a `type: password` field as `required:false` (as the
// template declares), while the deploy refuses 400 without it — so a caller that trusted the contract
// was refused (measured on grafana, 2026-09-21). The consequence asserted, on the wire through the real
// handler: the password field arrives `required:true`; a `secret` field (which the box DOES generate)
// keeps its declared `required:false`; and the stack's own metadata is not changed for the next reader.
func TestR619_PasswordFieldIsServedAsRequired(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "grafana")
if err := os.MkdirAll(app, 0o755); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n grafana:\n image: busybox\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(`display_name: Grafana
deploy_fields:
- env_var: GF_SECURITY_ADMIN_PASSWORD
label: Admin jelszo
type: password
generate: "password:16"
required: false
- env_var: GF_SECRET_KEY
label: Titkos kulcs
type: secret
generate: "hex:32"
required: false
`), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
r := &Router{stackMgr: m, cfg: cfg, logger: log.New(io.Discard, "", 0)}
read := func() map[string]bool {
w := httptest.NewRecorder()
r.getDeployFields(w, httptest.NewRequest(http.MethodGet, "/api/stacks/grafana/deploy-fields", nil), "grafana")
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var body struct {
Data struct {
Metadata struct {
DeployFields []struct {
EnvVar string `json:"env_var"`
Required bool `json:"required"`
} `json:"deploy_fields"`
} `json:"metadata"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
out := map[string]bool{}
for _, f := range body.Data.Metadata.DeployFields {
out[f.EnvVar] = f.Required
}
return out
}
got := read()
if !got["GF_SECURITY_ADMIN_PASSWORD"] {
t.Errorf("R-619: the password field reaches the wire as required:false, but the deploy refuses without it")
}
if req, ok := got["GF_SECRET_KEY"]; !ok || req {
t.Errorf("a secret field (the box generates it) must keep its declared required:false; got present=%v required=%v", ok, req)
}
if meta, _, _ := m.GetDeployFields("grafana"); meta.DeployFields[0].Required {
t.Errorf("the derivation leaked into the stack's metadata — it must be applied to the answer only")
}
}
@@ -0,0 +1,50 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
)
// R-642 (v0.263.0): a start or restart is never answered "completed" — measured 2026-09-23 on docmost
// and romm, both 200 "start completed" while `Restarting (1)` behind a 404 front door.
//
// COMPANION RED-PROOF (REPORT.md): put `Message: "Stack " + name + " " + action + " completed"` back in
// startAnswer — the first assertion fails.
func TestR642_StartIsNeverReportedCompleted(t *testing.T) {
for _, action := range []string{"start", "restart"} {
a := startAnswer("docmost", action, "restarting")
if strings.Contains(a.Message, "completed") {
t.Errorf("%s answered %q — a start cannot know it completed", action, a.Message)
}
if !strings.Contains(a.Message, "restarting") {
t.Errorf("%s must say the state it sees, got %q", action, a.Message)
}
if d, _ := a.Data.(map[string]interface{}); d["state"] != "restarting" {
t.Errorf("%s data = %v", action, a.Data)
}
}
}
// TestR642_TheHandlerUsesStartAnswer walks the router's AST: the stack-action handler must CALL
// startAnswer (a comment naming it would not count — the v0.154.0 / R-106 inert-seam class).
func TestR642_TheHandlerUsesStartAnswer(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
calls := 0
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "startAnswer" {
calls++
}
}
return true
})
if calls != 1 {
t.Errorf("startAnswer must be called exactly once from router.go, found %d", calls)
}
}
+12
View File
@@ -0,0 +1,12 @@
package api
import (
"os"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
)
// TestMain puts a silent docker stub on PATH for every test in this package: R-650, the fixtures
// here build a real stacks.Manager, and on the build host (DooPlex) that reached production Docker.
func TestMain(m *testing.M) { os.Exit(dockerexec.RunWithStub(m)) }
@@ -0,0 +1,67 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// v0.269.0 (`09` §3 decision 27, R-666) — while a held app's page says support is informed, the remove
// API allows only "remove the app, keep my data". Through the production wiring (NewManager + ScanStacks,
// the hold read through UpdateGuards as main.go wires it).
type heldGuards struct{ noWhole bool }
func (g heldGuards) HoldFor(string) (bool, string) { return true, "HELD" }
func (g heldGuards) HoldNoWholeCopy(string) bool { return g.noWhole }
func (g heldGuards) Busy(string) (bool, string) { return false, "" }
func (g heldGuards) CanBackUp(string) (bool, string) { return true, "" }
func (g heldGuards) BackupNow(context.Context, string) error { return nil }
func (g heldGuards) SafetyDump(context.Context, string) ([]string, error) { return nil, nil }
func (g heldGuards) RestorePoints(context.Context, string, func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) {
return stacks.UpdateRestorePoint{}, false, nil
}
func (g heldGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
return nil
}
// COMPANION RED-PROOF (REPORT): delete the decision-27 block in removeStack — the data-deleting remove
// then reaches RemoveStack and this test fails at "was not refused".
func TestR666_HeldWithNoWholeCopyRemovesOnlyKeepingData(t *testing.T) {
for _, body := range []string{`{"remove_hdd_data":true,"remove_backups":false}`, `{"remove_hdd_data":false,"remove_backups":true}`} {
r, dir := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true})
code, resp := postRemove(t, r, body)
if code != http.StatusConflict || resp.OK {
t.Fatalf("%s: HTTP %d ok=%v — a data-deleting remove of a stranded app was not refused", body, code, resp.OK)
}
if !strings.Contains(resp.Error, "ügyfélszolgálat") {
t.Fatalf("error = %q, want the support sentence", resp.Error)
}
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil {
t.Fatal("app.yaml gone — something was removed by a refused request")
}
}
// The dialog is told to offer keep-data only …
r, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true})
w := httptest.NewRecorder()
r.getStackHDDData(w, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app")
if !strings.Contains(w.Body.String(), `"keep_data_only":true`) {
t.Fatalf("hdd-data does not tell the dialog: %s", w.Body.String())
}
// … and a hold that NAMES a whole copy keeps the full dialog (positive control).
r2, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
r2.stackMgr.SetUpdateGuards(heldGuards{noWhole: false})
w2 := httptest.NewRecorder()
r2.getStackHDDData(w2, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app")
if strings.Contains(w2.Body.String(), "keep_data_only") {
t.Fatalf("a hold with a whole copy must keep the full dialog: %s", w2.Body.String())
}
}
@@ -0,0 +1,76 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-706 (v0.279.0) — a removal "with its backups" also deletes the app's off-site verification copy, and
// ONLY that app's. COMPANION RED-PROOF: remove the removeVerificationCopy call from removeStack → the AST
// check fails; make the helper skip the delete → the copy is still on disk.
func TestR706_RemovalWithBackupsDeletesTheVerificationCopy(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.DataDir = filepath.Join(dir, "data")
_ = os.MkdirAll(cfg.Paths.DataDir, 0o755)
_ = os.MkdirAll(drive, 0o755)
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
mine, other := bm.OffsiteRestoreScratchPath("cloudapp"), bm.OffsiteRestoreScratchPath("otherapp")
if mine == "" || other == "" {
t.Fatalf("no verification-copy location resolved (%q, %q)", mine, other)
}
for _, p := range []string{mine, other} {
_ = os.MkdirAll(p, 0o755)
_ = os.WriteFile(filepath.Join(p, "restored.bin"), []byte("x"), 0o644)
}
r := &Router{backupMgr: bm, logger: log.New(io.Discard, "", 0)}
got := r.removeVerificationCopy("cloudapp")
if len(got) != 1 || !strings.HasPrefix(got[0], mine) {
t.Fatalf("reported %v, want the copy at %s", got, mine)
}
if _, err := os.Stat(mine); !os.IsNotExist(err) {
t.Fatalf("the verification copy is still on disk: %v", err)
}
if _, err := os.Stat(filepath.Join(other, "restored.bin")); err != nil {
t.Fatalf("another app's verification copy was touched: %v", err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
called := false
for _, d := range f.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil {
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "removeVerificationCopy" {
called = true
}
return true
})
}
}
if !called {
t.Fatal("removeStack does not call removeVerificationCopy (R-706)")
}
}
@@ -0,0 +1,67 @@
package api
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-839: a deploy whose HDD_PATH names a folder INSIDE a registered drive is refused at the API, so
// the boot sweep can never again hold an app for asking a folder whether it is a mountpoint. Through
// the real deployStack handler. PATH is emptied so a regression that lets the deploy proceed can
// never reach a real docker (tests that reach real docker act on DooPlex).
func TestR839_DeployRefusesASubPathOfADrive(t *testing.T) {
t.Setenv("PATH", "")
lg := log.New(io.Discard, "", 0)
root := t.TempDir()
sett, err := settings.Load(filepath.Join(root, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
const drive = "/mnt/felhom-drives/scratch_hdd"
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
if err := os.MkdirAll(cfg.Paths.StacksDir, 0o755); err != nil {
t.Fatal(err)
}
m, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
r := &Router{stackMgr: m, cfg: cfg, sett: sett, logger: lg}
r.classifyFSPath = func(string) string { return "" }
deploy := func(hdd string) (int, string) {
w := httptest.NewRecorder()
body := `{"values":{"HDD_PATH":"` + hdd + `"}}`
r.deployStack(w, httptest.NewRequest(http.MethodPost, "/api/stacks/paperless-ngx/deploy", strings.NewReader(body)), "paperless-ngx")
return w.Code, w.Body.String()
}
code, body := deploy(drive + "/userdata/paperless-ngx")
if code != http.StatusConflict || !strings.Contains(body, "nem egy mappa") || !strings.Contains(body, drive) {
t.Fatalf("R-839: a folder inside a drive must be refused naming the drive; got %d %s", code, body)
}
// Controls: the drive itself and an unregistered path are not this gate's business.
for _, ok := range []string{drive, drive + "/", "/srv/elsewhere"} {
if _, b := deploy(ok); strings.Contains(b, "nem egy mappa") {
t.Errorf("R-839: %q must not be refused as a folder inside a drive: %s", ok, b)
}
}
if d, in := sett.DriveOfSubPath(drive + "x/userdata"); in {
t.Errorf("a sibling path sharing the prefix (%s) is not inside the drive", d)
}
}
@@ -0,0 +1,41 @@
package api
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// R-970 (v0.322.0): the router has no /config routes at all — even a SIGNED-IN request gets the router's 404 for every
// method, and the settings file is neither served nor written.
// RED-PROOF (2026-10-16, REPORT): with the `/config` GET case put back, this failed at „GET /api/config answered 200".
func TestR970_ConfigRoutesAreGone(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "controller.yaml")
planted := "customer:\n id: t\n domain: example.hu\nhub:\n api_key: TESTONLY-key-r970\n"
if err := os.WriteFile(path, []byte(planted), 0o600); err != nil {
t.Fatal(err)
}
r := &Router{configPath: path, logger: log.New(io.Discard, "", 0)}
r.restart = func() { t.Fatal("a /config request restarted the controller") }
for _, p := range []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash"} {
for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: e.example\n")))
if w.Code != http.StatusNotFound {
t.Errorf("%s %s answered %d, want the router's 404", m, p, w.Code)
}
if strings.Contains(w.Body.String(), "TESTONLY-key-r970") {
t.Fatalf("%s %s served the settings file", m, p)
}
}
}
if got, _ := os.ReadFile(path); string(got) != planted {
t.Fatal("the settings file was written")
}
}
+313 -201
View File
@@ -1,15 +1,12 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
@@ -109,8 +106,8 @@ func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, s
func (r *Router) SetRestarter(fn func()) { r.restart = fn }
// refuseNetworkStubDeploy is the deploy-time stub gate (RCA fix 2). Non-empty return = the
// Hungarian refusal for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
// local stub. Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
// refusal KEY for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
// local stub; the caller renders it in the request's language (v0.252.0, R-557). Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
// classification timeout/unknown fails OPEN (a wedged share is the unreachable badge's business);
// local (non-network) and empty paths keep today's behavior exactly.
func (r *Router) refuseNetworkStubDeploy(hdd string) string {
@@ -120,7 +117,7 @@ func (r *Router) refuseNetworkStubDeploy(hdd string) string {
if r.classifyFSPath(hdd) != system.FSClassStub {
return ""
}
return "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek."
return "api.deploy.netstorage_unreachable"
}
// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
@@ -180,17 +177,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/selfupdate/update" && req.Method == http.MethodPost:
r.selfupdateTrigger(w, req)
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
case path == "/config/apply" && req.Method == http.MethodPost:
r.configApply(w, req)
// GET /api/config/hash — return current config file hash
case path == "/config/hash" && req.Method == http.MethodGet:
r.configHash(w, req)
// GET /api/config — return raw controller.yaml content
case path == "/config" && req.Method == http.MethodGet:
r.configContent(w, req)
// v0.322.0 (R-970): /api/config, /api/config/hash and /api/config/apply are GONE. They read the box's
// controller.yaml with every secret in it and replaced it — reachable with the box's own hub key, unsigned,
// which contradicted `01-topology-and-trust.md` §4/§10. Nothing called them (the hub never connects to a box;
// the agent injects config at provision; the signed config bundle writes root files). Pinned by
// TestR970_ConfigRoutesAreGone and cmd/controller TestR970_HubKeyOpensNoDoor.
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
case path == "/selfrestart" && req.Method == http.MethodPost:
@@ -223,19 +214,19 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// POST /api/stacks/{name}/start
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
r.actionStack(w, "start", extractName(path, "/start"))
r.actionStack(w, req, "start", extractName(path, "/start"))
// POST /api/stacks/{name}/stop
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
r.actionStack(w, "stop", extractName(path, "/stop"))
r.actionStack(w, req, "stop", extractName(path, "/stop"))
// POST /api/stacks/{name}/restart
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
r.actionStack(w, "restart", extractName(path, "/restart"))
r.actionStack(w, req, "restart", extractName(path, "/restart"))
// POST /api/stacks/{name}/update
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
r.actionStack(w, "update", extractName(path, "/update"))
r.actionStack(w, req, "update", extractName(path, "/update"))
// POST /api/stacks/{name}/optional-config
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
@@ -358,11 +349,11 @@ func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
}
func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
func (r *Router) rescanStacks(w http.ResponseWriter, req *http.Request) {
r.logger.Printf("[INFO] [api] Manual stack rescan requested")
if err := r.stackMgr.ScanStacks(); err != nil {
r.logger.Printf("[ERROR] [api] Stack rescan failed: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
stackCount := len(r.stackMgr.GetStacks())
@@ -373,22 +364,49 @@ func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
})
}
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
func (r *Router) getStack(w http.ResponseWriter, req *http.Request, name string) {
stack, ok := r.stackMgr.GetStack(name)
if !ok {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
return
}
// v0.264.0 (R-606): the page polls this for the Update button's label and the outcome. Rendered in
// the reader's language; a Hungarian reader gets exactly the bytes it always got. GetStack returns a
// COPY, so nothing here touches the manager's own state.
// v0.265.0 (R-647): EVERY reader, Hungarian included — a Hungarian reader of an English box read
// the hold in English before. On a Hungarian box a Hungarian reader still gets the same bytes.
lang := r.langFor(req)
stack.UpdatePhaseLabel = stacks.UpdatePhaseLabelIn(lang, stack.UpdatePhase)
stack.UpdateError = r.stackMgr.UpdateErrorFor(*stack, lang)
stack.HoldReason = r.stackMgr.HoldReasonFor(*stack, lang)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
}
func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
// markPasswordFieldsRequired (R-619) serves the deploy refusal's real rule on the wire: a
// `type: password` field is MANDATORY however the template's `required` reads, because the deploy
// never generates a password the household has not seen (stacks/deploy.go, the "password" case).
// Served `required:false`, a caller that trusts the contract was refused 400. Derived here, not stored,
// so templates need no edit. meta is the handler's own fresh LoadMetadata copy, so changing it touches
// no shared state. Pinned by TestR619_PasswordFieldIsServedAsRequired.
func markPasswordFieldsRequired(meta *stacks.Metadata) {
if meta == nil {
return
}
for i := range meta.DeployFields {
if meta.DeployFields[i].Type == "password" {
meta.DeployFields[i].Required = true
}
}
}
func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name string) {
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
markPasswordFieldsRequired(meta)
data := map[string]interface{}{
"metadata": meta,
"app_config": appCfg,
@@ -398,13 +416,35 @@ func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name st
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
// deployStatusFor maps a deploy refusal to its HTTP status by the refusal's KIND (R-553).
//
// It used to read the customer's Hungarian sentence — `strings.Contains(err.Error(), "kötelező")`,
// „memória", plus the English "does not exist" / "already deployed". Localisation slice 2 translates
// exactly those words, and on that day every validation refusal would have become a 500: the customer
// sees a server error for a field they simply left empty, and the API contract changes silently.
// The sentences are untouched; the kinds come from internal/stacks (deploy_errors.go).
// Pinned by TestR553_Deploy_DecisionSurvivesWordingChange.
func deployStatusFor(err error) int {
switch {
case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice):
return http.StatusConflict
case errors.Is(err, stacks.ErrProtectedStack):
return http.StatusForbidden
case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing), errors.Is(err, stacks.ErrNeedsDrive),
errors.Is(err, stacks.ErrNotEnoughMemory):
return http.StatusBadRequest
}
return http.StatusInternalServerError
}
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req)
r.logger.Printf("[INFO] [api] Deploy requested for stack: %s", name)
r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)
var body struct {
Values map[string]string `json:"values"`
Values map[string]string `json:"values"`
KeptData string `json:"kept_data"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
@@ -422,7 +462,15 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)",
name, st.Meta.EffectiveLifecycle())
writeJSON(w, http.StatusConflict, apiResponse{OK: false,
Error: "Ez az alkalmazás jelenleg nem telepíthető."})
Error: r.msg(req, "api.deploy.not_installable")})
return
}
// v0.320.0: a protected infra stack is part of the box, never an app to install. Refused here, before the
// kept-data check or anything else that reads the request, and again inside DeployStack for every other caller.
if st, ok := r.stackMgr.GetStack(name); ok && (st.Protected || r.cfg.IsProtectedStack(name)) {
r.logger.Printf("[WARN] [api] Deploy refused for %s: a protected infra stack is part of the box", name)
writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: r.msg(req, "api.deploy.part_of_box")})
return
}
@@ -433,9 +481,8 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve {
r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)",
name, hr.AvailGB, hr.ReserveGB, hr.TotalGB)
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false, Error: fmt.Sprintf(
"Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
hr.AvailGB, hr.ReserveGB)})
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false,
Error: r.msg(req, "api.deploy.no_space", hr.AvailGB, hr.ReserveGB)})
return
}
@@ -443,9 +490,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
// must see a network filesystem (or its healthy idle autofs trigger) in THIS namespace — the one
// the app will consume the path in. A stub (plain local dir after a guest reboot) would silently
// send the app's data to the system drive.
if msg := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); msg != "" {
if key := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); key != "" {
r.logger.Printf("[WARN] [api] Deploy refused for %s: network HDD_PATH %s is a stub in the controller namespace", name, body.Values["HDD_PATH"])
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: msg})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, key)})
return
}
@@ -463,22 +510,38 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
return
}
// R-839: HDD_PATH must name the drive itself, never a folder inside it — see
// settings.DriveOfSubPath. The deploy page only offers drive roots; this guards every other caller.
if drive, inside := r.sett.DriveOfSubPath(body.Values["HDD_PATH"]); inside {
r.logger.Printf("[WARN] [api] Deploy refused for %s: HDD_PATH %s is a folder inside the drive %s, not the drive (R-839)", name, body.Values["HDD_PATH"], drive)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.deploy.hdd_path_inside_drive", drive)})
return
}
// `09` §3 decision 36: the app's drive folder already holds its old data → the household chooses.
if handled := r.keptDataAtInstall(w, req, name, body.Values["HDD_PATH"], body.KeptData); handled {
return
}
// R-704 (v0.278.0): a fresh install drops a hold an EARLIER install left under this name.
r.dropLeftoverHold(name, "install")
deployReq := stacks.DeployRequest{
StackName: name,
Values: body.Values,
KeptData: body.KeptData,
}
warning, err := r.stackMgr.DeployStack(deployReq)
if err != nil {
r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "already deployed") {
status = http.StatusConflict
// R-553 — the status code comes from the refusal's KIND (deployStatusFor), never from its words.
resp := apiResponse{OK: false, Error: r.errText(req, err)}
if errors.Is(err, stacks.ErrNeedsDrive) {
// D4 (`16` §5): the wizard renders the sentence with a link to the drives page.
resp.Data = map[string]string{"code": "needs_drive", "link": "/storage"}
}
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") {
status = http.StatusBadRequest
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, deployStatusFor(err), resp)
return
}
@@ -486,9 +549,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
// polls GET /api/stacks/{name}). The old "Stack X deployed" message asserted completion before it
// was true — misleading for API/script consumers. Report that the deploy STARTED, not that it
// finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
resp := apiResponse{OK: true, Message: "Telepítés elindítva – az állapot a kártyán követhető"}
if warning != "" {
resp.Data = map[string]string{"warning": warning}
resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")}
if d := r.deployWarningData(req, warning); d != nil {
resp.Data = d
}
writeJSON(w, http.StatusAccepted, resp)
@@ -566,7 +629,37 @@ func desiredStateForAction(action string) (string, bool) {
}
}
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
// stackOpStatusFor maps a stack-lifecycle refusal (stop/start/restart/update, remove, delete) to its
// HTTP status by the refusal's KIND (R-569) — the R-553 rule one handler family over.
//
// Until R-569 these three handlers matched "protected", "not found", "not deployed", "still running"
// and "not orphaned" in err.Error(). Those strings are internal English, so localisation never moved
// them; a reworded internal message would have, silently turning a 404/403/409 into a 500. The kinds
// come from internal/stacks (stack_errors.go); the messages are unchanged.
//
// One function serves the three families because their kinds are disjoint: an action never returns
// ErrNotOrphaned, a remove never returns an UpdateRefusal. Pinned by
// TestR569_StackOpStatus_SurvivesRewording and TestR569_HandlersUseTheKind.
func stackOpStatusFor(err error) int {
var ref *stacks.UpdateRefusal
switch {
case errors.Is(err, stacks.ErrStackNotFound):
return http.StatusNotFound
case errors.As(err, &ref):
if ref.Reason == "not_found" {
return http.StatusNotFound
}
return http.StatusConflict
case errors.Is(err, stacks.ErrProtectedStack):
return http.StatusForbidden
case errors.Is(err, stacks.ErrNotDeployed), errors.Is(err, stacks.ErrStillRunning),
errors.Is(err, stacks.ErrNotOrphaned):
return http.StatusConflict
}
return http.StatusInternalServerError
}
func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, name string) {
r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
r.dbg("actionStack: action=%s name=%s", action, name)
@@ -586,9 +679,23 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
// R-439 (slice 4): `update` is in this list. It was not until v0.237.0, so a held app could be
// updated — the one action most likely to make a held app's data worse. Pinned by
// TestR439_UpdateOfAHeldAppIsRefused.
// v0.269.0 (`09` §3 decision 28): an app the BOX stopped for a crash loop / OOM storm is started
// again by the household's Start — the hold is lifted and the app gets one more try. Restart and
// Update stay refused while it holds; any other hold kind is untouched.
if action == "start" && r.backupMgr != nil && r.backupMgr.HoldKind(name) == settings.HoldReasonUnhealthyStop {
if r.backupMgr.LiftUnhealthyStop(name) {
r.logger.Printf("[INFO] [api] %s: the unhealthy stop is LIFTED by Start — one more try (decision 28)", name)
}
}
if action == "start" || action == "restart" || action == "update" {
if held, why := r.restoreHoldFor(name); held {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why})
// `reason` (v0.261.0) — THIS LINE FIRES BEFORE UpdatePreflight, so without it a held app
// answers 409 with no machine-readable reason at all, and an unattended caller cannot
// tell it from a passing backup window: it would press a terminally-refused button for
// ever. Found while writing the reason test, not by reading. `held` is TERMINAL until a
// person acts, and it is the one reason that matters most to get right.
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why,
Data: map[string]string{"reason": "held"}})
return
}
}
@@ -597,7 +704,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
if gated, hdd := r.startGatedByMissingDrive(name); gated {
writeJSON(w, http.StatusConflict, apiResponse{
OK: false,
Error: fmt.Sprintf("A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.", hdd),
Error: r.msg(req, "api.start.drive_missing", hdd),
})
return
}
@@ -617,7 +724,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
if afterMB > usableMB {
writeJSON(w, http.StatusConflict, apiResponse{
OK: false,
Error: fmt.Sprintf("Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)", stackMemMB, usableMB-usedMB, usedMB, usableMB),
Error: r.msg(req, "api.start.not_enough_memory", stackMemMB, usableMB-usedMB, usedMB, usableMB),
})
return
}
@@ -634,7 +741,21 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
if ref.Reason == "not_found" {
status = http.StatusNotFound
}
writeJSON(w, status, apiResponse{OK: false, Error: ref.Message})
// errText, not ref.Message (v0.260.0): a refusal built with refuseUpdateErr carries its
// bundle key, so the household reads the refusal in its own language. A refusal still
// built from a Hungarian literal has no key and errText returns ref.Message byte for
// byte — which is why this line is safe to change for all of them at once, and why the
// R-524 downgrade refusal below is not a key nobody reads (the "seam built but never
// wired" class).
//
// `data.reason` (v0.261.0) is ADDITIVE and is for a caller that is not a person. The
// sentence says WHAT happened; only the reason says whether trying again can ever work —
// `busy`/`updating`/`deploying`/`migrating`/`self_updating` are TRANSIENT, `held` and
// `downgrade` are TERMINAL until a human acts. An unattended caller that cannot tell
// those apart either gives up on a passing backup window or presses a refused button for
// ever. The sentence is unchanged, so no page moves.
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, ref),
Data: map[string]string{"reason": ref.Reason}})
return
}
}
@@ -653,7 +774,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
r.logger.Printf("[ERROR] [api] %s for %s refused: could not record desired state: %v", action, name, derr)
writeJSON(w, http.StatusInternalServerError, apiResponse{
OK: false,
Error: "A művelet nem hajtható végre: az alkalmazás beállításai nem menthetők.",
Error: r.msg(req, "api.action.state_not_saved"),
})
return
}
@@ -675,18 +796,8 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
if err != nil {
r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err)
status := http.StatusInternalServerError
var ref *stacks.UpdateRefusal
if errors.As(err, &ref) {
status = http.StatusConflict
}
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -695,11 +806,25 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
// finished — and "completed" exists only as update_phase=done on GET /api/stacks/{name}, which is
// written after the app's health is known. Pinned by TestR443_UpdateIsNeverReportedCompleteSynchronously.
if action == "update" {
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: "Frissítés elindult – az állapot a kártyán követhető",
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msg(req, "api.update.started"),
Data: map[string]interface{}{"accepted": true, "completed": false}})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
// R-642 (v0.263.0): a start or restart is NEVER reported "completed". `compose up -d` returns 0
// over an app that is about to crash-loop — measured 2026-09-23 on docmost and romm, each answering
// 200 "start completed" while `Restarting (1)` behind a 404 front door. What this knows at return is
// the state the containers are in right now, so that is what it says; whether the APP works is
// the health probe's to answer, not this line's. Pinned by TestR642_StartIsNeverReportedCompleted.
if action == "start" || action == "restart" {
state := ""
_ = r.stackMgr.RefreshStatus()
if st, ok := r.stackMgr.GetStack(name); ok {
state = string(st.State)
}
writeJSON(w, http.StatusOK, startAnswer(name, action, state))
} else {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
}
// Trigger integration lifecycle hooks after successful action
if r.integrationMgr != nil {
@@ -726,11 +851,11 @@ func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request,
if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
r.logger.Printf("[ERROR] [api] Optional config update failed for %s: %v", name, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.settings.updated")})
}
// --- Integration API handlers ---
@@ -771,13 +896,13 @@ func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, pro
state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled)
if err != nil {
r.logger.Printf("[ERROR] [api] Integration toggle failed for %s:%s: %v", provider, target, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
msg := "Integráció engedélyezve"
msg := r.msg(req, "api.integration.enabled")
if !body.Enabled {
msg = "Integráció kikapcsolva"
msg = r.msg(req, "api.integration.disabled")
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
}
@@ -795,22 +920,23 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str
output, err := r.stackMgr.GetLogs(name, lines)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
}
func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) {
func (r *Router) getStackHDDData(w http.ResponseWriter, req *http.Request, name string) {
resp, err := r.stackMgr.GetStackHDDData(name)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
resp.KeepDataOnly = r.stackMgr.RemoveKeepsDataOnly(name) // decision 27: the dialog offers keep-data only
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
}
func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) {
func (r *Router) getStackBackupData(w http.ResponseWriter, req *http.Request, name string) {
if name == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
return
@@ -828,12 +954,68 @@ func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name
resp, err := r.stackMgr.GetStackBackupData(name, nsRoot, mirrors)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
}
// dropLeftoverHold (R-704, v0.278.0) clears the update or crash-loop hold of an app that is NOT installed,
// before a new install of it begins (a plain install, or „use my kept data"). Such a hold can only have
// been left by an earlier install: before v0.242.0 a removal kept update holds, and before v0.278.0 it
// kept crash-loop stops — measured 2026-09-28: a nextcloud update hold from 2026-09-13 on demo-hp made
// the backup skip the FRESH install's volumes and unit, so its off-site copy held no data; on 9202 a
// calcom crash-loop stop refused the fresh install's update. A restore hold (R-379) is never touched.
// Pinned by TestR704_AFreshInstallDropsALeftoverHold.
func (r *Router) dropLeftoverHold(name, why string) {
if r.sett == nil {
return
}
if st, ok := r.stackMgr.GetStack(name); ok && (st.Deployed || st.Deploying) {
return
}
if cleared, err := r.sett.ClearUpdateHold(name); err != nil {
r.logger.Printf("[WARN] [api] %s %s: the hold an earlier install left could not be cleared: %v", why, name, err)
} else if cleared {
r.logger.Printf("[INFO] [api] %s %s: a hold left by an EARLIER install of this app is dropped (R-704)", why, name)
}
}
// clearInterruptedRestoreNotice (R-552) drops the removed app's „Megszakadt visszaállítás" notice. The
// notice tells the household to run the restore again; once the app is removed that advice has no
// subject, and nothing else would ever clear it. Pinned by TestR552_RemoveClearsTheInterruptedRestoreNotice.
func (r *Router) clearInterruptedRestoreNotice(name string) {
if r.backupMgr == nil {
return
}
if r.backupMgr.ClearInterruptedRestore(name) {
r.logger.Printf("[INFO] [api] remove %s: its interrupted-restore notice is cleared with it (R-552)", name)
}
}
// removeVerificationCopy (R-706, v0.279.0) deletes the app's off-site VERIFICATION copy
// (`backups/offsite-restore/<app>`, left by a full off-site restore for the household to inspect) when the
// app is removed "with its backups". Measured 2026-09-28 on demo-hp: ~1 GB stayed after such a removal, and
// the app list no longer showed anything it belonged to. Returns the removed path as the removal reports it.
// Pinned by TestR706_RemovalWithBackupsDeletesTheVerificationCopy.
func (r *Router) removeVerificationCopy(name string) []string {
if r.backupMgr == nil {
return nil
}
var out []string
for _, c := range r.backupMgr.ListOffsiteRestoreCopies() {
if c.Stack != name {
continue
}
if err := r.backupMgr.DeleteOffsiteRestoreCopy(name); err != nil {
r.logger.Printf("[WARN] [api] remove %s: its off-site verification copy %s could not be deleted: %v", name, c.Path, err)
continue
}
out = append(out, fmt.Sprintf("%s (%s)", c.Path, c.SizeHuman))
}
return out
}
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
if name == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
@@ -853,6 +1035,14 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
}
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
// v0.269.0 (`09` §3 decision 27, R-666): while a held app's page says support is informed, only
// "remove the app, keep my data" is allowed. Refused BEFORE anything is removed (live-probes rule).
if (body.RemoveHDDData || body.RemoveBackups) && r.stackMgr.RemoveKeepsDataOnly(name) {
r.logger.Printf("[WARN] [api] Remove of %s with data REFUSED: the app is held with no whole copy (support is informed) — only keep-data is allowed (decision 27)", name)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, stacks.ErrRemoveKeepDataWhileSupport)})
return
}
// Compute backup paths to remove if requested. Disk-tier (cross-drive rsync)
// backup has moved to the host agent; only the app-data DB-dump path is removed here.
//
@@ -879,23 +1069,26 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
return
}
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
// R-633: "the backup side owns this app right now" is a CONFLICT, not a server error. It is
// matched by TYPE rather than by prose — the status mapping below greps the error text, and
// the busy sentence contains none of the words it looks for, so the first live run of this
// guard answered 500 while doing exactly the right thing.
var busy *stacks.RemoveBusyError
if errors.As(err, &busy) {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
status = http.StatusConflict
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...)
}
if body.RemoveBackups {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.removeVerificationCopy(name)...)
}
// R-486 (v0.240.0): the app's backup preferences — and with them the Tier-2 RECORD that
// tier2RecordedCopyDir needs — are forgotten ONLY when the customer asked for the backups to be
@@ -919,6 +1112,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
r.logger.Printf("[INFO] [api] remove %s: its update hold is cleared with it (R-491)", name)
}
}
r.clearInterruptedRestoreNotice(name)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
@@ -960,17 +1154,8 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
return
}
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
status = http.StatusConflict
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -992,9 +1177,9 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
}
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
// R-490 / R-465: the same default-storage-path fallback every other reader of the always-empty
// cfg.Paths.HDDPath has; without it hdd_configured was false on every box.
hddPath := r.cfg.Paths.HDDPath
// R-490 / R-465 / R-492: the default storage path is the only source (the always-empty global
// paths.hdd_path was deleted in R-492); without it hdd_configured was false on every box.
hddPath := ""
if r.sett != nil {
if p := r.sett.GetDefaultStoragePath(); p != "" {
hddPath = p
@@ -1104,7 +1289,7 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
// moved to the host agent (slice 8C).
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
func (r *Router) triggerBackup(w http.ResponseWriter, req *http.Request) {
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
@@ -1113,7 +1298,7 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
if r.backupMgr.IsRunning() {
r.dbg("triggerBackup: backup already running, rejecting")
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
return
}
@@ -1121,23 +1306,23 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
r.backupMgr.MarkManualRun() // R-182: operator-triggered — its digest must not be collapsed into the nightly one
go r.backupMgr.RunDBDumps(context.Background())
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.started")})
}
// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a
// different physical disk). Auto-targets and applies the rootfs-headroom guard internally.
func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) {
func (r *Router) triggerTier2(w http.ResponseWriter, req *http.Request) {
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
return
}
if r.backupMgr.IsRunning() {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
return
}
r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered")
go r.backupMgr.RunAllTier2()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.tier2_started")})
}
// --- Metrics handlers ---
@@ -1154,7 +1339,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
if err != nil {
r.logger.Printf("[ERROR] [api] Failed to query system metrics: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -1182,7 +1367,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
}})
}
func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) {
func (r *Router) metricsContainerSummary(w http.ResponseWriter, req *http.Request) {
if r.metricsStore == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
return
@@ -1191,7 +1376,7 @@ func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request)
summary, err := r.metricsStore.QueryContainerSummary()
if err != nil {
r.logger.Printf("[ERROR] [api] Failed to query container summary: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -1210,7 +1395,7 @@ func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name
samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
if err != nil {
r.logger.Printf("[ERROR] [api] Failed to query container metrics for %s: %v", name, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -1324,108 +1509,17 @@ func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
}
func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) {
if r.updater == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
return
}
if err := r.updater.TriggerUpdate("manual"); err != nil {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
r.logger.Println("[INFO] [api] Manual self-update triggered")
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
}
// --- Config apply handler ---
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
// Read YAML body (limit to 1MB)
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
return
}
if len(body) == 0 {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
return
}
// Validate it's parseable YAML by attempting to load it
if _, err := config.LoadFromBytes(body); err != nil {
r.logger.Printf("[WARN] [api] Config apply rejected: invalid YAML: %v", err)
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
return
}
// No-op guard: if the pushed config is byte-identical to what is already on disk, do
// nothing — don't rewrite, don't restart. The hub may re-push the same config
// idempotently, and a self-restart on every push would be a needless flap.
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."})
return
}
// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
if err := writeConfig0600(r.configPath, body); err != nil {
r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
return
}
// Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
// effect on restart — singletons such as the Cloudflare client are built once at
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
flushResponse(w)
if r.restart != nil {
r.restart()
}
}
// writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename),
// falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS
// enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml
// holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file.
func writeConfig0600(path string, body []byte) error {
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, body, 0600); err != nil {
return fmt.Errorf("writing temp config: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback
return fmt.Errorf("writing config: %w", err)
}
}
if err := os.Chmod(path, 0600); err != nil {
return fmt.Errorf("chmod config 0600: %w", err)
}
return nil
}
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
hash, err := config.FileHash(r.configPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
}
func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
data, err := os.ReadFile(r.configPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
return
}
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
w.Write(data)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.update.kicked_off")})
}
// --- Asset sync handlers ---
@@ -1464,3 +1558,21 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
func limitBody(w http.ResponseWriter, req *http.Request) {
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
}
// startAnswer is R-642's answer for a start or restart: what was requested and the state the
// containers are in right now — never "completed", which nothing at this point can know.
func startAnswer(name, action, state string) apiResponse {
return apiResponse{OK: true,
Message: "Stack " + name + " " + action + " requested — state now: " + state,
Data: map[string]interface{}{"state": state}}
}
// deployWarningData renders the deploy's soft warning for the answer. R-575: DeployStack hands back the
// warning's bundle KEY, never a sentence, so it is rendered here in the reader's language. nil = none.
// Pinned by TestR575_OvercommitWarningFollowsTheLanguage.
func (r *Router) deployWarningData(req *http.Request, warningKey string) map[string]string {
if warningKey == "" {
return nil
}
return map[string]string{"warning": r.msg(req, warningKey)}
}
@@ -1,7 +1,6 @@
package api
import (
"bytes"
"io"
"log"
"net/http"
@@ -28,61 +27,6 @@ func newRestartTestRouter(t *testing.T, prior []byte) (*Router, *int, string) {
return r, &calls, path
}
// a minimal config that passes config.LoadFromBytes (customer.id + customer.domain required).
var validConfig = []byte("customer:\n id: test\n domain: test.example\n")
func TestConfigApply_ChangedConfig_Restarts(t *testing.T) {
prior := []byte("customer:\n id: old\n domain: old.example\n")
r, calls, path := newRestartTestRouter(t, prior)
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
rec := httptest.NewRecorder()
r.configApply(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if *calls != 1 {
t.Fatalf("restart called %d times, want 1 (config changed)", *calls)
}
got, _ := os.ReadFile(path)
if !bytes.Equal(got, validConfig) {
t.Fatalf("config not written: got %q", got)
}
}
// COMPANION: an identical re-push must NOT restart (the old handler always restarted /
// always ran its post-apply hook regardless of whether anything changed).
func TestConfigApply_IdenticalConfig_NoRestart(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, validConfig) // prior == body
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
rec := httptest.NewRecorder()
r.configApply(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if *calls != 0 {
t.Fatalf("restart called %d times, want 0 (config byte-identical)", *calls)
}
}
func TestConfigApply_InvalidYAML_NoRestart(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, validConfig)
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader([]byte("customer:\n id: only-id-no-domain\n")))
rec := httptest.NewRecorder()
r.configApply(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (missing customer.domain)", rec.Code)
}
if *calls != 0 {
t.Fatalf("restart called %d times, want 0 (validation failed)", *calls)
}
}
func TestSelfRestart_InvokesRestarter(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, nil)
@@ -49,12 +49,14 @@ func (g *apiFakeGuards) RestorePoints(_ context.Context, _ string, accept func(s
}
return stacks.UpdateRestorePoint{}, false, g.points
}
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
func (g *apiFakeGuards) BackupNow(context.Context, string) error { return nil }
func (g *apiFakeGuards) SafetyDump(context.Context, string) ([]string, error) {
return nil, nil
}
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint) error { return nil }
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
return nil
}
const slice4AppYAML = "deployed: true\nenv: {}\npinned_images:\n app: nginx:1.27\n"
@@ -97,7 +99,7 @@ func newSlice4Router(t *testing.T) (*Router, *settings.Settings, *apiFakeGuards,
func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
t.Helper()
w := httptest.NewRecorder()
r.actionStack(w, "update", "app")
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", "app")
var resp apiResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
@@ -113,7 +115,7 @@ func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
func TestR439_UpdateOfAHeldAppIsRefused(t *testing.T) {
r, sett, g, dir := newSlice4Router(t)
g.points, g.cannotBackUp = nil, true // the preflight's own refusal would say "no backup" — not the hold
g.blindToHolds = true // only the router's line can produce the hold's sentence
g.blindToHolds = true // only the router's line can produce the hold's sentence
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "app", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-13T01:30:00Z"}); err != nil {
t.Fatal(err)
}
@@ -0,0 +1,199 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-609 (v0.261.0) — the 409 carries a machine-readable REASON, additively.
//
// WHY. `UpdateRefusal.Reason` has existed since v0.237.0 and never left the process: the body carried
// only the translated sentence. A caller that is not a person cannot parse a Hungarian sentence, and
// the distinction it needs is not decorative:
//
// TRANSIENT — busy, updating, deploying, migrating, self_updating → try again later
// TERMINAL — held, downgrade → never press this again
//
// An unattended caller that cannot tell them apart either gives up on a passing backup window, or
// presses a terminally-refused button on every pass for ever. `09` §6.2's caller reads exactly this.
//
// The SENTENCE is unchanged and stays where it was, so no page moves — this is purely additive.
// reasonOf pulls `data.reason` out of a refusal body, or "" when absent.
func reasonOf(t *testing.T, resp apiResponse) string {
t.Helper()
if resp.Data == nil {
return ""
}
m, ok := resp.Data.(map[string]interface{})
if !ok {
t.Fatalf("data is not an object: %#v", resp.Data)
}
s, _ := m["reason"].(string)
return s
}
// TestR609_EveryRefusalCarriesItsReason is table-driven over the refusal paths reachable without
// docker, and it INCLUDES the router's own hold line — which fires before UpdatePreflight and was
// the one that had no reason at all until this test was written.
//
// COMPANION RED-PROOF (run 2026-09-21): drop the `Data:` field from either writeJSON in actionStack's
// update paths. The corresponding row fails with `reason = ""`. Reverted.
func TestR609_EveryRefusalCarriesItsReason(t *testing.T) {
cases := []struct {
name string
arrange func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string)
wantReason string
wantCode int
}{
{
name: "held — the ROUTER's own line, before the preflight",
arrange: func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string) {
if err := sett.SetRestoreHold(settings.RestoreHold{
Stack: "app", At: "2026-09-21T08:00:00Z",
Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-21T01:30:00Z",
}); err != nil {
t.Fatal(err)
}
},
wantReason: "held", wantCode: http.StatusConflict,
},
{
name: "no_backup — nothing on any tier and none can be taken",
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, g *apiFakeGuards, _ string) {
g.points, g.cannotBackUp = nil, true
},
wantReason: "no_backup", wantCode: http.StatusConflict,
},
{
name: "self_updating — the controller is swapping itself (v0.261.0)",
arrange: func(_ *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {
r.stackMgr.SetSelfUpdatingCheck(func() bool { return true })
},
wantReason: "self_updating", wantCode: http.StatusConflict,
},
{
name: "downgrade — the box runs something newer than the catalog (R-524)",
arrange: func(t *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, dir string) {
setInstalledAndCatalog(t, r, dir, "nginx:1.28", "nginx:1.27")
},
wantReason: "downgrade", wantCode: http.StatusConflict,
},
{
name: "not_found — an app that exists nowhere",
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {},
wantReason: "not_found", wantCode: http.StatusNotFound,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r, sett, g, dir := newSlice4Router(t)
c.arrange(t, r, sett, g, dir)
name := "app"
if c.wantReason == "not_found" {
name = "no-such-app"
}
code, resp := postUpdateNamed(t, r, name)
if code != c.wantCode {
t.Errorf("code = %d, want %d (error %q)", code, c.wantCode, resp.Error)
}
if resp.OK {
t.Fatal("a refusal must not answer ok")
}
if got := reasonOf(t, resp); got != c.wantReason {
t.Errorf("reason = %q, want %q", got, c.wantReason)
}
// The sentence is the household's and must still be there, in words — never a bare key.
if resp.Error == "" {
t.Error("the reason is ADDITIVE: the sentence must still be present")
}
})
}
}
// TestR609_ASuccessfulActionCarriesNoReason — the field is for refusals only, so a caller cannot
// mistake a normal answer for one.
func TestR609_ASuccessfulActionCarriesNoReason(t *testing.T) {
r, _, _, _ := newSlice4Router(t)
_, resp := postStopNamed(t, r, "app")
if got := reasonOf(t, resp); got != "" {
t.Errorf("a non-refusal must carry no reason, got %q", got)
}
}
// ── helpers ─────────────────────────────────────────────────────────────────────────────────────
// postUpdateNamed is postUpdate for an arbitrary app name (the not_found row needs one that is absent).
func postUpdateNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
t.Helper()
w := httptest.NewRecorder()
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", name)
var resp apiResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
}
return w.Code, resp
}
// postStopNamed exercises a NON-update action, as the control that `reason` is refusal-only.
func postStopNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
t.Helper()
w := httptest.NewRecorder()
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "stop", name)
var resp apiResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
}
return w.Code, resp
}
// setInstalledAndCatalog puts the app AHEAD of the catalog, which is R-524's Ahead arm.
//
// It writes the REAL files the real code reads — `installed_images` into the app's app.yaml and a
// catalog template under <DataDir>/catalog-cache/templates/<app>/ — and then rescans. **No test-only
// seam is added to production code for this**: the whole point of the api tests here is that they go
// through the production handler over a real stacks.Manager, and a setter that only tests call would
// be a second way to reach the state, which is how the two drift apart.
func setInstalledAndCatalog(t *testing.T, r *Router, dir, installed, catalog string) {
t.Helper()
appYAML := slice4AppYAML + "installed_images:\n app:\n ref: " + installed +
"\n digest: sha256:x\n at: \"2026-09-01T00:00:00Z\"\n"
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil {
t.Fatal(err)
}
catDir := filepath.Join(r.cfg.Paths.DataDir, "catalog-cache", "templates", "app")
if err := os.MkdirAll(catDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(catDir, "docker-compose.yml"),
[]byte("services:\n app:\n image: "+catalog+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := r.stackMgr.ScanStacks(); err != nil {
t.Fatal(err)
}
// Positive control: the state this helper claims to create must actually exist, or the row it
// serves would pass for the wrong reason (a different refusal firing first).
if got := stacks.CatalogOrder(mustStack(t, r, "app")); got != stacks.UpdateOrderAhead {
t.Fatalf("fixture did not produce the AHEAD state; CatalogOrder = %v", got)
}
}
func mustStack(t *testing.T, r *Router, name string) stacks.Stack {
t.Helper()
st, ok := r.stackMgr.GetStack(name)
if !ok {
t.Fatalf("stack %q missing", name)
}
return *st
}
+15
View File
@@ -83,6 +83,21 @@ type RecoveryInfo struct {
// 0600 app.yaml.
PortableSecretEnvVars []string
PortableSecrets map[string]string
// InstalledImages (v0.275.0, R-696) is what each compose service is RUNNING — app.yaml's
// `installed_images`, as `ref@digest` (the plain ref when no digest was recorded). Stamped beside each
// data file the backup writes, so the data says which versions wrote it. Nil = unknown, never current.
InstalledImages map[string]string
// SetupGate (v0.314.0, R-1057) is the app's setup gate AT THIS BACKUP: „closed" (its first setup was not done) or
// „open" (and by whom). Nil for an app whose template has no gate. The restore applies it, so a backup made before
// the household's first setup never comes back open (`09` §3 decision 253).
SetupGate *GateState
}
// GateState is the setup gate as a backup records it (the unit's app.yaml `setup_gate:`). The same two keys as the
// live app.yaml's record, so a restore reads either file the same way (stacks.ReadGateAtBackup).
type GateState struct {
State string `yaml:"state"`
OpenedBy string `yaml:"opened_by,omitempty"`
}
// ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a
+98 -11
View File
@@ -4,7 +4,9 @@ import (
"bufio"
"compress/gzip"
"context"
"errors"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"io"
"log"
"os"
@@ -136,7 +138,7 @@ func DiscoverDatabases(ctx context.Context, logger *log.Logger, debug bool, know
// whole `docker ps` output, so a trailing empty field on the LAST line would be eaten and that
// row would arrive one column short. Image is never empty, so ending on it keeps every row the
// same width.
cmd := exec.CommandContext(ctx, "docker", "ps", "--format",
cmd := dockerexec.CommandContext(ctx, "docker", "ps", "--format",
"{{.ID}}\t{{.Names}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Image}}", "--filter", "status=running")
out, err := cmd.Output()
if err != nil {
@@ -226,6 +228,17 @@ func DumpAll(ctx context.Context, dbs []DiscoveredDB, dumpDir string, logger *lo
return results
}
// syncDumpDir fsyncs a directory so a rename inside it survives a power cut (R-10). A variable so a
// test can observe the call; production never replaces it.
var syncDumpDir = func(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
// CanonicalDumpName is the app's own dump filename for one database: `<stack>-<dbtype>.sql`. It is
// the name the replay loop matches EXACTLY, which is why nothing else may ever be written to it.
func CanonicalDumpName(db DiscoveredDB) string {
@@ -279,7 +292,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
defer cancel()
// Verify container is still running
checkCmd := exec.CommandContext(dumpCtx, "docker", "inspect", "--format", "{{.State.Running}}", db.ContainerID)
checkCmd := dockerexec.CommandContext(dumpCtx, "docker", "inspect", "--format", "{{.State.Running}}", db.ContainerID)
checkOut, err := checkCmd.Output()
if err != nil || strings.TrimSpace(string(checkOut)) != "true" {
result.Error = fmt.Errorf("container %s no longer running", db.ContainerName)
@@ -294,7 +307,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
var cmd *exec.Cmd
switch db.DBType {
case DBTypePostgres:
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
"pg_dump", "-U", db.DBUser, "-d", db.DBName,
"--clean", "--if-exists", "--no-owner", "--no-privileges")
if debug {
@@ -312,7 +325,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
}
return result
}
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
"mariadb-dump", "-u", "root", "-p***",
"--single-transaction", "--routines", "--triggers", db.DBName)
if debug {
@@ -320,7 +333,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
db.ContainerID[:12], db.DBName)
}
// Actual command with real password (not logged)
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
"mariadb-dump", "-u", "root", "-p"+password,
"--single-transaction", "--routines", "--triggers", db.DBName)
default:
@@ -391,6 +404,14 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
result.Duration = time.Since(start)
return result
}
// R-10 (T-6E-1): the rename is durable only once the DIRECTORY entry is on disk. Without this a
// power cut right after the rename can leave the old dump (or none) under the final name, even
// though the data itself was fsynced above. Best-effort, like atomicPromoteTar's dir.Sync() in
// internal/backup/backup.go — the dump is already complete, so a failed dir sync is logged, not
// fatal. Pinned by TestDumpOneTo_SyncsTheDumpDirectoryAfterRename.
if err := syncDumpDir(dumpDir); err != nil {
logger.Printf("[DEBUG] DumpOne: syncing dump directory %s after rename failed (best-effort): %v", dumpDir, err)
}
result.FilePath = finalPath
result.Size = stat.Size()
@@ -671,7 +692,7 @@ func ListDumpFiles(dumpDir string, cached func(name string, size int64, mod time
}
func populateDBEnv(ctx context.Context, db *DiscoveredDB) error {
cmd := exec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
cmd := dockerexec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
out, err := cmd.Output()
if err != nil {
@@ -759,14 +780,14 @@ func ImportDump(ctx context.Context, db DiscoveredDB, dumpPath string, logger *l
// MariaDB's DDL is not transactional, so a partial apply there is unavoidable at the engine
// and is why the rollback in offbox_reconstitute.go exists and is the actual fix. Do not
// read this flag as making the rollback optional.
cmd = exec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
cmd = dockerexec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
"psql", "-v", "ON_ERROR_STOP=1", "--single-transaction", "-U", user, "-d", dbName)
case DBTypeMariaDB:
password := getMariaDBPassword(impCtx, db.ContainerID)
if password == "" {
return fmt.Errorf("could not determine MariaDB root password for %s", db.ContainerName)
}
cmd = exec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
cmd = dockerexec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
"mariadb", "-u", "root", "-p"+password, db.DBName)
default:
return fmt.Errorf("unsupported DB type: %s", db.DBType)
@@ -813,10 +834,10 @@ func waitDBReady(ctx context.Context, db DiscoveredDB, timeout time.Duration) er
if user == "" {
user = "postgres"
}
cmd = exec.CommandContext(c, "docker", "exec", db.ContainerID, "pg_isready", "-U", user)
cmd = dockerexec.CommandContext(c, "docker", "exec", db.ContainerID, "pg_isready", "-U", user)
case DBTypeMariaDB:
pw := getMariaDBPassword(c, db.ContainerID)
cmd = exec.CommandContext(c, "docker", "exec", db.ContainerID, "mariadb-admin", "ping", "-u", "root", "-p"+pw)
cmd = dockerexec.CommandContext(c, "docker", "exec", db.ContainerID, "mariadb-admin", "ping", "-u", "root", "-p"+pw)
default:
cancel()
return fmt.Errorf("unsupported DB type: %s", db.DBType)
@@ -834,7 +855,7 @@ func waitDBReady(ctx context.Context, db DiscoveredDB, timeout time.Duration) er
}
func getMariaDBPassword(ctx context.Context, containerID string) string {
cmd := exec.CommandContext(ctx, "docker", "inspect", containerID,
cmd := dockerexec.CommandContext(ctx, "docker", "inspect", containerID,
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
out, err := cmd.Output()
if err != nil {
@@ -976,3 +997,69 @@ func cleanupTmpFiles(dumpDir string, logger *log.Logger) {
}
// M1: formatBytes removed — use humanizeBytes() from appdata.go (same package, no duplication).
// Completion markers — the last comment each engine's dump tool writes, and only when it finished.
// R-640 (measured 2026-09-23 on 9202): the first half of a real pg_dump, loaded with
// `psql -v ON_ERROR_STOP=1 --single-transaction`, returned rc 0 and left 42 tables with 0 users —
// psql treats end-of-file inside a COPY as end of data and commits. A truncated MariaDB dump fails
// its load, but only after it has already replaced half the tables. The header and CREATE TABLE
// checks in ValidateDump cannot see either: only the END of the file can.
const (
pgCompleteMarker = "-- PostgreSQL database dump complete"
mariadbCompleteMarker = "-- Dump completed"
)
// ErrDumpIncomplete is returned (wrapped) by CheckDumpComplete when the marker is absent.
var ErrDumpIncomplete = errors.New("database copy is incomplete: the engine's completion marker is missing")
// CheckDumpComplete reports whether a (possibly .gz) dump ends with its engine's completion marker.
// It reads the whole stream but keeps only the last 4 KiB — pg_dump may print a `\unrestrict` line
// after its marker, so the marker is searched in the tail rather than required on the last line.
// An engine this function does not know is an error: "cannot tell" must never load.
func CheckDumpComplete(path string, dbType DBType) error {
var marker string
switch dbType {
case DBTypePostgres:
marker = pgCompleteMarker
case DBTypeMariaDB:
marker = mariadbCompleteMarker
default:
return fmt.Errorf("%s: unknown database type %q, completeness cannot be checked", filepath.Base(path), dbType)
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening %s: %w", filepath.Base(path), err)
}
defer f.Close()
var r io.Reader = f
if strings.HasSuffix(path, ".gz") {
gr, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("opening gzip %s: %w", filepath.Base(path), err)
}
defer gr.Close()
r = gr
}
const tailSize = 4096
tail := make([]byte, 0, 2*tailSize)
buf := make([]byte, 64*1024)
for {
n, rerr := r.Read(buf)
if n > 0 {
tail = append(tail, buf[:n]...)
if len(tail) > tailSize {
tail = append(tail[:0], tail[len(tail)-tailSize:]...)
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
return fmt.Errorf("reading %s: %w", filepath.Base(path), rerr)
}
}
if !strings.Contains(string(tail), marker) {
return fmt.Errorf("%s (%s): %w", filepath.Base(path), dbType, ErrDumpIncomplete)
}
return nil
}
@@ -56,7 +56,7 @@ func TestAppBindAndCaptureRootAgree(t *testing.T) {
const sys = "/mnt/sys_drive"
for _, drive := range []string{"/mnt/felhom-usb", "/mnt/felhom-drives/hdd_1", "/mnt/sys_drive"} {
nsRoot := NamespaceRootFor(drive, sys)
appBind := UserdataDir(nsRoot) // what the deploy sets as ${USERDATA_PATH}
appBind := UserdataDir(nsRoot) // what the deploy sets as ${USERDATA_PATH}
captureRoot := UserdataDir(nsRoot) // what the capture set resolves RootUserdata against
if appBind != captureRoot {
t.Fatalf("drive %q: the app binds %q while the backup captures %q", drive, appBind, captureRoot)
@@ -0,0 +1,56 @@
package appbackup
import (
"context"
"io"
"log"
"os"
"path/filepath"
"testing"
)
// R-10 (T-6E-1): DumpOneTo fsynced the dump FILE and renamed it, but never fsynced the DIRECTORY, so
// the rename itself was not durable — the asymmetry with atomicPromoteTar (backup.go), which does.
// The consequence asserted: a successful dump syncs the directory that holds the final file, AFTER
// the final name exists (the sync must cover the rename, not precede it).
//
// No real docker: a `docker` stub in t.TempDir() on PATH (R-650's sanctioned seam) answers the
// running-check and prints a small SQL dump.
func TestDumpOneTo_SyncsTheDumpDirectoryAfterRename(t *testing.T) {
bin := t.TempDir()
stub := "#!/bin/sh\ncase \"$1\" in\n inspect) echo true ;;\n exec) printf 'CREATE TABLE t (id int);\\nINSERT INTO t VALUES (1);\\n' ;;\nesac\n"
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
dumpDir := filepath.Join(t.TempDir(), "unit")
final := filepath.Join(dumpDir, "app-postgres.sql")
var synced []string
finalExistedAtSync := false
orig := syncDumpDir
syncDumpDir = func(dir string) error {
synced = append(synced, dir)
if _, err := os.Stat(final); err == nil {
finalExistedAtSync = true
}
return orig(dir)
}
t.Cleanup(func() { syncDumpDir = orig })
db := DiscoveredDB{ContainerName: "app-db", ContainerID: "0123456789abcdef", DBType: DBTypePostgres, DBUser: "u", DBName: "d", StackName: "app"}
res := DumpOneTo(context.Background(), db, final, log.New(io.Discard, "", 0), false)
if res.Error != nil {
t.Fatalf("dump failed: %v", res.Error)
}
if _, err := os.Stat(final + ".tmp"); !os.IsNotExist(err) {
t.Errorf("the .tmp scratch file is still present after a successful dump")
}
if len(synced) != 1 || synced[0] != dumpDir {
t.Fatalf("R-10: the dump directory was not fsynced after the rename: synced=%v, want [%s]", synced, dumpDir)
}
if !finalExistedAtSync {
t.Errorf("R-10: the directory was synced BEFORE the final name existed — the rename is not covered")
}
}
@@ -190,9 +190,9 @@ func TestImportDumpErrorDoesNotCarryEngineStderr(t *testing.T) {
// test; what CAN be pinned is the thing the mutation changes.
//
// TWO PROPERTIES, both mutation-detectable:
// 1. `finalPath` is never reassigned — the caller's destination is the destination.
// 2. the scratch file is derived from `finalPath`, not from the canonical name, so a nightly dump
// and a safety dump running into the same directory cannot share it.
// 1. `finalPath` is never reassigned — the caller's destination is the destination.
// 2. the scratch file is derived from `finalPath`, not from the canonical name, so a nightly dump
// and a safety dump running into the same directory cannot share it.
func TestDumpOneToHonoursTheDestinationItWasGiven(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "dbdump.go", nil, 0)
@@ -0,0 +1,53 @@
package appbackup
import (
"compress/gzip"
"errors"
"os"
"path/filepath"
"testing"
)
// R-640: CheckDumpComplete reads the END of the copy. pg_dump 17.6+ prints `\unrestrict <key>` after
// its marker, so the marker need not be the last line.
func TestR640_CheckDumpComplete(t *testing.T) {
dir := t.TempDir()
big := make([]byte, 200*1024) // longer than the kept tail: the marker must be found at the END
for i := range big {
big[i] = 'x'
}
cases := []struct {
name, body string
t DBType
gz, ok bool
}{
{"pg complete", "-- PostgreSQL database dump\nCOPY t FROM stdin;\n1\n\\.\n\n--\n-- PostgreSQL database dump complete\n--\n\n\\unrestrict abc\n", DBTypePostgres, false, true},
{"pg cut inside COPY", "-- PostgreSQL database dump\nCOPY t FROM stdin;\n1\n", DBTypePostgres, false, false},
{"pg marker only at the start is not an end", "-- PostgreSQL database dump complete\n" + string(big) + "\n", DBTypePostgres, false, false},
{"pg complete, gzipped", "-- PostgreSQL database dump\n" + string(big) + "\n-- PostgreSQL database dump complete\n", DBTypePostgres, true, true},
{"pg cut, gzipped", "-- PostgreSQL database dump\n" + string(big), DBTypePostgres, true, false},
{"mariadb complete", "-- MariaDB dump 10.19\nINSERT INTO `t` VALUES (1);\n-- Dump completed on 2026-09-23\n", DBTypeMariaDB, false, true},
{"mariadb cut", "-- MariaDB dump 10.19\nINSERT INTO `t` VALUES (1),(2", DBTypeMariaDB, false, false},
{"unknown engine never passes", "anything\n-- Dump completed\n", DBType("sqlite"), false, false},
}
for i, c := range cases {
p := filepath.Join(dir, "d"+string(rune('a'+i))+".sql")
if c.gz {
p += ".gz"
f, _ := os.Create(p)
w := gzip.NewWriter(f)
w.Write([]byte(c.body))
w.Close()
f.Close()
} else if err := os.WriteFile(p, []byte(c.body), 0o644); err != nil {
t.Fatal(err)
}
err := CheckDumpComplete(p, c.t)
if (err == nil) != c.ok {
t.Errorf("%s: ok=%v, got err=%v", c.name, c.ok, err)
}
if !c.ok && c.t != "sqlite" && !errors.Is(err, ErrDumpIncomplete) {
t.Errorf("%s: want ErrDumpIncomplete, got %v", c.name, err)
}
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"errors"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"io"
"os"
@@ -221,7 +222,7 @@ func DecryptFile(inputPath, outputPath, password string) error {
}
if !hmac.Equal(mac.Sum(nil), storedMAC) {
os.Remove(outputPath)
return errors.New("jelszó hibás vagy a fájl sérült")
return util.MsgError("err.appexport.jelszo_hibas_vagy_a_fajl_serult")
}
return out.Sync()
+5 -4
View File
@@ -6,10 +6,11 @@ import (
"compress/gzip"
"context"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
@@ -696,7 +697,7 @@ func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Ma
// Renaming can't help either (the import couldn't map the new name), so the only honest
// outcome is a loud failure.
if prev, dup := claimed[subdir]; dup {
return fmt.Errorf("két adatkönyvtár azonos névvel végződik (%q: %s és %s) — a csomag nem tudná megkülönböztetni őket", subdir, prev, mount)
return util.MsgError("err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es", subdir, prev, mount)
}
claimed[subdir] = mount
@@ -730,7 +731,7 @@ func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Ma
// controller itself runs containerized (the v0.124.0 HIGH finding). Package var so unit tests
// inject a recorder (no docker on test boxes).
var dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", args...)
cmd := dockerexec.CommandContext(ctx, "docker", args...)
cmd.Stdin = stdin
cmd.Stdout = stdout
var errBuf bytes.Buffer
@@ -845,7 +846,7 @@ func assertBundleDataComplete(tmpDir string, manifest *Manifest) error {
// bundle. A needs_hdd app with NO data of any kind is a hollow bundle by definition; refuse it
// loudly so a future discovery gap can never again ship silently.
if manifest.NeedsHDD && !manifest.HasHDDData && !manifest.HasVolumeData {
return fmt.Errorf("a mentés nem tartalmaz alkalmazásadatot (0 adatkönyvtár, 0 kötet egy adattárolós alkalmazásnál)")
return util.MsgError("err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0")
}
return nil
}
@@ -22,13 +22,13 @@ type fabProv struct {
mounts []string
}
func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath }
func (p *fabProv) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath }
func (p *fabProv) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
// R-203: these fixtures use ENROLLED drive paths, where the namespace root IS the drive path.
// Delegating keeps that identity explicit rather than hardcoding it.
func (p *fabProv) GetStackNamespaceRoot(name string) string { return p.GetStackHDDPath(name) }
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
return p.binds, p.has
}
+11 -8
View File
@@ -5,6 +5,8 @@ import (
"compress/gzip"
"context"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"io"
"os"
"os/exec"
@@ -168,7 +170,7 @@ func ReadManifestFromEncryptedFAB(fabPath, password string) (*Manifest, error) {
defer os.Remove(tmpPath)
if err := DecryptFile(fabPath, tmpPath, password); err != nil {
return nil, fmt.Errorf("visszafejtés sikertelen: %w", err)
return nil, util.MsgError("err.appexport.visszafejtes_sikertelen", err)
}
return ReadManifestFromFAB(tmpPath)
@@ -689,7 +691,7 @@ func validateBundleData(tmpDir string, manifest *Manifest) error {
}
fi, err := os.Stat(filepath.Join(tmpDir, "data", "volumes", v+".tar"))
if err != nil || fi.Size() == 0 {
return fmt.Errorf("a(z) %q kötet adata hiányzik a csomagból", v)
return util.MsgError("err.appexport.a_z_kotet_adata_hianyzik_a", v)
}
}
for _, s := range manifest.HDDSubdirs {
@@ -698,7 +700,7 @@ func validateBundleData(tmpDir string, manifest *Manifest) error {
}
fi, err := os.Stat(filepath.Join(tmpDir, "data", "hdd", s+".tar"))
if err != nil || fi.Size() == 0 {
return fmt.Errorf("a(z) %q adatkönyvtár tartalma hiányzik a csomagból", s)
return util.MsgError("err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a", s)
}
}
return nil
@@ -936,11 +938,12 @@ func classifyDBImage(service, image, container string) *dbServiceInfo {
// composeExecEnv runs docker compose in the given stack directory with env vars.
func composeExecEnv(stackDir string, env map[string]string, args ...string) ([]byte, error) {
defer dockerexec.BeginImageWork(args)() // R-863: no image clean-up while a restore may pull
cmdArgs := append([]string{"compose"}, args...)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
cmd := dockerexec.CommandContext(ctx, "docker", cmdArgs...)
cmd.Dir = stackDir
cmd.Env = os.Environ()
for k, v := range env {
@@ -988,14 +991,14 @@ func waitForDB(containerID, dbType string, env map[string]string) error {
if user == "" {
user = "postgres"
}
cmd = exec.CommandContext(ctx, "docker", "exec", containerID,
cmd = dockerexec.CommandContext(ctx, "docker", "exec", containerID,
"pg_isready", "-U", user)
case "mariadb":
password := env["MYSQL_ROOT_PASSWORD"]
if password == "" {
password = env["MARIADB_ROOT_PASSWORD"]
}
cmd = exec.CommandContext(ctx, "docker", "exec", containerID,
cmd = dockerexec.CommandContext(ctx, "docker", "exec", containerID,
"mysqladmin", "ping", "-u", "root", "-p"+password)
default:
cancel()
@@ -1046,7 +1049,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
if dbName == "" {
dbName = user
}
cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
"psql", "-U", user, "-d", dbName)
case "mariadb":
password := env["MYSQL_ROOT_PASSWORD"]
@@ -1057,7 +1060,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
if dbName == "" {
dbName = env["MARIADB_DATABASE"]
}
cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
"mysql", "-u", "root", "-p"+password, dbName)
default:
return fmt.Errorf("unknown DB type: %s", dbType)
@@ -0,0 +1,620 @@
package backup
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Part A of the version-travel brief (controller v0.275.0, R-696, `07` §6.6): a backup's data and its
// version travel together. Measured before the fix on 9202 (`audits/version-travel-2026-09-26/A1/`): the
// periodic refresh re-captured the unit's DEFINITION two minutes after an update, over the previous
// version's DATA; Tier 1's time moved to the refresh; a restore in that window started a PostgreSQL 16
// datadir under the 18 definition and left the app down.
//
// Every test asserts a CONSEQUENCE a household or the update would see — which definition the restore
// starts, which time the precondition reads, which copy the release trusts — not the mechanism.
// vtStack is one app's stack dir + a provider whose ImagePins are what production derives them from:
// ParseComposeImages of the stack's compose (the adapter's GetStackRecoveryInfo does exactly that).
type vtStack struct {
t *testing.T
tmp string
drive string
stackDir string
fake *fakeRecoveryProvider
m *Manager
}
func vtCompose(pgMajor string) string {
return "services:\n app:\n image: example/app:0.96.0\n app-db:\n image: postgres:" + pgMajor + "-alpine\n"
}
func newVTStack(t *testing.T, pgMajor string) *vtStack {
t.Helper()
tmp := t.TempDir()
v := &vtStack{t: t, tmp: tmp, drive: filepath.Join(tmp, "drive"), stackDir: filepath.Join(tmp, "stack")}
if err := os.MkdirAll(v.stackDir, 0o755); err != nil {
t.Fatal(err)
}
v.fake = &fakeRecoveryProvider{hdd: v.drive, running: true}
v.m = &Manager{
logger: log.New(io.Discard, "", 0),
systemDataPath: filepath.Join(tmp, "system"),
stackProvider: v.fake,
version: "vtest",
}
v.setVersion(pgMajor)
return v
}
// setVersion is what an update does to the stack dir: the definition moves, and what runs moves with it.
func (v *vtStack) setVersion(pgMajor string) {
v.t.Helper()
mustWrite(v.t, filepath.Join(v.stackDir, "docker-compose.yml"), vtCompose(pgMajor))
mustWrite(v.t, filepath.Join(v.stackDir, ".felhom.yml"), "display_name: App "+pgMajor+"\n")
mustWrite(v.t, filepath.Join(v.stackDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: vt\n")
v.fake.info = RecoveryInfo{
StackDir: v.stackDir,
DisplayName: "App",
ImagePins: ParseComposeImages(filepath.Join(v.stackDir, "docker-compose.yml")),
NonSecretEnv: map[string]string{"SUBDOMAIN": "vt"},
InstalledImages: map[string]string{
"app": "example/app:0.96.0@sha256:aaa",
"app-db": "postgres:" + pgMajor + "-alpine@sha256:" + pgMajor + pgMajor,
},
}
}
func (v *vtStack) unitDir() string { return RecoveryUnitPath(v.drive, "app") }
// backupLegs is what a data run writes, through the SAME stamp call the legs make: a database dump and
// a volume tar, both dated `at` (so "the refresh ran later" is a fact of the clock, not of the test).
func (v *vtStack) backupLegs(at time.Time, marker string) {
v.t.Helper()
sql := filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql")
tar := filepath.Join(UnitVolumeDumpDir(v.unitDir()), "app_db.tar")
mustWrite(v.t, sql, pgDump(1)+"-- "+marker+"\n")
mustWrite(v.t, tar, "tar:"+marker)
for _, p := range []string{sql, tar} {
if err := os.Chtimes(p, at, at); err != nil {
v.t.Fatal(err)
}
}
v.m.stampDataFile("app", v.unitDir(), "db-dumps/app-postgres.sql")
v.m.stampDataFile("app", v.unitDir(), "volume-dumps/app_db.tar")
}
func (v *vtStack) manifest() *RecoveryManifest {
v.t.Helper()
man := readManifest(UnitManifestFile(v.unitDir()))
if man == nil {
v.t.Fatal("no readable manifest")
}
return man
}
func (v *vtStack) unitComposeImages() []string {
return ParseComposeImages(filepath.Join(UnitComposeDir(v.unitDir()), "docker-compose.yml"))
}
// theWindow builds the measured A1 state: a data run at PostgreSQL 16 two hours ago, then the update to
// 18, then the periodic refresh NOW.
func theWindow(t *testing.T) (*vtStack, time.Time) {
t.Helper()
v := newVTStack(t, "16")
dataAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Second)
v.backupLegs(dataAt, "written-by-16")
if err := v.m.CaptureRecoveryUnit("app"); err != nil { // the data run's capture
t.Fatal(err)
}
v.setVersion("18") // the guarded update moved the pin
if err := v.m.captureRecoveryUnit("app", false); err != nil { // the 5-minute refresh
t.Fatal(err)
}
return v, dataAt
}
// A5 red-proof 1 — the manifest refresh after a pin change no longer moves Tier 1's time.
// Pre-fix (v0.274.0): ListRestorePoints = newest of the manifest's and the dumps' mtimes → "now".
func TestA5_RefreshAfterAPinChangeDoesNotMoveTier1sTime(t *testing.T) {
v, dataAt := theWindow(t)
pts, found := v.m.ListRestorePoints("app")
if !found || len(pts) != 1 {
t.Fatalf("restore points = %v found=%v, want one", pts, found)
}
got, err := time.Parse(time.RFC3339, pts[0].Time)
if err != nil {
t.Fatal(err)
}
if !got.Equal(dataAt) {
t.Fatalf("Tier 1's time = %s, want the DATA's time %s — the refresh %s made a two-hour-old dump read as new",
got.Format(time.RFC3339), dataAt.Format(time.RFC3339), time.Since(got).Round(time.Second))
}
}
// The definition stays with its data: after the refresh, compose/ still names the 16 definition, the
// manifest says both (image_pins = what runs, data.image_pins = what wrote the data).
// Pre-fix: the refresh rewrote compose/ to 18 (A1 S3b).
func TestA2_TheUnitKeepsTheDefinitionItsDataBelongsTo(t *testing.T) {
v, _ := theWindow(t)
if got := v.unitComposeImages(); !samePins(got, ParseComposeImages(writeTmpCompose(t, vtCompose("16")))) {
t.Fatalf("the unit's compose/ names %v — the refresh paired the 18 definition with the 16 data", got)
}
man := v.manifest()
if man.Data == nil || !strings.Contains(strings.Join(man.Data.ImagePins, " "), "postgres:16-alpine") {
t.Fatalf("manifest data = %+v, want the 16 pins", man.Data)
}
if !strings.Contains(strings.Join(man.ImagePins, " "), "postgres:18-alpine") {
t.Fatalf("manifest image_pins = %v, want the app's CURRENT (18) pins", man.ImagePins)
}
if got := man.Data.Files["db-dumps/app-postgres.sql"].Images["app-db"]; !strings.HasPrefix(got, "postgres:16-alpine@sha256:") {
t.Fatalf("the dump's recorded engine = %q, want postgres:16 with its digest", got)
}
// And a SECOND refresh writes nothing: the frozen checksums describe what compose/ holds.
before, _ := os.Stat(UnitManifestFile(v.unitDir()))
time.Sleep(20 * time.Millisecond)
if err := v.m.captureRecoveryUnit("app", false); err != nil {
t.Fatal(err)
}
after, _ := os.Stat(UnitManifestFile(v.unitDir()))
if !after.ModTime().Equal(before.ModTime()) {
t.Fatal("a second refresh rewrote the manifest — the frozen unit thrashes the drive every five minutes")
}
}
func writeTmpCompose(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "docker-compose.yml")
mustWrite(t, p, body)
return p
}
// The next data run replaces the data AND the definition: both are 18 afterwards.
func TestA2_TheNextDataRunMovesDataAndDefinitionTogether(t *testing.T) {
v, _ := theWindow(t)
now := time.Now().UTC().Truncate(time.Second)
v.backupLegs(now, "written-by-18")
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
t.Fatal(err)
}
if got := strings.Join(v.unitComposeImages(), " "); !strings.Contains(got, "postgres:18-alpine") {
t.Fatalf("after a data run on 18 the unit's compose/ names %s, want 18", got)
}
if man := v.manifest(); man.Data == nil || man.Data.At != now.Format(time.RFC3339) || man.Data.Mixed {
t.Fatalf("data = %+v, want at %s, not mixed", man.Data, now.Format(time.RFC3339))
}
}
// vtRestorer records the definition the restore STARTS the data with.
type vtRestorer struct {
*fakeRecoveryProvider
startedWith []string
}
func (f *vtRestorer) RecreateStackDefinitionFromUnit(name, composeDir string, env map[string]string) error {
f.startedWith = ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml"))
return f.fakeRecoveryProvider.RecreateStackDefinitionFromUnit(name, composeDir, env)
}
func vtRestoreSeams(m *Manager) (vols *[]string) {
var vd []string
m.volumeReplayFrom = func(_, dir string) (int, error) { vd = append(vd, dir); return 1, nil }
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil
}
m.importDBDump = func(context.Context, DiscoveredDB, string) error { return nil }
return &vd
}
// A5 red-proof 3 — a restore in the window starts the OLD version with the OLD data, and says so.
// Pre-fix: the unit's definition was 18 (the refresh), so the 16 datadir was started under 18 (A1 S4).
func TestA5_ARestoreInTheWindowStartsTheOldVersionWithTheOldData(t *testing.T) {
v, dataAt := theWindow(t)
rec := &vtRestorer{fakeRecoveryProvider: v.fake}
v.m.stackProvider = rec
vtRestoreSeams(v.m)
res, err := v.m.RestoreFromRecoveryUnit("app")
if err != nil {
t.Fatalf("restore: %v", err)
}
if got := strings.Join(rec.startedWith, " "); !strings.Contains(got, "postgres:16-alpine") || strings.Contains(got, "postgres:18") {
t.Fatalf("the restore started the 16 data with the definition %s — a restore must never mix versions", got)
}
if !res.VersionChanged || !samePins(res.DataPins, ParseComposeImages(writeTmpCompose(t, vtCompose("16")))) || !res.DataAt.Equal(dataAt) {
t.Fatalf("result = changed %v pins %v at %s, want changed, the 16 pins, %s", res.VersionChanged, res.DataPins, res.DataAt, dataAt)
}
}
// A restore never starts data with a definition it does not belong to: a unit whose compose/ names other
// pins than its data, and a unit whose files were written by different versions, are refused BEFORE
// anything is touched (no stop, no volume, no recreate).
func TestA3_AMismatchedOrMixedUnitIsRefusedBeforeAnythingMoves(t *testing.T) {
t.Run("definition-not-the-data's", func(t *testing.T) {
v, _ := theWindow(t)
// What v0.274.0's refresh left behind: the NEW definition in compose/ over the old data.
mustWrite(t, filepath.Join(UnitComposeDir(v.unitDir()), "docker-compose.yml"), vtCompose("18"))
vols := vtRestoreSeams(v.m)
_, err := v.m.RestoreFromRecoveryUnit("app")
if !errors.Is(err, ErrUnitVersionMismatch) {
t.Fatalf("err = %v, want ErrUnitVersionMismatch", err)
}
if len(v.fake.calls) != 0 || len(*vols) != 0 {
t.Fatalf("the refusal touched the app: calls=%v volumes=%v", v.fake.calls, *vols)
}
})
t.Run("mixed", func(t *testing.T) {
v, _ := theWindow(t)
// The DB leg ran under 18, the volume leg failed and kept its 16 tar.
sql := filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql")
mustWrite(t, sql, pgDump(1))
v.m.stampDataFile("app", v.unitDir(), "db-dumps/app-postgres.sql")
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
t.Fatal(err)
}
if man := v.manifest(); man.Data == nil || !man.Data.Mixed {
t.Fatalf("data = %+v, want Mixed", man.Data)
}
vols := vtRestoreSeams(v.m)
_, err := v.m.RestoreFromRecoveryUnit("app")
if !errors.Is(err, ErrUnitVersionMismatch) {
t.Fatalf("err = %v, want ErrUnitVersionMismatch", err)
}
if len(v.fake.calls) != 0 || len(*vols) != 0 {
t.Fatalf("the refusal touched the app: calls=%v volumes=%v", v.fake.calls, *vols)
}
})
}
// An older unit (no stamps) restores as before: its definition, VersionsUnknown, no refusal.
func TestA3_AnUnstampedUnitRestoresAsBefore(t *testing.T) {
v := newVTStack(t, "16")
mustWrite(t, filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql"), pgDump(1))
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
t.Fatal(err)
}
if man := v.manifest(); man.Data != nil {
t.Fatalf("an unstamped dump produced data %+v — unknown must stay unknown", man.Data)
}
rec := &vtRestorer{fakeRecoveryProvider: v.fake}
v.m.stackProvider = rec
vtRestoreSeams(v.m)
res, err := v.m.RestoreFromRecoveryUnit("app")
if err != nil {
t.Fatalf("restore: %v", err)
}
if !res.VersionsUnknown || res.VersionChanged || len(rec.startedWith) == 0 {
t.Fatalf("result = %+v started=%v, want VersionsUnknown and the unit's definition started", res, rec.startedWith)
}
}
// A5 red-proof 4 — the update's precondition refuses a stale dump that a refresh made look new.
// Pre-fix: Tier 1 = the refresh's manifest time → "0m old" → accepted, and the update leaned on a copy
// of the previous version's data from two hours before.
func TestA5_ThePreconditionRefusesAStaleDumpARefreshMadeLookNew(t *testing.T) {
v, dataAt := theWindow(t)
now := time.Now()
fresh := func(p UpdateTierPoint) bool { return now.Sub(p.At) <= time.Hour }
p, ok, seen := v.m.UpdateRestorePoints(context.Background(), "app", fresh)
if ok {
t.Fatalf("the precondition ACCEPTED tier %d at %s (%s old) — the data is from %s",
p.Tier, p.At.Format(time.RFC3339), now.Sub(p.At).Round(time.Second), dataAt.Format(time.RFC3339))
}
if len(seen) != 1 || seen[0].Tier != UpdateTierLocal || !seen[0].At.Equal(dataAt) {
t.Fatalf("seen = %+v, want Tier 1 at the data's time %s", seen, dataAt.Format(time.RFC3339))
}
}
// A unit with no data file at all is dated by the data run that confirmed it, and a refresh keeps it.
func TestA4_AUnitWithoutDataFilesIsDatedByItsDataRun(t *testing.T) {
v := newVTStack(t, "16")
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
t.Fatal(err)
}
first := v.manifest().Data
if first == nil || first.At == "" {
t.Fatalf("data = %+v, want the data run's time", first)
}
v.setVersion("18")
if err := v.m.captureRecoveryUnit("app", false); err != nil {
t.Fatal(err)
}
if got := v.manifest().Data; got == nil || got.At != first.At {
t.Fatalf("a refresh moved the data time: %+v → %+v", first, got)
}
}
// The undo copies are not data: an update's safety dump written after the backup must not date Tier 1.
func TestA4_AnUndoCopyNeverDatesTheUnit(t *testing.T) {
v := newVTStack(t, "16")
old := time.Now().Add(-3 * time.Hour).UTC().Truncate(time.Second)
mustWrite(t, filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql"), pgDump(1))
_ = os.Chtimes(filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql"), old, old)
if err := v.m.captureRecoveryUnit("app", false); err != nil { // unstamped: the legacy rule
t.Fatal(err)
}
mustWrite(t, filepath.Join(UnitDBDumpDir(v.unitDir()), preRestoreDumpPrefix+"20260926T000000Z-app-postgres.sql"), pgDump(1))
pts, _ := v.m.ListRestorePoints("app")
if len(pts) != 1 || pts[0].Time != old.Format(time.RFC3339) {
t.Fatalf("Tier 1 = %+v, want the dump's %s (not the undo copy's, not the manifest's)", pts, old.Format(time.RFC3339))
}
}
// The stamps are written by the PRODUCTION leg (seam discipline): RunAppBackupNow → the dump seam → the
// stamp → the capture's `data`. A stamp helper nobody calls is the "seam built but never wired" shape.
func TestA2_TheUpdatesOwnBackupStampsItsDataThroughTheRealLeg(t *testing.T) {
v := newVTStack(t, "16")
v.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil
}
v.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult {
p := filepath.Join(dir, "app-postgres.sql")
mustWrite(t, p, pgDump(1))
return DumpResult{DB: db, FilePath: p}
}
v.m.perAppTier2 = func(string) error { return nil }
if err := v.m.RunAppBackupNow(context.Background(), "app"); err != nil {
t.Fatalf("RunAppBackupNow: %v", err)
}
man := v.manifest()
if man.Data == nil || man.Data.Mixed || len(man.Data.Files) != 1 {
t.Fatalf("data = %+v, want one stamped dump", man.Data)
}
st := man.Data.Files["db-dumps/app-postgres.sql"]
if st.Images["app-db"] != "postgres:16-alpine@sha256:1616" || !samePins(st.Pins, v.fake.info.ImagePins) {
t.Fatalf("stamp = %+v, want the running images and the definition's pins", st)
}
var raw map[string]interface{}
b, _ := os.ReadFile(UnitManifestFile(v.unitDir()))
_ = json.Unmarshal(b, &raw)
if _, ok := raw["data"]; !ok {
t.Fatal("manifest.json carries no `data` key")
}
}
// Tier 2: a mirror taken after the update of a unit whose data is older is as old as that data.
func TestA4_ATier2CopyIsAsOldAsItsData(t *testing.T) {
copyAt := time.Now().UTC().Truncate(time.Second)
dataAt := copyAt.Add(-2 * time.Hour)
p := Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: copyAt.Format(time.RFC3339), DataDate: dataAt.Format(time.RFC3339)}
got, ok := p.ProvenCopyTime()
if !ok || !got.Equal(dataAt) {
t.Fatalf("Tier 2 proven at %s ok=%v, want the data's %s", got, ok, dataAt)
}
p.DataDate = ""
if got, _ := p.ProvenCopyTime(); !got.Equal(copyAt) {
t.Fatalf("with no data date, Tier 2 = %s, want the copy's %s (as before)", got, copyAt)
}
}
// The household's version label names every image — a PostgreSQL step is visible in it.
func TestA3_PinsVersionNamesEveryImage(t *testing.T) {
got := PinsVersion([]string{"docmost/docmost:0.96.0@sha256:b5", "postgres:16-alpine@sha256:72", "gitea.dooplex.hu/x/redis:7-alpine"})
if got != "docmost:0.96.0, postgres:16-alpine, redis:7-alpine" {
t.Fatalf("PinsVersion = %q", got)
}
}
// vtReconProvider is the reconstitution fixture's provider with a LIVE version (18) and a recorder for
// the definition the restore writes.
type vtReconProvider struct {
*recordingProvider
livePins []string
wroteDef []string
wroteAtCall int
}
func (p *vtReconProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
return RecoveryInfo{DisplayName: "Immich", ImagePins: p.livePins}, true
}
func (p *vtReconProvider) RecreateStackDefinitionFromUnit(_, composeDir string, _ map[string]string) error {
p.wroteDef = ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml"))
p.wroteAtCall = len(p.calls)
p.calls = append(p.calls, "recreate")
return nil
}
// vtSnapshotUnit gives the fixture's scratch unit a definition and a `data` block (a snapshot taken by
// v0.275.0), and returns the unit dir.
func vtSnapshotUnit(t *testing.T, m *Manager, pgMajor string, withData bool) string {
t.Helper()
scratch, _, err := m.offboxRestoreScratchDir("immich")
if err != nil {
t.Fatal(err)
}
var unit string
_ = filepath.Walk(scratch, func(p string, fi os.FileInfo, _ error) error {
if fi != nil && !fi.IsDir() && fi.Name() == "manifest.json" {
unit = filepath.Dir(p)
}
return nil
})
if unit == "" {
t.Fatal("no scratch unit")
}
mustWrite(t, filepath.Join(UnitComposeDir(unit), "docker-compose.yml"), vtCompose(pgMajor))
mustWrite(t, filepath.Join(UnitComposeDir(unit), "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: vt\n")
man := readManifest(UnitManifestFile(unit))
if withData {
man.Data = &UnitData{At: "2026-09-26T02:15:01Z", ImagePins: ParseComposeImages(filepath.Join(UnitComposeDir(unit), "docker-compose.yml"))}
}
if err := writeManifest(UnitManifestFile(unit), man); err != nil {
t.Fatal(err)
}
return unit
}
// Off-site (Tier 3): v0.274.0 never wrote the definition — last night's snapshot data went under the
// app's NEW definition (A1, read from source). Now the snapshot's own definition is written BEFORE any
// file, volume or database is touched, and the database service is resolved from it.
func TestA3_TheOffsiteRestoreBringsTheSnapshotsVersionBack(t *testing.T) {
m, prov, imported := reconFixture(t, "20260926T021500Z", "2026-09-26T02:15:01Z", pgDump(1))
vp := &vtReconProvider{recordingProvider: prov, livePins: ParseComposeImages(writeTmpCompose(t, vtCompose("18")))}
m.SetStackProvider(vp)
vtSnapshotUnit(t, m, "16", true)
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err != nil {
t.Fatalf("reconstitute: %v", err)
}
if got := strings.Join(vp.wroteDef, " "); !strings.Contains(got, "postgres:16-alpine") {
t.Fatalf("the restore wrote the definition %q — want the snapshot's 16", got)
}
if vp.wroteAtCall != 1 || vp.calls[0] != "stop" {
t.Fatalf("calls = %v — the definition must be written right after the stop, before any data", vp.calls)
}
if !res.VersionChanged || res.DataAt.Format(time.RFC3339) != "2026-09-26T02:15:01Z" || len(*imported) != 1 {
t.Fatalf("result changed=%v at=%s imported=%v", res.VersionChanged, res.DataAt, *imported)
}
if got := strings.Join(vp.gotServices, ","); got != "app-db" {
t.Fatalf("the DB-only start was %q — the database service must come from the definition that RUNS (the snapshot's)", got)
}
}
// Same version, or a snapshot from before v0.275.0: nothing is written — the path of every earlier release.
func TestA3_TheOffsiteRestoreLeavesTheDefinitionWhenNothingDiffers(t *testing.T) {
for _, c := range []struct {
name string
live string
withData bool
unknown bool
}{{"same-version", "16", true, false}, {"pre-v0.275.0-snapshot", "18", false, true}} {
t.Run(c.name, func(t *testing.T) {
m, prov, _ := reconFixture(t, "20260926T021500Z", "2026-09-26T02:15:01Z", pgDump(1))
vp := &vtReconProvider{recordingProvider: prov, livePins: ParseComposeImages(writeTmpCompose(t, vtCompose(c.live)))}
m.SetStackProvider(vp)
vtSnapshotUnit(t, m, "16", c.withData)
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err != nil {
t.Fatalf("reconstitute: %v", err)
}
if vp.wroteDef != nil || res.VersionChanged || res.VersionsUnknown != c.unknown {
t.Fatalf("wrote %v changed=%v unknown=%v", vp.wroteDef, res.VersionChanged, res.VersionsUnknown)
}
})
}
}
// Tier 3: a snapshot pushed after a failed dump leg carries older data than its own time — the recorded
// push caps it; a snapshot newer than the last recorded push (another box) keeps its own time.
func TestA4_AnOffsiteCopyIsAsOldAsTheDataItWasPushedWith(t *testing.T) {
m, sett := newOffboxManager(t)
snap := time.Date(2026, 9, 26, 2, 15, 0, 0, time.UTC)
if got := m.offsiteDataTime("app", snap); !got.Equal(snap) {
t.Fatalf("no record: %s, want the snapshot's own time", got)
}
data := snap.Add(-24 * time.Hour)
if err := sett.SetOffsiteDataAt("app", settings.OffsiteDataRecord{PushedAt: snap.Add(time.Minute).Format(time.RFC3339), DataAt: data.Format(time.RFC3339)}); err != nil {
t.Fatal(err)
}
if got := m.offsiteDataTime("app", snap); !got.Equal(data) {
t.Fatalf("recorded push: %s, want the data's %s", got, data)
}
later := snap.Add(2 * time.Hour)
if got := m.offsiteDataTime("app", later); !got.Equal(later) {
t.Fatalf("a snapshot newer than the recorded push: %s, want its own %s", got, later)
}
}
// The push records the pushed unit's DATA time (the production call in runOffboxInternal).
func TestA4_ThePushRecordsTheUnitsDataTime(t *testing.T) {
m, sett := newOffboxManager(t)
v := newVTStack(t, "16")
dataAt := time.Now().Add(-26 * time.Hour).UTC().Truncate(time.Second)
v.backupLegs(dataAt, "x")
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
t.Fatal(err)
}
m.recordOffsiteDataAt("app", v.unitDir())
rec, ok := sett.GetOffsiteDataAt("app")
if !ok || rec.DataAt != dataAt.Format(time.RFC3339) || rec.PushedAt == "" {
t.Fatalf("record = %+v ok=%v, want data at %s", rec, ok, dataAt.Format(time.RFC3339))
}
}
// The production push path writes the record (seam discipline: recordOffsiteDataAt is CALLED by
// runOffboxInternal after a successful snapshot, not only testable on its own).
func TestA4_TheOffsiteRunRecordsThePushedDataTime(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
u := mkUnit(t, drive, "immich")
if err := writeManifest(UnitManifestFile(u), &RecoveryManifest{AppName: "immich", Data: &UnitData{At: "2026-09-25T02:15:01Z"}}); err != nil {
t.Fatal(err)
}
prov.hdd["immich"] = drive
prov.has["immich"] = true
_ = sett.SetAppOffbox("immich", true)
m.SetOffsitePreDumpFn(func(context.Context) error { return nil })
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
switch {
case contains(args, "cat") && contains(args, "config"):
return []byte(`{"version":2}`), nil
case contains(args, "snapshots"):
return []byte(`[]`), nil
case contains(args, "stats"):
return []byte(`{"total_size":123}`), nil
}
return nil, nil
})
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
rec, ok := sett.GetOffsiteDataAt("immich")
if !ok || rec.DataAt != "2026-09-25T02:15:01Z" || rec.PushedAt == "" {
t.Fatalf("after a successful push the data-time record is %+v ok=%v, want the unit's data time", rec, ok)
}
}
// R-699 (v0.275.0) — a just-installed app's unit, captured by the status refresh before any backup ran,
// holds only the definition. It stays LISTED (restorable as the definition) but is never a copy the
// update's precondition leans on; after a data run it is.
//
// COMPANION RED-PROOF (REPORT.md): drop the DataProven skip in updateTierPoint — the precondition then
// accepts the dump-less unit ("Tier 1 … 0s old"), which is what 9202 logged for tandoor on 2026-09-27.
func TestR699_ADefinitionOnlyUnitIsNotAPreconditionCopy(t *testing.T) {
v := newVTStack(t, "16")
if err := v.m.captureRecoveryUnit("app", false); err != nil { // the refresh, right after the install
t.Fatal(err)
}
pts, _ := v.m.ListRestorePoints("app")
if len(pts) != 1 || pts[0].DataProven {
t.Fatalf("points = %+v, want one listed, NOT proven", pts)
}
any := func(UpdateTierPoint) bool { return true }
if p, ok, _ := v.m.UpdateRestorePoints(context.Background(), "app", any); ok {
t.Fatalf("the precondition accepted tier %d at %s — a unit with no data is not a copy", p.Tier, p.At)
}
// The update's own "back up first" is a data run: afterwards the unit IS a copy.
v.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil
}
v.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult {
p := filepath.Join(dir, "app-postgres.sql")
mustWrite(t, p, pgDump(1))
return DumpResult{DB: db, FilePath: p}
}
v.m.perAppTier2 = func(string) error { return nil }
if err := v.m.RunAppBackupNow(context.Background(), "app"); err != nil {
t.Fatal(err)
}
if _, ok, _ := v.m.UpdateRestorePoints(context.Background(), "app", any); !ok {
t.Fatal("after a backup the own unit is still not a copy")
}
}
func TestR699_ADefinitionOnlyMirrorIsNotAPreconditionCopy(t *testing.T) {
p := Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: "2026-09-27T09:36:36Z", DataDate: "2026-09-27T09:36:36Z", DataUnproven: true}
if _, ok := p.ProvenCopyTime(); ok {
t.Fatal("a mirror of a definition-only unit counted as a proven copy")
}
}
+3 -3
View File
@@ -219,7 +219,7 @@ func (h *admissionHarness) runOneBackupRun() {
done := h.m.beginAdmissionRun()
defer done()
h.m.runVolumeDumps()
h.m.captureAllRecoveryUnits()
h.m.captureAllRecoveryUnits(false)
}
// ── The instrument: a checksum of the whole backup tree ──────────────────────────────────────────
@@ -664,7 +664,7 @@ func TestAdmission_IsWiredIntoEveryProductionWriteLeg(t *testing.T) {
// 2. The DB leg consults it BEFORE the dump. Order is the whole point: a gate after the write is
// the defect, relocated.
assertGateBefore(t, calls["runDBDumpsInternal"], "admitApp", "DumpOne",
assertGateBefore(t, calls["runDBDumpsInternal"], "admitApp", "dumpOneOrDefault", // v0.275.0: the dump goes through the seam wrapper
"the DATABASE leg dumps before consulting the reserve")
// 3. The volume leg consults it BEFORE the dump seam — which stops the stack as its first act.
@@ -691,7 +691,7 @@ func TestAdmission_IsWiredIntoEveryProductionWriteLeg(t *testing.T) {
}
return true
})
assertGateBefore(t, capCalls["captureAllRecoveryUnits"], "admitApp", "CaptureRecoveryUnit",
assertGateBefore(t, capCalls["captureAllRecoveryUnits"], "admitApp", "captureRecoveryUnit", // v0.275.0: the data-run flag is passed through
"the CAPTURE leg captures before consulting the reserve")
}
@@ -27,6 +27,7 @@ type AppBackupInfo = appbackup.AppBackupInfo
type AppDataPath = appbackup.AppDataPath
type AppDockerVolume = appbackup.AppDockerVolume
type RecoveryInfo = appbackup.RecoveryInfo
type GateState = appbackup.GateState
type ClassifiedBind = appbackup.ClassifiedBind
// --- type aliases (dbdump) ---
@@ -62,6 +62,23 @@ func (r AppStopReason) humanReason() string {
}
}
// MessageKey is the bundle key that names this reason in the household's language (R-585), or "" for a
// reason this build has no sentence for — the caller then shows the raw reason, as humanReason does.
// Pinned against humanReason by TestR585_InterruptedOperationFollowsTheHousehold (internal/notify): the
// English bundle text must equal what the operator's log line says.
func (r AppStopReason) MessageKey() string {
switch r {
case ReasonVolumeDump:
return "appstop.reason.volume_dump"
case ReasonOffboxReconstitute:
return "appstop.reason.offbox_reconstitute"
case ReasonAppExport:
return "appstop.reason.app_export"
default:
return ""
}
}
// AppStopMarker is the persisted "these apps were stopped by an operation that has not reported
// finishing — they are owed a restart" note.
type AppStopMarker struct {
@@ -279,7 +296,14 @@ func (g *AppStopGuard) Recover() *AppStopRecovery {
m.Reason.humanReason(), m.OpID, len(m.Stacks), m.Stacks)
res := &AppStopRecovery{Reason: m.Reason, OpID: m.OpID, StartedAt: m.StartedAt}
hs, _ := g.starter.(interface{ WantsStopped(string) bool })
for _, name := range m.Stacks {
if hs != nil && hs.WantsStopped(name) {
// R-721 (v0.283.1): the household pressed Stop while the interrupted operation had the app down.
// It is owed nothing; the marker still clears below.
g.logger.Printf("[INFO] [appstop] crash recovery: NOT restarting %s — the household stopped it", name)
continue
}
if err := g.starter.StartStack(name); err != nil {
// R-174: a REFUSAL is not a failure. The starter's gate has said this app must not be
// started (an absent data drive), so the app is left down deliberately and the holder
+275 -31
View File
@@ -3,9 +3,9 @@ package backup
import (
"context"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
@@ -13,8 +13,10 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// Manager orchestrates app-data backups: database dumps and Docker-volume tars.
@@ -23,6 +25,14 @@ import (
// moved out of the controller into the host agent (slice 8C). This Manager now
// only owns the app-data domain.
type Manager struct {
// R-897: when this controller started, and the seams of the post-boot drive wait (driveready.go). Zero
// startedAt = no wait (a Manager built outside NewManager, e.g. a test that does not ask for it).
startedAt time.Time
driveLive func(root string) bool
driveWaitPoll func(time.Duration)
nowFn func() time.Time
driveWarned map[string]bool
driveMu sync.Mutex // its own lock: the capture sweep may run while m.mu is held elsewhere
cfg *config.Config
logger *log.Logger
settings *settings.Settings
@@ -111,10 +121,20 @@ type Manager struct {
// offboxSSH (v0.142.0) is the raw-ssh exec seam for the orphaned-repo move-aside (restic has no
// rename); tests inject a fake. Nil → the real ssh invocation (defaultOffboxSSH).
offboxSSH func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error)
// offsiteMoveAside (v0.289.0, decision 69) asks the hub to set the repository aside.
offsiteMoveAside func(ctx context.Context) (string, error)
// offsiteWindow (v0.289.0, decision 68) asks the hub for a clean-up window. nil → no box retention.
offsiteWindow OffsiteWindowClient
// offsiteAbandon (v0.290.0, decision 74) hands a due set-aside deletion to the hub.
offsiteAbandon OffsiteAbandonClient
// offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable
// in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb).
offboxSizer func(path string) int64
// offsiteFit (decision 50, v0.283.0) — the last off-site size estimate against the quota, for the page.
offsiteFitMu sync.Mutex
offsiteFit OffsiteFit
offsiteFitRunning bool
// offboxNow (v0.206.0, R-241) is the abandonment countdown's clock. Nil → time.Now.
//
// IT EXISTS SO THE TERMINAL STEP IS TESTABLE WITHOUT SHORTENING A LIVE TIMER (§7.4). The sweep is
@@ -143,6 +163,9 @@ type Manager struct {
// offboxFreeFn (3a) — the free-space probe for the restore headroom gate, overridable in tests (the
// Windows `go test` host has no `df`). Nil → the real diskFreeBytes (df --output=avail).
offboxFreeFn func(path string) int64
// driveMountedFn (R-362) answers "is this registered drive still mounted?" when a restore cannot
// create its directory. nil → system.IsMountPoint. Tests inject it; production never sets it.
driveMountedFn func(path string) bool
// offboxLatestSnapFn (R-357) overrides the restic snapshot lookup, and it exists for one reason:
// without it, ReconstituteFromOffsite's new headroom gate cannot be tested at the level that
@@ -154,6 +177,13 @@ type Manager struct {
// INIT/TEST ONLY. Nil in production, where offboxLatestSnapshot runs unchanged.
offboxLatestSnapFn func(ctx context.Context, stack string) (string, []string, error)
// keptUnitRestoreFn (R-691 (2)) — the kept load's unit restore; nil → RestoreFromRecoveryUnitAt.
// INIT/TEST ONLY.
keptUnitRestoreFn func(app, unitDir string) (UnitRestoreResult, error)
// hollow (Part D, v0.279.0) — the running apps whose newest copy holds no data. See hollow_watch.go.
hollow hollowWatch
// F17 restore seams — overridable in tests so the .sql re-import orchestration can be unit-tested
// without Docker. Default to the real DiscoverDatabases / ImportDump (lazy-init in reimportDBDumps).
discoverDBs func(ctx context.Context) ([]DiscoveredDB, error)
@@ -170,8 +200,21 @@ type Manager struct {
// disconnected) can be unit-tested without Docker. Nil → the real DumpAppVolumesSafe.
dumpVolumesSafe func(stackName string) error
// dumpOne (v0.275.0) — the per-database dump seam, nil → the real DumpOne. It lets a test drive the
// REAL legs (and so the stamps they write) without a database container.
dumpOne func(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.Logger, debug bool) DumpResult
// stampMu serialises writes of a unit's data-stamps.json (v0.275.0, data_versions.go). The legs run
// under the running flag already; this keeps a stray concurrent caller from losing a stamp.
stampMu sync.Mutex
// updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck.
updatingCheck func(stackName string) bool
// versionCheck (R-645) — nil-safe; see versionSkip / SetVersionCheck.
versionCheck func(stackName string) (detail string, mismatch bool)
// undoCopyRemover (R-671, v0.272.0) deletes an app's leftover undo copies — stacks.Manager.RemoveUndoCopies,
// wired in main.go (SetUndoCopyRemover). nil-safe: without it the copies stay, as before.
undoCopyRemover func(stackName string) int
// R-475 update-precondition seams, one per tier. Nil → the real Tier2UnitRestorePoint /
// ListRestorePoints / OffsiteSnapshotTimes. They let a test reach Tier 1 and Tier 3 without a drive
@@ -179,6 +222,8 @@ type Manager struct {
updateTier2PointFn func(stackName string) (Tier2RestorePoint, error)
updateTier1PointsFn func(stackName string) ([]RestorePoint, bool)
updateOffsiteTimesFn func(ctx context.Context) (map[string]time.Time, error)
// freeBytesFn (v0.269.0, decision 26): the whole restore's disk-floor check; nil → statfs.
freeBytesFn func(path string) int64
// R-354 volume-REPLAY seam — the mirror of the F17 DB seams above, so the off-site path's new
// volume leg is unit-testable without Docker. Nil → the real restoreDockerVolumesFrom.
@@ -241,6 +286,11 @@ type Manager struct {
// and Tier-2 correctly refuses them — which makes the off-drive tests unrunnable rather than wrong.
// Nil → the real system.SamePhysicalDevice (production always takes this path).
samePhysicalDevice func(a, b string) bool
// diskJudge (v0.317.0, felhom.eu architecture/16-storage-physical-identity.md §4) asks the HOST AGENT
// whether two guest paths share a physical disk. supported=false (agent older than 0.157.0, or the
// call failed) → diskRelation keeps the old st_dev rule, logged once. nil → the old rule (tests).
diskJudge func(a, b string) (v DiskVerdict, supported bool)
diskLegacyOnce sync.Once
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
@@ -267,6 +317,9 @@ type Manager struct {
// R-550: persistence of the above (restore_record.go) and the per-app interrupted notices.
opRecordPath string
opInterrupted map[string]RestoreOpResult
// R-519: the app-data run record (run_record.go) and the start of the last run a stop cut off (zero = none).
runRecordPath string
runInterrupted time.Time
// Cached status for page rendering (refreshed periodically)
cachedStatus *FullBackupStatus
@@ -279,9 +332,13 @@ type FullBackupStatus struct {
Running bool
// DB Dumps
LastDBDump *DBDumpStatus
DumpFiles []DumpFileInfo
DiscoveredDBs []DiscoveredDB
LastDBDump *DBDumpStatus
// InterruptedRunAt (R-519): the start of the last app-data run a power cut or a restart cut off; zero = the
// newest run finished. Shown on /backups and /backups/apps until a run ends with every step OK.
InterruptedRunAt time.Time
Interrupted bool // InterruptedRunAt is set (the template gate: a time.Time is always "true" to `if`)
DumpFiles []DumpFileInfo
DiscoveredDBs []DiscoveredDB
// Schedule
DBDumpSchedule string
@@ -304,10 +361,6 @@ type FullBackupStatus struct {
// backup used to render with a blank drive label). Matches the tier-2 UI wording.
const systemDriveLabel = "Belső SSD (rendszer)"
// singleCopyNotice is the honest single-drive signal (F6): shown when no off-drive tier-2 target
// exists, instead of silently implying a 3-2-1 guarantee the box cannot provide.
const singleCopyNotice = "Csak egy másolat készül (nincs második meghajtó) — a 3-2-1 mentéshez csatlakoztasson egy második meghajtót vagy offsite tárolót."
// DBDumpStatus holds the last DB dump result.
type DBDumpStatus struct {
LastRun time.Time
@@ -326,6 +379,7 @@ func NewManager(cfg *config.Config, sett *settings.Settings, logger *log.Logger)
logger: logger,
settings: sett,
systemDataPath: cfg.Paths.SystemDataPath,
startedAt: time.Now(),
}
// R-166: its OWN file next to quiesce-state.json, never inside it — one file, one writer.
m.appStop = NewAppStopGuard(filepath.Join(cfg.Paths.DataDir, "appstop-state.json"), logger)
@@ -516,6 +570,11 @@ func (m *Manager) beginOffsiteRunStamp(runID string) func() {
func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
start := time.Now()
m.logger.Printf("[INFO] [backup] Starting database dump run")
// R-519: the run is on record while it runs; a stop before markRunEnded leaves it "running", which the next
// start turns into the page's interrupted-run notice (run_record.go). Every return path ends the record.
m.markRunStarted(start)
runOK := false
defer func() { m.markRunEnded(runOK) }()
// R-181: open the per-run admission scope HERE, because this function is the single orchestrator
// of all three write legs. Each app's reserve verdict is taken at its first write of this run and
@@ -532,7 +591,13 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
defer m.beginRunSummary(kind, newRunID())()
defer m.emitRunSummary()
dbs, err := DiscoverDatabases(ctx, m.logger, m.isDebug(), m.knownStackNames())
discover := m.discoverDBs
if discover == nil {
discover = func(ctx context.Context) ([]DiscoveredDB, error) {
return DiscoverDatabases(ctx, m.logger, m.isDebug(), m.knownStackNames())
}
}
dbs, err := discover(ctx)
if err != nil {
m.logger.Printf("[ERROR] [backup] Database discovery failed: %v", err)
return err
@@ -567,6 +632,14 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
continue
}
// R-645 (09 §3 decision 142): an app not running its pinned version is left alone — its dump
// would land in the unit beside the good definition and become the restore point. A SKIP: the
// unit keeps the last good copy, and the backups page says why.
if why, skip := m.versionSkip(db.StackName); skip {
m.logger.Printf("[WARN] [backup] Skipping DB dump for %s — it is not running its pinned version (%s); the last good backup is kept (R-645)", db.StackName, why)
summary = append(summary, fmt.Sprintf("SKIP %s (not running its pinned version)", db.ContainerName))
continue
}
// R-181: the reserve, BEFORE the first byte of this app's backup is written. This is usually
// where an app's verdict is taken, because the DB leg runs first; the volume leg and the
// capture then read the same memo. SKIP, not FAIL — a deliberate hold is not a broken dump,
@@ -579,7 +652,7 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), db.StackName)
result := DumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
result := m.dumpOneOrDefault(ctx, db, dumpDir)
results = append(results, result)
if result.Error != nil {
@@ -590,6 +663,8 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
} else {
totalSize += result.Size
summary = append(summary, fmt.Sprintf("OK %s (%s)", result.DB.ContainerName, humanizeBytes(result.Size)))
// v0.275.0 (R-696): the dump records the versions that wrote it, at the moment it is written.
m.stampDataFile(db.StackName, RecoveryUnitPath(m.namespaceRoot(drivePath), db.StackName), "db-dumps/"+filepath.Base(result.FilePath))
// Persist validation result to settings.json
if m.settings != nil && result.FilePath != "" {
@@ -637,14 +712,18 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
strings.Join(failedSummaryLines(summary), "; "))
}
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest).
m.captureAllRecoveryUnits()
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest). A DATA run:
// the capture folds the stamps the legs above just wrote (v0.275.0).
m.captureAllRecoveryUnits(true)
// Part D (v0.279.0): a RUNNING app whose unit still holds no data after this run is an alarm.
m.checkLocalCopies()
// F5 (CAMPAIGN-3): after the units are fresh on the CURRENT drives, prune any orphaned
// backups/primary/<app> dir an app left on an OLD drive when its HDD_PATH moved — pure disk
// residue, invisible in the snapshot list. Guarded (deployed + different current drive only).
m.pruneStalePrimaryDirs()
runOK = allOK
// No silent partials: a DB-dump or volume-dump failure fails the whole run.
if !allOK {
return fmt.Errorf("some backup steps failed: %s", strings.Join(failedSummaryLines(summary), "; "))
@@ -702,6 +781,12 @@ func (m *Manager) runVolumeDumps() (summary []string, dumped int, allOK bool) {
summary = append(summary, fmt.Sprintf("SKIP %s volumes (held)", stack.Name))
continue
}
// R-645: never stop, dump or restart an app that is not running its pinned version.
if why, skip := m.versionSkip(stack.Name); skip {
m.logger.Printf("[WARN] [backup] Skipping volume dump for %s — it is not running its pinned version (%s); the last good backup is kept (R-645)", stack.Name, why)
summary = append(summary, fmt.Sprintf("SKIP %s volumes (not running its pinned version)", stack.Name))
continue
}
// Volume check FIRST — a volume-less stack must not be stopped at all (see gate-order note).
if len(m.stackProvider.GetDockerVolumes(stack.Name)) == 0 {
if m.isDebug() {
@@ -722,6 +807,16 @@ func (m *Manager) runVolumeDumps() (summary []string, dumped int, allOK bool) {
continue
}
// R-634 (v0.265.0): an app whose deploy is still running is NOT an installation to back up,
// and stopping it is what broke the deploy. The list is taken once at the start of the run, so
// the question is asked again here, immediately before the stop. A SKIP, not a failure: there
// is nothing of the household's in it yet, and a FAIL would page the operator for a deploy.
if m.stackIsDeploying(stack.Name) {
m.logger.Printf("[INFO] [backup] Skipping volume dump for %s — the app is still being deployed (R-634)", stack.Name)
summary = append(summary, fmt.Sprintf("SKIP %s volumes (deploying)", stack.Name))
continue
}
// R-181: the reserve, ahead of DumpAppVolumesSafe so a refused app is NOT stopped. For an app
// that already has a DB this is a memo lookup taken before its DB dump; for a volume-only app
// this is where its verdict is taken, still before its first byte.
@@ -806,6 +901,8 @@ func (m *Manager) DumpAppVolumes(stackName string) error {
if info, _ := os.Stat(tarPath); info != nil {
m.logger.Printf("[INFO] [backup] Volume dump: %s/%s → %s", stackName, volName, humanizeBytes(info.Size()))
}
// v0.275.0 (R-696): the tar records the versions that wrote it.
m.stampDataFile(stackName, RecoveryUnitPath(m.namespaceRoot(drivePath), stackName), "volume-dumps/"+volName+".tar")
}
// Clean up tars (and any orphan `.tar.tmp` from a killed run) for volumes that no longer exist.
@@ -844,7 +941,7 @@ func (m *Manager) tarVolumeOrDefault(volName, dumpDir string) ([]byte, error) {
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
cmd := dockerexec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol:ro",
"-v", dumpDir+":/out",
"alpine", "tar", "cf", "/out/"+volName+".tar.tmp", "-C", "/vol", ".")
@@ -913,6 +1010,12 @@ func (m *Manager) DumpAppVolumesSafe(stackName string) error {
dumpErr := m.DumpAppVolumes(stackName)
if hs, ok := m.stackProvider.(interface{ WantsStopped(string) bool }); ok && hs.WantsStopped(stackName) {
// R-721: the household pressed Stop while the dump had the app down — it stays stopped.
m.logger.Printf("[INFO] [backup] %s NOT restarted after the volume dump: the household stopped it meanwhile", stackName)
m.appStop.End() // nothing is owed a restart — the household wants it stopped
return dumpErr
}
m.logger.Printf("[INFO] [backup] Restarting %s after volume dump", stackName)
startErr := m.stackProvider.StartStack(stackName)
if startErr != nil {
@@ -1141,7 +1244,7 @@ func (m *Manager) RefreshCache(nextDBDump time.Time) {
func() {
defer m.beginRunSummary(runKindRefresh, "")()
defer m.emitRunSummary()
m.captureAllRecoveryUnits()
m.captureAllRecoveryUnits(false) // a REFRESH: never moves the unit's data time or its definition away from its data
}()
}
@@ -1149,6 +1252,7 @@ func (m *Manager) RefreshCache(nextDBDump time.Time) {
m.mu.Lock()
status.Running = m.running
status.LastDBDump = m.lastDBDump
status.InterruptedRunAt, status.Interrupted = m.runInterrupted, !m.runInterrupted.IsZero()
// Cross-check lastDBDump results inside lock to prevent torn writes.
if m.lastDBDump != nil && len(files) > 0 {
@@ -1191,7 +1295,8 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
// Update dynamic fields that don't need subprocess calls
status.Running = m.running
status.NextDBDump = nextDBDump
status.SingleCopyWarning = m.singleCopyWarning() // F6: honest single-drive signal
status.SingleCopyWarning = m.singleCopyWarning() // F6: honest single-drive signal
status.InterruptedRunAt, status.Interrupted = m.runInterrupted, !m.runInterrupted.IsZero() // R-519
// Deep-copy lastDBDump so callers cannot mutate shared state.
if m.lastDBDump != nil {
copyDump := *m.lastDBDump
@@ -1207,6 +1312,12 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
var results []DumpResult
var latestTime time.Time
for _, f := range status.DumpFiles {
// 2026-10-10: a `pre-restore-` undo copy (R-379) is not part of any run — it stays in the file
// list, never in the run's table (demo-hp listed adventurelog four times after a restart).
// Pinned by TestSynthesisedRunListsNoUndoCopy.
if f.IsUndo {
continue
}
results = append(results, DumpResult{
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
FilePath: f.FileName,
@@ -1217,10 +1328,15 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
latestTime = f.ModTime
}
}
status.LastDBDump = &DBDumpStatus{
LastRun: latestTime,
Results: results,
Success: true,
// R-519: after a restart the run's result is not in memory, so it is SYNTHESISED from the files. A run
// the stop cut off left a fresh .sql beside last night's tars — "Success" must not be read off that.
// Only undo copies on disk → no run to show (nil, as with no files at all).
if len(results) > 0 {
status.LastDBDump = &DBDumpStatus{
LastRun: latestTime,
Results: results,
Success: m.runInterrupted.IsZero(),
}
}
}
@@ -1234,6 +1350,8 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
DBDumpSchedule: m.cfg.Backup.DBDumpSchedule,
NextDBDump: nextDBDump,
SingleCopyWarning: m.singleCopyWarning(), // F6
InterruptedRunAt: m.runInterrupted, // R-519
Interrupted: !m.runInterrupted.IsZero(),
}
if m.lastDBDump != nil {
copyDump := *m.lastDBDump
@@ -1246,38 +1364,121 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
return status
}
// DiskVerdict is the agent's answer to "do these two paths share a physical disk?" (`16` §4).
type DiskVerdict struct {
Different bool
Proven bool
Reason string // "" | same_disk | vm | network | usb_bridge | weak_wwn | no_serial | unmapped
}
// DiskRelation is what a Tier-2 decision acts on.
type DiskRelation int
const (
// DiskDifferent — a different physical disk, proven by the agent (or the old st_dev rule said so):
// the copy is made and COUNTS as a second copy.
DiskDifferent DiskRelation = iota
// DiskSame — the same physical disk: no second copy is made (`16` D1).
DiskSame
// DiskUnknown — the agent cannot prove it (a VM, a USB bridge that hides the serial): the copy is made
// but does NOT count, and the page says why (`16` D2).
DiskUnknown
)
// SetDiskJudge wires the agent's same-disk call (main: agentapi FeatureSameDisk).
func (m *Manager) SetDiskJudge(fn func(a, b string) (DiskVerdict, bool)) { m.diskJudge = fn }
// diskRelation is the ONE place a Tier-2 or one-copy decision asks "same disk?" (`16` §4). The agent's
// verdict wins when it can give one; "unmapped" (a path the agent did not make, e.g. an absent drive) and
// an older agent fall back to today's st_dev rule — never a silent change, never a crash. Pinned by
// diskrelation_test.go.
func (m *Manager) diskRelation(a, b string) (DiskRelation, string) {
if m.diskJudge != nil {
v, ok := m.diskJudge(a, b)
if ok && v.Reason != "unmapped" {
switch {
case v.Proven && v.Different:
return DiskDifferent, ""
case v.Proven:
return DiskSame, v.Reason
default:
return DiskUnknown, v.Reason
}
}
if !ok {
m.diskLegacyOnce.Do(func() {
m.logger.Printf("[INFO] [backup] same-disk: the host agent cannot answer (older than 0.157.0, or the call failed) — using the filesystem-device rule")
})
}
}
if m.sameDevice(a, b) {
return DiskSame, "st_dev"
}
return DiskDifferent, "st_dev"
}
// sameDevice reports whether two paths sit on the same physical device, through the test seam when
// one is installed. Nil seam → system.SamePhysicalDevice, i.e. byte-for-byte the previous behaviour.
func (m *Manager) sameDevice(a, b string) bool {
if m.samePhysicalDevice != nil {
return m.samePhysicalDevice(a, b)
}
// R-668 (v0.269.0): FAIL CLOSED. system.SamePhysicalDevice answers "different" when either path cannot
// be stat'd, and a Tier-2 target chosen on that answer is written wherever the path gets re-created —
// measured on 9202 2026-09-24: a removed folder on nextcloud's OWN disk became its "second drive". A
// path we cannot read is treated as the SAME disk: no second copy is claimed on it.
if _, err := os.Stat(a); err != nil {
m.logger.Printf("[WARN] [backup] same-disk check: %s cannot be read (%v) — treated as the same disk", a, err)
return true
}
if _, err := os.Stat(b); err != nil {
m.logger.Printf("[WARN] [backup] same-disk check: %s cannot be read (%v) — treated as the same disk", b, err)
return true
}
return system.SamePhysicalDevice(a, b)
}
// hasOffDriveTarget reports whether any registered, schedulable storage path lives on a physical disk
// OTHER than the system drive — i.e. whether a genuine off-drive (tier-2) copy is possible at all.
// When false the box is single-drive: tier-1 is the ONLY local copy and 3-2-1 needs a 2nd drive or
// offsite (F6 — surfaced honestly via SingleCopyWarning, never a faked guarantee).
func (m *Manager) hasOffDriveTarget() bool {
// offDriveCopyState reports whether any registered, schedulable, NON-network storage path is on a physical
// disk OTHER than the system drive — counted only when proven (`16` §4). unknown is true when such a path
// exists but the agent cannot prove it is another disk (a VM, a hidden USB serial). A NAS never counts
// (R-1075: it is never a Tier-2 target, so it must not hide the one-copy notice).
func (m *Manager) offDriveCopyState() (counted, unknown bool) {
if m.settings == nil || m.systemDataPath == "" {
return false
return false, false
}
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if sp.Path == m.systemDataPath || m.sameDevice(m.systemDataPath, sp.Path) {
if sp.Path == m.systemDataPath || sp.IsNetwork() {
continue
}
return true
switch rel, _ := m.diskRelation(m.systemDataPath, sp.Path); rel {
case DiskDifferent:
return true, false
case DiskUnknown:
unknown = true
}
}
return false
return false, unknown
}
// singleCopyWarning returns the honest single-drive notice, or "" when an off-drive target exists.
// hasOffDriveTarget reports whether a COUNTED off-drive (tier-2) copy is possible at all.
func (m *Manager) hasOffDriveTarget() bool {
counted, _ := m.offDriveCopyState()
return counted
}
// singleCopyWarning returns the honest notice (`16` §4), or "" when a counted off-drive target exists:
// "only one copy" when there is no second disk (or it is the same disk); "cannot check" when a second
// drive exists whose disk the agent cannot prove. Both sentences are i18n (informal voice — R-1072).
func (m *Manager) singleCopyWarning() string {
if m.hasOffDriveTarget() {
counted, unknown := m.offDriveCopyState()
switch {
case counted:
return ""
case unknown:
return m.note("note.backup.single_copy_unproven")
default:
return m.note("note.backup.single_copy")
}
return singleCopyNotice
}
// sysDriveLabelFor returns the drive label for a stack's tier-1 restore point — the clear
@@ -1375,3 +1576,46 @@ func dbNames(dbs []DiscoveredDB) string {
}
return strings.Join(names, ", ")
}
// boxLang is the language a SAVED note is written in (release C, R-557 slice 2 option 1).
//
// A background run has no request and no reader, so it writes in the box's language at the moment it
// writes. A household that switches sees last night's note in the old language until the next run
// rewrites it — the operator's ruling, and the reason the reader never translates stored text.
func (m *Manager) boxLang() string {
if m.settings == nil {
return i18n.Default
}
return m.settings.GetLanguage()
}
// note renders a saved note in the box's language.
func (m *Manager) note(key string, args ...interface{}) string {
return util.Text(m.boxLang(), key, args...)
}
// noteErr renders an error into a saved note in the box's language: its bundle message when it
// carries one (release B), its own text otherwise.
func (m *Manager) noteErr(err error) string { return util.ErrText(m.boxLang(), err) }
// deployingReporter is the OPTIONAL half of the stack provider that can say a deploy is in flight
// (R-634). Optional on purpose: the production adapter implements it, and the many test fakes that
// predate it need not — a provider that cannot answer is read as "not deploying", today's behaviour.
type deployingReporter interface {
IsDeploying(name string) bool
}
func (m *Manager) stackIsDeploying(name string) bool {
if d, ok := m.stackProvider.(deployingReporter); ok {
return d.IsDeploying(name)
}
return false
}
// dumpOneOrDefault runs the dumpOne seam, or the real DumpOne.
func (m *Manager) dumpOneOrDefault(ctx context.Context, db DiscoveredDB, dumpDir string) DumpResult {
if m.dumpOne != nil {
return m.dumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
}
return DumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
}
@@ -112,7 +112,7 @@ func TestFloor_RefusesTheAppAndLeavesItsPreviousUnitByteIdentical(t *testing.T)
}
before := checksumFile(t, prev)
h.m.captureAllRecoveryUnits()
h.m.captureAllRecoveryUnits(false)
// The refused app must NOT have been attempted at all — the floor is checked BEFORE any write.
for _, hit := range h.prov.infoHits {
@@ -170,7 +170,7 @@ func TestFloor_NeverDeletesAnotherAppsUnit(t *testing.T) {
}
before := checksumFile(t, keep)
h.m.captureAllRecoveryUnits()
h.m.captureAllRecoveryUnits(false)
if _, err := os.Stat(keep); err != nil {
t.Fatalf("another app's unit was DELETED to make room: %v — nothing here is generational, so "+
@@ -190,7 +190,7 @@ func TestFloor_LargeUnitWithAmpleSpaceIsCaptured(t *testing.T) {
// A huge app on a huge, mostly-empty filesystem: 40% used, 600 GB free.
h.usage["immich"] = &UnitSpace{Path: h.dir, UsedPercent: 40, AvailGB: 600, TotalGB: 1000, UsedGB: 400}
h.m.captureAllRecoveryUnits()
h.m.captureAllRecoveryUnits(false)
if len(h.events) != 0 {
t.Fatalf("a capture was refused on a filesystem with 600 GB free (%+v) — the floor has become "+
@@ -209,7 +209,7 @@ func TestFloor_TheOld20GCeilingIsGone(t *testing.T) {
// deliberately far above 20 so that a literal `UsedGB > 20` cap cannot survive this test: a
// fixture sitting exactly on the old boundary would pass under the very shape it forbids.
h.usage["immich"] = &UnitSpace{Path: h.dir, UsedPercent: 40, AvailGB: 180, TotalGB: 300, UsedGB: 120}
h.m.captureAllRecoveryUnits()
h.m.captureAllRecoveryUnits(false)
if len(h.events) != 0 {
t.Fatalf("refused with 180 GB free: %+v — a fixed per-area limit survives somewhere", h.events)
}
@@ -258,7 +258,7 @@ func TestFloor_UnreadableFilesystemNeitherRefusesNorWarns(t *testing.T) {
h := newFloorHarness(t, "immich")
// No entry → the injected reader returns nil, which is what system.GetDiskUsage does on error.
h.m.captureAllRecoveryUnits()
h.m.captureAllRecoveryUnits(false)
if len(h.events) != 0 {
t.Fatalf("an UNREADABLE filesystem produced %d alert(s): %+v — an absent, unmounted or "+
@@ -275,7 +275,7 @@ func TestFloor_UnreadableFilesystemNeitherRefusesNorWarns(t *testing.T) {
func TestErrCaptureFloor_IsMatchable(t *testing.T) {
h := newFloorHarness(t, "immich")
h.setSpace("immich", 99, 0.2)
h.m.captureAllRecoveryUnits()
h.m.captureAllRecoveryUnits(false)
if len(h.events) != 1 {
t.Fatalf("want 1 event, got %d", len(h.events))
}
@@ -0,0 +1,50 @@
package backup
import (
"io"
"log"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Decision 28: the stop is a hold (same store — no start path revives it), Start lifts ONLY that kind,
// and a second stop within 24 h says support is informed.
func TestD28_HoldTripsAndLift(t *testing.T) {
sett := slice4Settings(t)
m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
t0 := time.Date(2026, 9, 24, 22, 0, 0, 0, time.UTC)
trip, err := m.HoldUnhealthy("gokapi", "crash_loop", t0)
if err != nil || trip != 1 {
t.Fatalf("trip=%d err=%v", trip, err)
}
held, why := m.RestoreHoldForLang("gokapi", "hu")
if !held || !strings.Contains(why, "Indítás gombbal") || strings.Contains(why, "gyfélszolgálat") {
t.Fatalf("first stop: held=%v %q", held, why)
}
if !m.UpdateHeldStacks()["gokapi"] {
t.Fatal("an unhealthy stop is a product stop — it must be in the app-down suppression set")
}
if m.HoldKind("gokapi") != settings.HoldReasonUnhealthyStop || !m.LiftUnhealthyStop("gokapi") {
t.Fatal("Start could not lift the unhealthy stop")
}
if held, _ := m.RestoreHoldFor("gokapi"); held {
t.Fatal("still held after Start")
}
trip, _ = m.HoldUnhealthy("gokapi", "oom_storm", t0.Add(3*time.Hour))
_, why = m.RestoreHoldForLang("gokapi", "en")
if trip != 2 || !strings.Contains(why, "support has been told") {
t.Fatalf("second stop within 24 h: trip=%d %q", trip, why)
}
m.LiftUnhealthyStop("gokapi")
if trip, _ = m.HoldUnhealthy("gokapi", "crash_loop", t0.Add(30*time.Hour)); trip != 1 {
t.Fatalf("a stop 27 h after the last is a first stop again, got trip %d", trip)
}
// Start never lifts another kind.
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "nextcloud", At: t0.Format(time.RFC3339), Reason: settings.HoldReasonUpdateFailed})
if m.LiftUnhealthyStop("nextcloud") {
t.Fatal("Start lifted an UPDATE hold")
}
}
+309
View File
@@ -0,0 +1,309 @@
package backup
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── The backup's data and its version travel together (controller v0.275.0, R-696, `07` §6.6) ────────
//
// WHAT WENT WRONG. The recovery unit's DEFINITION (compose/, image_pins) was re-captured by the periodic
// status refresh as soon as the app's pin moved, while its DATA (db-dumps/, volume-dumps/) is written
// only by the backup legs. So for up to a day after an update the unit said "new version" and held the
// old version's data. Measured on 9202 2026-09-26 (`audits/version-travel-2026-09-26/A1/`): after a
// PostgreSQL 16 → 18 step the unit held the 18 definition over a 16 dump and a 16 datadir tar; a restore
// poured the 16 datadir back, `postgres:18` refused it and the app was left down. And Tier 1's "proven
// at" was the manifest's refresh time, so the kept pre-conversion copy was released on a backup taken
// BEFORE the conversion (demo-hp, 2026-09-26 night).
//
// THE SHAPE. Every data file a backup leg writes gets a STAMP beside it, at the moment it is written:
// its size and mtime (so a file rewritten by anything else is recognised as unstamped), the definition's
// image pins, and what each service was running (`installed_images`, ref@digest). The unit capture folds
// the stamps into the manifest's `data` block — the time of the unit's data (its OLDEST stamped file:
// the copy is as fresh as its stalest part) and the versions that wrote it — and KEEPS the definition
// the data belongs to: when the pins have moved since the data was written, compose/ is not rewritten
// until the next data run replaces the data. The manifest's `image_pins` stays the app's CURRENT pins,
// so it says both. Restores read `data` to start the data with its own definition (restore_unit.go),
// and the update/release read `data.at` as the unit's time (restore_points.go).
//
// UNKNOWN IS NEVER CURRENT. A unit whose data files are not all validly stamped (written before
// v0.275.0, or by a path that does not stamp) has NO `data` block, restores as before with a WARN, and
// its time is the newest DATA file's mtime — never the manifest's.
// dataStampsFile sits in the unit root, beside manifest.json, so it travels with every copy of the unit
// (the Tier-2 mirror copies the whole unit, the off-site snapshot includes it).
const dataStampsFile = "data-stamps.json"
// DataStamp is one data file's record, written by the leg that wrote the file.
type DataStamp struct {
// At is the file's mtime when it was stamped, RFC3339Nano UTC — the stamp is valid only while the
// file still has exactly this mtime and Size.
At string `json:"at"`
Size int64 `json:"size"`
// Pins are the definition's image pins (the compose `image:` lines) when the file was written.
Pins []string `json:"pins"`
// Images is service -> ref@digest running when the file was written. Empty when not observed.
Images map[string]string `json:"images,omitempty"`
}
// UnitData is the manifest's account of the data the unit holds.
type UnitData struct {
// At is RFC3339 UTC: the unit's data time — the oldest data file's stamp, or, for a unit with no data
// files, the data run that confirmed it. This is the unit's "proven at", never the manifest's time.
At string `json:"at"`
// ImagePins are the definition pins the data belongs to — what compose/ holds. Empty when Mixed.
ImagePins []string `json:"image_pins"`
// Images is the running set (service -> ref@digest) when the oldest file was written.
Images map[string]string `json:"images,omitempty"`
// Files are the stamps, keyed by the path relative to the unit ("db-dumps/x.sql").
Files map[string]DataStamp `json:"files,omitempty"`
// Mixed: the files were written under DIFFERENT pins — no one definition fits all of them, so a
// restore of this unit is refused (a restore never starts data with a definition it does not belong to).
Mixed bool `json:"mixed,omitempty"`
}
// DataTime parses At. False when absent or unreadable.
func (d *UnitData) DataTime() (time.Time, bool) {
if d == nil || d.At == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, d.At)
if err != nil {
return time.Time{}, false
}
return t, true
}
func readDataStamps(unitDir string) map[string]DataStamp {
data, err := os.ReadFile(filepath.Join(unitDir, dataStampsFile))
if err != nil {
return map[string]DataStamp{}
}
var out map[string]DataStamp
if json.Unmarshal(data, &out) != nil || out == nil {
return map[string]DataStamp{}
}
return out
}
// stampDataFile records the file at <unitDir>/<rel> as written NOW by the current definition and
// running images. Called by the legs right after a dump or a tar is promoted to its final name. A failure
// to stamp is a WARN and leaves the file unstamped — the unit then reads as "versions unknown", which is
// the pre-v0.275.0 behaviour, never a false claim.
func (m *Manager) stampDataFile(stackName, unitDir, rel string) {
fi, err := os.Stat(filepath.Join(unitDir, rel))
if err != nil {
m.logger.Printf("[WARN] [backup] %s: cannot stamp %s (%v) — its versions will read as unknown", stackName, rel, err)
return
}
var pins []string
var images map[string]string
if m.stackProvider != nil {
if info, ok := m.stackProvider.GetStackRecoveryInfo(stackName); ok {
pins, images = definitionPins(info), info.InstalledImages
}
}
m.stampMu.Lock()
defer m.stampMu.Unlock()
stamps := readDataStamps(unitDir)
stamps[rel] = DataStamp{At: fi.ModTime().UTC().Format(time.RFC3339Nano), Size: fi.Size(), Pins: pins, Images: images}
// Entries whose file is gone (a volume the app no longer has) are dropped here, so the file stays small.
for k := range stamps {
if _, err := os.Stat(filepath.Join(unitDir, k)); err != nil {
delete(stamps, k)
}
}
body, err := json.MarshalIndent(stamps, "", " ")
if err == nil {
err = atomicWrite(filepath.Join(unitDir, dataStampsFile), append(body, '\n'), 0644)
}
if err != nil {
m.logger.Printf("[WARN] [backup] %s: writing the data stamp for %s failed (%v) — its versions will read as unknown", stackName, rel, err)
return
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [backup] %s: stamped %s (%d B) with pins %v", stackName, rel, fi.Size(), pins)
}
}
// foldUnitData builds the manifest's `data` block from the unit's data files and their stamps.
//
// - no data files: a data run confirms the unit NOW under the current pins; a refresh keeps `prev`;
// - every file validly stamped: the oldest stamp's time; its pins when all agree, else Mixed;
// - any file unstamped or re-written since its stamp: nil — the versions are UNKNOWN.
func foldUnitData(unitDir string, dbDumps, volDumps []string, dataRun bool, now time.Time, pins []string, images map[string]string, prev *UnitData) *UnitData {
var rels []string
for _, n := range dbDumps {
rels = append(rels, "db-dumps/"+n)
}
for _, n := range volDumps {
rels = append(rels, "volume-dumps/"+n)
}
if len(rels) == 0 {
if dataRun {
return &UnitData{At: now.UTC().Format(time.RFC3339), ImagePins: pins, Images: images}
}
return prev
}
stamps := readDataStamps(unitDir)
out := &UnitData{Files: map[string]DataStamp{}}
var oldest time.Time
for _, rel := range rels {
st, ok := stamps[rel]
if !ok {
return nil
}
fi, err := os.Stat(filepath.Join(unitDir, rel))
if err != nil || fi.Size() != st.Size || fi.ModTime().UTC().Format(time.RFC3339Nano) != st.At {
return nil
}
t := fi.ModTime().UTC()
if oldest.IsZero() || t.Before(oldest) {
oldest = t
out.ImagePins, out.Images = st.Pins, st.Images
}
out.Files[rel] = st
}
for _, st := range out.Files {
if !samePins(st.Pins, out.ImagePins) {
out.Mixed = true
}
}
if out.Mixed {
out.ImagePins, out.Images = nil, nil
}
out.At = oldest.Format(time.RFC3339)
return out
}
// samePins compares two pin lists as SETS (compose order is file order on both sides, but a set compare
// cannot be fooled by it).
func samePins(a, b []string) bool {
if len(a) != len(b) {
return false
}
x := append([]string(nil), a...)
y := append([]string(nil), b...)
sort.Strings(x)
sort.Strings(y)
return stringSliceEqual(x, y)
}
// unitDataEqual is the manifest-rewrite check's view of `data`.
func unitDataEqual(a, b *UnitData) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
if a.At != b.At || a.Mixed != b.Mixed || !stringSliceEqual(a.ImagePins, b.ImagePins) || len(a.Files) != len(b.Files) {
return false
}
for k, v := range a.Files {
w, ok := b.Files[k]
if !ok || v.At != w.At || v.Size != w.Size {
return false
}
}
return true
}
// PinsVersion is the household's name for a set of pins: every image as `name:tag`, registry path and
// digest stripped, in compose order ("docmost:0.96.0, postgres:16-alpine, redis:7-alpine"). All of them,
// because a version step can move ANY service — a PostgreSQL 16 → 18 step leaves the app's own tag as it
// was, and a label naming only the first image would read the same on both sides of it (seen on the
// first draft of the mismatch sentence: „(0.96.0) … (0.96.0)").
func PinsVersion(pins []string) string {
out := make([]string, 0, len(pins))
for _, ref := range pins {
if i := strings.Index(ref, "@"); i >= 0 {
ref = ref[:i]
}
if i := strings.LastIndex(ref, "/"); i >= 0 {
ref = ref[i+1:]
}
out = append(out, ref)
}
return strings.Join(out, ", ")
}
// recordOffsiteDataAt remembers the data time of the unit just pushed off-site (v0.275.0, R-696).
func (m *Manager) recordOffsiteDataAt(stackName, unitDir string) {
if m.settings == nil {
return
}
t, ok := unitNewestArtifact(unitDir)
if !ok {
return
}
now := time.Now().UTC().Format(time.RFC3339)
if err := m.settings.SetOffsiteDataAt(stackName, settings.OffsiteDataRecord{PushedAt: now, DataAt: t.UTC().Format(time.RFC3339)}); err != nil {
m.logger.Printf("[WARN] [offbox] %s: recording the pushed copy's data time failed: %v — the off-site copy is dated by its snapshot", stackName, err)
}
}
// offsiteDataTime caps an off-site snapshot's time with the data time this box recorded when it pushed
// it. A snapshot NEWER than the last recorded push (taken by another box, or a record lost) keeps its
// own time: the cap applies only to a copy this box knows the content of.
func (m *Manager) offsiteDataTime(stackName string, snapshotAt time.Time) time.Time {
if m.settings == nil {
return snapshotAt
}
rec, ok := m.settings.GetOffsiteDataAt(stackName)
if !ok {
return snapshotAt
}
pushed, perr := time.Parse(time.RFC3339, rec.PushedAt)
data, derr := time.Parse(time.RFC3339, rec.DataAt)
if perr != nil || derr != nil || snapshotAt.After(pushed) || !data.Before(snapshotAt) {
return snapshotAt
}
return data
}
// UnitDumpStamp is one stamped database dump of an app's own unit (v0.275.0).
type UnitDumpStamp struct {
File string
At time.Time
Images map[string]string
}
// UnitDumpStamps returns the app's OWN unit's database dumps as its manifest records them — only when
// the unit's data is known (a `data` block); an unstamped unit returns nothing, and a release that needs
// one waits (fail closed).
func (m *Manager) UnitDumpStamps(stackName string) []UnitDumpStamp {
man := readManifest(UnitManifestFile(m.primaryUnitDirFor(stackName)))
if man == nil || man.Data == nil {
return nil
}
var out []UnitDumpStamp
for rel, st := range man.Data.Files {
if !strings.HasPrefix(rel, "db-dumps/") || !strings.HasSuffix(rel, ".sql") {
continue
}
t, err := time.Parse(time.RFC3339Nano, st.At)
if err != nil {
continue
}
out = append(out, UnitDumpStamp{File: rel, At: t, Images: st.Images})
}
sort.Slice(out, func(i, j int) bool { return out[i].File < out[j].File })
return out
}
// definitionPins are the pins of the definition a capture copies into compose/: the stack dir's own
// docker-compose.yml, parsed. ONE source for the stamps, the fold and the freeze, so "the data's pins"
// and "the pins compose/ holds" are read from the same file the restore will start (production's
// RecoveryInfo.ImagePins is that parse too; a provider that says otherwise cannot make them disagree).
func definitionPins(info RecoveryInfo) []string {
if info.StackDir != "" {
if p := ParseComposeImages(filepath.Join(info.StackDir, "docker-compose.yml")); len(p) > 0 {
return p
}
}
return info.ImagePins
}
@@ -0,0 +1,137 @@
package backup
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// felhom.eu architecture/16-storage-physical-identity.md §4 — the agent's verdict decides Tier-2 and the
// one-copy notice. Each test asserts the CONSEQUENCE (is a target picked, does it count, what does the page
// say), not the mechanism.
// judgeBy answers per target path; paths not in the map are "unmapped" (→ the old st_dev rule).
func judgeBy(m map[string]DiskVerdict) func(a, b string) (DiskVerdict, bool) {
return func(a, b string) (DiskVerdict, bool) {
if v, ok := m[b]; ok {
return v, true
}
return DiskVerdict{Reason: "unmapped"}, true
}
}
var (
provenDifferent = DiskVerdict{Different: true, Proven: true}
provenSame = DiskVerdict{Different: false, Proven: true, Reason: "same_disk"}
unknownVM = DiskVerdict{Different: true, Proven: false, Reason: "vm"}
)
func addDrive(t *testing.T, sett *settings.Settings, sp settings.StoragePath) {
t.Helper()
sp.Schedulable = true
if err := sett.AddStoragePath(sp); err != nil {
t.Fatal(err)
}
}
// Row 1: different + proven → made, counted, notice hidden.
func TestDisk16_ProvenDifferent_CountedNoNotice(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/b": provenDifferent}))
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
if err != nil || tg.Unproven || tg.NamespaceRoot != "/mnt/b" {
t.Fatalf("want /mnt/b counted, got %+v err=%v", tg, err)
}
if w := m.singleCopyWarning(); w != "" {
t.Fatalf("a proven second disk must hide the notice, got %q", w)
}
}
// Row 2: same disk (two partitions) → NOT made (D1), notice shown — even though st_dev would say "different".
func TestDisk16_SameDisk_NotMadeAndNoticeShown(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/part2", Label: "P2"})
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return provenSame, true })
if tg, err := m.selectTier2TargetFrom("app", "/mnt/part1", 1, 1); err == nil {
t.Fatalf("a same-disk partition must not be a Tier-2 target, got %+v", tg)
}
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
t.Fatalf("same disk: want the one-copy notice, got %q", w)
}
}
// Row 3: unknown (a VM) → made, NOT counted (D2), notice shown with the "cannot check" sentence.
func TestDisk16_Unknown_MadeNotCountedCannotCheck(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/media_a", Label: "A"})
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return unknownVM, true })
tg, err := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1)
if err != nil || !tg.Unproven || tg.UnprovenReason != "vm" {
t.Fatalf("unknown: want the copy made and marked unproven, got %+v err=%v", tg, err)
}
w := m.singleCopyWarning()
if w != m.note("note.backup.single_copy_unproven") || !strings.Contains(w, "nem tudja ellenőrizni") {
t.Fatalf("unknown: want the cannot-check notice, got %q", w)
}
// A proven-different drive beats an unknown one.
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/real", Label: "R"})
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/felhom-drives/media_a": unknownVM, "/mnt/felhom-drives/real": provenDifferent}))
if tg, _ := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1); tg == nil || tg.NamespaceRoot != "/mnt/felhom-drives/real" || tg.Unproven {
t.Fatalf("a proven drive must win over an unknown one, got %+v", tg)
}
}
// Row 4: a network share → never a Tier-2 target and never hides the notice (R-1075).
func TestDisk16_NetworkShare_NeverHidesTheNotice(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/nas", Label: "NAS", Kind: settings.StorageKindNetwork})
// No judge at all — the OLD st_dev rule calls the NAS "different", which is exactly R-1075.
if m.hasOffDriveTarget() {
t.Fatal("R-1075: a NAS made the box claim an off-drive copy")
}
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
t.Fatalf("a NAS must not hide the one-copy notice, got %q", w)
}
if tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1); err == nil && !tg.IsSystemDrive {
t.Fatalf("a NAS must never be a Tier-2 target, got %+v", tg)
}
}
// An older agent (supported=false) keeps today's behaviour exactly: the st_dev seam decides.
func TestDisk16_OldAgent_KeepsTheStDevRule(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
calls := 0
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { calls++; return DiskVerdict{}, false })
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
if err != nil || tg.NamespaceRoot != "/mnt/b" || tg.Unproven {
t.Fatalf("old agent: want today's pick (/mnt/b, counted), got %+v err=%v", tg, err)
}
if calls == 0 {
t.Fatal("the judge was never asked")
}
if m.singleCopyWarning() != "" {
t.Fatal("old agent: a second drive by st_dev must still hide the notice (today's behaviour)")
}
// And the st_dev rule's own same-disk answer still stands.
m.samePhysicalDevice = func(a, b string) bool { return true }
if _, err := m.selectTier2TargetFrom("app", "/srv/x", 1, 1); err == nil {
t.Fatal("old agent: st_dev 'same' must still refuse the target")
}
}
// The success record carries the mark the app row renders.
func TestDisk16_UnprovenCopyIsRecorded(t *testing.T) {
m, sett := newTestManager(t, "/srv/sys")
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a", Unproven: true, UnprovenReason: "vm"}, 1, "", 0)
cd := sett.GetCrossDriveConfig("app")
if cd == nil || !cd.Unproven || cd.UnprovenReason != "vm" {
t.Fatalf("want the unproven mark recorded, got %+v", cd)
}
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a"}, 1, "", 0)
if cd := sett.GetCrossDriveConfig("app"); cd.Unproven {
t.Fatal("a later proven copy must clear the mark")
}
}

Some files were not shown because too many files have changed in this diff Show More