Compare commits

...

89 Commits

Author SHA1 Message Date
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
361 changed files with 18636 additions and 3772 deletions
+10 -10
View File
@@ -6,17 +6,17 @@ paths: ["controller/**/*.go", "controller/**/*.html", "controller/**/*.css", "co
## The ONE entry point ## The ONE entry point
**Run `python3 controller/scripts/controller_gates.py` (from `controller/`) after ANY change in this **Run `python3 scripts/controller_gates.py` from `controller/` (or `python3
repo.** It runs the local gates — `template_id_gate`, `emoji_gate`, `native_confirm_gate`, controller/scripts/controller_gates.py` from the repo root) after ANY change in this repo.** It runs
`offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`, `docker_run_volume_path_gate`, every gate in the runner's `GATES` table, the shared `reuse_refs_check`, `instructions_gate` and
`secret_in_markup_gate`, `retrieval_promise_gate`, `debug_route_gate` — plus `reuse_refs_check`, `observations_gate` among them, streaming each gate's own output and exiting non-zero if any fails;
`instructions_gate` and `observations_gate` on the repo root, streaming each gate's own output and one, `golden-notice`, is ADVISORY and never refuses. **The `GATES` table is the list; this sentence is a
exiting non-zero if any fails. **The runner's `GATES` table is the list; this sentence is a pointer to pointer to it, not a second copy** — it drifted twice (it said "seven" while nine were registered, then
it, not a second copy** — it has already drifted once (it said "seven" while nine were registered). 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. - `--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.** - **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. 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 - **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 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 **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 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. 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 **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. 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 **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`.
+35 -2
View File
@@ -6,7 +6,8 @@ unconditional: true
> Goal sessions, nightly sessions, "work the register" sessions. **A session that starts from > 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 > `/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 > 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 ## 1. What you may pick up on your own
@@ -22,7 +23,8 @@ unconditional: true
customer data; anything that changes a promise the product makes to a customer; anything that 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 reverses a documented design decision (`documentation/architecture/` — a design decision is not a
defect, R-370); anything on DooPlex or ep0; baking or vouching a golden; promoting a defect, R-370); anything on DooPlex or ep0; baking or vouching a golden; promoting a
catalog version; a new external dependency. 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) ## 2. When you may decide instead of ask (operator grant, 2026-09-14)
@@ -49,6 +51,11 @@ it **first** in the morning note. A decision you cannot write in that shape is o
8. **Hungarian text is searched with ASCII fragments**, with a positive and a negative control. 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. 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". 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 ## 4. The morning note
@@ -56,3 +63,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 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 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. 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.
+377 -1
View File
@@ -1,4 +1,361 @@
## unreleased ## v0.309.0 — the simpler notification page: three group switches (R-388 slice 1); the fill warning in the household's language (R-521 F7) (2026-10-12)
**MinAgent: 0.131.0** (unchanged — no agent call changed). **No wire change, no hub change:** the page still saves the
same `enabled_events` list. Design `08-alarm-ladder.md` §8.1, `09` §3 decision 200; operator answers 2026-10-12 evening
(Q1 „as picked": group 3 on for households; Q2 „as picked": the cooldown field under „Részletek").
- **The default list follows Q1 (lead session).** `DefaultEnabledEvents` gains `storage_reconnected`,
`backup_integrity_failed`, `crossdrive_failed`, `health_recovered`, so a household with NO saved list sees groups 1 and 3
fully on; group 2 stays „egyedi" because `app_start_failed` is off by the R-329 ruling. Nothing is seeded into stored
prefs. `TestR388_DefaultListAsGroups` (red-proved: the old list rendered all three as custom).
- **R-388 slice 1 — three switches instead of eighteen.** The notification page shows „A gépem és a meghajtóim" /
"My machine and drives", „Az alkalmazásaim" / "My apps" and „Amit mi intézünk" / "What we handle", each one switch
with one sentence of what it sends. Membership is §8.1's table (`settings.NotificationGroups`; all 18 switches of
v0.308.0 are in it, each in exactly one group). Switching a group on or off writes or clears all its detectors.
A group whose saved detectors are partly on shows „egyedi" / "custom" and saving the page without touching it
stores the list **unchanged** — no household's saved choice changes. The 18 per-detector switches (now ordered by
group) and the cooldown field sit under a „Részletek" / "Details" fold (`<details>`, no script). A form without
the new group fields (an old page left open) is handled exactly as before.
- **Not changed, for the lead to decide:** `settings.DefaultEnabledEvents` fills **no** group completely —
machine misses `storage_reconnected`; apps misses `app_start_failed` (OFF by the R-329 ruling, 2026-08-23);
„what we handle" misses `backup_integrity_failed`, `crossdrive_failed`, `health_recovered`. So a fresh box (and
every household on today's default) sees all three groups as „egyedi". Pinned by `TestR388_DefaultListAsGroups`.
- Tests (`internal/web/r388_notif_groups_test.go`, the real render + the real save over a temp-file `Settings`,
posting back exactly what the rendered form carries): `TestR388_EveryPageSwitchIsInExactlyOneGroup`,
`TestR388_EachGroupRoundTrips` (RED without the switch-off branch: „group machine switched off still stores
[...] — the ticked details overrode the group switch"; RED without the switch-on branch: „group machine switched
on stored [], want exactly [...]"), `TestR388_CustomGroupSavedBackUnchanged` (RED with an unticked group always
clearing: „an untouched save CHANGED the stored list"), `TestR388_CustomGroupSwitchedOnTakesAll`,
`TestR388_FullListRendersThreeGroupsOn`, `TestR388_DefaultListAsGroups`, `TestR388_DetailsFoldSavesAnIndividualSwitch`.
- **R-521 F7 — the guest's own disk at 95 % sends a disk event, in the household's language.** Read first: the
guest's root filesystem (`/`, „Alkalmazások területe") has been a fill-watch target since v0.191.0, and since v0.297.0
(R-363/R-547) it is checked every 10 minutes — F7 was measured on v0.242.0, when the check ran once a day, so its
10½-minute fill fell between two checks. That path now has a test end to end (the real target list, the real
watcher, the real push): the root filesystem at F7's numbers (95 %, 3.5 GB free) sends the hub's existing
`disk_critical` (severity `critical`, already allowlisted — no hub change), once. **What was still missing and is
fixed:** the sentence was Hungarian only. It is now the bundle's `event.fill.critical` / `event.fill.warning`, the
household's rendering rides beside it as `message_customer` (R-558; new `Notifier.PushEventHousehold`), the two
built-in areas are named in the household's language („App area", „System and backup area"), and the kept-folders
sentence follows the same language. **The Hungarian wire text changes by one word only:** the R-516 te-form gate refuses the formal „Kérjük," in the
bundle, so „Kérjük, szabadíts fel helyet…" became „Szabadíts fel helyet…" (and „Kérjük, mielőbb szabadíts fel
helyet:" became „Szabadíts fel helyet mielőbb:"); everything else is the old literal byte for byte
(`TestMessageHungarianWireText` pins the exact bytes; with „Kérjük, " restored it ran green against 68294ec's literal).
The hub has no `customerMessages` entry for these two types, so it mails this text as sent. Tests: `TestR521F7_RootFilesystemSendsTheDiskEventInBothLanguages`
(RED without the root target: „pushes = 0 ([]), want exactly 1 disk event for the root filesystem"; RED with the push
closure dropping the household text: „household (English) message = """), `TestR521F7_HouseholdLanguageRidesTheEvent`
(RED with the watcher rendering no household text: „English message "" lacks "App area"").
- **Parity fixtures:** `settings_notifications_hub` and `settings_notifications_clear_confirm` re-captured for this
template change (the switch list regrouped under the fold, the three group switches added; the two old section
labels „Hibák és figyelmeztetések:" / „Tájékoztató:" removed with their keys); a new case
`settings_notifications_groups_custom` (every group „egyedi") captured when born. No other fixture moved.
## v0.308.0 — the controller batch of 2026-10-12: clear-address warning, a secret sweep over every page, NVMe heat, the share globe, Hungarian dates and numbers (2026-10-12)
**MinAgent: 0.131.0** (unchanged — no agent call changed). **R-266 needs the hub of the same day** to act on the new
`read_failed` field (an older hub ignores an unknown JSON field and bands the zeros, today's behaviour). Operator rulings
2026-10-12, `09` §3 decisions 196, 204–207, 223, 227.
- **R-922 (S1) — clearing the notification address is warned first.** An empty address over a stored one saves nothing
and sends nothing on the first press; the page says „Ha törlöd az értesítési e-mail-címet, nem kapsz több értesítő
levelet." / "If you remove the notification e-mail address, you will get no more notification e-mails." with its own
confirm button. Only the confirmed save clears it and sends `email_cleared` (v0.305.0). Server-side (no script), so it
holds for any client. What is sent to the hub is unchanged. `TestR922_ClearWarnsFirst` (RED without the warning block:
„the address was cleared before the warning"); a new parity case `settings_notifications_clear_confirm`.
- **R-255 (S9) — one test renders EVERY page with planted secrets.** `TestR255_NoPlantedSecretReachesAnyPage` renders
all 142 parity cases of all 35 page templates, in both languages, with a unique value in every secret-named field of
their data (any depth) plus the keys handlers really set (`RetrievalPassword`, `InitialCreds.Password`, …), and fails
on any that reaches the HTML. Pages are DERIVED from the templates, so a new page without a case fails it. **It found
one leak, fixed here:** an app's optional-config `secret_input` (a key the household typed in, e.g. a metadata
provider's API key) was rendered into `value="…"` of its settings page on every open. It is now fetched on demand
through the existing reveal endpoint (authorised for `secret_input` fields only), and a blank field never revealed is
not sent, so saving other fields keeps the key. RED: the old `deploy.html` → „a planted secret reached the HTML — from
deploy_new.CurrentValues.API_KEY"; a planted `{{.RetrievalPassword}}` in settings_security → caught.
`TestR255_OptionalSecretIsRevealedOnDemand` (RED without the new reveal branch: 403).
- **R-266 (S10, controller half) — a failed root statfs says so on the wire.** `report.StorageReport` gains
`read_failed` (omitempty); the `/` entry carries `true` when `SystemInfo.DiskKnown` is false, the numbers stay zeros.
`TestRootStorageEntry_ReadFailed` (RED without the assignment).
- **R-333 (S11) — no heat band for NVMe.** An NVMe drive's temperature alone never raises Figyelmeztetés or Hiba; its own
`critical_warning` (incl. the temperature bit) still makes it Hiba; spinning/SATA bands unchanged. The alert text no
longer names heat for an NVMe. `TestDiskVerdict_NVMeHasNoHeatBand` (RED with the guard removed: a 72 °C NVMe was Hiba).
- **R-577 (S12) — the language globe on the guest share pages**, the visitor's own (`/lang`, their cookie; the household's
setting is the default as on the sign-in page). The share's „Hibás jelszó" line now follows the visitor's language
(`flash.share.wrong_password`). `TestGuestSharePagesHaveGlobe` replaces `TestGuestSharePagesHaveNoGlobe` (RED without
the globe line); `TestGuestSharePage_HonoursVisitorCookie` drives `/s/<token>` through the full mux.
- **R-562 (S28) — Hungarian dates and numbers in Hungarian, ISO-like in English.** One set of layouts in
`internal/i18n/format.go`; every template date/size func is per-language (`formatFuncs`), the Go-side page data in
`internal/web` follows, page scripts use `fhNum`/`fhDateTime` (layout.html), precomputed sizes go through `numText`.
Raw RFC3339 stamps that reached pages (geo sync, drive retirement, share-backup runs, sealed-at) now render as local
dates. **The Hungarian parity fixtures were re-captured for this release only**: 107 changed by date/number format
only (machine-classified), the rest by the edited page scripts, the share globe and the deploy fix — named in
REPORT.md. `TestR562_BackupsPageFormatsPerLanguage`, `TestR562_FormatFuncsPerLanguage`, `TestFormat_*` (RED with the
formatters ignoring the language).
- **R-521 (S32, controller half) — the lost-drive option.** The household's „Meghajtó leválasztva" option already exists,
saves and is sent in `enabled_events`; `TestR521_StorageDisconnectedOptionRoundTrips` now pins it both ways (RED with
the option dropped from the handler). It has been in `DefaultEnabledEvents` since v0.17.0 — left as it is (see REPORT).
- **R-554 (S27) — recovery-info.txt points at the dashboard; the first-boot setup wizard is deleted.** (Own commit, so it
can be held back; **the operator approves the Hungarian text before release.**) (a) `recovery-info.txt` now says:
open the dashboard, restore an app on Biztonsági mentés → Visszaállítás, and after a reinstall reopen the earlier
off-site backups on Adatok visszaszerzése with the ten-word recovery code — in the box's language (new bundle key
`recovery_info.body`); no more docker-setup.sh / port 8081 / „Visszaállítás mentésből". `TestRecoveryInfo_PointsAtDashboardNotWizard`
(RED with the old text: „hu: the file still carries "docker-setup.sh""). (b) `internal/setup/` (wizard, templates,
its CSRF, `.needs-setup`) and `runSetupMode` are deleted, with the debug page's „DR / Telepítő varázsló" trigger and
its copy. A box whose first ingestion failed now serves ONE bilingual waiting page, keeps `/api/health` green, and
retries every minute (`bootstrap.WaitForConfig`); once configured it starts normally. `TestWaitForConfig_ServesWaitingPageThenStarts`
(RED with the page answering 404: „GET / = 404 — an unconfigured box must show the waiting page, not a 404").
`web.setup_listen` is still read (an old controller.yaml loads) and unused.
## v0.307.0 — after a restart the Databases table lists each database once, not every undo copy (2026-10-10)
**MinAgent: 0.131.0** (unchanged — no agent call changed). Carries v0.306.0 (R-570). Released 2026-10-10 alone (no hub
change).
- **Backup → Apps „Databases": one row per database after a controller restart.** SEEN 2026-10-10 on demo-hp's household
guest (0.305.0, while retaking the website pictures, R-910): adventurelog four rows, bookstack four rows, every one
stamped with the night's time. Cause: after a restart the last run's result is not in memory, so `GetFullStatus`
SYNTHESISES it from the dump files on disk — and listed every `pre-restore-` undo copy (R-379) as one more row of the
run. Now the synthesis skips undo copies (they stay in the file list below, as R-379 decided), and with only undo
copies on disk there is no synthesised run at all. Tests `TestSynthesisedRunListsNoUndoCopy` (the measured shape;
RED before: 5 rows for 2 databases) and `TestSynthesisedRunAbsentWhenOnlyUndoCopies` (RED with the guard removed).
## v0.306.0 — the off-site note decides on its kind only; the pre-0.251.0 text fallback is gone (R-570) (2026-10-10)
**MinAgent: 0.131.0** (unchanged — no agent call changed). **Built and pushed to the registry, NEVER delivered** — superseded the same afternoon by v0.307.0, which carries this change too.
- **R-570 — the legacy text fallback in `offboxWarningDisplay` is deleted** (`internal/web/handlers.go`), with its
constant `offboxStaleWarningMarker`. It existed only for a box whose last off-site run predated v0.251.0 and so
carried the „nincs mentésre jelölt alkalmazás" note without a KIND. The row's condition is met, read on each box's
own `settings.json` on 2026-10-10: demo-hp, demo-felhom and Tester 1 each have `offbox.last_success`
2026-10-10 ~02:16Z on controller 0.304.0; Tester 2 was installed 2026-10-04 on a far newer controller. The page now
decides on `LastWarningKind` ONLY: the same words with no kind are shown verbatim. Red-proof:
`TestR553_StaleNoteDecisionSurvivesWordingChange`'s new row „no kind: the words alone decide nothing" FAILED against
the fallback (it returned the replacement) and passes without it. `TestR570SentenceStaysHungarian` is deleted (it
was named for this row and said so); `TestR240_ZeroSelectionRunDoesNotSaySuccess` no longer pins the words, only the
kind. The zero-selection note itself is unchanged and still Hungarian — localisation may now translate it.
## v0.305.0 — check first, stop second (R-921); a household's clear deletes the hub's address (R-922, controller half) (2026-10-10)
**MinAgent: 0.131.0** (unchanged — R-921 reads only `GET /backup/tiers` and `GET /backup/status?target=`, both served
since agent v0.97.0; a pre-R-82 agent answers 404 and the pre-check stands aside). **R-922 needs the hub of the same
day** to act on the new field (an older hub ignores an unknown JSON field and keeps the address — today's behaviour).
Released 2026-10-10 with hub v0.145.0 (the hub first).
- **R-921 — no app is stopped for a tier the agent will refuse because another tier's job is in flight.** Measured
2026-10-08 night on demo-hp: the off-site window stopped every app, the agent answered „a heavy operation is already
in flight" (`busy=backup:local`, the night OS step after the local copy), and the apps were down about a minute for
no copy. The agent refuses a backup for two reasons; the controller can SEE only one before a stop. New
`quiesce.InFlightProber` (`internal/quiesce/precheck.go`) + `agentapi.Client.BackupJobsInFlight` (tiers → each
tier's job phase, `running`/`snapshotted` = in flight): when a job of ANOTHER tier is in flight the window returns
before the marker and stops nothing; the tier stays due (no breaker, no contention verdict) and is asked again at the
next poll. The window's own tier in flight is left as before (the agent answers its start with the running job). A
pre-check that cannot be answered fails toward backing up (WARN, then today's path). **What it cannot see — the
measured instance:** the agent's host-wide heavy-operation gate (the OS step, a restore-test, fstrim) is served by
no endpoint; there the BUSY answer is the first signal and the apps are resumed AT ONCE — pinned:
`TestR921_BusyRefusalResumesAtOnce` (the next event after the refusal is the first restart, no agent call in
between; with and without the pre-check, i.e. also the race „free at the check, busy at the start"). Closing the
measured case before the stop needs the agent to serve its gate (an additive field) — not built here. Tests
`TestR921_*` (quiesce, 4), `TestBackupJobsInFlight_*` (agentapi, 3); the adapter's wiring is pinned at compile time
(`var _ quiesce.InFlightProber = quiesceBackend{}`).
- **R-922 (controller half, operator ruling 2026-10-09 11:19, option A) — the push says when the household cleared its
address.** `POST /api/v1/preferences` gains `"email_cleared": true` (omitted otherwise). Rule: an empty address
saved where one was stored is a deliberate clear; `settings.NotificationPrefs.EmailCleared` persists it, and every
push carries it while the address stays empty (the dashboard save, the debug resync, and now the startup sync, which
also runs for a cleared box — so a clear the hub missed reaches it later). A box that never had an address never
sends it (the hub's no-clobber guard keeps protecting a seeded address); a new address drops it. Tests
`TestR922_DeliberateClearSendsEmailCleared`, `TestR922_NeverConfiguredBoxSendsNoFlag`, `TestR922_NewAddressDropsFlag`
(web, on the wire against an httptest hub), `TestR922_ClearedByRule`, `TestR922_MarkerPersistsAndDrivesStartupSync`
(settings). The wire-contract gate's `controller -> hub (POST /preferences)` root checks the hub can decode it.
## v0.304.0 — the operator's decision sheet (D1, D3, D4, D8), a daytime press never cancels the night (R-899), the honest recovery answer (R-304), three dashboard layout fixes (2026-10-09)
**MinAgent: 0.131.0** (unchanged). The operator actions (D1) need hub 0.144.0 to arrive; an older hub sends none. The
`trigger=manual` on a press (R-899) is read by agent 0.154.0; an older agent ignores it (the old behaviour).
### Part (2026-10-08, evening) — the operator's decision sheet: operator actions (D1), sign-ins survive a restart (D4), health issues in the household's language (D3), a failed off-site restore holds the app (D8)
**MinAgent: 0.131.0** (unchanged — no agent change). Operator rulings 2026-10-08 14:16, `09` §3 decisions 185–194.
New wire fields (additive, the hub of the same day carries their twins): reply `operator_actions`, report
`operator_action_results`. **This controller needs the hub of the same day for D1** (an older hub sends no actions;
nothing breaks).
- **D1 — operator actions in the report reply (R-314, R-279; R-177 folded).** `internal/report/opactions.go`: a CLOSED
switch in the running process — `offsite_backup_now` (the household's four checks, then the same run),
`abandon_stop`, `abandon_extend` (1–30 days, never earlier than the current date, refused once the deletion is the
hub's), `run_job` with `fill-watch`, `offsite-integrity`, `offsite-proof`, `disk-health-check` (new
`scheduler.RunNow(name)`, refuses unknown or running jobs; `scheduler.OnDemand(ctx)`). Anything else answers
`refused` and calls nothing. Once per id (in memory; every action is safe to repeat after a restart). The result
rides the next report. `TestOpActions_ClosedList` fails if an action is added. **Fixed on the way (small, no row):**
`ExtendAbandon` could move a deletion EARLIER and could edit the box date during the hub phase (both refused now);
`StopAbandon` reported success while the hub's deletion stayed pending (error now); the reply read limit 4 KiB →
64 KiB (a larger reply dropped every field).
- **D4 — dashboard sign-ins survive the controller's own restart (R-35, option C).** `internal/web/session_store.go`:
`<data>/dashboard-sessions.json` (0600, tmp+fsync+rename) holds sha256(cookie) → expiry + CSRF token, never the
cookie. Logout and a password change end the session on disk too. **Security review fixes:** the file carries a
fingerprint of the password hash in force, so rows written under another password are dropped at load (a password
change revokes even if its save failed); a revoking save that fails removes the file. Tests `TestR35_*` (8).
Small fix on the way: `TestR650_NoBareDockerExec` skipped a non-`.go` file that vanished mid-walk (a race with a
parallel stacks test).
- **D3, dashboard half — the last six health producers carry their sentence (R-79 option A).** Docker unreachable,
protected container down, storage unavailable / not separate / usage high / almost full: a `MsgRef` each; the hub
wire text is unchanged byte for byte (wire golden green). Six keys `health.*` (hu+en); the four storage Hungarian
values equal the frozen literals. Tests `TestR79_RemainingProducersCarryTheirDashboardSentence`,
`TestR79_HealthBannersFollowTheHousehold`.
- **D8, first half — a failed off-site replay that follows a moved definition or a replaced volume HOLDS the app
(R-893 option C).** `offbox_reconstitute.go`: the live definition is kept before the snapshot's is written and
written back on failure; the stack stays stopped with a new hold `restore_mixed` (operator-only to clear); the
household reads that it needs our help (keys `err.backup.db_restore_failed_held_mixed`, `note.reconstitute.held_mixed`);
the operator is told through the existing `backup_run_failures` event (leg `restore-hold-mixed`, no new hub type).
No version change and no volume replaced → today's behaviour (`TestR379_ScenarioA` green). Tests `TestR893_*`.
Known limit: placed files are not undone (the second half, „put back exactly as it was", is its own row).
**Security review fixes (same evening):** the same hold now covers EVERY failure after the snapshot's definition is
written or a volume replaced — a failed placement (after a definition write), a failed volume replay that replaced
at least one volume, a failed DB-only start — not only a failed replay (`TestR893_AVersionChangeThenAFailedDBStartHoldsTheApp`;
`TestR354_ScenarioE` updated: a partly replaced volume set now HOLDS instead of starting, the volume named in the
operator's notice); the hold is persisted BEFORE the stop, and the app-stop marker is kept when it cannot be
(`TestR893_HoldIsPersistedBeforeTheStop`); new household sentence `err.backup.restore_failed_held_mixed` (hu+en)
for the non-replay cases; the hold note no longer claims the database is the pre-restore one. Placed files ALONE
still do not hold (option C as ruled). **D1:** a `run_job` „done" now says it ran, not what it found (the two
off-site checks return nil whatever their verdict).
### Part (2026-10-08, afternoon) — three dashboard layout fixes: the Apps card header (R-909), the launcher on a phone (R-907), the empty „+5 more warnings" (R-906)
**MinAgent: 0.131.0** (unchanged — nothing on the wire, nothing the agent sees). Hungarian text: not one string
changed; three Apps-page parity fixtures re-captured for the markup alone (below).
- **R-909 — the Apps card header.** The title row and every tag were siblings in ONE `space-between` flex row; with
two or more tags (any installed app) the one-word name ran under them, and the logo was centred on the name block,
so a card with an address carried its logo lower. Now every card has the same two rows: logo + name (+ the address
under it, top-aligned), then the tags in their own `.stack-tags` row, left-aligned and wrapping. The address is one
line cut with an ellipsis (`.subdomain-text`, `min-width: 0`), full address in `title`, „↗" on the same line; the
unpublished-route warning stays under it. `TestStackCardHeaderLayout` (both languages, all 8 `stacks_full` cards +
the CSS rules; red against 05e12921: no tags row, no address title, `space-between`, `align-items: center`).
- **R-907 — the launcher's share button on a phone.** In the 768 px block `.page-header` wraps, so the button drops
to its own line under the title and the domain chip, its text kept whole (`white-space: nowrap`). It applies to
every page header on a phone (a header that fits is unchanged). `TestR907_PageHeaderWrapsOnAPhone` (red against
05e12921: the phone block had no `.page-header` rule).
- **R-906 — „+ 5 további figyelmeztetés" with nothing above it.** `GetAlerts` capped the list at five over ALL alerts,
and the layout then dropped the Inline and other-page ones; on 9202 the five visible ones were inline „not on a
separate drive" warnings, so only the overflow line rendered and the hub-off warning was cut. New
`AlertManager.GetBannerAlerts(page, lang)` filters with the layout's own rule (`bannerShows`) BEFORE the cap;
`baseData` and the monitoring page use it. `GetAlerts` is unchanged for the debug dump. Tests `TestR906_*` (the 9202
case on /launcher, /stacks, /backups, /monitoring, hu and en: no overflow line, the hub-off banner shown; red with
the two calls pointed back at `GetAlerts` — every page showed the overflow line and no warning).
- **Banners, seen on 9202 once R-906 let the real ones through (small, fixed in the session):** on a phone a banner
now wraps (a long path breaks, the link drops to its own line inside the banner — the „Rendszermonitor" link ran
90 px past a 390 px screen), and `.alert-link` takes the banner's colour (it had none and rendered browser-default
blue, underlined). Pinned in `TestR907_PageHeaderWrapsOnAPhone` (red against d5f2e47's CSS).
- **Parity fixtures re-captured, in their own commit:** `stacks_full`, `stacks_unhealthy_stop`,
`stacks_install_interrupted`. Measured: each equals its predecessor with exactly the template change applied
(wrapper `stack-title-text`, the address span + `title`, the `stack-tags` row) — byte-for-byte, 3 of 3 — and the
visible text is identical. No other fixture moved.
### Part (2026-10-08) — a daytime press never cancels the night's whole-guest backup (R-899; operator ruling 2026-10-08, option A); an honest answer when older recovery packages were not checked, and the operator's mail (R-304)
**MinAgent: 0.131.0** (unchanged — the new `trigger=manual` query is ignored by an older agent, which keeps running the
OS leg after a press exactly as before; the night itself is fixed by the controller alone).
- **R-899:** the agent answers `/backup/due` from the newest archive on the tier, and a „Mentés most" press is an archive
like any other. So a press at 08:49 made the 24 h tier due at 08:49 the next day — after the gate window
[W+2h, W+6h) closed — and that night had no whole-guest backup, no OS leg and no kernel step (demo-hp, 2026-10-07 → 08).
Now the quiesce loop keeps a durable ledger beside its marker (`whole-guest-ledger.json`: the last successful press and
the last successful scheduled run per tier). A tier the agent calls „not due" is still due on the scheduled path when
a press succeeded after its last scheduled success and that success is older than tonight's gate opening. Such an
„owed" tier never fires the window gate's safety valve (it waits for the window). A scheduled success inside tonight's
window ends it; a failed one does not. `internal/quiesce/nightowed.go`; tests `TestR899_*` (6; red-proved: with the
rule off, the 2026-10-07 replay started nothing on night 2 — `started=[local local]`, and two more failed).
- **R-304 — the recovery screen no longer calls a code wrong when earlier packages were not checked.** The agent (same
day) answers **424** when the current sealed package refused the code, no earlier package opened it, and at least one
earlier package the hub holds was never tried (no key material, over the hub's cap, malformed, past the attempt cap, or
the list could not be read). `agentapi.RecoveryOlderUnchecked`; the page says we do not know whether the code is wrong,
names the mistype as one possibility and sends the household to support — new bundle key `recovery.older_unchecked`,
Hungarian and English. Not version-gated on purpose (it accuses nobody; a stray 424 lands on a neutral sentence).
Tests `TestR304_OlderUnchecked_IsNotAWrongCode` (hu + en; red-proved: without the case the page fell to the „nem tudjuk
biztosan, miért" default) and `TestR304_Classify424`.
- **R-304 option C (operator ruling 2026-10-08 09:04, `09` §3 decision 183):** when a household's recovery code OPENS an
older sealed package (agent 422) or MAY open one (424), the controller sends the operator-only event
`recovery_older_package` (warning → the operator's mail): the class and the package's date, never the code. At most
once per day per box (`<data>/recovery-older-mail.day`, survives a restart). **Needs the hub release of the same day**
(the type is in its allowlist and `operatorOnlyEvents` there); against an older hub the event is refused (400) and only
logged. `internal/web/recovery_older_mail.go`; tests `TestR304_OlderPackageMail_*` (red-proved twice: without the calls
`events = []`; without the day check `sent 2 events, want still 1`).
- **R-298 side finding:** the storage page no longer offers „Leválasztás" or „Formázás" on a user-data drive that is ALSO
the whole-system backup target (agent `backup_target`): the agent refuses its eject (403) and a data-bearing format needs
a signed job. JS in `templates/storage.html` `actions()`; the two storage parity fixtures regenerated (only these lines
differ). Not verified in a browser.
- R-717: two stale comments corrected (`after_setup.go` no longer names opengist among the `command` apps;
`signup_block.go` shows opengist's current `PathRegexp` block).
- The press now reaches the agent as `POST /backup?…trigger=manual` (`agentapi.StartBackupForTrigger`); an agent that
knows it runs no OS leg after a press (agent, same day). Test `TestR899_PressCarriesTriggerManual` asserts the query the
agent receives for each of the four shapes.
## v0.303.0 — after a restart, a backup capture waits for the drive (R-897; `09` §3 decision 176) (2026-10-07)
**MinAgent: 0.131.0** (unchanged — no agent route is read; the signal is the controller's own mount table).
- **R-897:** after a host restart the agent binds each drive under `/mnt/felhom-drives` and may normalize it once more
(umount + mount) when the guest started after the first bind; a recovery-unit capture in that second failed
(`mkdir /mnt/felhom-drives/hdd_1/backups: permission denied`, demo-hp 2026-10-07) and the operator got a false backup
failure. Now, for 10 minutes after the controller starts (`backup.DriveWaitWindow`), a capture for an app on a drive
runs only once that drive is a LIVE mount in the controller's own namespace (`/proc/self/mountinfo`, the same signal
the startup app gate uses): the 5-minute refresh skips the app until then; a data run waits (polls every 5 s). After
the window it runs anyway and says so once at WARN. The agent's bind order is unchanged. `internal/backup/driveready.go`;
tests `TestDriveReady_*` (3; red-proved: without the call the capture ran while the drive was not bound).
## Unreleased (2026-10-07) — the shared rule file; no code change
- `.claude/rules/unprompted-work.md`: rule 11 (every helper prompt carries the brief's fences in full, `09` §3 decision 160) and the §1 line „no hub image build or hub deploy in a session the operator does not attend" (decision 162). Identical in all five copies.
## v0.302.0 — the format list hides drives in use; 22 more messages in the household's language; three SMART counters carried (R-542, R-516, R-330; `09` §3 decision 161) (2026-10-07)
**MinAgent: 0.131.0** (unchanged — no new agent route is read).
- **R-542:** `GET /api/disks/candidates` no longer offers a drive that backs a REGISTERED storage path — not under `initialize` (the format wizard renders that list as-is, so a household's in-use data drive was offered for formatting) and not again under `attach`. The join is the guest's own mount table (a registered path's mount source is the host device). Fail-safe: an unreadable mount table empties `initialize`; `attach` passes through. Tests `TestR542_*` (two red-proofs observed).
- **R-516 (the Go-literal leftover):** 22 messages that lived as Go string literals — the escrow handler (6: no password set, too many attempts, staging failed, a code already being made, a code not shown, no active job), the share-page password (2), the export upload size mismatch, the network-storage uid refusal and in-use removal, and the network-storage attach failures (10 categories + the Windows-name tip) — are bundle keys now (`api.escrow.*`, `api.share.*`, `api.export.*`, `api.netstorage.*`), in the te-form in Hungarian (14 of them were in the formal „ön" form: „próbálja", „állítson be", „várja meg", „indítsa", „töltse", „ellenőrizze", „használja", „kapcsolja be", „állítsa", „jelezze", „távolítsa el", „adjon meg"), with English. A failed attach job is rendered in the READER's language at the status read (it ran detached; it keeps its category, server and uid). The NAS app-namespace refusal says „Válassz" (stays a settings constant). Left on purpose: the setup wizard and `recovery-info.txt` (R-554), and the SMART and fill-watch texts — they are the hub event's message, mailed as is (wire text). Tests `TestR516_GoLiteralFormalFormsAreGone` (keys in both languages + a source scan with a positive control), `TestR516_NetAddFailureFollowsTheReader`; red-proofs `felhom.eu/documentation/audits/night-burndown-2026-10-06/ctrl/R-516-red*.txt`.
- **R-330 (disk health Phase 2, the wire only):** `agentapi.SmartSummary` decodes the three SATA counters agent v0.150.0+ sends — `reported_uncorrect` (187), `command_timeout` (188), `udma_crc_errors` (199); nil = unknown (an older agent or a device that does not report them), a measured 0 stays 0. **Carried only:** no verdict, banner or mail reads them yet (that would change what a household is told — the row's next slice). MinAgent unchanged: an older agent simply omits them. Tests `TestR330_DecodesTheThreeCounters` (red-proved), `TestR330_CountersChangeNoVerdictYet`.
## v0.301.0 — one short stop per backup tier; restores replay before the app starts; the family window reopens a command-closed sign-up (R-518, R-638, R-717; `09` §3 154, 156) (2026-10-06)
**MinAgent: 0.131.0** (unchanged — R-518 reads the agent's existing `Primary` tier flag, R-82 agents ≥ 0.97.0).
- **R-518 (decision 156, option A — reverses R-82's one window for both tiers):** a quiesce window runs only the FIRST due tier and starts the apps again at its `snapshotted`; the upload finishes with the apps running; another due tier waits for a later cycle in its own short stop. „Mentés most" makes the LOCAL (primary) copy only; with the local storage absent it makes no copy and stops nothing (ERROR line). A tier that refuses to start inside a window still lets the next one try in that window (no copy made yet). The button text and its confirm say so in hu + en (about 1–1.5 minutes — an estimate from measured parts, not a measured press). Tests: TestFirstTierSnapshot_ResumesAppWhileUploadContinues (the red test), TestBothTiersDue_FirstCycleRunsOnlyFirstTier, TestManualPress_RunsOnlyLocalTier, TestLeftoverTier_RunsInNextCycleInItsOwnWindow, TestManualRunTiers_NoPrimaryAvailable_BacksUpNothing — red-proofed.
- **R-638 (decision 154, option A):** the no-manifest fallback `RestoreApp` starts only the database services, replays, then starts the app (it started the whole stack at the CURRENT definition first); a dump with no identifiable database service is refused before anything is touched; a failed volume step skips the replay. A unit restore whose volume leg failed does not replay. The loader is unchanged; no delete step added. The two main paths were measured safe first on 9202 (docmost, romm). Known limit, not fixed: the off-site rollback's reverse direction (R-893). Tests: TestR638_* (7), red-proofed.
- **R-717:** `after_setup` gains `open_command` / `open_success`: the household's 15-minute window marks the lock `opening` on disk, lifts the env, runs the open command; only full success records `lifted`; a failed open closes it again at once and the app page says so (`app_info.signup_native_open_failed`, hu + en). The close command runs at the window's end, at every start and after a successful update (`markNativeLockForReapply`); a failed close is retried every 2 minutes. Tests: after_setup_r717_test.go (7), red-proofed. Proven live on 9202 with wishlist.
- The shared rule file's copies line names five copies (decision 152).
## Unreleased (2026-10-06 afternoon) — instruction files kept true (`09` §3 decision 150); no code change
- `.claude/rules/unprompted-work.md`: §5 „Instruction files" (the operator's standing rule) and the copies line names the four copies (it said „all three repos").
- `.claude/rules/gates.md` + `CLAUDE.md`: the gates command is `python3 scripts/controller_gates.py` from `controller/` (the old path did not exist from there); the gate list is a pointer to the `GATES` table (it named ten local gates while fourteen run; `golden-notice` is advisory); `observations_gate.py` is shared too; the decoy gate is named with its `felhom.eu/` prefix.
- `CLAUDE.md`: protected stacks include samba, always (`config.go` `alwaysProtectedStacks`); the design pointer names `architecture/NN-*.md`.
## v0.300.0 — the night backup leaves an app alone that runs another version than it saved; after a crash boot app mails wait 15 minutes (R-645, R-856; operator rulings `09` §3 142, 143) (2026-10-06)
**MinAgent: 0.131.0** (unchanged — R-856 reads the agent's `GET /host/crash-guard` (agent 0.149.0); an older agent answers 404 and the controller keeps the normal 90 s grace).
- R-645 (09 decision 142): the night backup skips an app whose pinned version is not the version it runs — DB dump, volume dump, recovery-unit capture and Tier-2 all leave it alone, the log says so per leg, and the backups page shows one amber line (hu + en). A hold lifted by hand no longer lets the just-failed definition overwrite the good recovery unit. Unknown (no pin / no running record) never skips. Manual 'back up now' and the update's own pre-update copy are unchanged. Strings (hu + en): `backup.status.version_skip (hu: 'A(z) %s nem a beállított ve`.
- R-856 (09 decision 143, extends 129): after a crash boot of the host, the controller's app mails (app_start_failed, app_stopped_unhealthy) wait about 15 minutes instead of 90 s; a normal boot keeps 90 s. The fact comes from the host crash guard through the agent's new GET /host/crash-guard; without it (older agent, no guard) every boot keeps 90 s. MinAgent: unchanged (degrades to today's behaviour on an older agent); the effect needs the agent release that adds the route.
## v0.299.0 — the real disk percentage (df's), every household mail in its language, no formal form left in the bundle, the hold panel points at the kept log (R-889, R-585, R-621, R-516) (2026-10-06)
**MinAgent: 0.131.0** (unchanged). **Behaviour change, by operator ruling (`09` §3 decision 138):** every disk percent the
box shows or alarms on is now `df`'s (used / (used + available)); on a disk with the usual 5 % root reserve it reads up to
~5 points HIGHER than before, so a nearly full disk reaches the fill alarms (85 % / 95 %) a little earlier. Changed
household strings: `common.rendszer_gyoker` „Rendszer (/)" → „Rendszer" (en „System (/)" → „System"), plus the R-516
te-form rewording below.
- **R-889:** one function, `system.DFUsedPercent`, 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`: the old
formula 21.5 %, `df` 23 %); `TestR889_EveryDiskPercentIsTheDFOne` refuses a percent divided by the whole filesystem
(red-proved). The tile's „(/)" measured the docker data volume, not `/`.
- R-516: the last formal („ön") forms in the dashboard bundle — the debug RESET prompt, the storage and network-storage confirms and NAS guide, the security, system, deploy, restore and remote-backup copy (49 sentences) — use the te-form; the formal-form gate's stem list is widened (it counted 61 on the previous bundle, 0 now), with decoys; 38 parity fixtures changed by exactly those bytes; seven Go-side keys listed as REWORDED. Test `TestR516_WidenedFormalFormsAreGone`; red-proved (old bundle: 39 failing lines).
- R-585 (rest): the remote-backup check's two events (passed / found an error) and the alert for an app-data operation a controller restart cut short now follow the household's language. The check's Hungarian is byte-identical; the cut-short alert used to send the operator's English sentence even to a Hungarian household and now has a Hungarian sentence (its English is unchanged). local_api_endpoint_drift is operator-only and English by design - unchanged. NotifyBackupFailed (no caller left) removed. Strings (hu + en): `event.backup_integrity_failed, event.backup_integrity_ok, event.backup_integrity_ok_subset (hu = base literals); NEW: event.appstop.{failed,failed_held,held,restarted,restarted_held}, appstop.reason.{volume_dump,offbox_reconstitute,app_export}`.
- R-621 (rest): a held app's hold panel now says the app's own log from before the stop was kept, with a Napló link to it. Strings (hu + en): `app_info.hold_log_kept (hu: „Az alkalmazás saját naplója megmaradt a leállítás előttről — ebből látszik, miért nem indult el az új változat.”)`.
- R-516 items 7-10, 12: the storage page shows a drive's disconnect time in local time; an unplugged drive has one banner instead of two; the disk, memory, CPU and temperature banners follow the household's language (the hub report is unchanged); the last formal sentences the gate counts now use the te-form (formal-form ceiling 14 -> 0, 119 parity fixtures changed by exactly those bytes). Strings (hu + en): `NEW: health.system_disk_high, health.system_disk_critical, health.data_disk_high, health.data_disk_critical, health.memory_high, health.cpu_high, health.temperature_high (hu+en). REWORDED hu: alert.deadapp.group, flash.login.password_changed, launcher.biztosan_kikapcsolja_a_megosztast_a, launcher.olvassa_be_telefonnal_a_gyors, layout.ha_ujratelepiti_az_alkalmazast_az, deploy.a_kontener_leallt_ellenorizze_a, debug.kattintson_a_gombra_a_telemetria, app_import.a_feltoltes_megszakadt_ellenorizze_a, app_import.fab_csomag_feltoltese_huzza_ide, backups_remote.a_megerosites_nem_erkezett_meg, backups_shared.empty_body, settings_system.ezek_az_ertekek_a_helyi, storage.levalasztva_2 (time format)`.
- R-426 (controller): decoys for docker-v and the shared reuse-refs / instructions / observations gates in scripts/test_gate_decoys.py (COVERS); the four controller EXEMPT entries in felhom.eu's decoy_coverage_gate.py can be deleted.
- Decision (`09` §3 decision 136, decided by CC unattended — operator may reverse): the hold panel LINKS to the kept log rather than showing it inline.
## v0.298.0 — the burn-down night: an own off-site target can be removed, a cut-off Remove finishes, the real address on app pages, honest status codes and tunnel row, no catalog credentials on disk, a half-sentence gate (≈30 rows) (2026-10-06)
**MinAgent: 0.131.0** (unchanged — R-25's controller half falls back for agents that send no `fs_uuid`, logged as a warning).
- R-729 + R-545: „Távoli mentési cél törlése" on the Távoli mentés page (POST /backup/offbox/clear, reveal-then-confirm) — the household can remove its own off-site target; the SSH key and known-host line are deleted, nothing on the target is touched; the repository password is kept whenever the hub holds a sealed package, the escrow is confirmed or the target ever held a backup. Refused for the Felhom-provided tier, during a running backup operation, and during an abandonment countdown. New strings (hu + en): `backups_remote.tavoli_mentesi_cel_torlese, backups_remote.cel_torlese_magyarazat, backups_remote.cel_torlese_megerosites, flash.offbox.clear_needs_confirmation, flash.offbox.clear_hub_tier, flash.offbox.clear_busy, flash.offbox.clear_abandon_pending, flash.offbox.target_cleared (hu+en)`. - R-729 + R-545: „Távoli mentési cél törlése" on the Távoli mentés page (POST /backup/offbox/clear, reveal-then-confirm) — the household can remove its own off-site target; the SSH key and known-host line are deleted, nothing on the target is touched; the repository password is kept whenever the hub holds a sealed package, the escrow is confirmed or the target ever held a backup. Refused for the Felhom-provided tier, during a running backup operation, and during an abandonment countdown. New strings (hu + en): `backups_remote.tavoli_mentesi_cel_torlese, backups_remote.cel_torlese_magyarazat, backups_remote.cel_torlese_megerosites, flash.offbox.clear_needs_confirmation, flash.offbox.clear_hub_tier, flash.offbox.clear_busy, flash.offbox.clear_abandon_pending, flash.offbox.target_cleared (hu+en)`.
- R-621: a held app's logs (logs page, /api/stacks/<n>/logs, remote diagnostics) show the log the hold kept before stopping it, when the app has no live output. The hold panel itself does not show it yet. - R-621: a held app's logs (logs page, /api/stacks/<n>/logs, remote diagnostics) show the log the hold kept before stopping it, when the app has no live output. The hold panel itself does not show it yet.
@@ -8,6 +365,25 @@
- R-607: a catalog sync that moves the catalog but no installed app's files now refreshes the update badge's input at once and no longer says „nincs változás". - R-607: a catalog sync that moves the catalog but no installed app's files now refreshes the update badge's input at once and no longer says „nincs változás".
- R-615: changing the catalog address (git.repo_url) now takes effect — the box re-clones from the new repository instead of fetching the old one for ever; a changed token only updates the stored address. - R-615: changing the catalog address (git.repo_url) now takes effect — the box re-clones from the new repository instead of fetching the old one for ever; a changed token only updates the stored address.
- R-25 (controller half): drive set-up mounts the new filesystem by the UUID the agent verified it made, not by re-reading the device path; an older agent still works through the old path, logged as a warning. - R-25 (controller half): drive set-up mounts the new filesystem by the UUID the agent verified it made, not by re-reading the device path; an older agent still works through the old path, logged as a warning.
- R-578: the settings-callback deadlock guard now covers every controller package — `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` derives the lock-taking `*Settings` methods and every helper that reaches one (transitively, per package) from source, and convicts any such call inside a settings `Update*` callback with file:line; decoy test included; REUSE.md lookalike row. Test-only, no binary change.
- R-569: the stop/start/restart/update, remove and delete API handlers pick their status code by the refusal's KIND (`stacks.ErrStackNotFound`/`ErrProtectedStack`/`ErrNotDeployed`/`ErrStillRunning`/`ErrNotOrphaned`, via `util.KindErrorf`, message bytes unchanged) through `api.stackOpStatusFor`, no longer by matching English words in `err.Error()`. Side effect, stated: an unrelated wrapped error that merely contained "not found"/"protected" (e.g. docker output) now answers 500 instead of 404/403. The two "currently being deployed" refusals still answer 500 as before.
- R-492: removed the always-empty global `paths.hdd_path` (`cfg.Paths.HDDPath`, env `FELHOM_PATHS_HDD_PATH`) and every reader's fallback to it (report builder, health check, `/api/system`, web `primaryHDDPath`, metrics collector, and the `fallbackHDDPath` parameter of `AutoDiscoverStoragePaths`); the storage registry is the only source. An old `controller.yaml` that still names `hdd_path` keeps loading (pinned by `TestR492_OldConfigWithHDDPathStillLoads`).
- R-576: `scripts/i18n_go_parity.py` learns two questions about the CALL, not only the text — ARITY (at every message-helper call with a literal key, the argument count equals the printf verbs in the key's Hungarian value; spread `args...` calls are skipped) and NO-CONCAT (a bundle-key literal is never an operand of `+`). 485 calls judged, none convicted today. Three new decoys in `test_gate_decoys.py`. Tooling only.
- R-574: `web/handler_debug.go`'s 39 Hungarian literals classified — 38 are debug-page copy (every `writeDebugJSON` message/error plus the pre-0.83 agent notice) and now come from the bundle (`debug.api.*`, 30 keys, hu byte-identical, en added); one is payload (the test event's message sent to the hub) and stays a literal. The diagnostic dump holds no Hungarian. Note: one existing hu value keeps the formal „írja be” (bytes frozen by the parity rule; the page is operator-facing).
- R-488: `go test ./internal/backup` 444 s → ~2 s. `waitForHealthy`'s clock (3 s settle / 5 s poll / 90 s timeout) is three package vars, production values unchanged and pinned by `TestR488_HealthWaitDefaultsAreProduction`, shortened once in the package's new `TestMain`. Found on the way: `TestOffbox_ConfirmedReset` spawned a real `ssh felhom@nas.local` (10 s ConnectTimeout) — `newOffboxManager` now installs a failing SSH fake. No binary behaviour change.
- R-325 (controller half): `scripts/retrieval_promise_gate.py` imports `RETRIEVAL_STEMS` from `felhom.eu/scripts/customer_copy_vocab.py` instead of carrying its own `STEMS` literal; an absent/empty shared vocabulary is INCONCLUSIVE (exit 2), never a pass. Two decoys. Tooling only.
- R-126 (09 decision 128): a .fab export to a network drive (NAS) without a bundle password is refused (400, household sentence hu+en: app_export.network_drive_needs_password); with a password it runs; local drives unchanged. Pinned by TestExportStart_NetworkDriveNeedsPassword. Strings (hu + en): `app_export.network_drive_needs_password (hu+en; registered in scripts/i18n_go_keys.json _preexisting)`.
- R-270: the local_api drift detector (R-77) also names a TOKEN-only divergence between controller.yaml and bootstrap.json (constant-time compare, boolean only, nothing reconciled - R-78); own banner key alert.local_api_token_drift and operator message 'local_api token drift' on the existing local_api_endpoint_drift event. Pinned by TestR270_TokenOnlyDriftIsNamedNotLeaked. Register: correct R-268's rotation recipe (write the token into BOTH files, restart the controller). Strings (hu + en): `alert.local_api_token_drift (hu+en; registered in scripts/i18n_go_keys.json _preexisting)`.
- R-616 (supersedes the first R-616 line): the catalog clone stores no credentials - plain URL cloned, Basic credentials per git command as http.<origin>.extraHeader (GIT_CONFIG_COUNT); R-615's origin compare uses credential-free forms so a set token never re-clones; a pre-fix user:token@ origin is rewritten clean. Pinned by TestR616_*.
- R-498: the app page's „Első lépések" (and the rest of the app copy) show the household's real address instead of the catalog's literal wiki.DOMAIN/paste.DOMAIN - new AppInfo.WithAddress (effective subdomain + customer domain, copy-only) applied in appDetailHandler; pinned over every catalog template in hu+en (TestR498_*). REUSE.md row added.
- R-682: a Remove cut off by a controller restart is finished at the next start - RemoveStack journals itself (.felhom-remove-pending) before compose down and clears it on every return; RecoverInterruptedRemoves (wired before RecoverUpdates/boot reconcile) finishes it ONCE through RemoveStack, keeping drive data and backups even if the household asked to delete them (logged). Pinned by TestR682_*.
- R-757: InjectMissingFields no longer invents a generated secret that only after_install reads (in after_install.env, named by no compose definition) for an already-installed app - the app never received it, so the reveal answered a login it does not have (calibre-web ADMIN_USER). Pinned by TestR757_AfterInstallOnlyFieldIsNotInvented. Note: boxes that already got the invented ADMIN_USER keep it (no cleanup of existing values).
- R-522: the dashboard's Cloudflare Tunnel row carries „Kívülről nem érhető el" (with a plain title) while cloudflared's /ready health check fails (R-841 made the state read unhealthy instead of „Fut"); hu+en keys dashboard.tunnel_offline(_title); parity case dashboard_tunnel_offline. Pinned by TestR522_TunnelOfflineTagOnlyForUnhealthyCloudflared. The 'no page tells the household the box is offline' half beyond this row is not built (the banner path stays as is). Strings (hu + en): `dashboard.tunnel_offline, dashboard.tunnel_offline_title (hu+en)`.
- R-575: the soft memory-overcommit warning travels as its bundle key (stacks.KeyMemoryOvercommitWarning) from memoryVerdict through DeployStack; the deploy API renders it in the request's language (deployWarningData) - no more Hungarian on an English page; msgHU removed (no other use). Pinned by TestR575_*.
- R-585 (part): db_dump_failed, the off-box backup_failed and offbox_enlarge_blocked push bundle keys (event.db_dump_failed / event.offbox_backup_failed / event.offbox_enlarge_blocked) so an English household's mail is English; NotifyDBDumpFailed(errMsg), new NotifyOffboxBackupFailed(detail), NotifyOffboxEnlargeBlocked(stack, estimate, usedGB, quotaGB). Hungarian unchanged (go-parity). Left: integrity_failed/ok, app-stop backup_failed, endpoint drift. Strings (hu + en): `event.db_dump_failed, event.offbox_backup_failed, event.offbox_enlarge_blocked (hu = base literals byte-for-byte; en new)`.
- R-516 items 1+4: the sidebar's 'Debug' reads 'Hibakeresés' in Hungarian (layout.hibakereses); the storage pages' four formal forms are te-form; parity fixtures updated by exactly those bytes; HU_FORMAL_CEILING 17 -> 14. Strings (hu + en): `layout.hibakereses (hu 'Hibakeresés', en 'Debug'); changed hu values: storage.nincs_regisztralt_adattarolo_adjon_hozza, storage_init.nincs_elerheto_meghajto_az_inicializalas, storage_attach.valassza_ki_a_mar_fajlrendszerrel, storage_init.valassza_ki_a_formazando_felhasznaloi`.
- R-718: the close-sign-up card and the setup-gate card say „Ehhez az alkalmazás egyszer újraindul, és közben rövid ideig nem érhető el." where the app has its own switch (after_setup.env); key app_info.restarts_once; parity cases app_info_close_signup_native / app_info_setup_gate_native. Strings (hu + en): `app_info.restarts_once (hu+en)`.
- Decision (`09` §3 decision 134, decided by CC unattended — operator may reverse): a Remove cut off by a restart is finished once at the next start, keeping drive data and backups.
- Decision (`09` §3 decision 133, decided by CC unattended — operator may reverse): removing an own off-site target keeps the repository password whenever anything could depend on it. - Decision (`09` §3 decision 133, decided by CC unattended — operator may reverse): removing an own off-site target keeps the repository password whenever anything could depend on it.
## v0.297.0 — small fixes from the burn-down: plain refusals that name a route, honest countdowns, a locked off-site store named, no shared metadata, gofmt gated (24 rows) (2026-10-05) ## v0.297.0 — small fixes from the burn-down: plain refusals that name a route, honest countdowns, a locked off-site store named, no shared metadata, gofmt gated (24 rows) (2026-10-05)
+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` | | which box may I break | `felhom.eu/documentation/runbooks/target-selection.md` |
| host addresses, break-glass, node facts | `felhom.eu/documentation/operations/nodes.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** | | 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 ## 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. - 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. - 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. - `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) - Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller,
cannot be stopped from the UI. and always samba) cannot be stopped from the UI.
- Verify a container image HAS the healthcheck tool before using it (BusyBox wget / python3 / curl — - Verify a container image HAS the healthcheck tool before using it (BusyBox wget / python3 / curl —
the catalog `REUSE.md` maps the families). the catalog `REUSE.md` maps the families).
- `IsRunning()` is CONCURRENCY, false during a verification restore — display MUST use - `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 | | 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 ./...` | | Green gate | `go build ./... && go vet ./... && go test ./...` |
| Build + deploy | the **`felhom-build-deploy`** skill — do not hand-roll it | | Build + deploy | the **`felhom-build-deploy`** skill — do not hand-roll it |
+8
View File
@@ -9,6 +9,14 @@
Last updated: 2026-10-04 (v0.293.0 — the Docker-socket self-heal, R-860) Last updated: 2026-10-04 (v0.293.0 — the Docker-socket self-heal, R-860)
> **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):** > **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 > `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). > once) so Docker restarts it on the current socket, then restarts any other socket user on an older inode (traefik).
+21 -8
View File
@@ -1,11 +1,24 @@
# REPORT — controller v0.297.0 (2026-10-05, burn-down round 2) # REPORT — felhom-controller v0.309.0 (2026-10-12 evening)
Full session report: `felhom.eu/REPORT-burndown2-2026-10-05.md`. Baseline `114ff27` (v0.296.0). **MinAgent 0.131.0**. Baseline 68294ec (v0.308.0). Commits 462196e, 402fa41, 7626a28 (helper session, under the brief's full fences) and the
lead's default-list commit. MinAgent 0.131.0 (unchanged). No wire change, no hub change.
24 rows fixed (see the CHANGELOG entry), plus two small twins found on the way (the banner countdown at 0 days; the rest ## R-388 slice 1 — the notification page in three switches (`08` §8.1, `09` §3 decision 200; operator Q1/Q2 „as picked")
of `deepCopyStack`'s shared references). R-126 and R-325 NOT done: R-126 needs an operator choice between two options; - Three group switches with one sentence each (hu+en); the 18 per-detector switches and the cooldown under a „Részletek"
R-325 needs a same-step change in felhom.eu's `hub_copy_gate.py`. Tests + red-proofs: `<details>` fold (Q2).
`felhom.eu/documentation/audits/burndown2-2026-10-05/controller-red-proofs.txt`. - A group is a view over `enabled_events` (`settings.NotificationGroups` = the §8.1 table). A moved group switch sets or
clears its detectors; an untouched one leaves the per-detector switches in charge, so an „egyedi" group is saved back
unchanged. Old forms without group fields behave as v0.308.0.
- Default list (Q1): groups 1 and 3 fully on for households with no saved list; group 2 „egyedi" (`app_start_failed`
off, R-329). No stored prefs are seeded.
- Tests `internal/web/r388_notif_groups_test.go` (7), red-proved (switch-off branch, untouched-group save, switch-on
branch, the default list). Parity: 2 fixtures re-captured, 1 new case.
`go build/vet/test ./...` green; `controller_gates.py --fast` green (incl. the new gofmt gate). Image, golden and ## R-521 F7 — the guest's own disk event in the household's language
delivery: see the session report. - The root filesystem is a fill-watch target (since v0.191.0) checked every 10 min (since v0.297.0); now pinned end to end.
- Fixed: the fill warning was Hungarian only — now bundle keys `event.fill.*`, the household text as `message_customer`
(`Notifier.PushEventHousehold`). One Hungarian word changed: the formal „Kérjük," dropped (te-form gate).
- Red-proved three ways (root target removed; household text dropped; watcher renders none).
- Not measured: "/" and `/mnt/sys_drive` may be one filesystem on a box, so one fill could send two disk events.
Suite `go build/vet/test ./...` rc 0; `controller/scripts/controller_gates.py --fast` rc 0.
+24 -10
View File
@@ -59,9 +59,12 @@
|---|---|---|---|---| |---|---|---|---|---|
| `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 | | `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.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 | | `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.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) |
| `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 text fallback in `offboxWarningDisplay` is LEGACY only (kind == "") and is removed when R-570 closes | | `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 ### HTTP/JSON envelopes + flash messages
@@ -91,6 +94,7 @@
| `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 | | `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 | | `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 | | **`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 | | `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 | | `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 | | `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
@@ -109,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 | | `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) | | `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!) | | `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.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 | | `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 | | `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
@@ -121,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 | | `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 | | `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 | | `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.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.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. | | `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. |
@@ -148,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` (`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.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` | | `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 | | `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.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 | | `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 |
@@ -158,7 +163,7 @@
| `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.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` | | `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 | | `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 | | `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 | | `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 | | `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 |
@@ -242,6 +247,7 @@
|---|---|---|---|---| |---|---|---|---|---|
| `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) | | `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 | | `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 | | `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.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.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 |
@@ -253,17 +259,21 @@
| `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 | | `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 | | `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 | | `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 | — | | `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.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.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.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 | | `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 ### Scheduler / time / UI
| Symbol | File | Short signature | Use for | Gotchas | | 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.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 | | `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" | | `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" |
@@ -272,9 +282,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 | | `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 | | `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"]` | | `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) | | `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.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 | | `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) | | `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 | | `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 |
@@ -309,8 +321,9 @@
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` | | `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) | | `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`) | | `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) | | `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` | | `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 | | `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 |
@@ -320,6 +333,7 @@
|---|---|---|---| |---|---|---|---|
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go | | `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 | | `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) | | `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 | | `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 `>=`) | | `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 `>=`) |
@@ -348,6 +362,7 @@
| `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 | | `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 | | `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 | | `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 | | `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 | | `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.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) |
@@ -398,13 +413,12 @@ Cross-repo edges:
| Duplication | Locations | | 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` | | 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` | | 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) | | 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 | | 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` | | 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) | | 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 ×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) | | 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` | | 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) | | 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) |
+117 -114
View File
@@ -112,7 +112,7 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
component; state chips are `.tag`; informational pills are `.metarow`. **Fonts (Plus Jakarta Sans + component; state chips are `.tag`; informational pills are `.metarow`. **Fonts (Plus Jakarta Sans +
JetBrains Mono, variable woff2, latin+latin-ext) and a 30-icon Lucide sprite are vendored in the JetBrains Mono, variable woff2, latin+latin-ext) and a 30-icon Lucide sprite are vendored in the
binary** (`internal/web/static/fonts/` served at `/static/fonts/`; `templates/icons.html`) — no CDN, binary** (`internal/web/static/fonts/` served at `/static/fonts/`; `templates/icons.html`) — no CDN,
no emoji. The setup wizard serves the same embedded stylesheet via `web.StyleCSS()`. no emoji.
- **Guest RAM resize (v0.143.0, R-24; MinAgent 0.90.0)** — the Rendszer page's "Szerver memória (RAM)" - **Guest RAM resize (v0.143.0, R-24; MinAgent 0.90.0)** — the Rendszer page's "Szerver memória (RAM)"
card shows the guest's current/used memory + the allowed range and lets the customer resize it. The card shows the guest's current/used memory + the allowed range and lets the customer resize it. The
controller only proxies + maps the agent's machine `code` to Hungarian; **the agent (felhom-agent controller only proxies + maps the agent's machine `code` to Hungarian; **the agent (felhom-agent
@@ -298,10 +298,15 @@ self-reported failure gets:
- **Rendben** — clean. - **Rendben** — clean.
- **Figyelmeztetés** — a first sighting of unreadable sectors below the bar, reallocated sectors, NVMe - **Figyelmeztetés** — a first sighting of unreadable sectors below the bar, reallocated sectors, NVMe
media errors, `percentage_used ≥ 90`, or `temperature_c ≥ 55`. media errors, `percentage_used ≥ 90`, or `temperature_c ≥ 55` (spinning/SATA drives only).
- **Hiba** — the drive reports FAILING, **or** (v0.215.0) unreadable sectors are **sustained** across - **Hiba** — the drive reports FAILING, **or** (v0.215.0) unreadable sectors are **sustained** across
two consecutive checks, unreadable sectors sit alongside reallocation, the count reaches **64**, two consecutive checks, unreadable sectors sit alongside reallocation, the count reaches **64**,
`temperature_c ≥ 60`, NVMe's `critical_warning` is set, or `percentage_used ≥ 100`. `temperature_c ≥ 60` (spinning/SATA drives only), NVMe's `critical_warning` is set, or `percentage_used ≥ 100`.
- **No heat band for NVMe (v0.308.0, R-333, `09` §3 decision 206).** The 55/60 °C bands are spinning-disk bands;
a healthy NVMe idles at 53 °C and passes 60 °C under load. An NVMe drive (`agentapi.IsNVMe`: the summary
carries `critical_warning`, `percentage_used` or `media_errors`) never reaches Figyelmeztetés/Hiba from its
temperature; its own `critical_warning` (bit 1 is its temperature alarm) still makes it Hiba. Pinned by
`TestDiskVerdict_NVMeHasNoHeatBand`.
- **Nincs adat** — nil/UNKNOWN/old agent. Never alarms, and never erases a disk's stored history. - **Nincs adat** — nil/UNKNOWN/old agent. Never alarms, and never erases a disk's stored history.
`DiskVerdictFor(smart, prior)` takes an `agentapi.DiskPrior`; a zero prior is the fail-safe, so a first `DiskVerdictFor(smart, prior)` takes an `agentapi.DiskPrior`; a zero prior is the fail-safe, so a first
@@ -913,6 +918,13 @@ Each app can define rich metadata in `.felhom.yml`:
`--clear-restore-hold <app>`, **which requires a controller restart**. `--single-transaction` on `--clear-restore-hold <app>`, **which requires a controller restart**. `--single-transaction` on
the Postgres import is a belt only; MariaDB DDL is not transactional, which is why the rollback the Postgres import is a belt only; MariaDB DDL is not transactional, which is why the rollback
is the fix. is the fix.
- **Off-site restore of one app: a failed replay after a moved definition or a replaced volume HOLDS the app
(R-893 option C, `09` §3 decision 192).** The rollback puts back the database rows only; the snapshot's files,
volumes and older definition would stay. So when the snapshot's definition was written or a named volume was
replaced, the live definition is written back, the app is held stopped (`restore_mixed`, operator-cleared like
a restore hold), the household reads that it needs our help, and the operator gets a `backup_run_failures`
line (leg `restore-hold-mixed`). Otherwise the ladder above is unchanged. „Put back exactly as it was" is the
next slice.
- **Where an off-site restore puts the data (v0.219.0, R-356).** `ReconstituteFromOffsite` and - **Where an off-site restore puts the data (v0.219.0, R-356).** `ReconstituteFromOffsite` and
`PlaceOffsiteRestore` resolve the destination with `Manager.GetAppDrivePath` — **the same `PlaceOffsiteRestore` resolve the destination with `Manager.GetAppDrivePath` — **the same
resolver `CaptureRecoveryUnit` wrote the snapshot with**: the app's `HDD_PATH` if it declares resolver `CaptureRecoveryUnit` wrote the snapshot with**: the app's `HDD_PATH` if it declares
@@ -1294,7 +1306,10 @@ The nightly backup has two phases that run sequentially. All paths are **per-dri
> **Multi-tier whole-guest backup (v0.174.0, R-82 Slice B).** The agent can serve SEVERAL whole-guest > **Multi-tier whole-guest backup (v0.174.0, R-82 Slice B).** The agent can serve SEVERAL whole-guest
> backup tiers with independent cadences — "local daily + PBS weekly" (agent >= v0.97.0, > backup tiers with independent cadences — "local daily + PBS weekly" (agent >= v0.97.0,
> `GET /backup/tiers`). The controller owns quiescing, so it reconciles them: it collects EVERY due > `GET /backup/tiers`). The controller owns quiescing, so it reconciles them: it collects EVERY due
> tier up front and runs them inside **ONE quiesce window** — one stop, N sequential backups (vzdump > tier up front. **Since v0.301.0 (R-518, `09` §3 decision 156) a window runs ONLY the first due tier and resumes the
> apps at its `snapshotted`; the next due tier waits for a later cycle with its own short stop, and „Mentés most" makes
> the LOCAL copy only (nothing, and no stop, when the local storage is absent). The rest of this paragraph is the
> pre-v0.301.0 rule, kept for history:** it ran them inside **ONE quiesce window** — one stop, N sequential backups (vzdump
> holds a guest lock), one resume. Two cycles on the weekly night would mean two app outages for one > holds a guest lock), one resume. Two cycles on the weekly night would mean two app outages for one
> night's work. The app stays quiesced until the **LAST** tier snapshots, so every tier is > night's work. The app stays quiesced until the **LAST** tier snapshots, so every tier is
> app-consistent; the consequence is that both-due-night downtime is *(first tier's full backup)* + > app-consistent; the consequence is that both-due-night downtime is *(first tier's full backup)* +
@@ -1878,6 +1893,10 @@ appear in the restore dropdown with per-app snapshot filtering.
**Tier 1 restore** (`RestoreApp`): **Tier 1 restore** (`RestoreApp`):
- Stop app → resolve app's home drive → `restic restore <id> --target / --include <path>...` → populate Docker volumes from restored tars → restart app → health check - Stop app → resolve app's home drive → `restic restore <id> --target / --include <path>...` → populate Docker volumes from restored tars → restart app → health check
- **v0.301.0 (R-638):** with a database dump, the no-manifest fallback starts ONLY the database services, replays, then
starts the app (before, the whole stack started at the current definition first); a dump with no identifiable
database service is refused before anything is touched; a failed volume step skips the replay. A unit restore whose
volume leg failed does not replay either.
- Restore paths: config dir, DB dump dir, volume dump dir, HDD mounts - Restore paths: config dir, DB dump dir, volume dump dir, HDD mounts
- Docker volumes restored via `restoreDockerVolumes()`: `docker volume rm -f` → `docker volume create` → `docker run alpine tar xf` - Docker volumes restored via `restoreDockerVolumes()`: `docker volume rm -f` → `docker volume create` → `docker run alpine tar xf`
@@ -1968,6 +1987,12 @@ that folder is never a dead end, and an install never runs into it silently (R-6
- **Where it lives.** `<drive>/kept/` is beside `appdata/` and `userdata/`, inside neither: no app bind, FileBrowser - **Where it lives.** `<drive>/kept/` is beside `appdata/` and `userdata/`, inside neither: no app bind, FileBrowser
userdata source, Samba share or backup leg reads it. It is in `ProtectedHDDPaths`. userdata source, Samba share or backup leg reads it. It is in `ProtectedHDDPaths`.
- **The drive-full warning** ends with the kept folders on that drive and their sizes (`fillwatch.SetExtra`). - **The drive-full warning** ends with the kept folders on that drive and their sizes (`fillwatch.SetExtra`).
- **The fill warning in the household's language (v0.309.0, R-521 F7).** `disk_warning` / `disk_critical` carry the
Hungarian sentence AND the household's own (`message_customer`), from the bundle keys `event.fill.*`; the two built-in
areas („Alkalmazások területe" = the guest's root filesystem as the controller sees it, „Rendszer- és mentési terület")
are named in that language too. The guest's root filesystem has been a target since v0.191.0 and checked every 10 min
since v0.297.0 (R-363/R-547); F7 (2026-09-14, v0.242.0, daily check) is pinned end to end by
`TestR521F7_RootFilesystemSendsTheDiskEventInBothLanguages`.
- **`after_install:` (v0.279.0, decision 45)** — `{service, user?, env: [NAMES], command: [...], success: MARKER}`: one - **`after_install:` (v0.279.0, decision 45)** — `{service, user?, env: [NAMES], command: [...], success: MARKER}`: one
command in the app's own container after a FRESH install (never after a restore or a kept-data load), with the named command in the app's own container after a FRESH install (never after a restore or a kept-data load), with the named
deploy values filled into `${NAME}`; the output must carry `success`. Recorded in `app.yaml` `after_install`; the app deploy values filled into `${NAME}`; the output must carry `success`. Recorded in `app.yaml` `after_install`; the app
@@ -2011,6 +2036,9 @@ that folder is never a dead end, and an install never runs into it silently (R-6
into app.yaml + one `compose up -d`, or a command) set when the gate opens, after the block. The window lifts and the into app.yaml + one `compose up -d`, or a command) set when the gate opens, after the block. The window lifts and the
loop re-applies it. `POST /apps/<slug>/close-signup` for an app installed before the rule: lock record loop re-applies it. `POST /apps/<slug>/close-signup` for an app installed before the rule: lock record
(`opened_by: close-signup`), block, switch; never a gate. Code: `internal/stacks/after_setup.go`. (`opened_by: close-signup`), block, switch; never a gate. Code: `internal/stacks/after_setup.go`.
**v0.301.0 (R-717):** `open_command` / `open_success` reopen a command-closed switch for the household's 15-minute
window (marked `opening` on disk first; a failed open closes it again and the page says so); `command` runs again at
the window's end, at every start and after a successful update; a failed close is retried every 2 min.
- **The family gate (v0.287.0, decisions 63/64)** — `.felhom.yml` `family_gate: true` puts a PERMANENT door in front of - **The family gate (v0.287.0, decisions 63/64)** — `.felhom.yml` `family_gate: true` puts a PERMANENT door in front of
the app: only the household's family members (each with their own name and password, „Család" card on the security the app: only the household's family members (each with their own name and password, „Család" card on the security
page) and the household itself get through; `family_gate_except:` lists literal path prefixes left to the app's own page) and the household itself get through; `family_gate_except:` lists literal path prefixes left to the app's own
@@ -2602,6 +2630,10 @@ Continuously monitors registered storage paths for disconnection/reconnection (p
| Protected containers | — | not running | | Protected containers | — | not running |
| Storage paths | not a mount point (data on SSD), drive disconnected | path inaccessible, disk >= 95% | | Storage paths | not a mount point (data on SSD), drive disconnected | path inaccessible, disk >= 95% |
Every issue and warning carries a bundle key beside its frozen wire text (R-516 item 10; the last six producers —
Docker, protected containers, the four storage lines — since R-79, `09` §3 decision 187), so the dashboard banner shows
it in the household's language while the hub report keeps its bytes.
Backup destination validation (`CheckBackupDestination`) has tiered checks: Backup destination validation (`CheckBackupDestination`) has tiered checks:
- Path doesn't exist → critical/blocked - Path doesn't exist → critical/blocked
- Not writable → critical/blocked - Not writable → critical/blocked
@@ -2700,7 +2732,7 @@ The controller pushes structured events to the Hub's `/api/v1/event` endpoint. T
| `health_recovered` | info | Health status recovers (fail/warn→ok) | | `health_recovered` | info | Health status recovers (fail/warn→ok) |
| `disk_warning` | warning | Disk usage crosses 90% | | `disk_warning` | warning | Disk usage crosses 90% |
| `disk_critical` | **critical** | Disk usage crosses 95% (this row read `error` until v0.223.0; the emitter is `fillwatch.Band.Severity()` and it has always sent `critical`) | | `disk_critical` | **critical** | Disk usage crosses 95% (this row read `error` until v0.223.0; the emitter is `fillwatch.Band.Severity()` and it has always sent `critical`) |
| `storage_disconnected` | error | Storage drive physically removed | | `storage_disconnected` | error | Storage drive physically removed. The household's „Meghajtó leválasztva" option (R-521, `09` §3 decision 227) carries it in `enabled_events`; the hub mails a household a lost drive only when that option is on. Pinned by `TestR521_StorageDisconnectedOptionRoundTrips` |
| `storage_reconnected` | info | Storage drive reconnected | | `storage_reconnected` | info | Storage drive reconnected |
| `controller_started` | info | Controller process starts | | `controller_started` | info | Controller process starts |
| `controller_updated` | info/error | Self-update success or failure | | `controller_updated` | info/error | Self-update success or failure |
@@ -2884,8 +2916,23 @@ live hardware), which is exactly what the marker covers.
#### Default Enabled Events #### Default Enabled Events
Events the customer receives notifications for (configurable in settings): Events the customer receives notifications for (configurable in settings) — `settings.DefaultEnabledEvents`:
`backup_failed`, `db_dump_failed`, `disk_warning`, `disk_critical`, `storage_disconnected`, `node_down`, `health_critical`, `expected_backup_missed`, `expected_dbdump_missed` `backup_failed`, `db_dump_failed`, `disk_warning`, `disk_critical`, `storage_disconnected`, `node_down`, `health_critical`, `expected_backup_missed`, `expected_dbdump_missed`, `offbox_enlarge_blocked`, `app_update_undone`, `app_update_held`, `app_stopped_unhealthy`
#### The page: three group switches (v0.309.0, R-388 slice 1, `08` §8.1, `09` §3 decision 200)
The notification page shows three switches instead of one per detector — „A gépem és a meghajtóim" / "My
machine and drives", „Az alkalmazásaim" / "My apps", „Amit mi intézünk" / "What we handle" — each with one
sentence of what it sends. The membership table is `settings.NotificationGroups` (every detector on the page
is in exactly one group, pinned by `TestR388_EveryPageSwitchIsInExactlyOneGroup`). A group is a VIEW over the
same `enabled_events` list: no wire change, no hub change. A group with some detectors on and some off shows
as „egyedi" / "custom". The per-detector switches and the cooldown field sit under a „Részletek" / "Details"
fold (`<details>`, no script). The save rule (`Server.applyNotificationGroupSwitches`): a group switch the
household MOVED wins (sets or clears all its detectors); a group switch left where it was rendered leaves the
per-detector switches authoritative — so a custom group saved untouched writes the list back byte-for-byte
(`TestR388_CustomGroupSavedBackUnchanged`). A POST without the `group_<key>_shown` fields (an old page) is
handled exactly as before. Note: today's default list fills NO group completely (each misses a member), so a
fresh box shows all three as „egyedi" (`TestR388_DefaultListAsGroups` pins this until the default is decided).
#### Preference Sync #### Preference Sync
@@ -2900,6 +2947,12 @@ provisioning-seeded alert delivery). The form re-renders with a Hungarian error
ticked events preserved; no save, no sync. Clearing the e-mail with **zero** events enabled is ticked events preserved; no save, no sync. Clearing the e-mail with **zero** events enabled is
allowed (an intentional turn-everything-off). allowed (an intentional turn-everything-off).
**Clearing the address is warned first (v0.308.0, R-922, `09` §3 decision 196):** an empty box over a
STORED address saves nothing and sends nothing on the first press; the page says „you will get no more
notification mails" (both languages) with its own confirm button (`confirm_clear=1`). Only the confirmed
save clears the address and sends `email_cleared` (v0.305.0). Server-side, so it holds without JavaScript.
The hub's registered address is not the box's and is not touched. Pinned by `TestR922_ClearWarnsFirst`.
--- ---
### 7. Update Management ### 7. Update Management
@@ -3217,16 +3270,37 @@ Five sections:
2. **Version & update** — current/latest version, check/update buttons, auto-update status, last update result 2. **Version & update** — current/latest version, check/update buttons, auto-update status, last update result
3. **Storage paths** — add/remove, edit labels, set default, toggle schedulable, per-path app list with sizes, safe disconnect/reconnect for USB drives 3. **Storage paths** — add/remove, edit labels, set default, toggle schedulable, per-path app list with sizes, safe disconnect/reconnect for USB drives
4. **Password change** — current + new + confirm, min 8 chars 4. **Password change** — current + new + confirm, min 8 chars
5. **Notifications** — email, event checkboxes, cooldown hours, test email button 5. **Notifications** — email, three group switches (v0.309.0), the per-event checkboxes and cooldown hours under „Részletek", test email button
--- ---
#### Sign-ins survive a restart (R-35, `09` §3 decision 188)
Dashboard sessions are kept in `<data>/dashboard-sessions.json` (0600, atomic write): only sha256(cookie) → expiry +
CSRF token, never the cookie, plus a fingerprint of the password hash in force. A settings push, an update or a crash
restart no longer signs the household out. Expiry is unchanged (7 days). Logout and a password change end the session
on disk too; rows written under another password are dropped at load; a revoking save that fails removes the file.
Pinned by `internal/web/session_store_test.go` (`TestR35_*`).
### 9. Central Hub Reporting ### 9. Central Hub Reporting
#### Operator actions (`internal/report/opactions.go`, `09` §3 decision 185)
The hub may ask the running controller, in the report reply (`operator_actions`), for one of a CLOSED list:
`offsite_backup_now`, `abandon_stop`, `abandon_extend` (1–30 days; never earlier than the current date; refused once
the deletion is the hub's), `run_job` (`fill-watch`, `offsite-integrity`, `offsite-proof`, `disk-health-check`).
Anything else is answered `refused` and calls nothing. Each id runs once per process; the result rides the next report
(`operator_action_results: [{id, outcome, message}]`, outcome `done` / `refused` / `failed`). No action deletes data,
starts a countdown or shortens one (`TestOpActions_ClosedList`). The box's log records every action it receives.
#### Report Push (`internal/report/`) #### Report Push (`internal/report/`)
Periodic JSON push (default every 15 min) to the central felhom-hub service: Periodic JSON push (default every 15 min) to the central felhom-hub service:
- System: hostname, OS, CPU, memory, disk usage, uptime - System: hostname, OS, CPU, memory, disk usage, uptime
- Storage: one entry per mount (`/` first). **`read_failed: true` (v0.308.0, R-266)** marks the root entry when
its statfs failed (`SystemInfo.DiskKnown` false); the numbers stay the zeros the failed read left and the hub
skips banding that entry instead of reading it as an empty disk. Absent on a measured entry. Pinned by
`TestRootStorageEntry_ReadFailed`.
- Containers: running/stopped counts, per-container CPU/memory - Containers: running/stopped counts, per-container CPU/memory
- Backup: last DB-dump run, success (disk-tier backup is the host agent's; restic password is no longer reported — removed v0.69.0) - Backup: last DB-dump run, success (disk-tier backup is the host agent's; restic password is no longer reported — removed v0.69.0)
- Health: current status, issues, warnings - Health: current status, issues, warnings
@@ -3311,77 +3385,18 @@ The hub service (separate Go app in the `felhom.eu` repo) provides:
- Color coding: green (<30min), yellow (30-60min), red (>60min since last report) - Color coding: green (<30min), yellow (30-60min), red (>60min since last report)
- 90-day report + event retention with daily prune at 04:30 Budapest time - 90-day report + event retention with daily prune at 04:30 Budapest time
### 10. First-Run Setup Wizard ### 10. Not configured yet — the waiting page (the setup wizard is deleted, v0.308.0, R-554)
When the controller starts with no valid customer configuration (`customer.id` empty), it enters **setup mode** — a web-based wizard that handles all initial configuration. This replaces the old interactive shell wizard in `docker-setup.sh`. The host agent provisions every box: at first boot the controller ingests its configuration from the hub
(`internal/bootstrap`, `MaybeIngest`). The first-boot **setup wizard** (`internal/setup/`, the docker-setup.sh era,
#### Setup Mode Detection (`internal/setup/setup.go`) port 8081) was obsolete by design (`02-controller-module-map.md`) and is **deleted** (`09` §3 decision 222). A box whose
ingestion failed (no or bad `bootstrap.json`, a hub it cannot reach, a failed write) — `bootstrap.Unconfigured(cfg)`,
`NeedsSetup(cfg)` returns true when `customer.id` is empty or a `.needs-setup` marker file exists. In setup mode, the controller skips normal startup (no scheduler, no backup, no stacks) and serves only the wizard UI on two listeners: i.e. `customer.id` empty — now serves ONE page on its listener, in both languages („A Felhom még nem érte el a
- `:8080` — behind Traefik (accessible via domain, e.g. `https://felhom.example.com`) központot" / "Felhom has not reached the central service yet"), answers `/api/health` with
- `:8081` — direct HTTP (accessible via LAN IP, e.g. `http://192.168.0.100:8081`) `waiting_for_hub: true`, and retries the ingestion every minute (`bootstrap.WaitForConfig`). Once configured it closes
the page and starts normally on the same listener. The `.needs-setup` marker and the debug page's „DR / Telepítő
#### Wizard Flow varázsló" trigger are gone; `web.setup_listen` is still read so an old `controller.yaml` loads, and is unused.
Pinned by `TestWaitForConfig_ServesWaitingPageThenStarts`.
```
┌──────────────────────────────────┐
│ 1. Welcome │
│ Choose: Restore / Fresh install │
└─────────┬───────────┬────────────┘
│ │
┌─────▼─────┐ ┌──▼───────────────┐
│ 2a. Scan │ │ 2b. Hub download │
│ drives for│ │ (customer ID + │
│ local │ │ password) │
│ backups │ │ │
└─────┬─────┘ └──────┬────────────┘
│ │
┌─────▼─────┐ │
│ 2a.2 Hub │ │
│ recovery │ │
│ (fallback)│ │
└─────┬─────┘ │
│ │
┌─────▼─────┐ ┌──────▼───────────┐
│ Execute │ │ Execute fresh │
│ restore │ │ install │
└─────┬─────┘ └──────┬───────────┘
│ │
└───────┬───────┘
▼
os.Exit(0) → Docker restarts
→ normal mode
```
#### Hub Pre-Seeding
When `docker-setup.sh` is run with `--hub-customer` / `--hub-password`, the controller receives
pre-seeded credentials via environment variables:
| Env var | Purpose |
|---------|---------|
| `FELHOM_SETUP_CUSTOMER_ID` | Pre-fills customer ID in wizard forms |
| `FELHOM_SETUP_PASSWORD` | Pre-fills retrieval password for auto-processing |
In hub mode, the welcome page shows three cards instead of two:
1. **"Visszaállítás a Hub-ról"** — auto-calls `PullRecovery()`, shows infra backup details
2. **"Visszaállítás helyi meghajtóról"** — standard drive scan
3. **"Friss telepítés"** — auto-calls `PullConfig()`, downloads config only
Both hub paths auto-process when credentials are pre-seeded (no form entry needed).
On error, the wizard falls back to the manual form with the error displayed.
#### Key Components
| File | Purpose |
|------|---------|
| `setup/setup.go` | `NeedsSetup()` detection, `SetupState` persistence to `setup-state.json` |
| `setup/handlers.go` | HTTP handlers for each wizard step (welcome, scan, hub-restore, fresh, manual) |
| `setup/scanner.go` | Scans all block devices for `.felhom-infra-backup/` directories (current + `history/`) via `lsblk` + temp mounts; returns rich info (app names, disk count) |
| `setup/hub.go` | Hub recovery pull (`GET /api/v1/recovery/{id}`) and config download |
| `setup/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) |
| `setup/network.go` | Detects local IPs for LAN access URL display |
| `setup/templates/` | 8 embedded HTML templates (Hungarian, dark theme matching main UI) — includes `setup_hub_versions.html` for Hub backup version picker |
#### Local Infra Backup (`internal/backup/local_infra.go`) #### Local Infra Backup (`internal/backup/local_infra.go`)
@@ -3392,11 +3407,15 @@ The controller writes infrastructure snapshots to **every connected drive** afte
- `{timestamp}-backup.json` + `{timestamp}-metadata.json` pairs (timestamp format: `20060102T150405Z`) - `{timestamp}-backup.json` + `{timestamp}-metadata.json` pairs (timestamp format: `20060102T150405Z`)
- Oldest entries pruned when count exceeds 5 - Oldest entries pruned when count exceeds 5
During setup wizard drive scan, both current and historical backups are discovered, integrity-verified, and offered for one-click restore. The scan results table shows app names/count, disk count, and a "korábbi" badge for historical versions. (The setup wizard's drive scan that offered these for one-click restore is deleted, v0.308.0, R-554.)
#### Recovery Info (`internal/recovery/info.go`) #### Recovery Info (`internal/recovery/info.go`)
Generates `recovery-info.txt` on the system data partition with customer ID, Hub URL, retrieval password, and recovery instructions in Hungarian. Updated on startup and after config changes. Also displayed on the Settings page in a "Vészhelyzeti információk" section. Generates `recovery-info.txt` (0600) in the data directory at every start when a retrieval password is set: customer
ID, Hub URL, retrieval password, support contacts, and — since v0.308.0 (R-554) — what to do: the dashboard's
**Biztonsági mentés → Visszaállítás** page, and after a reinstall the **Adatok visszaszerzése** page with the
household's recovery code (ten words). Written in the box's language from the bundle key `recovery_info.body`; it no
longer points at the deleted setup wizard. Pinned by `TestRecoveryInfo_PointsAtDashboardNotWizard`.
**No secret is rendered into a page's response body (v0.207.0 + v0.208.0, R-249/R-254).** Three endpoints implement one rule — the page carries a BOOLEAN, the value comes from an explicit authenticated POST with `Cache-Control: no-store`, and the reveal is LOGGED (reading a value off markup left no trace at all): **No secret is rendered into a page's response body (v0.207.0 + v0.208.0, R-249/R-254).** Three endpoints implement one rule — the page carries a BOOLEAN, the value comes from an explicit authenticated POST with `Cache-Control: no-store`, and the reveal is LOGGED (reading a value off markup left no trace at all):
@@ -3412,29 +3431,11 @@ They are deliberately **per-secret**, not one generic "reveal any named secret"
### 11. Disaster Recovery ### 11. Disaster Recovery
When a system drive fails and is replaced, the recovery flow uses the setup wizard: Whole-guest recovery is the host agent's and the hub's (PBS restore, provisioning a replacement guest); the controller
has no recovery wizard any more (R-554, v0.308.0). On a box that is running, an app is restored on the dashboard's
``` **Biztonsági mentés → Visszaállítás** page. After a reinstall the earlier off-site backups are reopened on the
1. docker-setup.sh deploys fresh controller with minimal config **Adatok visszaszerzése** page (`/recovery`) with the household's recovery code. A box that cannot reach the hub at its
- With --hub-customer: credentials pre-seeded via env vars first boot waits on the page described in §10.
- Without: user enters credentials manually in wizard
2. Controller detects empty customer.id → enters setup mode
3. User opens wizard at http://<LAN-IP>:8081
4. Hub mode: welcome page shows Hub restore / local scan / fresh install
Non-hub mode: welcome page shows restore / fresh install
5. Hub restore: auto-connects to Hub, shows version picker if multiple versions
Local restore: scans all drives for .felhom-infra-backup/ directories (current + history/)
6. User selects backup version → restore: config, settings, passwords, disk layout
7. Controller restarts into normal mode with full config
8. Controller auto-mounts surviving drives by UUID from disk layout
9. Dashboard shows "Visszaállítás" (Restore) page for app-level recovery
10. User confirms → sequential restore: rsync first, restic fallback, DB import
```
**Backup sources (priority order):**
1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed
2. **Hub recovery endpoint** (`GET /api/v1/recovery/{id}`) — requires retrieval password, supports `?version=ID` for specific versions; Hub retains ~14 versions via GFS pruning (7 daily / 4 weekly / 3 monthly)
3. **Manual config** (wizard form) — enter all details manually as last resort
**Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments). **Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments).
@@ -3969,7 +3970,7 @@ run SAVES follow the language as well, and the switch is a **globe** — on the
sign-in, claim and recovery pages. Since **v0.257.0** the app catalog's own copy follows it, and since sign-in, claim and recovery pages. Since **v0.257.0** the app catalog's own copy follows it, and since
**v0.258.0/v0.259.0** so do the last Go-composed sentences: the update and lifecycle badges, the **v0.258.0/v0.259.0** so do the last Go-composed sentences: the update and lifecycle badges, the
data-folder promise, the channel banners, **the claim page's nine answers (R-596)** and **the Backup data-folder promise, the channel banners, **the claim page's nine answers (R-596)** and **the Backup
page's protection warnings and tier names (R-598)**. Still Hungarian: the one R-570 producer, the 18 page's protection warnings and tier names (R-598)**. Still Hungarian: the zero-selection off-site note (no reader depends on its words since R-570 closed, 2026-10-10; not yet translated), the 18
formal „ön" forms (counted and ratcheted, R-516), **everything the operator reads** (by design), and formal „ön" forms (counted and ratcheted, R-516), **everything the operator reads** (by design), and
the apps' own UIs (not ours). the apps' own UIs (not ours).
@@ -4059,8 +4060,17 @@ the apps' own UIs (not ours).
never reads the cookie. `/lang` is CSRF-exempt because its only achievable effect is the language of never reads the cookie. `/lang` is CSRF-exempt because its only achievable effect is the language of
the page the victim's own browser shows them — **if it gains a second effect it needs CSRF that day**. the page the victim's own browser shows them — **if it gains a second effect it needs CSRF that day**.
`safeBackPath` refuses a protocol-relative `//host` as well as an absolute URL. A successful CLAIM `safeBackPath` refuses a protocol-relative `//host` as well as an absolute URL. A successful CLAIM
carries the cookie into the household setting (§16). **No globe on the guest share pages or the carries the cookie into the household setting (§16). **The guest share pages carry the globe too (v0.308.0,
not-found page** — R-577. R-577, `09` §3 decision 207)** — the visitor's own: it posts to `/lang`, their cookie decides, the household's
setting is the default as on the sign-in page, and the share's wrong-password line follows it. The not-found
page has none. Pinned by `TestGuestSharePagesHaveGlobe`, `TestGuestSharePage_HonoursVisitorCookie`.
- **Dates and numbers per language (v0.308.0, R-562, `09` §3 decision 223):** Hungarian „2026. 10. 10. 15:04" and
a decimal comma, English „2026-10-10 15:04" and a decimal point — in every template func that prints a date
or a size (`formatFuncs(lang)` in `funcmap.go`, layouts in `internal/i18n/format.go`), in the Go-side page
data built in `internal/web`, and in page scripts (`fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` in
`layout.html`). A size another package already formatted („1.5 GB") goes through `numText` /
`i18n.LocalizeDecimals`. CSS widths keep their point. The Hungarian parity fixtures were re-captured ONCE for
this release (the change is classified date/number-only, see REPORT.md).
- **Report:** the hub report carries `"language"` (always present). No hub release reads it yet. - **Report:** the hub report carries `"language"` (always present). No hub release reads it yet.
- **Tools and gates:** `scripts/i18n_extract.py` converts a template (moves each Hungarian run into - **Tools and gates:** `scripts/i18n_extract.py` converts a template (moves each Hungarian run into
hu.json, leaves a marker); `scripts/i18n_missing_gate.py` (in `controller_gates.py`) checks keys hu.json, leaves a marker); `scripts/i18n_missing_gate.py` (in `controller_gates.py`) checks keys
@@ -4197,13 +4207,6 @@ controller/
│ │ ├── builder.go # Hub report builder (all subsystems → JSON) │ │ ├── builder.go # Hub report builder (all subsystems → JSON)
│ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth, parses customer_blocked) │ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth, parses customer_blocked)
│ │ └── infra_pull.go # DR: pull recovery/config from Hub (retrieval password auth) │ │ └── infra_pull.go # DR: pull recovery/config from Hub (retrieval password auth)
│ ├── setup/ # First-run setup wizard (web-based, replaces docker-setup.sh wizard)
│ │ ├── setup.go # NeedsSetup() detection, state persistence
│ │ ├── handlers.go # HTTP handlers for all wizard steps
│ │ ├── scanner.go # Drive scanner for local infra backups
│ │ ├── csrf.go # Lightweight CSRF (cookie + hidden field)
│ │ ├── network.go # Local IP detection for LAN access URLs
│ │ └── templates/ # 7 wizard HTML templates (Hungarian)
│ ├── recovery/info.go # Recovery info file generator (recovery-info.txt) │ ├── recovery/info.go # Recovery info file generator (recovery-info.txt)
│ └── web/ │ └── web/
│ ├── server.go # HTTP server, routing, static files, catch-all middleware, executeTemplate wrapper │ ├── server.go # HTTP server, routing, static files, catch-all middleware, executeTemplate wrapper
@@ -4301,7 +4304,7 @@ monitoring:
web: web:
listen: ":8080" listen: ":8080"
setup_listen: ":8081" # Plain HTTP for setup wizard LAN access setup_listen: ":8081" # UNUSED since v0.308.0 (the setup wizard is deleted, R-554)
hub: hub:
enabled: true enabled: true
@@ -135,7 +135,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
body := mainBody(t) body := mainBody(t)
names := callsInMain(t, body) 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 " + 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)") "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 // 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 // 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 // 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 // "A biztonsági mentés sikertelen!" about an app nothing is wrong with. The guard must consult
// Alarming(). // Alarming().
@@ -156,7 +156,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
} }
carries := false carries := false
for _, name := range callsInMain(t, ifst.Body) { for _, name := range callsInMain(t, ifst.Body) {
if name == "NotifyBackupFailed" { if name == "NotifyInterruptedOperation" {
carries = true carries = true
} }
} }
@@ -188,7 +188,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
if !guardedByAlarming { if !guardedByAlarming {
t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " + t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " +
"recovery that only REFUSED starts (drive absent) would be reported through " + "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)") "failed when the drive gate was simply doing its job (R-174)")
} }
} }
+138 -139
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -10,6 +9,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/sockheal" "gitea.dooplex.hu/admin/felhom-controller/internal/sockheal"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -35,9 +35,9 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/channelhealth" "gitea.dooplex.hu/admin/felhom-controller/internal/channelhealth"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare" cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/fillwatch" "gitea.dooplex.hu/admin/felhom-controller/internal/fillwatch"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra" "gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations" "gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
"gitea.dooplex.hu/admin/felhom-controller/internal/mailrelay" "gitea.dooplex.hu/admin/felhom-controller/internal/mailrelay"
@@ -53,7 +53,6 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/selftest" "gitea.dooplex.hu/admin/felhom-controller/internal/selftest"
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate" "gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/setup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync" catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system" "gitea.dooplex.hu/admin/felhom-controller/internal/system"
@@ -324,11 +323,25 @@ func main() {
system.DebugLogger = logger system.DebugLogger = logger
} }
// --- Setup mode: if no customer ID configured, run setup wizard --- // --- Not configured yet: wait for the hub (R-554, v0.308.0) ---
if setup.NeedsSetup(cfg) { // The first-boot setup wizard is deleted. A box whose first ingestion failed shows ONE waiting page
logger.Printf("[INFO] felhom-controller %s — setup mode", Version) // (both languages) and retries every minute; once configured it falls through to the normal start
runSetupMode(cfg, logger) // below, on the same listener. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.
return if bootstrap.Unconfigured(cfg) {
logger.Printf("[INFO] felhom-controller %s — not configured yet: waiting for the hub", Version)
waitCtx, waitStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
ln, lerr := net.Listen("tcp", cfg.Web.Listen)
if lerr != nil {
logger.Printf("[ERROR] waiting page: cannot listen on %s: %v — retrying the configuration without it", cfg.Web.Listen, lerr)
ln = nil
}
next := bootstrap.WaitForConfig(waitCtx, ln, *configPath, cfg, logger, pull, bootstrap.WaitRetryInterval, Version)
waitStop()
if next == nil {
logger.Printf("[INFO] Received a stop signal while waiting for the configuration — exiting")
return
}
cfg = next
} }
// --- Local API connectivity probe (slice 8A) --- // --- Local API connectivity probe (slice 8A) ---
@@ -355,7 +368,7 @@ func main() {
// --- Auto-discover storage paths from deployed apps --- // --- Auto-discover storage paths from deployed apps ---
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger) discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger) sett.AutoDiscoverStoragePaths(discoveredPaths, logger)
// --- Load or create encryption key --- // --- Load or create encryption key ---
encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key") encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key")
@@ -387,7 +400,7 @@ func main() {
stackMgr.MigrateEncryption() stackMgr.MigrateEncryption()
// --- First-boot base-infrastructure bring-up --- // --- First-boot base-infrastructure bring-up ---
// We are guaranteed configured here (setup.NeedsSetup returned false above), so deploy the base // We are guaranteed configured here (bootstrap.Unconfigured returned false above), so deploy the base
// stack (traefik-public network → traefik → cloudflared → filebrowser) the controller needs for // stack (traefik-public network → traefik → cloudflared → filebrowser) the controller needs for
// routing + external access. Runs in a goroutine so a slow first-boot image pull never delays the // routing + external access. Runs in a goroutine so a slow first-boot image pull never delays the
// web server; non-fatal (idempotent + single-flight, the health loop re-attempts each tick). // web server; non-fatal (idempotent + single-flight, the health loop re-attempts each tick).
@@ -497,6 +510,11 @@ func main() {
// sweep could bring up a half-updated app with no record of why. Pin-backs for updates interrupted // sweep could bring up a half-updated app with no record of why. Pin-backs for updates interrupted
// before anything ran happen here too. The resumed health wait itself is launched further down, // before anything ran happen here too. The resumed health wait itself is launched further down,
// once the backup side is wired, because a resumed update that fails must be able to HOLD. // once the backup side is wired, because a resumed update that fails must be able to HOLD.
// R-682: a Remove a restart cut off is finished first (drive data and backups kept) — before the
// boot reconciler, which would otherwise start the half-removed app again.
if names := stackMgr.RecoverInterruptedRemoves(); len(names) > 0 {
log.Printf("[WARN] [stacks] %d remove(s) interrupted by the restart were finished: %v", len(names), names)
}
if resumed := stackMgr.RecoverUpdates(); len(resumed) > 0 { if resumed := stackMgr.RecoverUpdates(); len(resumed) > 0 {
logger.Printf("[WARN] [update] %d interrupted update(s) will resume after the backup side is wired: %v", len(resumed), resumed) logger.Printf("[WARN] [update] %d interrupted update(s) will resume after the backup side is wired: %v", len(resumed), resumed)
} }
@@ -523,10 +541,7 @@ func main() {
if metricsStore != nil { if metricsStore != nil {
defer metricsStore.Close() defer metricsStore.Close()
metricsHDDPath := cfg.Paths.HDDPath metricsHDDPath := sett.GetDefaultStoragePath() // R-492: no global fallback any more
if p := sett.GetDefaultStoragePath(); p != "" {
metricsHDDPath = p
}
metricsCollector := metrics.NewMetricsCollector(metricsStore, cpuCollector, metricsHDDPath, logger) metricsCollector := metrics.NewMetricsCollector(metricsStore, cpuCollector, metricsHDDPath, logger)
metricsCollector.Start(ctx) metricsCollector.Start(ctx)
defer metricsCollector.Stop() defer metricsCollector.Stop()
@@ -598,6 +613,8 @@ func main() {
// updated, not only once it is held — found live in Scenario F, see backup.Manager.isHeld. // updated, not only once it is held — found live in Scenario F, see backup.Manager.isHeld.
if backupMgr != nil { if backupMgr != nil {
backupMgr.SetUpdatingCheck(stackMgr.IsUpdating) backupMgr.SetUpdatingCheck(stackMgr.IsUpdating)
// R-645 (09 §3 decision 142): the night backup leaves an app that is not running its pinned version alone.
backupMgr.SetVersionCheck(stackMgr.PinNotRunning)
// R-671 (v0.272.0): a restore that lifts an update hold removes the undo copies that hold kept. // R-671 (v0.272.0): a restore that lifts an update hold removes the undo copies that hold kept.
backupMgr.SetUndoCopyRemover(stackMgr.RemoveUndoCopies) backupMgr.SetUndoCopyRemover(stackMgr.RemoveUndoCopies)
} }
@@ -693,7 +710,15 @@ func main() {
// deliberate hold through it would email the customer "A biztonsági mentés sikertelen!" about an // deliberate hold through it would email the customer "A biztonsági mentés sikertelen!" about an
// app nothing is wrong with. The refusal is already logged at WARN with its reason. // app nothing is wrong with. The refusal is already logged at WARN with its reason.
if appStopRecovery != nil && appStopRecovery.Alarming() { if appStopRecovery != nil && appStopRecovery.Alarming() {
notifier.NotifyBackupFailed(appStopRecovery.Message(), appStopRecovery.Detail()) // R-585: facts, not the operator's English sentence — `backup_failed` is customer-enabled by
// default, so this line reaches the household and must follow its language.
notifier.NotifyInterruptedOperation(notify.InterruptedOperation{
ReasonKey: appStopRecovery.Reason.MessageKey(),
Reason: string(appStopRecovery.Reason),
Restarted: len(appStopRecovery.Restarted),
Failed: len(appStopRecovery.Failed),
Refused: len(appStopRecovery.Refused),
}, appStopRecovery.Detail())
} else if appStopRecovery != nil { } else if appStopRecovery != nil {
logger.Printf("[WARN] [appstop] %s — not alarming: %s", appStopRecovery.Message(), appStopRecovery.Detail()) logger.Printf("[WARN] [appstop] %s — not alarming: %s", appStopRecovery.Message(), appStopRecovery.Detail())
} }
@@ -924,9 +949,23 @@ func main() {
// deadAppHeartbeatEvery-th scan emits one INFO carrying the scan count and what it found, so an // deadAppHeartbeatEvery-th scan emits one INFO carrying the scan count and what it found, so an
// operator can always answer "is it running, and what does it see?" from a default box — and a // operator can always answer "is it running, and what does it see?" from a default box — and a
// STALLED detector is visible as the heartbeat stopping. // STALLED detector is visible as the heartbeat stopping.
//
// R-856 (`09` §3 decision 143, extending 129): after a CRASH boot of the host the grace is about 15
// minutes, not 90 s — the 2026-10-04 crash-guard test mailed app_start_failed / app_stopped_unhealthy
// 3.5 and 9 minutes after the boot for apps still coming up. The fact is the host crash guard's,
// read through the agent (GET /host/crash-guard); unknown (no agent, an older agent's 404) keeps 90 s.
var crashProbe crashboot.Probe
if cfg.LocalAPI.Endpoint != "" && cfg.LocalAPI.Token != "" {
if ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint); err != nil {
logger.Printf("[WARN] [deadapp] agent client init failed (%v) — the boot grace cannot learn of a crash boot; 90 s", err)
} else {
crashProbe = crashboot.AgentProbe(ac.CrashGuard)
}
}
bootGrace := crashboot.New(startTime, crashProbe, logger)
sched.Every("deadapp-check", 30*time.Second, func(ctx context.Context) error { sched.Every("deadapp-check", 30*time.Second, func(ctx context.Context) error {
if time.Since(startTime) < deadAppBootGrace { if bootGrace.Within(time.Now()) {
return nil // still inside the startup settle window return nil // still inside the startup settle window (90 s, or ~15 min after a crash boot)
} }
dead, states := scanDeployedAppRunStates(stackMgr, quiesceLoop, appStopGuard, backupMgr) dead, states := scanDeployedAppRunStates(stackMgr, quiesceLoop, appStopGuard, backupMgr)
alertMgr.SetDeadAppAlerts(dead) alertMgr.SetDeadAppAlerts(dead)
@@ -1050,6 +1089,15 @@ func main() {
RecordEscrowKeyHash: sett.SetHubEscrowKeySHA256, RecordEscrowKeyHash: sett.SetHubEscrowKeySHA256,
Logger: logger, Logger: logger,
} }
// R-314/R-279/R-177 (`09` §3 decision 185): the operator's closed list of actions, carried in the
// report reply and run in THIS process (opactions_wiring.go). The result rides the next report;
// a finished result fires an out-of-cycle report through the trigger once it exists.
opActions := report.NewOperatorActions(ctx, operatorActionHandlers(backupMgr, sched, func() {
if f, ok := opActionReportFire.Load().(func()); ok && f != nil {
f()
}
}, logger), logger)
report.SetOperatorActions(opActions)
// Wire hub verification: update settings when hub reports customer status // Wire hub verification: update settings when hub reports customer status
hubPusher.OnPushResponse = func(resp *report.PushResponse) { hubPusher.OnPushResponse = func(resp *report.PushResponse) {
if resp.CustomerBlocked { if resp.CustomerBlocked {
@@ -1093,6 +1141,8 @@ func main() {
// generation). The web gate reads it on the next request — no restart needed. // generation). The web gate reads it on the next request — no restart needed.
claimSync := &report.ClaimSync{Settings: sett, Logger: logger} claimSync := &report.ClaimSync{Settings: sett, Logger: logger}
claimSync.Reconcile(resp.Claim) claimSync.Reconcile(resp.Claim)
// Decision 185: the operator's pending actions (each acted on once per id).
opActions.Reconcile(resp.OperatorActions)
} }
// Wire hub push status into alert manager for dashboard alerts // Wire hub push status into alert manager for dashboard alerts
alertMgr.SetHubPushStatus(func() web.HubPushStatusData { alertMgr.SetHubPushStatus(func() web.HubPushStatusData {
@@ -1142,7 +1192,7 @@ func main() {
dbDumpLeg := withLeg(nightchain.LegDBDump, func(ctx context.Context) error { dbDumpLeg := withLeg(nightchain.LegDBDump, func(ctx context.Context) error {
err := backupMgr.RunDBDumps(ctx) err := backupMgr.RunDBDumps(ctx)
if err != nil { if err != nil {
notifier.NotifyDBDumpFailed("Adatbázis mentés sikertelen", err.Error()) notifier.NotifyDBDumpFailed(err.Error())
} else { } else {
notifier.NotifyDBDumpCompleted(notify.DBDumpDetails{}) notifier.NotifyDBDumpCompleted(notify.DBDumpDetails{})
} }
@@ -1246,8 +1296,7 @@ func main() {
if err != nil { if err != nil {
// F-DIAG: a distinct message per cause, and the detail SANITISED — a raw err.Error() // F-DIAG: a distinct message per cause, and the detail SANITISED — a raw err.Error()
// carries the sftp:user@host:/path repo reference off the box. // carries the sftp:user@host:/path repo reference off the box.
notifier.NotifyBackupFailed("Off-box (NAS) mentés sikertelen", notifier.NotifyOffboxBackupFailed(backupMgr.OffsiteFailureMessage(err, dur))
backupMgr.OffsiteFailureMessage(err, dur))
} }
}) })
// R-203 (operator half): a completed off-site run could NOT capture a directory an app declares // R-203 (operator half): a completed off-site run could NOT capture a directory an app declares
@@ -1345,6 +1394,13 @@ func main() {
// path over. // path over.
backupMgr.SetRestoreHoldNotify(func(stack string, replayErr, rollbackErr error) { backupMgr.SetRestoreHoldNotify(func(stack string, replayErr, rollbackErr error) {
d := notify.BackupRunFailuresDetails{RunKind: "offsite-reconstitute", Failed: 1, Attempted: 1} d := notify.BackupRunFailuresDetails{RunKind: "offsite-reconstitute", Failed: 1, Attempted: 1}
if rollbackErr == nil { // R-893: the rollback WORKED, but files/volumes/version had already moved
d.Apps = append(d.Apps, notify.RunFailureDetail{App: stack, Leg: "restore-hold-mixed", Reason: "replay failed after files/volumes/version moved"})
msg := fmt.Sprintf("App %q is HELD STOPPED for support: its off-site database restore failed. The database was rolled back to the pre-restore copy and the live definition written back, "+
"but its files and named volumes are the snapshot's — a mixed state (R-893). The app will not start from any path until the hold is cleared. Replay error: %v", stack, replayErr)
notifier.NotifyBackupRunFailures(msg, d)
return
}
reason := "rollback failed" reason := "rollback failed"
if rollbackErr != nil { if rollbackErr != nil {
reason = rollbackErr.Error() reason = rollbackErr.Error()
@@ -1358,9 +1414,7 @@ func main() {
// triggered by the engine (only NEW blocks notify), so the hub's per-event-type cooldown suffices — // triggered by the engine (only NEW blocks notify), so the hub's per-event-type cooldown suffices —
// no controller-side timer (the hub owns cooldown). // no controller-side timer (the hub owns cooldown).
backupMgr.SetOffboxEnlargeBlockedNotifier(func(stack string, estBytes int64, usedGB, quotaGB int) { backupMgr.SetOffboxEnlargeBlockedNotifier(func(stack string, estBytes int64, usedGB, quotaGB int) {
notifier.NotifyOffboxEnlargeBlocked(fmt.Sprintf( notifier.NotifyOffboxEnlargeBlocked(stack, appbackup.HumanizeBytes(estBytes), usedGB, quotaGB) // R-585
"A(z) %s teljes távoli mentése (~%s) túllépné a tárhelykeretet (%d/%d GB). A konfiguráció és az adatbázis továbbra is mentésre kerül; nagyobb kerethez vedd fel velünk a kapcsolatot.",
stack, appbackup.HumanizeBytes(estBytes), usedGB, quotaGB))
}) })
// v0.142.0 offsite-repo continuity: push a hub event on the orphaned/reset transitions (once per // v0.142.0 offsite-repo continuity: push a hub event on the orphaned/reset transitions (once per
// transition — the Manager guards nightly re-fire). Operator-visible on the customer page. // transition — the Manager guards nightly re-fire). Operator-visible on the customer page.
@@ -1465,7 +1519,9 @@ func main() {
// collision costs one skipped day, not a missed check, because due-ness makes tomorrow try // collision costs one skipped day, not a missed check, because due-ness makes tomorrow try
// again. What would be a real defect is a slot that collides EVERY night; this is not one. // again. What would be a real defect is a slot that collides EVERY night; this is not one.
sched.Daily("offsite-integrity", "06:00", func(ctx context.Context) error { sched.Daily("offsite-integrity", "06:00", func(ctx context.Context) error {
runOffsiteIntegrityCheck(ctx, backupMgr, notifier, logger, false) // Decision 185: an operator's run_job runs the check even when it is not due — the operator
// asked for a check, not for „is a check due". The schedule still checks due-ness.
runOffsiteIntegrityCheck(ctx, backupMgr, notifier, logger, scheduler.OnDemand(ctx))
return nil return nil
}) })
@@ -1536,21 +1592,12 @@ func main() {
} }
}) })
if notifier != nil { if notifier != nil {
fillWatcher.SetNotify(func(e fillwatch.Event) { fillWatcher.SetNotify(fillEventPush(notifier))
// The CUSTOMER's event — deliberately not operator-only. A customer can free space,
// delete files or add a drive, so this alert is theirs (D-c). The dynamic Hungarian
// message carries the label and the free space; the hub has NO customerMessages entry for
// these two types precisely so the template cannot discard them.
notifier.PushEvent(e.Band.EventType(), e.Band.Severity(), e.Message, map[string]any{
"path": e.Target.Path, "label": e.Target.Label,
"used_percent": e.Usage.UsedPercent, "avail_gb": e.Usage.AvailGB,
"total_gb": e.Usage.TotalGB, "band": e.Band.String(),
})
})
} }
// `09` §3 decision 36: the warning names the kept folders on the filling drive, with their sizes — // `09` §3 decision 36: the warning names the kept folders on the filling drive, with their sizes —
// space the household can free on the kept-data list. Nothing is deleted by the box (D3). // space the household can free on the kept-data list. Nothing is deleted by the box (D3).
fillWatcher.SetExtra(func(t fillwatch.Target) string { return keptSpaceSentence(stackMgr, t.Path) }) fillWatcher.SetExtra(func(t fillwatch.Target, lang string) string { return keptSpaceSentence(stackMgr, t.Path, lang) })
fillWatcher.SetLang(sett.GetLanguage)
sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { return fillWatcher.Check() }) sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { return fillWatcher.Check() })
sched.Every("fill-watch-interval", fillWatchInterval, func(ctx context.Context) error { return fillWatcher.Check() }) sched.Every("fill-watch-interval", fillWatchInterval, func(ctx context.Context) error { return fillWatcher.Check() })
@@ -1669,6 +1716,8 @@ func main() {
HubURL: cfg.Hub.URL, HubURL: cfg.Hub.URL,
SupportEmail: "support@felhom.eu", SupportEmail: "support@felhom.eu",
SupportURL: "https://felhom.eu/kapcsolat", SupportURL: "https://felhom.eu/kapcsolat",
DashboardURL: "https://felhom." + cfg.Customer.Domain, // R-554: the Restore page, not the wizard
Lang: sett.GetLanguage(),
} }
if err := recovery.GenerateRecoveryFile(info, Version, cfg.Paths.DataDir); err != nil { if err := recovery.GenerateRecoveryFile(info, Version, cfg.Paths.DataDir); err != nil {
logger.Printf("[WARN] Failed to generate recovery-info.txt: %v", err) logger.Printf("[WARN] Failed to generate recovery-info.txt: %v", err)
@@ -1749,8 +1798,9 @@ func main() {
if notifier.IsEnabled() { if notifier.IsEnabled() {
go func() { go func() {
prefs := sett.GetNotificationPrefs() prefs := sett.GetNotificationPrefs()
if prefs.Email != "" { // R-922: a household's clear is pushed too, so a clear the hub missed reaches it at the next start.
if err := notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours); err != nil { if prefs.SyncOnStartup() {
if err := notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil {
logger.Printf("[WARN] Failed to sync notification preferences on startup: %v", err) logger.Printf("[WARN] Failed to sync notification preferences on startup: %v", err)
} }
} }
@@ -1780,6 +1830,7 @@ func main() {
} }
reportTrigger = report.NewTrigger(fireReport, logger) reportTrigger = report.NewTrigger(fireReport, logger)
go reportTrigger.Run(ctx) go reportTrigger.Run(ctx)
opActionReportFire.Store(reportTrigger.Fire)
// Direction-2 immediate-sync (v0.140.0): hold a hanging GET against the hub's wait channel // Direction-2 immediate-sync (v0.140.0): hold a hanging GET against the hub's wait channel
// and fire the trigger the instant operator intent moves — so an operator save round-trips in // and fire the trigger the instant operator intent moves — so an operator save round-trips in
@@ -2013,12 +2064,13 @@ func main() {
// local_api endpoint drift (R-77, from the 2026-07-25 outage): controller.yaml and bootstrap.json // local_api endpoint drift (R-77, from the 2026-07-25 outage): controller.yaml and bootstrap.json
// can disagree indefinitely and silently — the island migration rewrote the latter and the // can disagree indefinitely and silently — the island migration rewrote the latter and the
// controller kept dialling the former for 17.5 h, alerting only "agent unreachable". This NAMES // controller kept dialling the former for 17.5 h, alerting only "agent unreachable". This NAMES
// the fault; it deliberately does not reconcile the files (R-78 owns which one wins). // the fault; it deliberately does not reconcile the files (R-78 owns which one wins). Since R-270 it
// also names a TOKEN-only divergence (a rotation that reached bootstrap.json only).
// //
// Startup-only is sufficient and correct: both files are read at boot and neither changes under a // Startup-only is sufficient and correct: both files are read at boot and neither changes under a
// running controller, so a periodic re-check would add noise without adding signal. // running controller, so a periodic re-check would add noise without adding signal.
if d := bootstrap.DetectEndpointDrift(*configPath, cfg, logger); d != nil { if d := bootstrap.DetectEndpointDrift(*configPath, cfg, logger); d != nil {
alertMgr.SetEndpointDriftAlert(true, "alert.endpoint_drift", d.HungarianMessage()) alertMgr.SetEndpointDriftAlert(true, d.AlertKey(), d.HungarianMessage())
if notifier != nil { if notifier != nil {
notifier.NotifyEndpointDrift(d.EnglishMessage(), d.FingerprintAgrees) notifier.NotifyEndpointDrift(d.EnglishMessage(), d.FingerprintAgrees)
} }
@@ -2470,28 +2522,52 @@ const fillWatchInterval = 10 * time.Minute
// DECOMMISSIONED drive is dropped — it is deliberately out of service, and its fill is not news. A // DECOMMISSIONED drive is dropped — it is deliberately out of service, and its fill is not news. A
// DISCONNECTED one is kept in the list but will read as unreadable and be skipped by §8.4, which is // DISCONNECTED one is kept in the list but will read as unreadable and be skipped by §8.4, which is
// the correct outcome: its absence already has its own alert. // the correct outcome: its absence already has its own alert.
// fillEventPusher is the one notifier method fillEventPush needs — an interface so the R-521 F7 test
// can record what is pushed without a hub.
type fillEventPusher interface {
PushEventHousehold(eventType, severity, message, household string, details interface{})
}
// fillEventPush turns a fillwatch crossing into the hub event.
//
// The CUSTOMER's event — deliberately not operator-only. A customer can free space, delete files or
// add a drive, so this alert is theirs (D-c). The dynamic Hungarian message carries the label and the
// free space; the hub has NO customerMessages entry for these two types precisely so the template
// cannot discard them. R-521 F7: the household's own rendering rides beside the Hungarian (R-558's
// `message_customer`), so an English household is told in English. Pinned by
// TestR521F7_RootFilesystemSendsTheDiskEventInBothLanguages.
func fillEventPush(n fillEventPusher) func(fillwatch.Event) {
return func(e fillwatch.Event) {
n.PushEventHousehold(e.Band.EventType(), e.Band.Severity(), e.Message, e.MessageHousehold, map[string]any{
"path": e.Target.Path, "label": e.Target.Label,
"used_percent": e.Usage.UsedPercent, "avail_gb": e.Usage.AvailGB,
"total_gb": e.Usage.TotalGB, "band": e.Band.String(),
})
}
}
func fillTargets(cfg *config.Config, sett *settings.Settings) []fillwatch.Target { func fillTargets(cfg *config.Config, sett *settings.Settings) []fillwatch.Target {
seen := make(map[string]bool) seen := make(map[string]bool)
var out []fillwatch.Target var out []fillwatch.Target
add := func(path, label string) { add := func(path, label, labelKey string) {
if path == "" || seen[path] { if path == "" || seen[path] {
return return
} }
seen[path] = true seen[path] = true
out = append(out, fillwatch.Target{Path: path, Label: label}) out = append(out, fillwatch.Target{Path: path, Label: label, LabelKey: labelKey})
} }
// The docker data-root as seen from inside the guest, and the system-data area. // The docker data-root as seen from inside the guest, and the system-data area.
add(system.DockerVolumePath, "Alkalmazások területe") add(system.DockerVolumePath, "Alkalmazások területe", "event.fill.label_apps")
if cfg != nil { if cfg != nil {
add(cfg.Paths.SystemDataPath, "Rendszer- és mentési terület") add(cfg.Paths.SystemDataPath, "Rendszer- és mentési terület", "event.fill.label_system")
} }
if sett != nil { if sett != nil {
for _, sp := range sett.GetStoragePaths() { for _, sp := range sett.GetStoragePaths() {
if sp.Decommissioned { if sp.Decommissioned {
continue continue
} }
add(sp.Path, sp.Label) add(sp.Path, sp.Label, "")
} }
} }
return fillwatch.SortTargets(out) return fillwatch.SortTargets(out)
@@ -3446,8 +3522,9 @@ func (b quiesceBackend) Due(ctx context.Context) (bool, *int64, error) {
return r.Due, r.AgeSecs, err return r.Due, r.AgeSecs, err
} }
func (b quiesceBackend) StartBackup(ctx context.Context) (string, error) { func (b quiesceBackend) StartBackup(ctx context.Context) (string, error) {
r, err := b.c.StartBackup(ctx) // R-899: a press says so. F-A1: the untargeted (pre-R-82) path can 409 identically.
return r.JobID, mapBusy(err) // F-A1: the untargeted (pre-R-82) path can 409 identically r, err := b.c.StartBackupForTrigger(ctx, "", quiesce.IsManualTrigger(ctx))
return r.JobID, mapBusy(err)
} }
func (b quiesceBackend) BackupStatus(ctx context.Context) (string, error) { func (b quiesceBackend) BackupStatus(ctx context.Context) (string, error) {
r, err := b.c.BackupStatus(ctx) r, err := b.c.BackupStatus(ctx)
@@ -3483,7 +3560,7 @@ func (b quiesceBackend) DueFor(ctx context.Context, target string) (bool, *int64
return r.Due, r.AgeSecs, r.AgeState, err return r.Due, r.AgeSecs, r.AgeState, err
} }
func (b quiesceBackend) StartBackupFor(ctx context.Context, target string) (string, error) { func (b quiesceBackend) StartBackupFor(ctx context.Context, target string) (string, error) {
r, err := b.c.StartBackupFor(ctx, target) r, err := b.c.StartBackupForTrigger(ctx, target, quiesce.IsManualTrigger(ctx)) // R-899: a press says so
// F-A1: translate the agent's HTTP 409 into the loop's vocabulary, exactly as Tiers translates a // F-A1: translate the agent's HTTP 409 into the loop's vocabulary, exactly as Tiers translates a
// 404 into ErrTiersUnsupported. 409 means the agent's R-85 single-flight gate refused because a // 404 into ErrTiersUnsupported. 409 means the agent's R-85 single-flight gate refused because a
// restore-test (or another backup) holds it — CONTENTION, not failure. `internal/quiesce` keeps // restore-test (or another backup) holds it — CONTENTION, not failure. `internal/quiesce` keeps
@@ -3509,6 +3586,15 @@ func (b quiesceBackend) BackupStatusFor(ctx context.Context, target string) (str
return r.Phase, err return r.Phase, err
} }
// BackupJobsInFlight satisfies quiesce.InFlightProber (R-921): the loop asks it before it stops any app.
// The compile-time assertion below is the wiring pin — a pre-check whose adapter stops implementing the
// interface would silently fall back to "stop, then ask" (a seam built but never wired).
var _ quiesce.InFlightProber = quiesceBackend{}
func (b quiesceBackend) BackupJobsInFlight(ctx context.Context) ([]string, error) {
return b.c.BackupJobsInFlight(ctx)
}
// quiesceTierNotifier adapts *notify.Notifier to quiesce.TierNotifier (R-97a). // quiesceTierNotifier adapts *notify.Notifier to quiesce.TierNotifier (R-97a).
// //
// The whole-guest tier had NO route to the hub at all — `internal/quiesce` did not import // The whole-guest tier had NO route to the hub at all — `internal/quiesce` did not import
@@ -3639,71 +3725,6 @@ func probeLocalAPI(cfg *config.Config, logger *log.Logger) {
} }
} }
// runSetupMode starts the setup wizard on dual listeners and blocks until signal.
func runSetupMode(cfg *config.Config, logger *log.Logger) {
ips := setup.DetectLocalIPs()
setup.LogSetupMode(cfg.Customer.Domain, ips, cfg.Web.SetupListen, logger)
setupSrv := setup.NewServer(cfg, cfg.Paths.DataDir, logger, Version)
handler := setupSrv.Handler()
// Health endpoint wrapper (returns setup_mode: true)
healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true, "message": "felhom-controller is healthy",
"setup_mode": true, "version": Version,
})
})
// Mux for both listeners
mux := http.NewServeMux()
mux.HandleFunc("/api/health", healthHandler)
mux.Handle("/", handler)
// Start main listener (:8080, behind Traefik for domain access)
mainServer := &http.Server{
Addr: cfg.Web.Listen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
logger.Printf("[INFO] Setup wizard (main) listening on %s", cfg.Web.Listen)
if err := mainServer.ListenAndServe(); err != http.ErrServerClosed {
logger.Printf("[ERROR] Main HTTP server error: %v", err)
}
}()
// Start setup-only listener (:8081, direct HTTP for LAN access)
setupServer := &http.Server{
Addr: cfg.Web.SetupListen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
logger.Printf("[INFO] Setup wizard (LAN) listening on %s", cfg.Web.SetupListen)
if err := setupServer.ListenAndServe(); err != http.ErrServerClosed {
logger.Printf("[ERROR] Setup HTTP server error: %v", err)
}
}()
// Wait for signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
logger.Printf("[INFO] Received signal %v, shutting down setup wizard...", sig)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
mainServer.Shutdown(shutdownCtx)
setupServer.Shutdown(shutdownCtx)
logger.Println("[INFO] Setup wizard stopped")
}
// discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values. // discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values.
func discoverHDDPaths(stacksDir string, logger *log.Logger) []string { func discoverHDDPaths(stacksDir string, logger *log.Logger) []string {
entries, err := os.ReadDir(stacksDir) entries, err := os.ReadDir(stacksDir)
@@ -3773,7 +3794,7 @@ func runOffsiteIntegrityCheck(ctx context.Context, mgr *backup.Manager, n *notif
// design (08 §6.1). A weekly success e-mail is how people stop reading their alerts. It is // design (08 §6.1). A weekly success e-mail is how people stop reading their alerts. It is
// still pushed, because the hub stores it and the event stream is where "was it checked?" is // still pushed, because the hub stores it and the event stream is where "was it checked?" is
// answered. // answered.
n.NotifyIntegrityOK(integrityOKMsg(res)) n.NotifyIntegrityOK(res.Duration.Round(time.Second).String(), res.ReadDataSubset) // R-585: facts; the bundle composes
return res return res
default: default:
mgr.RecordIntegrityVerdict(res) mgr.RecordIntegrityVerdict(res)
@@ -3783,22 +3804,11 @@ func runOffsiteIntegrityCheck(ctx context.Context, mgr *backup.Manager, n *notif
// The customer gets a SENTENCE. restic's own words go to the log, truncated, where the operator // The customer gets a SENTENCE. restic's own words go to the log, truncated, where the operator
// can diagnose without a rebuild. R-379 is the reason that split exists: 615 bytes of raw // can diagnose without a rebuild. R-379 is the reason that split exists: 615 bytes of raw
// database text reached a customer once. // database text reached a customer once.
n.NotifyIntegrityFailed(integrityFailedMsg, "restic check reported repository errors") n.NotifyIntegrityFailed("restic check reported repository errors") // R-585: the sentence is event.backup_integrity_failed
return res return res
} }
} }
// R-359 customer-facing strings. Named constants because tests assert them verbatim and because a
// silent edit is how an honest message drifts into a comforting one.
const (
// integrityFailedMsg names what to do and what NOT to do. "Ne törölj semmit" is load-bearing: a
// customer who believes their backups are broken may try to "start fresh", which destroys the one
// copy that might still be partly recoverable.
integrityFailedMsg = "A távoli mentés ellenőrzése hibát talált a tárolóban. A mentések egy része sérült lehet. Ne törölj semmit, és vedd fel velünk a kapcsolatot."
integrityOKBase = "A távoli mentés ellenőrzése rendben lezajlott."
)
// ── R-87 — the nightly off-site PROOF's one caller ───────────────────────────────────────────── // ── R-87 — the nightly off-site PROOF's one caller ─────────────────────────────────────────────
// //
// ONE function, like runOffsiteIntegrityCheck above, so a future debug button cannot drift from the // ONE function, like runOffsiteIntegrityCheck above, so a future debug button cannot drift from the
@@ -3859,17 +3869,6 @@ func proofEmptyDetail(res backup.ProofResult) string {
return d return d
} }
// integrityOKMsg states what was actually checked, so a structure-only pass is never read as a
// full data verification. The depth is a fact the customer's sentence has to carry: "checked" means
// two different things depending on it.
func integrityOKMsg(res backup.IntegrityResult) string {
msg := integrityOKBase + " (" + res.Duration.Round(time.Second).String()
if res.ReadDataSubset != "" {
msg += ", a mentett adatok " + res.ReadDataSubset + "-át újraolvasva"
}
return msg + ")"
}
// updateGuardsAdapter implements stacks.UpdateGuards over the backup manager (slice 4). The stacks // updateGuardsAdapter implements stacks.UpdateGuards over the backup manager (slice 4). The stacks
// package cannot import backup, so this is the one place the two meet. Nil-safe on b: a box with // package cannot import backup, so this is the one place the two meet. Nil-safe on b: a box with
// backup disabled has no restore point, and the update is refused for that true reason. // backup disabled has no restore point, and the update is refused for that true reason.
@@ -4086,7 +4085,7 @@ func budapestLoc() *time.Location {
// keptSpaceSentence is the drive-full warning's kept-data sentence for one drive (Hungarian, like the // keptSpaceSentence is the drive-full warning's kept-data sentence for one drive (Hungarian, like the
// warning it extends): each kept folder by app name and size. "" when the drive holds none. // warning it extends): each kept folder by app name and size. "" when the drive holds none.
func keptSpaceSentence(sm *stacks.Manager, drive string) string { func keptSpaceSentence(sm *stacks.Manager, drive, lang string) string {
items := sm.ListKept([]string{drive}) items := sm.ListKept([]string{drive})
if len(items) == 0 { if len(items) == 0 {
return "" return ""
@@ -4095,5 +4094,5 @@ func keptSpaceSentence(sm *stacks.Manager, drive string) string {
for _, it := range items { for _, it := range items {
parts = append(parts, fmt.Sprintf("%s (%s)", it.DisplayName, appbackup.HumanizeBytes(it.SizeBytes))) parts = append(parts, fmt.Sprintf("%s (%s)", it.DisplayName, appbackup.HumanizeBytes(it.SizeBytes)))
} }
return util.Text(i18n.Default, "kept.diskwarn", strings.Join(parts, ", ")) return util.Text(lang, "kept.diskwarn", strings.Join(parts, ", "))
} }
@@ -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])
}
}
}
+28 -9
View File
@@ -5,6 +5,8 @@ import (
"go/parser" "go/parser"
"go/token" "go/token"
"testing" "testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
) )
// R-359 — the scheduled job must be PROVEN WIRED. // 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) { 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 // The customer gets a sentence; restic's words go to the log. R-379: 615 bytes of raw database
// text reached a customer once. // text reached a customer once. R-585: the sentence is a bundle key now, so BOTH languages are
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} { // held to it — an English household must be told what not to do just as plainly.
if contains(integrityFailedMsg, bad) { b, err := i18n.Shared()
t.Errorf("the failure sentence carries machine detail %q: %q", bad, integrityFailedMsg) if err != nil {
} t.Fatal(err)
} }
// It must still tell them what to do — and what NOT to do. for _, tc := range []struct {
for _, want := range []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"} { lang string
if !contains(integrityFailedMsg, want) { wants []string
t.Errorf("the failure sentence is a dead end; missing %q", want) }{
{"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,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,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)
}
}
@@ -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") 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")
}
}
+50 -1
View File
@@ -64,6 +64,21 @@ func targetQuery(target string) string {
return "?target=" + url.QueryEscape(target) 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 // 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. // — that filtering happens agent-side (latestSuccessfulBackupForTarget); this just asks per tier.
func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse, error) { 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. // StartBackupFor enqueues a backup of this guest ON THE GIVEN TIER.
func (c *Client) StartBackupFor(ctx context.Context, target string) (BackupResponse, error) { 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 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 { if err != nil {
return out, err return out, err
} }
@@ -105,6 +127,33 @@ func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResp
return out, nil 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). // SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
type SetBackupTargetResponse struct { type SetBackupTargetResponse struct {
Target string `json:"target"` Target string `json:"target"`
+11 -9
View File
@@ -255,6 +255,9 @@ const (
PhaseRunning = "running" PhaseRunning = "running"
PhaseDone = "done" PhaseDone = "done"
PhaseFailed = "failed" 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). // 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. // 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) { func (c *Client) StartBackup(ctx context.Context) (BackupResponse, error) {
var out BackupResponse return c.StartBackupForTrigger(ctx, "", false)
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
} }
// BackupStatus reports the current/last backup job phase for this guest. // BackupStatus reports the current/last backup job phase for this guest.
@@ -1065,6 +1060,13 @@ type SmartSummary struct {
ReallocatedSectors *int `json:"reallocated_sectors"` ReallocatedSectors *int `json:"reallocated_sectors"`
PendingSectors *int `json:"pending_sectors"` PendingSectors *int `json:"pending_sectors"`
OfflineUncorrectable *int `json:"offline_uncorrectable"` 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. // NVMe attributes.
CriticalWarning *int `json:"critical_warning"` CriticalWarning *int `json:"critical_warning"`
MediaErrors *int `json:"media_errors"` 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)
}
}
+22 -6
View File
@@ -59,7 +59,7 @@ type DiskPrior struct {
// //
// 1. nil / "" / UNKNOWN -> Nincs adat // 1. nil / "" / UNKNOWN -> Nincs adat
// 2. Health == FAILING -> Hiba (drive self-reports) // 2. Health == FAILING -> Hiba (drive self-reports)
// 3. temperature_c >= 60 -> Hiba // 3. temperature_c >= 60 (NOT for NVMe — R-333) -> Hiba
// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba // 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba
// 5. percentage_used >= 100 -> Hiba // 5. percentage_used >= 100 -> Hiba
// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED) // 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED)
@@ -69,7 +69,7 @@ type DiskPrior struct {
// 10. reallocated > 0 -> Figyelmeztetés // 10. reallocated > 0 -> Figyelmeztetés
// 11. media_errors > 0 -> Figyelmeztetés // 11. media_errors > 0 -> Figyelmeztetés
// 12. percentage_used >= 90 -> Figyelmeztetés // 12. percentage_used >= 90 -> Figyelmeztetés
// 13. temperature_c >= 55 -> Figyelmeztetés // 13. temperature_c >= 55 (NOT for NVMe — R-333) -> Figyelmeztetés
// 14. otherwise -> Rendben // 14. otherwise -> Rendben
// //
// WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187, // WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187,
@@ -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 // 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. // 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. // Pure: no clock, no I/O, no logging. Everything it needs arrives as an argument.
func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict { func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
// 1 — no data. Never alarms. // 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, // 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. // because the overall verdict is structurally unable to report this class of fault.
nvme := IsNVMe(s)
switch { switch {
case atLeast(s.TemperatureC, TemperatureFailC): // 3 case !nvme && atLeast(s.TemperatureC, TemperatureFailC): // 3
return DiskVerdictFail return DiskVerdictFail
case positive(s.CriticalWarning): // 4 case positive(s.CriticalWarning): // 4
return DiskVerdictFail return DiskVerdictFail
@@ -118,7 +125,7 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
return DiskVerdictWarn return DiskVerdictWarn
case atLeast(s.PercentageUsed, percentageUsedWarn): // 12 case atLeast(s.PercentageUsed, percentageUsedWarn): // 12
return DiskVerdictWarn return DiskVerdictWarn
case atLeast(s.TemperatureC, temperatureWarnC): // 13 case !nvme && atLeast(s.TemperatureC, temperatureWarnC): // 13
return DiskVerdictWarn return DiskVerdictWarn
} }
return DiskVerdictOK // 14 return DiskVerdictOK // 14
@@ -190,12 +197,21 @@ func DegradedAttributes(s *SmartSummary) []string {
if atLeast(s.PercentageUsed, percentageUsedWarn) { if atLeast(s.PercentageUsed, percentageUsedWarn) {
out = append(out, "elhasználódás") 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. // Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. Not for an
if atLeast(s.TemperatureC, temperatureWarnC) { // 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") out = append(out, "hőmérséklet")
} }
return out 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 positive(p *int) bool { return p != nil && *p > 0 }
func atLeast(p *int, n int) bool { return p != nil && *p >= n } 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 // out loud — a true sentence about our own incuriosity that a customer reads as a statement about
// their code. // their code.
RecoveryCodeOpensRetained 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. // 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 RecoveryCodeOpensRetained
} }
return RecoveryUnknown 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: case http.StatusBadRequest:
if trustRefusal { if trustRefusal {
return RecoveryAskedAndRefused return RecoveryAskedAndRefused
@@ -276,6 +286,8 @@ func (f RecoveryFailure) String() string {
return "bundle-too-old" return "bundle-too-old"
case RecoveryCodeOpensRetained: case RecoveryCodeOpensRetained:
return "code-opens-retained" return "code-opens-retained"
case RecoveryOlderUnchecked:
return "older-unchecked"
default: default:
return "unknown" return "unknown"
} }
@@ -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'")
}
}
@@ -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)
}
}
+48 -36
View File
@@ -544,8 +544,8 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
// was true — misleading for API/script consumers. Report that the deploy STARTED, not that 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. // finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")} resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")}
if warning != "" { if d := r.deployWarningData(req, warning); d != nil {
resp.Data = map[string]string{"warning": warning} resp.Data = d
} }
writeJSON(w, http.StatusAccepted, resp) writeJSON(w, http.StatusAccepted, resp)
@@ -623,6 +623,36 @@ func desiredStateForAction(action string) (string, bool) {
} }
} }
// 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) { 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.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
r.dbg("actionStack: action=%s name=%s", action, name) r.dbg("actionStack: action=%s name=%s", action, name)
@@ -760,17 +790,7 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n
if err != nil { if err != nil {
r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err) r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err)
status := http.StatusInternalServerError status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
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: r.errText(req, err)}) writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return return
} }
@@ -1052,16 +1072,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)}) writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
return return
} }
status := http.StatusInternalServerError status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
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 deployed") || strings.Contains(err.Error(), "still running") {
status = http.StatusConflict
}
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)}) writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return return
} }
@@ -1137,16 +1148,7 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message}) writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
return return
} }
status := http.StatusInternalServerError status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
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: r.errText(req, err)}) writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return return
} }
@@ -1169,9 +1171,9 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
} }
func (r *Router) systemInfo(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 // R-490 / R-465 / R-492: the default storage path is the only source (the always-empty global
// cfg.Paths.HDDPath has; without it hdd_configured was false on every box. // paths.hdd_path was deleted in R-492); without it hdd_configured was false on every box.
hddPath := r.cfg.Paths.HDDPath hddPath := ""
if r.sett != nil { if r.sett != nil {
if p := r.sett.GetDefaultStoragePath(); p != "" { if p := r.sett.GetDefaultStoragePath(); p != "" {
hddPath = p hddPath = p
@@ -1649,3 +1651,13 @@ func startAnswer(name, action, state string) apiResponse {
Message: "Stack " + name + " " + action + " requested — state now: " + state, Message: "Stack " + name + " " + action + " requested — state now: " + state,
Data: map[string]interface{}{"state": 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)}
}
@@ -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 // AppStopMarker is the persisted "these apps were stopped by an operation that has not reported
// finishing — they are owed a restart" note. // finishing — they are owed a restart" note.
type AppStopMarker struct { type AppStopMarker struct {
+38 -4
View File
@@ -25,6 +25,14 @@ import (
// moved out of the controller into the host agent (slice 8C). This Manager now // moved out of the controller into the host agent (slice 8C). This Manager now
// only owns the app-data domain. // only owns the app-data domain.
type Manager struct { 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 cfg *config.Config
logger *log.Logger logger *log.Logger
settings *settings.Settings settings *settings.Settings
@@ -202,6 +210,8 @@ type Manager struct {
// updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck. // updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck.
updatingCheck func(stackName string) bool 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, // 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. // wired in main.go (SetUndoCopyRemover). nil-safe: without it the copies stay, as before.
undoCopyRemover func(stackName string) int undoCopyRemover func(stackName string) int
@@ -368,6 +378,7 @@ func NewManager(cfg *config.Config, sett *settings.Settings, logger *log.Logger)
logger: logger, logger: logger,
settings: sett, settings: sett,
systemDataPath: cfg.Paths.SystemDataPath, systemDataPath: cfg.Paths.SystemDataPath,
startedAt: time.Now(),
} }
// R-166: its OWN file next to quiesce-state.json, never inside it — one file, one writer. // 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) m.appStop = NewAppStopGuard(filepath.Join(cfg.Paths.DataDir, "appstop-state.json"), logger)
@@ -620,6 +631,14 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
continue 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 // 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 // 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, // capture then read the same memo. SKIP, not FAIL — a deliberate hold is not a broken dump,
@@ -761,6 +780,12 @@ func (m *Manager) runVolumeDumps() (summary []string, dumped int, allOK bool) {
summary = append(summary, fmt.Sprintf("SKIP %s volumes (held)", stack.Name)) summary = append(summary, fmt.Sprintf("SKIP %s volumes (held)", stack.Name))
continue 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). // 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 len(m.stackProvider.GetDockerVolumes(stack.Name)) == 0 {
if m.isDebug() { if m.isDebug() {
@@ -1286,6 +1311,12 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
var results []DumpResult var results []DumpResult
var latestTime time.Time var latestTime time.Time
for _, f := range status.DumpFiles { 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{ results = append(results, DumpResult{
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName}, DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
FilePath: f.FileName, FilePath: f.FileName,
@@ -1298,10 +1329,13 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
} }
// R-519: after a restart the run's result is not in memory, so it is SYNTHESISED from the files. A run // 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. // the stop cut off left a fresh .sql beside last night's tars — "Success" must not be read off that.
status.LastDBDump = &DBDumpStatus{ // Only undo copies on disk → no run to show (nil, as with no files at all).
LastRun: latestTime, if len(results) > 0 {
Results: results, status.LastDBDump = &DBDumpStatus{
Success: m.runInterrupted.IsZero(), LastRun: latestTime,
Results: results,
Success: m.runInterrupted.IsZero(),
}
} }
} }
+103
View File
@@ -0,0 +1,103 @@
package backup
import (
"os"
"strings"
"time"
)
// R-897 (2026-10-07): after a host restart the agent binds each drive under /mnt/felhom-drives, and when the guest
// started after that first bind it NORMALIZES it once more (umount + mount, felhom-agent localapi/intermediary.go). A
// recovery-unit capture that ran in that second failed — `mkdir /mnt/felhom-drives/hdd_1/backups: permission
// denied` — and the operator got a backup failure for a healthy app. The kernel lane restarts boxes at night, so this
// would have come after every kernel step.
//
// The rule: for DriveWaitWindow after the controller starts, a capture for an app on a drive runs only once that
// drive's bind is LIVE in this process's mount namespace (the same signal the startup app gate uses,
// web/intermediary.go driveBindLive). A periodic refresh skips the app until then (it runs every 5 minutes anyway);
// a data run waits for it. After the window the capture runs whatever the bind says, and says so once at WARN. The
// agent's bind order is unchanged. Pinned by driveready_test.go.
// DriveWaitWindow is how long after the controller starts a capture waits for a drive's bind.
const DriveWaitWindow = 10 * time.Minute
const drivesParent = "/mnt/felhom-drives/"
// driveRootOf is "/mnt/felhom-drives/<name>" for a path under it, "" for any other path (the system-data SSD).
func driveRootOf(p string) string {
if !strings.HasPrefix(p, drivesParent) {
return ""
}
name := strings.SplitN(strings.TrimPrefix(p, drivesParent), "/", 2)[0]
if name == "" {
return ""
}
return drivesParent + name
}
// mountinfoHasTarget reports whether root is a mount target in this process's own mount namespace.
func mountinfoHasTarget(root string) bool {
data, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
if f := strings.Fields(line); len(f) >= 5 && f[4] == root {
return true
}
}
return false
}
func (m *Manager) now() time.Time {
if m.nowFn != nil {
return m.nowFn()
}
return time.Now()
}
// driveReady says whether a capture for an app at drivePath may run now (true outside the post-boot window, for a
// non-drive path, or once the bind is live). dataRun: wait (poll) for the bind until the window ends instead of
// skipping.
func (m *Manager) driveReady(drivePath string, dataRun bool) bool {
root := driveRootOf(drivePath)
if root == "" || m.startedAt.IsZero() {
return true
}
live := m.driveLive
if live == nil {
live = mountinfoHasTarget
}
deadline := m.startedAt.Add(DriveWaitWindow)
for {
if live(root) {
return true
}
if !m.now().Before(deadline) {
m.driveMu.Lock()
warned := m.driveWarned[root]
if m.driveWarned == nil {
m.driveWarned = map[string]bool{}
}
m.driveWarned[root] = true
m.driveMu.Unlock()
if !warned {
m.logger.Printf("[WARN] [backup] %s is still not bound live %s after the controller started — capturing anyway (R-897)",
root, DriveWaitWindow)
}
return true
}
if !dataRun {
if m.isDebug() {
m.logger.Printf("[DEBUG] [backup] recovery-unit refresh waits for %s — its bind is not live yet after the start (R-897)", root)
}
return false
}
sleep := m.driveWaitPoll
if sleep == nil {
sleep = time.Sleep
}
m.logger.Printf("[INFO] [backup] waiting for %s to be bound live before the capture (post-boot, R-897)", root)
sleep(5 * time.Second)
}
}
@@ -0,0 +1,78 @@
package backup
import (
"testing"
"time"
)
// drivePathProvider puts every app on /mnt/felhom-drives/hdd_1 (the measured R-897 case).
type drivePathProvider struct{ unitFailProvider }
func (p *drivePathProvider) GetStackHDDPath(string) string { return "/mnt/felhom-drives/hdd_1" }
// R-897: right after a boot, a capture for an app on a drive waits until the drive's bind is LIVE. The drive reads
// not-bound, then bound → the capture (here a capture that fails, so it is visible as a unit event) starts only after.
// COMPANION RED-PROOF (observed): remove the driveReady call in captureAllRecoveryUnits → "captured while the drive
// was not bound".
func TestDriveReady_RefreshWaitsForTheBindThenCaptures(t *testing.T) {
m, got := newUnitNotifyManager(t, []string{"calibre-web"}, map[string]bool{"calibre-web": true})
base := m.stackProvider.(*unitFailProvider)
m.stackProvider = &drivePathProvider{*base}
now := time.Date(2026, 10, 7, 15, 16, 28, 0, time.UTC)
m.startedAt, m.nowFn = now.Add(-20*time.Second), func() time.Time { return now }
bound := false
var asked []string
m.driveLive = func(root string) bool { asked = append(asked, root); return bound }
m.captureAllRecoveryUnits(false)
if len(*got) != 0 {
t.Fatalf("captured while the drive was not bound: %+v", *got)
}
if len(asked) == 0 || asked[0] != "/mnt/felhom-drives/hdd_1" {
t.Fatalf("the drive root asked = %v", asked)
}
bound = true
m.captureAllRecoveryUnits(false)
if len(*got) != 1 {
t.Fatalf("after the bind the capture must run: %+v", *got)
}
}
// After the window the capture runs whatever the bind says (and says so).
func TestDriveReady_AfterTheWindowItRunsAnyway(t *testing.T) {
m, got := newUnitNotifyManager(t, []string{"calibre-web"}, map[string]bool{"calibre-web": true})
base := m.stackProvider.(*unitFailProvider)
m.stackProvider = &drivePathProvider{*base}
now := time.Date(2026, 10, 7, 15, 30, 0, 0, time.UTC)
m.startedAt, m.nowFn = now.Add(-DriveWaitWindow), func() time.Time { return now }
m.driveLive = func(string) bool { return false }
m.captureAllRecoveryUnits(false)
if len(*got) != 1 {
t.Fatalf("after %s the capture must run anyway: %+v", DriveWaitWindow, *got)
}
}
// A data run WAITS (polls) instead of skipping; a non-drive path and a Manager without a start time never wait.
func TestDriveReady_DataRunPollsAndOthersNeverWait(t *testing.T) {
m, _ := newUnitNotifyManager(t, nil, nil)
now := time.Date(2026, 10, 7, 15, 16, 28, 0, time.UTC)
m.startedAt, m.nowFn = now, func() time.Time { return now }
n := 0
m.driveLive = func(string) bool { n++; return n >= 3 }
polls := 0
m.driveWaitPoll = func(d time.Duration) { polls++; now = now.Add(d) }
if !m.driveReady("/mnt/felhom-drives/hdd_1/x", true) || polls != 2 {
t.Fatalf("data run: polls=%d", polls)
}
if !m.driveReady("/var/lib/felhom/system-data", false) {
t.Fatal("the system SSD never waits")
}
m2, _ := newUnitNotifyManager(t, nil, nil)
m2.driveLive = func(string) bool { return false }
if !m2.driveReady("/mnt/felhom-drives/hdd_1", false) {
t.Fatal("no start time → no wait")
}
if driveRootOf("/mnt/felhom-drives/") != "" || driveRootOf("/mnt/felhom-drives/hdd_2/a/b") != "/mnt/felhom-drives/hdd_2" {
t.Fatal("driveRootOf")
}
}
+50
View File
@@ -0,0 +1,50 @@
package backup
import (
"os"
"testing"
"time"
)
// R-488 — the package's ONE test clock for the post-restore health wait.
//
// Before this, every restore fixture slept waitForHealthy's 3 s settle for real, and every fixture
// whose fake provider never reports "running" waited out the full 90 s timeout: measured 2026-10-05,
// 51 of 599 tests took ≥ 1 s and together held 442 of the package's 444 s, two of them alone 279 s.
// The production values are captured here BEFORE they are shortened, so the pin below reads what a
// box runs, not what the tests run.
var prodHealthSettle, prodHealthInterval, prodHealthTimeout = healthSettle, healthInterval, healthTimeout
func TestMain(m *testing.M) {
healthSettle = 0
healthInterval = 2 * time.Millisecond
healthTimeout = 50 * time.Millisecond
os.Exit(m.Run())
}
// The shortening is a TEST seam only: a box must still give a restored stack 3 s to settle, poll
// every 5 s, and wait 90 s before calling the restore unhealthy.
func TestR488_HealthWaitDefaultsAreProduction(t *testing.T) {
if prodHealthSettle != 3*time.Second || prodHealthInterval != 5*time.Second || prodHealthTimeout != 90*time.Second {
t.Fatalf("production health wait changed: settle=%v interval=%v timeout=%v (want 3s/5s/1m30s) — "+
"a restored app now gets a different grace on every box", prodHealthSettle, prodHealthInterval, prodHealthTimeout)
}
}
// The consequence the seam must not break: a stack that never comes up is still reported as a
// failure (only sooner), and one that is up is accepted.
func TestR488_WaitForHealthyStillJudges(t *testing.T) {
m := &Manager{}
m.stackProvider = &fakeRecoveryProvider{running: false}
start := time.Now()
if err := m.waitForHealthy("app", healthTimeout); err == nil {
t.Fatal("a stack that never reaches running must fail the health wait")
}
if d := time.Since(start); d > 5*time.Second {
t.Fatalf("the shortened clock is not in force: the wait took %v", d)
}
m.stackProvider = &fakeRecoveryProvider{running: true}
if err := m.waitForHealthy("app", healthTimeout); err != nil {
t.Fatalf("a running stack must pass: %v", err)
}
}
+3 -3
View File
@@ -1186,9 +1186,9 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
// must not be told "nothing is selected". // must not be told "nothing is selected".
// R-240: the sentence no longer opens with „Sikeres" — a run that covered nothing was // R-240: the sentence no longer opens with „Sikeres" — a run that covered nothing was
// congratulating the household on it. The verdict stays `ok` (an unconfigured selection is // congratulating the household on it. The verdict stays `ok` (an unconfigured selection is
// not a failure). Still a Hungarian LITERAL and still carrying the lowercase marker // not a failure). Still a Hungarian LITERAL; since R-570 closed (2026-10-10) no reader depends
// „nincs mentésre jelölt alkalmazás" (R-570: boxes with the older persisted text are // on its words — the page decides on warnKind — so localisation may translate it.
// recognised by those words). Pinned by TestR240_ZeroSelectionRunDoesNotSaySuccess. // Pinned by TestR240_ZeroSelectionRunDoesNotSaySuccess.
if len(apps) == 0 && !runResult.sharesBackedUp { if len(apps) == 0 && !runResult.sharesBackedUp {
warns = append(warns, "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás") warns = append(warns, "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás")
warnKind = OffboxWarnNoAppsSelected // R-553: the page reads this, not the sentence warnKind = OffboxWarnNoAppsSelected // R-553: the page reads this, not the sentence
@@ -356,7 +356,21 @@ func (m *Manager) ExtendAbandon(days int) (time.Time, error) {
} }
return time.Time{}, fmt.Errorf("no abandonment countdown is running on this box — nothing to extend") return time.Time{}, fmt.Errorf("no abandonment countdown is running on this box — nothing to extend")
} }
// Decision 74: once the deletion is the HUB's, a box-side date changes the page and not the hub's
// schedule — the hub would still delete on its own date. Stop it instead (StopAbandon cancels it
// at the hub). Pinned by TestD1_ExtendRefusedWhileTheDeletionIsTheHubs.
if st.HubPending {
return time.Time{}, fmt.Errorf("the deletion is already pending at the hub (due %s) — it cannot be extended from the box; stop it instead",
st.HubDueAt.Format("2006-01-02"))
}
due := m.abandonNow().UTC().AddDate(0, 0, days) due := m.abandonNow().UTC().AddDate(0, 0, days)
// `09` §3 decision 185: an extension never moves the deletion EARLIER. „N days from now" can land
// before today's due date (day 1 of 14, extend by 3); that would shorten the countdown, which no
// lever may do. Pinned by TestD1_ExtendNeverShortensTheCountdown.
if !due.After(st.DueAt) {
return time.Time{}, fmt.Errorf("%d day(s) from now (%s) is not later than the current deletion date %s — that would shorten the countdown",
days, due.Format("2006-01-02"), st.DueAt.Format("2006-01-02"))
}
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonAt = due.Format(time.RFC3339) o.AbandonAt = due.Format(time.RFC3339)
}); err != nil { }); err != nil {
@@ -380,5 +394,13 @@ func (m *Manager) StopAbandon() error {
return fmt.Errorf("no abandonment countdown is running on this box — nothing to stop") return fmt.Errorf("no abandonment countdown is running on this box — nothing to stop")
} }
m.CancelAbandon("stopped by an operator") m.CancelAbandon("stopped by an operator")
// CancelAbandon keeps the countdown when the hub's pending deletion could not be cancelled (so the
// sweep retries). Report that as a failure — an operator told „stopped" while the hub still deletes
// on schedule is the comfort this project keeps removing. Pinned by
// TestD1_StopReportsFailureWhenTheHubCancelFails.
if after := m.AbandonStatus(); after.Active {
return fmt.Errorf("the countdown could not be stopped (the hub's pending deletion was not cancelled; see the log) — it is still due %s",
after.DueAt.Format("2006-01-02"))
}
return nil return nil
} }
@@ -0,0 +1,85 @@
package backup
import (
"context"
"errors"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// `09` §3 decision 185 (D1): the operator's abandon_extend and abandon_stop now arrive from the hub,
// with no shell and no human reading the CLI's printout. Three guarantees the CLI lever did not give,
// each pinned here because the hub action relies on it:
// 1. an „extend" never moves the deletion EARLIER (decision 185: no action may shorten a countdown);
// 2. an „extend" is refused while the deletion is the HUB's (decision 74) — the box's own date would
// change on the page while the hub still deleted on its date;
// 3. a „stop" that could not cancel the hub's pending deletion reports a failure, never success.
func TestD1_ExtendNeverShortensTheCountdown(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, sett, _ := abandonFixture(t, start)
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatal(err)
}
before := m.AbandonStatus().DueAt // start + 14 days
// Day 1: „extend by 3 days" would mean day 4 — ten days EARLIER than today's due date.
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 1) })
if _, err := m.ExtendAbandon(3); err == nil {
t.Fatal("an extension that moves the deletion earlier was accepted")
}
if got := m.AbandonStatus().DueAt; !got.Equal(before) {
t.Fatalf("a refused extension changed the due date: %v -> %v", before, got)
}
if got := sett.GetOffboxTarget().AbandonAt; got != before.Format(time.RFC3339) {
t.Fatalf("settings.json due date moved: %q", got)
}
// Control: a real extension (later than today's due date) is accepted.
if due, err := m.ExtendAbandon(20); err != nil || !due.After(before) {
t.Fatalf("a real extension: due=%v err=%v", due, err)
}
}
func TestD1_ExtendRefusedWhileTheDeletionIsTheHubs(t *testing.T) {
m, sett := newOffboxManager(t)
pinTarget(t, sett)
hubDue := time.Now().Add(5 * 24 * time.Hour).UTC()
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonRepoPath = "/home/felhom-repo.orphaned-20260901"
o.AbandonHubDueAt = hubDue.Format(time.RFC3339)
}); err != nil {
t.Fatal(err)
}
if !m.AbandonStatus().HubPending {
t.Fatal("fixture: expected a hub-phase deletion")
}
if _, err := m.ExtendAbandon(30); err == nil {
t.Fatal("an extension during the hub phase was accepted — the hub would still delete on its own date")
}
if got := sett.GetOffboxTarget().AbandonAt; got != "" {
t.Fatalf("the refused extension wrote a box-side date %q the page would show", got)
}
}
type failingCancelAbandon struct{ fakeAbandon }
func (f *failingCancelAbandon) Cancel(context.Context) error { return errors.New("hub unreachable") }
func TestD1_StopReportsFailureWhenTheHubCancelFails(t *testing.T) {
m, sett := newOffboxManager(t)
pinTarget(t, sett)
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonRepoPath = "/home/felhom-repo.orphaned-20260901"
o.AbandonHubDueAt = time.Now().Add(5 * 24 * time.Hour).UTC().Format(time.RFC3339)
}); err != nil {
t.Fatal(err)
}
m.SetOffsiteAbandonClient(&failingCancelAbandon{})
if err := m.StopAbandon(); err == nil {
t.Fatal("StopAbandon returned success while the hub's deletion is still pending")
}
if !m.AbandonStatus().Active {
t.Fatal("fixture: the countdown must still be running after a failed cancel")
}
}
@@ -369,6 +369,9 @@ func (m *Manager) RestoreHoldForLang(stack, lang string) (bool, string) {
if t, err := time.Parse(time.RFC3339, h.At); err == nil { if t, err := time.Parse(time.RFC3339, h.At); err == nil {
when = t.Format("2006-01-02 15:04") when = t.Format("2006-01-02 15:04")
} }
if h.Reason == settings.HoldReasonRestoreMixed { // R-893
return true, util.Text(lang, "note.reconstitute.held_mixed", stack, when)
}
return true, util.Text(lang, "note.reconstitute.held", stack, when) return true, util.Text(lang, "note.reconstitute.held", stack, when)
} }
@@ -441,6 +444,99 @@ func (m *Manager) holdAppAfterFailedRollback(stack string, replayErr, rollbackEr
} }
} }
// liveDefinition is the app's definition as it ran before an off-site restore wrote the snapshot's
// (R-893): a copy of its compose files in a temporary folder, and its full env held in memory only.
type liveDefinition struct {
dir string
env map[string]string
}
func (d *liveDefinition) cleanup() {
if d != nil && d.dir != "" {
_ = os.RemoveAll(d.dir)
}
}
// captureLiveDefinition copies the live docker-compose.yml (required) and .felhom.yml (when present) and
// reads the live env (non-secret values plus the live secrets, decrypted from the guest's app.yaml) — the
// same two sources the restore path uses. Values are never logged.
func (m *Manager) captureLiveDefinition(stack string) (*liveDefinition, error) {
composePath, ok := m.stackProvider.GetStackComposePath(stack)
if !ok || composePath == "" {
return nil, fmt.Errorf("no live compose path for %s", stack)
}
dir, err := os.MkdirTemp("", "felhom-livedef-")
if err != nil {
return nil, err
}
d := &liveDefinition{dir: dir, env: map[string]string{}}
for _, fname := range []string{"docker-compose.yml", ".felhom.yml"} {
data, rErr := os.ReadFile(filepath.Join(filepath.Dir(composePath), fname))
if rErr != nil {
if fname == "docker-compose.yml" {
d.cleanup()
return nil, rErr
}
continue
}
if wErr := os.WriteFile(filepath.Join(dir, fname), data, 0o600); wErr != nil {
d.cleanup()
return nil, wErr
}
}
if info, ok := m.stackProvider.GetStackRecoveryInfo(stack); ok {
for k, v := range info.NonSecretEnv {
d.env[k] = v
}
for k, v := range m.stackProvider.RecoverStackSecrets(stack, info.SecretEnvVars) {
d.env[k] = v
}
}
return d, nil
}
// holdAppAfterMixedRestore (R-893, `09` §3 decision 192, option C) is the hold for a failed replay whose
// database rollback WORKED but which had already moved more than the database: the live definition is
// written back (when the snapshot's was written), the database service is stopped again, and the app is
// held with HoldReasonRestoreMixed. The operator is notified with no rollback error — that is how the
// notification tells this case from R-379's double failure.
func (m *Manager) holdAppAfterMixedRestore(stack string, replayErr error, live *liveDefinition) {
// The hold is PERSISTED FIRST (security review 2026-10-08): a controller that dies between the stop and
// the hold would otherwise restart the app from the R-166 marker with nothing refusing it. When the hold
// cannot be persisted, the app-stop marker is KEPT, so nothing reads the window as finished.
held := false
if m.settings == nil {
m.logger.Printf("[ERROR] [offbox] %s: cannot persist the restore hold — no settings wired; the app is stopped but NOTHING will refuse a restart", stack)
} else {
h := settings.RestoreHold{Stack: stack, At: time.Now().UTC().Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}
if replayErr != nil {
h.ReplayError = replayErr.Error()
}
if err := m.settings.SetRestoreHold(h); err != nil {
m.logger.Printf("[ERROR] [offbox] %s: persisting the restore hold FAILED: %v — the app is stopped and the app-stop marker is kept", stack, err)
} else {
held = true
}
}
if live != nil {
if err := m.stackProvider.RecreateStackDefinitionFromUnit(stack, live.dir, live.env); err != nil {
m.logger.Printf("[ERROR] [offbox] %s: writing the live definition back FAILED: %v — the app is held at the snapshot's definition", stack, err)
} else {
m.logger.Printf("[INFO] [offbox] %s: the live definition is written back", stack)
}
}
if err := m.stackProvider.StopStack(stack); err != nil {
m.logger.Printf("[WARN] [offbox] %s: stopping the app before the hold failed: %v", stack, err)
}
m.logger.Printf("[ERROR] [offbox] %s: the restore stopped half-way after files/volumes/version had moved — HOLDING the app stopped for support (R-893); error was: %v", stack, replayErr)
if held && m.appStop != nil {
m.appStop.End()
}
if m.restoreHoldNotify != nil {
m.restoreHoldNotify(stack, replayErr, nil)
}
}
// SetRestoreHoldNotify wires the operator notification for a held app (cmd/controller/main.go). // SetRestoreHoldNotify wires the operator notification for a held app (cmd/controller/main.go).
func (m *Manager) SetRestoreHoldNotify(fn func(stack string, replayErr, rollbackErr error)) { func (m *Manager) SetRestoreHoldNotify(fn func(stack string, replayErr, rollbackErr error)) {
m.restoreHoldNotify = fn m.restoreHoldNotify = fn
@@ -732,6 +828,21 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
} }
} }
// --- THE LIVE DEFINITION, KEPT FOR A FAILED REPLAY (R-893, `09` §3 decision 192) -------------
// When the snapshot's definition is about to be written, keep the LIVE one first, so a failed
// replay below can write it back before the app is held. Taken before the first mutation; a
// failure here refuses with nothing touched — without it the failure branch could not be undone.
var liveDef *liveDefinition
if defineFromSnapshot {
ld, ldErr := m.captureLiveDefinition(stack)
if ldErr != nil {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: could not keep the live definition: %v — nothing was touched", stack, ldErr)
return res, fmt.Errorf("restoring %s: keeping the live definition failed: %w", stack, ldErr)
}
liveDef = ld
defer liveDef.cleanup()
}
// --- WHICH SERVICE HOLDS THE DATABASE (R-47) ------------------------------------------------ // --- WHICH SERVICE HOLDS THE DATABASE (R-47) ------------------------------------------------
// Read from the compose that will RUN: the live one, or — when the app comes back at the snapshot's // Read from the compose that will RUN: the live one, or — when the app comes back at the snapshot's
// version — the snapshot unit's, which is written into the stack dir before the first start. Resolved // version — the snapshot unit's, which is written into the stack dir before the first start. Resolved
@@ -830,8 +941,13 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
} }
n, cErr := copier(pl.src, pl.dst) n, cErr := copier(pl.src, pl.dst)
if cErr != nil { if cErr != nil {
// Best-effort bring-up: leaving the app stopped after a partial copy would turn a failed // R-893 option C (security review 2026-10-08): once the snapshot's (possibly older) definition
// restore into an outage. // is written, a start would run that version on the live data — HOLD the app instead.
if defineFromSnapshot {
m.holdAppAfterMixedRestore(stack, cErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
// Nothing moved yet: best-effort bring-up — a failed restore must not also be an outage.
if sErr := restartStack(); sErr != nil { if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after failed placement also failed: %v", stack, sErr) m.logger.Printf("[WARN] [offbox] %s: restart after failed placement also failed: %v", stack, sErr)
} }
@@ -856,8 +972,16 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
nVols, vErr := volReplay(stack, filepath.Join(scratchUnit, "volume-dumps")) nVols, vErr := volReplay(stack, filepath.Join(scratchUnit, "volume-dumps"))
res.VolumesReplayed = nVols res.VolumesReplayed = nVols
if vErr != nil { if vErr != nil {
// A partial replay must never read as a completion. Bring the app back up rather than leaving // A partial replay must never read as a completion. R-893 (security review 2026-10-08): a volume
// an outage, then surface it — the same shape the file leg above uses. // replay that failed may have removed or half-replaced a volume, and the definition and files may
// already be the snapshot's — HOLD the app rather than start it on a mix.
// R-893 option C (security review 2026-10-08): a volume already replaced, or the snapshot's
// definition written → HOLD (the volume detail reaches the operator through the hold notice).
if defineFromSnapshot || nVols > 0 {
m.holdAppAfterMixedRestore(stack, vErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
// Nothing replaced yet: bring the app back up rather than leaving an outage, then surface it.
if sErr := restartStack(); sErr != nil { if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after failed volume replay also failed: %v", stack, sErr) m.logger.Printf("[WARN] [offbox] %s: restart after failed volume replay also failed: %v", stack, sErr)
} }
@@ -872,6 +996,11 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// under ON_ERROR_STOP=1 (H4). Starting only the database service closes that window entirely. // under ON_ERROR_STOP=1 (H4). Starting only the database service closes that window entirely.
if hasDB { if hasDB {
if err := m.stackProvider.StartStackServices(stack, dbServices); err != nil { if err := m.stackProvider.StartStackServices(stack, dbServices); err != nil {
// R-893 option C (security review 2026-10-08): volumes or the definition already moved → HOLD.
if defineFromSnapshot || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, err, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
// Best-effort bring-up: a failed restore must not also be an outage. // Best-effort bring-up: a failed restore must not also be an outage.
if sErr := restartStack(); sErr != nil { if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: full start after failed DB-only start also failed: %v", stack, sErr) m.logger.Printf("[WARN] [offbox] %s: full start after failed DB-only start also failed: %v", stack, sErr)
@@ -907,6 +1036,18 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, m.undoCopyPhrase(safetySet)) return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, m.undoCopyPhrase(safetySet))
} }
res.RolledBack = true res.RolledBack = true
// --- R-893: A ROLLED-BACK DATABASE IS NOT „BACK AS IT WAS" WHEN MORE MOVED ---------
// The rollback puts back the database rows only. When the snapshot's definition was
// written or a named volume was replaced, the app would start on a mix: the snapshot's
// files and volumes (and maybe its older version) under the newer database. Write the
// live definition back, stop the database service again, and HOLD the app for support.
// Pinned by r893_mixed_restore_test.go; the plain case keeps TestR379_ScenarioA.
// Placed FILES alone do not hold (option C as ruled): that mix is what „put back exactly as it
// was" (option A, the next slice) removes.
if defineFromSnapshot || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, iErr, liveDef)
return res, util.MsgError("err.backup.db_restore_failed_held_mixed", stack)
}
if sErr := restartStack(); sErr != nil { if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: start after a successful rollback failed: %v", stack, sErr) m.logger.Printf("[WARN] [offbox] %s: start after a successful rollback failed: %v", stack, sErr)
} }
@@ -924,7 +1065,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// so without this line a successful off-site restore would leave the app refusing its next start. // so without this line a successful off-site restore would leave the app refusing its next start.
// Pinned by TestR475_OffsiteRestoreClearsAnUpdateHold. // Pinned by TestR475_OffsiteRestoreClearsAnUpdateHold.
m.clearUpdateHoldAfterRestore(stack) m.clearUpdateHoldAfterRestore(stack)
if err := m.waitForHealthy(stack, 90*time.Second); err != nil { if err := m.waitForHealthy(stack, healthTimeout); err != nil {
m.logger.Printf("[WARN] [offbox] %s reconstituted but health check failed: %v", stack, err) m.logger.Printf("[WARN] [offbox] %s reconstituted but health check failed: %v", stack, err)
} }
@@ -40,6 +40,13 @@ func newOffboxManager(t *testing.T) (*Manager, *settings.Settings) {
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil { if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// R-488: no test reaches a real ssh. Until 2026-10-05 the default runner was left in place, so a
// run that reached the set-aside path (TestOffbox_ConfirmedReset's first-night run) spawned a real
// `ssh felhom@nas.local` from DooPlex and waited out its 10 s ConnectTimeout. This fake fails the
// way an unreachable host does — immediately — and a test that needs ssh to work installs its own.
m.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
return []byte("ssh: test harness: no network"), errors.New("exit status 255")
})
return m, sett return m, sett
} }
@@ -156,37 +156,6 @@ func backupNoteHU(t *testing.T, key string) string {
return newNoteManager(t).note(key) return newNoteManager(t).note(key)
} }
// TestR570SentenceStaysHungarian — the ONE saved note release C may not translate.
//
// A box upgraded to 0.251.0 carries the OLD persisted warning with no kind until its next off-site
// run rewrites it, so `offboxWarningDisplay` still falls back to a substring test on those words when
// the kind is empty (R-553's documented exception). Translating the PRODUCER while that fallback is
// load-bearing would strand exactly the boxes the fallback exists for: their stale note would stop
// being recognised and would keep telling a household that nothing is covered.
//
// **Delete this test when R-570 closes** — it is named for the row on purpose.
//
// RED-PROOF (REPORT): turn the producer into m.note("…") → this test fails naming the line.
func TestR570SentenceStaysHungarian(t *testing.T) {
const sentence = "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás"
src, err := os.ReadFile("offbox.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), `"`+sentence+`"`) {
t.Errorf("the R-570 producer no longer writes %q as a literal — a box that has not run off-site "+
"since 0.251.0 carries that exact text with no kind, and offboxWarningDisplay finds it by "+
"those words. Translating it strands them. Close R-570 first.", sentence)
}
// And the sentence must not have quietly acquired a bundle key either: a key would render
// Hungarian today and something else the day someone adds a translation.
for _, k := range []string{"note.offsite.no_apps_selected", "note.offsite.zero_toggle"} {
if strings.Contains(string(src), k) {
t.Errorf("the R-570 producer now names a bundle key (%s) — same problem, one step further away", k)
}
}
}
// TestNoteHelpersAreNotCalledUnderTheSettingsLock — release C (R-557), and it is a REGRESSION test, // TestNoteHelpersAreNotCalledUnderTheSettingsLock — release C (R-557), and it is a REGRESSION test,
// not a precaution. // not a precaution.
// //
@@ -203,6 +172,9 @@ func TestR570SentenceStaysHungarian(t *testing.T) {
// //
// RED-PROOF (REPORT): put `m.note(...)` back inside the final UpdateOffboxStatus callback → this test // RED-PROOF (REPORT): put `m.note(...)` back inside the final UpdateOffboxStatus callback → this test
// names the file and the line, in a second, instead of the suite hanging for 25 minutes. // names the file and the line, in a second, instead of the suite hanging for 25 minutes.
//
// R-578: the controller-wide successor is TestR578NothingTakesTheSettingsLockInsideASettingsCallback
// (internal/settings) — it derives the lockers and helpers from source in every package.
func TestNoteHelpersAreNotCalledUnderTheSettingsLock(t *testing.T) { func TestNoteHelpersAreNotCalledUnderTheSettingsLock(t *testing.T) {
callback := regexp.MustCompile(`\.Update\w*\(func\(`) callback := regexp.MustCompile(`\.Update\w*\(func\(`)
note := regexp.MustCompile(`\b(m|s)\.(note|noteErr|boxLang)\(`) note := regexp.MustCompile(`\b(m|s)\.(note|noteErr|boxLang)\(`)
@@ -10,7 +10,7 @@ import (
// SUCCESSFUL right beside NOTHING SELECTED, the same shape as R-203/R-234 (a warning beside a success // SUCCESSFUL right beside NOTHING SELECTED, the same shape as R-203/R-234 (a warning beside a success
// is read as a success). The consequence asserted through the real run: the verdict stays `ok` (an // is read as a success). The consequence asserted through the real run: the verdict stays `ok` (an
// empty selection is not a failure), the note says plainly that the run saved nothing, it does NOT // empty selection is not a failure), the note says plainly that the run saved nothing, it does NOT
// say „Sikeres", and it still carries the lowercase marker the R-570 legacy fallback reads. // say „Sikeres", and it records its KIND (the page decides on the kind; R-570 removed the text fallback).
func TestR240_ZeroSelectionRunDoesNotSaySuccess(t *testing.T) { func TestR240_ZeroSelectionRunDoesNotSaySuccess(t *testing.T) {
m, sett := newOffboxManager(t) m, sett := newOffboxManager(t)
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
@@ -32,9 +32,6 @@ func TestR240_ZeroSelectionRunDoesNotSaySuccess(t *testing.T) {
if !strings.Contains(st.LastWarning, "semmit nem mentett") { if !strings.Contains(st.LastWarning, "semmit nem mentett") {
t.Errorf("R-240: the note does not say the run saved nothing: %q", st.LastWarning) t.Errorf("R-240: the note does not say the run saved nothing: %q", st.LastWarning)
} }
if !strings.Contains(st.LastWarning, "nincs mentésre jelölt alkalmazás") {
t.Errorf("the R-570 marker is gone from the note: %q", st.LastWarning)
}
if st.LastWarningKind != OffboxWarnNoAppsSelected { if st.LastWarningKind != OffboxWarnNoAppsSelected {
t.Errorf("the kind is not recorded: %q", st.LastWarningKind) t.Errorf("the kind is not recorded: %q", st.LastWarningKind)
} }
@@ -190,24 +190,33 @@ func TestR354_ScenarioD_LiveRecoveryUnitIsNeverWritten(t *testing.T) {
} }
// TestR354_ScenarioE_PartialReplayIsAFailure — a volume replay that fails must never read as a // TestR354_ScenarioE_PartialReplayIsAFailure — a volume replay that fails must never read as a
// completion, and must name what failed. // completion, and must name what failed (to the operator).
//
// UPDATED 2026-10-08 (R-893 option C, `09` §3 decision 192): one volume HAS been replaced, so a start
// would run the app on a mix of the snapshot's volume and the live rest. The app is now HELD stopped
// for support instead of brought up; the volume that did not come back is named in the operator's
// hold notice (the household reads the plain hold sentence).
func TestR354_ScenarioE_PartialReplayIsReportedAsFailure(t *testing.T) { func TestR354_ScenarioE_PartialReplayIsReportedAsFailure(t *testing.T) {
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1)) m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
seedScratchVolumes(t, m, "immich", "immich_a.tar", "immich_b.tar") seedScratchVolumes(t, m, "immich", "immich_a.tar", "immich_b.tar")
m.volumeReplayFrom = func(_, _ string) (int, error) { m.volumeReplayFrom = func(_, _ string) (int, error) {
return 1, fmt.Errorf("failed to restore 1 volume(s): [immich_b]") return 1, fmt.Errorf("failed to restore 1 volume(s): [immich_b]")
} }
var noticed error
m.SetRestoreHoldNotify(func(_ string, replayErr, _ error) { noticed = replayErr })
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false) res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil { if err == nil {
t.Fatal("a partial volume replay must be reported as a failure, not a completion") t.Fatal("a partial volume replay must be reported as a failure, not a completion")
} }
if !strings.Contains(err.Error(), "immich_b") { if noticed == nil || !strings.Contains(noticed.Error(), "immich_b") {
t.Errorf("the failure must name the volume that did not come back; got %q", err.Error()) t.Errorf("the operator's hold notice must name the volume that did not come back; got %v", noticed)
} }
// Best-effort bring-up: a failed restore must not also be an outage. if prov.fullStarted {
if !prov.fullStarted { t.Error("the app was STARTED on a partly replaced set of volumes — it must be held (R-893)")
t.Error("the app was left stopped after a failed volume replay") }
if held, _ := m.RestoreHoldFor("immich"); !held {
t.Error("no restore hold was left after a partial volume replay")
} }
// The count of what DID come back is still carried, so the report can say "1 of 2". // The count of what DID come back is still carried, so the report can say "1 of 2".
if res.VolumesReplayed != 1 { if res.VolumesReplayed != 1 {
@@ -0,0 +1,252 @@
package backup
import (
"context"
"errors"
"io"
"log"
"path/filepath"
"strings"
"testing"
)
// R-638 option A (09-update-architecture §3 decision 154) — a replay must meet the COPY's own schema.
//
// The loader overlays a copy on the live database: it only removes what the copy knows about. So a
// replay is only safe when the database it lands in holds the copy's own files, not a newer app's
// migrated schema. Measured 2026-10-06 on scratch 9202: the unit restore after a docmost 0.95 → 0.96
// migration put back exactly the copy's 42 tables — the main path is safe because it restores the
// volume FIRST and replays with ONLY the database up. These tests pin the two side paths that did not:
//
// - Slice 1, the no-manifest fallback RestoreApp: it started the WHOLE stack at the CURRENT
// definition before the replay, so a newer app could migrate the old data underneath it.
// - Slice 2, the unit restore whose volume leg failed: it replayed anyway, over a database volume
// that is not the copy's own.
//
// All Docker-reaching seams are injected (volumeReplayFrom, discoverDBs, importDBDump); no test here
// touches a daemon.
// r638FallbackFixture builds a Manager whose app "app" has NO recovery unit (so RestoreFromRecoveryUnit
// takes the RestoreApp fallback), a LIVE compose with the given body, and — optionally — a replayable
// dump at the app's on-drive dump path, which is where RestoreApp replays from.
func r638FallbackFixture(t *testing.T, compose string, withDump bool) (*Manager, *fakeRecoveryProvider, *[]string) {
t.Helper()
tmp := t.TempDir()
drive := filepath.Join(tmp, "drive")
stackDir := filepath.Join(tmp, "stack")
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), compose)
if withDump {
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "app"), "app-postgres.sql"), pgDump(1))
}
prov := &fakeRecoveryProvider{hdd: drive, running: true, info: RecoveryInfo{StackDir: stackDir}}
m := &Manager{
logger: log.New(io.Discard, "", 0),
systemDataPath: filepath.Join(tmp, "sys"), // != drive ⇒ nsRoot = drive
stackProvider: prov,
}
// The volume leg is a seam here so the test can never reach Docker, even if a tar appears.
m.volumeReplayFrom = func(string, string) (int, error) { return 0, nil }
db := DiscoveredDB{StackName: "app", ContainerName: "immich-postgres", DBType: DBTypePostgres}
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { return []DiscoveredDB{db}, nil }
var imported []string
m.importDBDump = func(_ context.Context, _ DiscoveredDB, p string) error {
imported = append(imported, p)
return nil
}
return m, prov, &imported
}
// --- Slice 1: the no-manifest fallback ------------------------------------------------------------
// TestR638_FallbackReplaysBeforeTheAppStarts is the slice-1 assertion. It captures the provider's
// state AT THE MOMENT the importer fires: the database service must be up and the full stack must
// NOT be — a full start at the current definition is exactly the window in which a newer app
// migrates the restored data before the old copy is poured over it.
//
// COMPANION RED-PROOF: on the pre-fix RestoreApp (StartStack before reimportDBDumpsCtx) this fails on
// `the FULL stack was already up when the replay fired` — saved in
// audits/design-build-2026-10-06/B/red-slice1-fallback-order.txt.
func TestR638_FallbackReplaysBeforeTheAppStarts(t *testing.T) {
m, prov, imported := r638FallbackFixture(t, immichLikeCompose, true)
var dbUpAtReplay, fullUpAtReplay bool
var callsAtReplay string
m.importDBDump = func(_ context.Context, _ DiscoveredDB, p string) error {
dbUpAtReplay = len(prov.gotServices) > 0
fullUpAtReplay = prov.fullStarted
callsAtReplay = strings.Join(prov.calls, ",")
*imported = append(*imported, p)
return nil
}
if err := m.RestoreApp("app", ""); err != nil {
t.Fatalf("fallback restore: %v", err)
}
if len(*imported) != 1 {
t.Fatalf("expected exactly one replay, got %v", *imported)
}
if fullUpAtReplay {
t.Fatalf("the FULL stack was already up when the replay fired (calls before the replay: %q) — a newer app can migrate the restored data before the copy is loaded (R-638)", callsAtReplay)
}
if !dbUpAtReplay {
t.Fatal("the database service was NOT started before the replay — the importer has no container to talk to")
}
if got := strings.Join(prov.gotServices, ","); got != "immich-postgres" {
t.Fatalf("DB-only phase started %q, want only the database service immich-postgres", got)
}
if got := strings.Join(prov.calls, ","); got != "stop,startsvc:immich-postgres,start" {
t.Fatalf("sequence = %q, want stop → volumes → db-only start → replay → full start", got)
}
}
// TestR638_FallbackThroughUnitRestoreKeepsTheOrder reaches the fallback the way production does: a
// unit restore that finds no manifest. Same ordering, and the counts stay UNKNOWN (not zero).
func TestR638_FallbackThroughUnitRestoreKeepsTheOrder(t *testing.T) {
m, prov, imported := r638FallbackFixture(t, immichLikeCompose, true)
res, err := m.RestoreFromRecoveryUnit("app")
if err != nil {
t.Fatalf("restore: %v", err)
}
if !res.CountsUnknown {
t.Error("the fallback's counts must stay UNKNOWN, not zero")
}
if len(*imported) != 1 {
t.Fatalf("expected exactly one replay, got %v", *imported)
}
if got := strings.Join(prov.calls, ","); got != "stop,startsvc:immich-postgres,start" {
t.Fatalf("sequence = %q, want stop → db-only start → replay → full start", got)
}
}
// TestR638_FallbackNoDumpTakesOneFullStart is the negative: with nothing to replay there is no
// DB-only window, and the flow is the old stop → volumes → full start.
func TestR638_FallbackNoDumpTakesOneFullStart(t *testing.T) {
m, prov, imported := r638FallbackFixture(t, immichLikeCompose, false)
if err := m.RestoreApp("app", ""); err != nil {
t.Fatalf("fallback restore: %v", err)
}
if len(prov.gotServices) != 0 {
t.Fatalf("the DB-only phase ran with nothing to replay: %v", prov.gotServices)
}
if got := strings.Join(prov.calls, ","); got != "stop,start" {
t.Fatalf("sequence = %q, want the unchanged stop → full start", got)
}
if len(*imported) != 0 {
t.Fatalf("nothing should have been replayed, got %v", *imported)
}
}
// TestR638_FallbackRefusesWhenNoDBServiceIdentifiable: a dump exists but the live compose names no
// database service that could be started alone. The only alternative is the old full start + replay
// — the R-638 shape — so it refuses, with ZERO mutations, exactly as the unit and off-site paths do.
func TestR638_FallbackRefusesWhenNoDBServiceIdentifiable(t *testing.T) {
m, prov, imported := r638FallbackFixture(t, noDBCompose, true)
volTouched := false
m.volumeReplayFrom = func(string, string) (int, error) { volTouched = true; return 0, nil }
err := m.RestoreApp("app", "")
if err == nil {
t.Fatal("expected a refusal: a dump exists but no database service can be started for it")
}
if !strings.Contains(err.Error(), "nem azonosítható") {
t.Fatalf("refusal must say the database service could not be identified, got: %v", err)
}
if len(prov.calls) != 0 {
t.Fatalf("ZERO mutations required, but the provider was called: %v", prov.calls)
}
if volTouched {
t.Fatal("volumes were replaced despite the refusal")
}
if len(*imported) != 0 {
t.Fatalf("a replay happened despite the refusal: %v", *imported)
}
}
// TestR638_FallbackVolumeFailureSkipsTheReplay is slice 2's rule on the fallback path: when the
// volume leg failed, the database volume is not known to hold the copy's own files, so the copy is
// NOT poured over it. The failure is still returned and the app is still brought back up.
func TestR638_FallbackVolumeFailureSkipsTheReplay(t *testing.T) {
m, prov, imported := r638FallbackFixture(t, immichLikeCompose, true)
m.volumeReplayFrom = func(string, string) (int, error) {
return 0, errors.New("failed to restore 1 volume(s): [app_db]")
}
err := m.RestoreApp("app", "")
if err == nil || !strings.Contains(err.Error(), "completed with data errors") || !strings.Contains(err.Error(), "app_db") {
t.Fatalf("the volume failure must surface as the data-error outcome naming the volume, got: %v", err)
}
if len(*imported) != 0 {
t.Fatalf("the importer ran after a failed volume leg: %v", *imported)
}
if !prov.fullStarted {
t.Fatal("the app was left down after the failure — today's failure path brings it back up")
}
if got := strings.Join(prov.calls, ","); got != "stop,start" {
t.Fatalf("sequence = %q, want stop → full start (no DB-only window, no replay)", got)
}
}
// --- Slice 2: unit restore with a failed volume leg -----------------------------------------------
// TestR638_UnitVolumeFailureNeverCallsTheImporter is the slice-2 assertion. A volume leg that errors
// means the database's volume may still be the LIVE (possibly newer, migrated) one, or a half-filled
// one — the copy overlays it and leaves whatever it does not know about. So the importer must not run.
//
// What must NOT change from today's failure path: the error reaches the caller as "completed with data
// errors" (no silent success), the definition is the unit's, and the app is brought back up.
//
// COMPANION RED-PROOF: on the pre-fix restore_unit.go (dataErr set, then fall through to the replay)
// this fails on `the importer ran after a failed volume leg` — saved in
// audits/design-build-2026-10-06/B/red-slice2-no-replay-after-volume-failure.txt.
func TestR638_UnitVolumeFailureNeverCallsTheImporter(t *testing.T) {
m, prov, imported := r47UnitFixture(t, immichLikeCompose, true)
m.volumeReplayFrom = func(string, string) (int, error) {
return 1, errors.New("failed to restore 1 volume(s): [app_immich_postgres_data]")
}
res, err := m.RestoreFromRecoveryUnit("app")
if len(*imported) != 0 {
t.Fatalf("the importer ran after a failed volume leg: %v", *imported)
}
if err == nil {
t.Fatal("a failed volume leg must be surfaced, not reported as success")
}
if !strings.Contains(err.Error(), "completed with data errors") || !strings.Contains(err.Error(), "app_immich_postgres_data") {
t.Fatalf("the pre-existing outcome must be preserved and name the failed volume, got: %v", err)
}
if res.VolumesReplayed != 1 {
t.Errorf("the partial volume count must still be reported, got %d", res.VolumesReplayed)
}
if res.DBsReplayed != 0 {
t.Errorf("no database was replayed, but the result says %d", res.DBsReplayed)
}
if prov.gotEnv == nil {
t.Error("the unit's definition must still be written, as on today's failure path")
}
if !prov.fullStarted {
t.Fatal("the app was left down — today's failure path brings it back up")
}
if got := strings.Join(prov.calls, ","); got != "stop,recreate,start" {
t.Fatalf("sequence = %q, want stop → recreate → full start (no DB-only window, no replay)", got)
}
}
// TestR638_UnitVolumeSuccessStillReplays is the positive control for slice 2: the skip is keyed on the
// volume FAILURE, not on the presence of volumes — a clean volume leg still replays.
func TestR638_UnitVolumeSuccessStillReplays(t *testing.T) {
m, prov, imported := r47UnitFixture(t, immichLikeCompose, true)
m.volumeReplayFrom = func(string, string) (int, error) { return 1, nil }
res, err := m.RestoreFromRecoveryUnit("app")
if err != nil {
t.Fatalf("restore: %v", err)
}
if len(*imported) != 1 || res.DBsReplayed != 1 {
t.Fatalf("a clean volume leg must still replay: imported=%v replayed=%d", *imported, res.DBsReplayed)
}
if got := strings.Join(prov.calls, ","); got != "stop,recreate,startsvc:immich-postgres,start" {
t.Fatalf("sequence = %q", got)
}
}
@@ -0,0 +1,141 @@
package backup
import (
"context"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// R-645 (09 §3 decision 142) — the night backup skips an app whose pinned version is not the version
// it runs.
//
// THE HAND-LIFT SHAPE, measured 2026-09-23 on 9202: docmost was HELD after a failed 0.95.0 → 0.96.0
// update; `--clear-restore-hold docmost` + the restart ran, and three seconds later the capture wrote
// the 0.96.0 definition into the unit the hold sentence had named. This drives the whole night run
// (DB leg, volume leg, capture) and then Tier 2 over that state — the hold is gone, the stack dir names
// the failed version, the pin says 0.96.0, the running record says 0.95.0 — and asserts the
// CONSEQUENCE: the good unit's tree is byte-identical afterwards, and its compose still names 0.95.0.
//
// COMPANION RED-PROOF: make versionSkip return ("", false) — the unit's compose/docker-compose.yml then
// names docmost/docmost:0.96.0 and the tree fingerprint differs, and this test fails.
func TestR645_HandLiftedHoldKeepsTheGoodUnit(t *testing.T) {
h := newAdmissionHarness(t, "docmost", "free")
h.m.settings = slice4Settings(t)
ns := h.nsRoot()
// The good unit: written by an earlier backup of 0.95.0. Its manifest carries no data stamps (a unit
// from before v0.275.0), so nothing else in the capture freezes its definition — this skip is the
// only thing standing between the failed definition and the restore point.
h.seedUnit(t, "docmost", 0)
goodCompose := "services:\n docmost:\n image: docmost/docmost:0.95.0\n"
if err := os.WriteFile(filepath.Join(RecoveryUnitComposePath(ns, "docmost"), "docker-compose.yml"), []byte(goodCompose), 0o644); err != nil {
t.Fatal(err)
}
// The stack dir after the failed update: it names the version that just failed.
for _, app := range []string{"docmost", "free"} {
dir := filepath.Join(h.dir, "stacks", app)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
img := "docmost/docmost:0.96.0"
if app == "free" {
img = "free/free:1.0"
}
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n "+app+":\n image: "+img+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
// The hold existed and was lifted by hand — exactly the operator CLI's act.
if err := h.m.HoldAfterFailedUpdate("docmost", time.Now(), time.Now(), UpdateTierLocal); err != nil {
t.Fatal(err)
}
if _, err := h.m.settings.ClearRestoreHold("docmost"); err != nil {
t.Fatal(err)
}
if held, _ := h.m.RestoreHoldFor("docmost"); held {
t.Fatal("setup: the hold must be lifted")
}
// stacks.Manager.PinNotRunning's answer for this shape (pinned by TestR645_PinNotRunning_* there).
h.m.SetVersionCheck(func(name string) (string, bool) {
if name == "docmost" {
return "docmost runs docmost/docmost:0.95.0, pinned docmost/docmost:0.96.0", true
}
return "", false
})
var dumped []string
h.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{
{StackName: "docmost", ContainerName: "docmost-postgres"},
{StackName: "free", ContainerName: "free-postgres"},
}, nil
}
h.m.dumpOne = func(_ context.Context, db DiscoveredDB, dumpDir string, _ *log.Logger, _ bool) DumpResult {
dumped = append(dumped, db.StackName)
_ = os.MkdirAll(dumpDir, 0o755)
p := filepath.Join(dumpDir, db.StackName+"-postgres.sql")
_ = os.WriteFile(p, []byte("-- FRESH DUMP OF THE FAILED VERSION\n"), 0o644)
return DumpResult{DB: db, FilePath: p, Size: 36}
}
unitRoot := RecoveryUnitPath(ns, "docmost")
before := treeFingerprint(t, unitRoot)
_ = h.m.runDBDumpsInternal(context.Background())
var mirrored []string
h.m.perAppTier2 = func(name string) error { mirrored = append(mirrored, name); return nil }
h.m.RunAllTier2()
if after := treeFingerprint(t, unitRoot); after != before {
t.Errorf("the good unit was rewritten by the night run after a hand-lifted hold:\nbefore:\n%s\nafter:\n%s", before, after)
}
b, _ := os.ReadFile(filepath.Join(RecoveryUnitComposePath(ns, "docmost"), "docker-compose.yml"))
if !strings.Contains(string(b), "docmost/docmost:0.95.0") {
t.Errorf("the unit's definition must still be the good 0.95.0 one, got:\n%s", b)
}
for _, list := range [][]string{dumped, h.volDumped, h.prov.stopped, mirrored} {
for _, n := range list {
if n == "docmost" {
t.Errorf("a night leg touched docmost (db=%v vol=%v stopped=%v mirrored=%v)", dumped, h.volDumped, h.prov.stopped, mirrored)
}
}
}
// It says so in the log — the operator's half of the ruling.
if !strings.Contains(h.logs.String(), "Recovery unit NOT captured for docmost — it is not running its pinned version") {
t.Errorf("the skip must be logged, log:\n%s", h.logs.String())
}
// Positive control: the app that runs its pin is backed up as before.
if len(dumped) != 1 || dumped[0] != "free" || len(h.volDumped) != 1 || h.volDumped[0] != "free" || len(mirrored) != 1 || mirrored[0] != "free" {
t.Errorf("positive control: the other app must still be dumped and mirrored (db=%v vol=%v mirrored=%v)", dumped, h.volDumped, mirrored)
}
if _, err := os.Stat(RecoveryUnitManifestPath(ns, "free")); err != nil {
t.Errorf("positive control: the other app's unit must be captured: %v", err)
}
}
// The page's half: the household reads one plain sentence, in their language; nothing when the app
// runs its pin or when nothing is wired (unknown never skips).
func TestR645_VersionSkipSentence(t *testing.T) {
m := &Manager{}
if skip, why := m.VersionSkipFor("docmost", "hu"); skip || why != "" {
t.Fatalf("no check wired must read as no skip, got (%v, %q)", skip, why)
}
m.SetVersionCheck(func(name string) (string, bool) { return "x", name == "docmost" })
skip, hu := m.VersionSkipFor("docmost", "hu")
if !skip || !strings.Contains(hu, "docmost") || !strings.Contains(hu, "jszakai ment") || !strings.Contains(hu, "Vedd fel") {
t.Errorf("hu sentence = %q", hu)
}
_, en := m.VersionSkipFor("docmost", "en")
if !strings.Contains(en, "night backup leaves it out") || strings.Contains(en, "jszakai") {
t.Errorf("en sentence = %q", en)
}
if skip, _ := m.VersionSkipFor("free", "hu"); skip {
t.Error("an app running its pin must not read as skipped")
}
}
@@ -0,0 +1,185 @@
package backup
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-893 slice 1 (`09` §3 decision 192, option C of audits/day-2026-10-08/design-R-893.md).
//
// An off-site restore of one app writes the snapshot's definition (when the version differs), copies the
// snapshot's files and replaces the named volumes BEFORE the database replay. When the replay then fails,
// the rollback puts back only the database rows. Until this slice the app was started on that mix —
// an older definition, the snapshot's files and volumes, the newer database — and the household was told
// „your data is back as it was before the restore". That is true only of the database rows.
//
// Now: the database is still rolled back, the LIVE definition is written back, and the app is HELD
// stopped with a sentence that says it needs support. Pinned here:
// (a) the definition write is called a second time, with the live definition;
// (b) the app is NOT started;
// (c) a hold is left (kind restore_mixed), and the operator is notified;
// (d) neither the error nor the hold sentence claims the data is back as it was (hu and en).
// The no-version-change, no-volume case keeps today's behaviour: TestR379_ScenarioA.
// r893Provider records EVERY definition write (vtReconProvider keeps only the last).
type r893Provider struct {
*vtReconProvider
defs [][]string
onStop func()
}
func (p *r893Provider) StopStack(name string) error {
if p.onStop != nil {
p.onStop()
}
return p.vtReconProvider.StopStack(name)
}
func (p *r893Provider) RecreateStackDefinitionFromUnit(name, composeDir string, env map[string]string) error {
p.defs = append(p.defs, ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml")))
return p.vtReconProvider.RecreateStackDefinitionFromUnit(name, composeDir, env)
}
func r893Fixture(t *testing.T, versionChange bool, volumes int) (*Manager, *r893Provider, *int, *int) {
t.Helper()
m, prov, _ := reconFixture(t, "20260926T021500Z", "2026-09-26T02:15:01Z", pgDump(1))
m.importDBDump = func(context.Context, DiscoveredDB, string) error { return errors.New("replay blew up") }
rolled := 0
m.SetRollbackImportFn(func(context.Context, DiscoveredDB, string) error { rolled++; return nil })
notified := 0
m.SetRestoreHoldNotify(func(_ string, replayErr, rollbackErr error) {
notified++
if replayErr == nil || rollbackErr != nil {
t.Errorf("notify got replay=%v rollback=%v — want the replay error and NO rollback error", replayErr, rollbackErr)
}
})
m.volumeReplayFrom = func(string, string) (int, error) { return volumes, nil }
live := "16"
if versionChange {
live = "18"
}
// The LIVE definition, on disk where the app runs.
if err := os.WriteFile(prov.composePath, []byte(vtCompose(live)), 0o644); err != nil {
t.Fatal(err)
}
vp := &r893Provider{vtReconProvider: &vtReconProvider{recordingProvider: prov, livePins: ParseComposeImages(prov.composePath)}}
m.SetStackProvider(vp)
vtSnapshotUnit(t, m, "16", true)
return m, vp, &rolled, &notified
}
func assertR893Held(t *testing.T, m *Manager, vp *r893Provider, err error, rolled, notified int) {
t.Helper()
if err == nil {
t.Fatal("a failed replay must be surfaced as a failure")
}
if rolled != 1 {
t.Fatalf("the database rollback must still run exactly once, ran %d", rolled)
}
// (b)
if vp.fullStarted {
t.Fatalf("the app was STARTED on a mixed state — calls %v", vp.calls)
}
if last := vp.calls[len(vp.calls)-1]; last != "stop" {
t.Errorf("the database service must be stopped again before the hold; last call %q (%v)", last, vp.calls)
}
// (c)
held, sentence := m.RestoreHoldFor("immich")
if !held {
t.Fatal("no hold was left — every start path would start the app on the mixed state")
}
if k := m.HoldKind("immich"); k != settings.HoldReasonRestoreMixed {
t.Errorf("hold kind %q, want %q", k, settings.HoldReasonRestoreMixed)
}
if notified != 1 {
t.Errorf("the operator must be notified once, got %d", notified)
}
// (d) — ASCII fragments for the Hungarian (the accented word is matched by its ASCII stem too).
for _, s := range []string{err.Error(), sentence} {
low := strings.ToLower(s)
if strings.Contains(low, "visszaker") || strings.Contains(low, "fut tov") {
t.Errorf("the sentence still claims the data is back / the app runs: %q", s)
}
if !strings.Contains(low, "kapcsolatot") {
t.Errorf("the sentence must send the household to support: %q", s)
}
}
_, en := m.RestoreHoldForLang("immich", "en")
if strings.Contains(strings.ToLower(en), "back as it was") || !strings.Contains(en, "help") {
t.Errorf("English hold sentence: %q", en)
}
if enErr := util.Text("en", "err.backup.db_restore_failed_held_mixed", "immich"); strings.Contains(enErr, "back as it was") || !strings.Contains(enErr, "STOPPED") {
t.Errorf("English error sentence: %q", enErr)
}
}
func TestR893_AVersionChangeThenAFailedReplayHoldsTheAppAtItsLiveDefinition(t *testing.T) {
m, vp, rolled, notified := r893Fixture(t, true, 0)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
assertR893Held(t, m, vp, err, *rolled, *notified)
// (a)
if len(vp.defs) != 2 {
t.Fatalf("definition writes %v — want the snapshot's, then the LIVE one written back", vp.defs)
}
if got := strings.Join(vp.defs[1], " "); !strings.Contains(got, "postgres:18-alpine") {
t.Fatalf("the definition written back is %q — want the live 18", got)
}
}
func TestR893_AReplacedVolumeThenAFailedReplayHoldsTheApp(t *testing.T) {
m, vp, rolled, notified := r893Fixture(t, false, 1)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
assertR893Held(t, m, vp, err, *rolled, *notified)
if len(vp.defs) != 0 {
t.Fatalf("no version changed, yet a definition was written: %v", vp.defs)
}
}
// Security review 2026-10-08 (G1): the hold covers EVERY failure after the snapshot's definition is written, not only a
// failed replay. Here the DB-only start fails after a version change: before the fix the app was started at the
// snapshot's (older) definition on the live database. Now the live definition is written back and the app is held.
// RED-PROOF: restore `restartStack()` as the only action in the StartStackServices failure branch → started → FAILS.
func TestR893_AVersionChangeThenAFailedDBStartHoldsTheApp(t *testing.T) {
m, vp, rolled, notified := r893Fixture(t, true, 0)
vp.startSvcErr = context.DeadlineExceeded
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed DB-only start must be surfaced")
}
if vp.fullStarted {
t.Fatalf("the app was STARTED at the snapshot's definition on the live data — calls %v", vp.calls)
}
if held, _ := m.RestoreHoldFor("immich"); !held {
t.Fatal("no hold was left after a failed DB-only start that followed a version change")
}
if len(vp.defs) != 2 || !strings.Contains(strings.Join(vp.defs[1], " "), "postgres:18-alpine") {
t.Fatalf("definition writes %v — want the snapshot's, then the LIVE one written back", vp.defs)
}
if *rolled != 0 || *notified != 1 {
t.Errorf("rollback ran %d (want 0: nothing was replayed), notified %d (want 1)", *rolled, *notified)
}
}
// Security review 2026-10-08 (G3): the hold is persisted BEFORE the app is stopped, so a controller that dies in
// between cannot restart the app from its app-stop marker with nothing refusing it. The provider's StopStack asserts
// the hold is already on disk at the moment it is called.
// RED-PROOF: move SetRestoreHold after StopStack in holdAppAfterMixedRestore → FAILS.
func TestR893_HoldIsPersistedBeforeTheStop(t *testing.T) {
m, vp, _, _ := r893Fixture(t, true, 0)
seenAtStop := []bool{}
vp.onStop = func() {
held, _ := m.RestoreHoldFor("immich")
seenAtStop = append(seenAtStop, held)
}
_, _ = m.ReconstituteFromOffsite(context.Background(), "immich", false)
if len(seenAtStop) == 0 || !seenAtStop[len(seenAtStop)-1] {
t.Fatalf("the last stop ran before the hold was persisted (held at each stop: %v)", seenAtStop)
}
}
+17 -1
View File
@@ -416,7 +416,7 @@ func (m *Manager) floorVerdict(u *UnitSpace, estGiB float64) (*UnitSpace, floorR
availAfter := u.AvailGB - estGiB availAfter := u.AvailGB - estGiB
usedAfter := u.UsedPercent usedAfter := u.UsedPercent
if u.TotalGB > 0 { if u.TotalGB > 0 {
usedAfter = (u.UsedGB + estGiB) / u.TotalGB * 100 usedAfter = system.DFUsedPercent(u.UsedGB+estGiB, u.AvailGB-estGiB) // R-889: df's number after the write
} }
if availAfter < FloorFreeGiB || usedAfter >= FloorUsedPercent { if availAfter < FloorFreeGiB || usedAfter >= FloorUsedPercent {
return u, floorSize return u, floorSize
@@ -450,6 +450,11 @@ func (m *Manager) captureAllRecoveryUnits(dataRun bool) {
if m.settings != nil && (m.settings.IsDisconnected(drivePath) || m.settings.IsDecommissioned(drivePath)) { if m.settings != nil && (m.settings.IsDisconnected(drivePath) || m.settings.IsDecommissioned(drivePath)) {
continue // drive not writable — skip, the existing unit stays as-is continue // drive not writable — skip, the existing unit stays as-is
} }
// R-897: right after a boot the agent may still be (re)binding the drive; a capture in that second failed
// with "permission denied" (measured on demo-hp 2026-10-07). Wait for the bind — bounded (driveready.go).
if !m.driveReady(drivePath, dataRun) {
continue
}
// Slice 4: a HELD app's unit is its RESTORE POINT, and the hold text names that copy's date. // Slice 4: a HELD app's unit is its RESTORE POINT, and the hold text names that copy's date.
// Re-capturing it would write the definition the app is held ON (after a failed update: the new // Re-capturing it would write the definition the app is held ON (after a failed update: the new
// version that would not start) into the unit, and the next Tier-2 run would mirror it over the // version that would not start) into the unit, and the next Tier-2 run would mirror it over the
@@ -458,6 +463,17 @@ func (m *Manager) captureAllRecoveryUnits(dataRun bool) {
if m.isHeld(stack.Name) { if m.isHeld(stack.Name) {
continue continue
} }
// R-645 (09 §3 decision 142): the unit of an app that is not running its pinned version stays
// as it is — the measured case wrote a just-failed definition over the good unit within seconds
// of a hand-lifted hold. Said at WARN on a data run; the periodic refresh repeats it only at DEBUG.
if why, skip := m.versionSkip(stack.Name); skip {
if dataRun {
m.logger.Printf("[WARN] [backup] Recovery unit NOT captured for %s — it is not running its pinned version (%s); the last good unit is kept (R-645)", stack.Name, why)
} else if m.isDebug() {
m.logger.Printf("[DEBUG] [backup] recovery-unit refresh skipped for %s — not running its pinned version (%s)", stack.Name, why)
}
continue
}
m.noteAttempted(stack.Name) m.noteAttempted(stack.Name)
// The reserve, checked BEFORE anything is written. Per app, and the loop continues. // The reserve, checked BEFORE anything is written. Per app, and the loop continues.
if !m.admitApp(stack.Name) { if !m.admitApp(stack.Name) {
+93 -20
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec" "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"os" "os"
"strings" "strings"
"time" "time"
@@ -16,6 +17,10 @@ import (
// (AppVolumeDumpPath) and relies on the DB dumps already present on the app's drive. // (AppVolumeDumpPath) and relies on the DB dumps already present on the app's drive.
// The stack is stopped before the volume import and restarted after. // The stack is stopped before the volume import and restarted after.
// //
// R-638: the order is stop → volumes → DB-only start → replay → full start, the same as the unit
// restore (R-47). The replay is skipped when the volume leg failed, and a dump with no identifiable
// database service is refused before anything is touched.
//
// snapshotID is retained for API/UI signature compatibility; with restic removed it // snapshotID is retained for API/UI signature compatibility; with restic removed it
// is only used for logging (the source of truth is now the on-disk volume tars). // is only used for logging (the source of truth is now the on-disk volume tars).
func (m *Manager) RestoreApp(stackName, snapshotID string) error { func (m *Manager) RestoreApp(stackName, snapshotID string) error {
@@ -48,9 +53,35 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error {
m.logger.Printf("[INFO] [backup] Starting app-data restore for %s (drive=%s)", stackName, drivePath) m.logger.Printf("[INFO] [backup] Starting app-data restore for %s (drive=%s)", stackName, drivePath)
// R-638 (option A, 09 §3 decision 154): which compose service holds the database, and is there
// anything to replay? Resolved BEFORE the first mutation, from the LIVE compose — this fallback has no
// unit, so the live definition is the one that will run — exactly as the off-site path does for an app
// that keeps its definition (offbox_reconstitute.go, "WHICH SERVICE HOLDS THE DATABASE").
dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), stackName)
hasDumps := hasReplayableDump(dumpDir)
var dbServices []string
if hasDumps {
if composePath, ok := m.stackProvider.GetStackComposePath(stackName); ok && composePath != "" {
svcs, dsErr := DBServiceNames(composePath)
if dsErr != nil {
// "cannot tell" is not "no database" — leave it empty and let the gate below refuse.
m.logger.Printf("[WARN] [backup] %s: could not read the live compose services: %v", stackName, dsErr)
}
dbServices = svcs
}
// Fail-closed, the same gate as the unit and off-site paths: the only alternative to refusing is
// to start the WHOLE stack and replay into it — the R-638 shape, where a newer app migrates the
// restored data before the old copy is poured over it. Refused with the live app untouched.
// Pinned by TestR638_FallbackRefusesWhenNoDBServiceIdentifiable.
if len(dbServices) == 0 {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: a .sql dump exists but no database service is identifiable in the live compose", stackName)
return util.MsgError("err.backup.az_adatbazis_szolgaltatas_nem_azonosithato_a", stackName)
}
}
// Stop the app before restore // Stop the app before restore
if m.isDebug() { if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreApp: step 1/3 — stopping app %s", stackName) m.logger.Printf("[DEBUG] RestoreApp: step 1/4 — stopping app %s", stackName)
} }
if err := m.stackProvider.StopStack(stackName); err != nil { if err := m.stackProvider.StopStack(stackName); err != nil {
m.logger.Printf("[WARN] RESTORE could not stop %s: %v (proceeding anyway)", stackName, err) m.logger.Printf("[WARN] RESTORE could not stop %s: %v (proceeding anyway)", stackName, err)
@@ -62,32 +93,58 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error {
// Populate Docker volumes from restored tars // Populate Docker volumes from restored tars
if m.isDebug() { if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreApp: step 2/3 — restoring Docker volumes for %s", stackName) m.logger.Printf("[DEBUG] RestoreApp: step 2/4 — restoring Docker volumes for %s", stackName)
} }
if _, err := m.restoreDockerVolumes(stackName, drivePath); err != nil { _, volErr := m.restoreDockerVolumes(stackName, drivePath)
m.logger.Printf("[ERROR] RESTORE volume restore failed for %s: %v", stackName, err) if volErr != nil {
dataErr = err m.logger.Printf("[ERROR] RESTORE volume restore failed for %s: %v", stackName, volErr)
dataErr = volErr
} }
// Restart the app // F17: replay the captured .sql dump (the legacy path never did this, so DB-resident data did not
// come back). Runs after the volume restore so the dump WINS over any tar copy.
//
// R-638: with ONLY the database service up — the same DB-only start the unit restore uses (R-47).
// This used to run after a FULL StartStack at the CURRENT definition, which gave a newer app the
// window to migrate the just-restored data; the loader then overlays the copy and removes only what
// the copy knows about, so the newer tables stayed behind (measured 2026-09-23, romm 5.0 → 5.3).
// Pinned by TestR638_FallbackReplaysBeforeTheAppStarts.
//
// And NOT when the volume leg failed: the database's volume is then not known to hold the copy's
// own files (it may still be the live, newer one), and the overlay would leave the difference
// behind. The failure is already in dataErr and is returned below. Pinned by
// TestR638_FallbackVolumeFailureSkipsTheReplay.
if hasDumps {
if volErr != nil {
m.logger.Printf("[ERROR] [backup] RESTORE %s: NOT replaying the database copy — the volume restore failed, so the database is not known to be the copy's own", stackName)
} else {
if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreApp: step 3/4 — starting only %v and replaying the DB copy for %s", dbServices, stackName)
}
if err := m.stackProvider.StartStackServices(stackName, dbServices); err != nil {
m.logger.Printf("[ERROR] [backup] DB-only start for %s: %v", stackName, err)
if dataErr == nil {
dataErr = err
}
} else if _, err := m.reimportDBDumpsCtx(stackName, m.namespaceRoot(drivePath)); err != nil {
m.logger.Printf("[ERROR] RESTORE DB re-import failed for %s: %v", stackName, err)
if dataErr == nil {
dataErr = err
}
}
}
}
// Restart the app — every exit from the DB-only window ends in a full start.
if m.isDebug() { if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreApp: step 3/3 — restarting app %s after restore", stackName) m.logger.Printf("[DEBUG] RestoreApp: step 4/4 — starting app %s after restore", stackName)
} }
if err := m.stackProvider.StartStack(stackName); err != nil { if err := m.stackProvider.StartStack(stackName); err != nil {
m.logger.Printf("[WARN] RESTORE could not restart %s after restore: %v", stackName, err) m.logger.Printf("[WARN] RESTORE could not restart %s after restore: %v", stackName, err)
} }
// F17: replay the captured .sql dump into the now-running DB (the legacy path never did this, so
// DB-resident data did not come back). Runs after volume restore so the dump WINS over any tar copy.
if _, err := m.reimportDBDumpsCtx(stackName, m.namespaceRoot(drivePath)); err != nil {
m.logger.Printf("[ERROR] RESTORE DB re-import failed for %s: %v", stackName, err)
if dataErr == nil {
dataErr = err
}
}
// Verify app started successfully // Verify app started successfully
if err := m.waitForHealthy(stackName, 90*time.Second); err != nil { if err := m.waitForHealthy(stackName, healthTimeout); err != nil {
m.logger.Printf("[WARN] [backup] Restore completed but app health check failed: %v", err) m.logger.Printf("[WARN] [backup] Restore completed but app health check failed: %v", err)
} }
@@ -109,7 +166,13 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error {
// re-derive: the caller cannot count volumes afterwards without re-reading the directory the restore // re-derive: the caller cannot count volumes afterwards without re-reading the directory the restore
// has already consumed. // has already consumed.
func (m *Manager) restoreDockerVolumes(stackName, drivePath string) (int, error) { func (m *Manager) restoreDockerVolumes(stackName, drivePath string) (int, error) {
return m.restoreDockerVolumesFrom(stackName, AppVolumeDumpPath(m.namespaceRoot(drivePath), stackName)) // R-638: through the same volume-replay seam the unit and off-site paths use (R-354), so the
// fallback's ordering can be tested without a Docker daemon. Nil in production.
replay := m.volumeReplayFrom
if replay == nil {
replay = m.restoreDockerVolumesFrom
}
return replay(stackName, AppVolumeDumpPath(m.namespaceRoot(drivePath), stackName))
} }
// restoreDockerVolumesFrom is restoreDockerVolumes with an EXPLICIT dump directory, and it returns how // restoreDockerVolumesFrom is restoreDockerVolumes with an EXPLICIT dump directory, and it returns how
@@ -203,13 +266,23 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro
return restored, nil return restored, nil
} }
// waitForHealthy's clock (R-488). These are the PRODUCTION values; they are variables only so the
// package's tests can shorten them in ONE place (main_test.go TestMain) instead of every restore
// test sleeping 3 s of settle and every not-running fixture waiting out the full 90 s — that was
// ~280 s of the package's ~440 s. TestR488_HealthWaitDefaultsAreProduction pins the values.
var (
healthSettle = 3 * time.Second // initial settling time before the first poll
healthInterval = 5 * time.Second // between polls
healthTimeout = 90 * time.Second // how long a restored stack gets to reach running
)
// waitForHealthy waits for a stack to reach running state after restore. // waitForHealthy waits for a stack to reach running state after restore.
// Forces a docker ps refresh on each poll to avoid stale state. // Forces a docker ps refresh on each poll to avoid stale state.
func (m *Manager) waitForHealthy(stackName string, timeout time.Duration) error { func (m *Manager) waitForHealthy(stackName string, timeout time.Duration) error {
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
interval := 5 * time.Second interval := healthInterval
time.Sleep(3 * time.Second) // initial settling time time.Sleep(healthSettle) // initial settling time
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
if m.stackProvider == nil { if m.stackProvider == nil {
+11 -2
View File
@@ -449,7 +449,16 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
// R-47: the replay happens with ONLY the database service up. This used to run after // R-47: the replay happens with ONLY the database service up. This used to run after
// RecreateStackFromUnit had already brought the WHOLE stack up, letting the application rebuild // RecreateStackFromUnit had already brought the WHOLE stack up, letting the application rebuild
// schema objects underneath the replay (H4, DIAG-immich-restore-round2-2026-07-19). // schema objects underneath the replay (H4, DIAG-immich-restore-round2-2026-07-19).
if hasDumps { //
// R-638 (option A, 09 §3 decision 154): NOT when the volume leg failed. The replay overlays the copy
// and removes only what the copy knows about, so it is safe only over the copy's OWN database files
// — which a failed volume leg does not guarantee (the volume may still be the live, newer one, or
// half-filled). The failure is already in dataErr and returned below; the definition is still the
// unit's and the app is still started, exactly as before. Pinned by
// TestR638_UnitVolumeFailureNeverCallsTheImporter.
if hasDumps && volErr != nil {
m.logger.Printf("[ERROR] [backup] %s: NOT replaying the database copy — the volume restore failed, so the database is not known to be the copy's own", stackName)
} else if hasDumps {
if err := m.stackProvider.StartStackServices(stackName, dbServices); err != nil { if err := m.stackProvider.StartStackServices(stackName, dbServices); err != nil {
m.logger.Printf("[ERROR] [backup] DB-only start for %s: %v", stackName, err) m.logger.Printf("[ERROR] [backup] DB-only start for %s: %v", stackName, err)
if dataErr == nil { if dataErr == nil {
@@ -468,7 +477,7 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
if err := m.stackProvider.StartStack(stackName); err != nil { if err := m.stackProvider.StartStack(stackName); err != nil {
return res, fmt.Errorf("starting %s after restore from unit: %w", stackName, err) return res, fmt.Errorf("starting %s after restore from unit: %w", stackName, err)
} }
if err := m.waitForHealthy(stackName, 90*time.Second); err != nil { if err := m.waitForHealthy(stackName, healthTimeout); err != nil {
m.logger.Printf("[WARN] [backup] %s restored but health check failed: %v", stackName, err) m.logger.Printf("[WARN] [backup] %s restored but health check failed: %v", stackName, err)
} }
@@ -0,0 +1,54 @@
package backup
import (
"testing"
"time"
)
// After a controller restart the Backup → Apps „Databases" table is SYNTHESISED from the dump files on disk (the last
// run's result is not in memory). Seen 2026-10-10 on demo-hp after the 0.305.0 delivery: every `pre-restore-` undo copy
// (R-379) was listed as one more row of the night's run — adventurelog four times, bookstack four times, each stamped
// with the run's time. An undo copy is not part of any run: it stays in the file list (its visibility is a recorded
// decision), but the run table shows one row per database, the newest real dump.
func TestSynthesisedRunListsNoUndoCopy(t *testing.T) {
m, _ := newTestManager(t, "/srv/sys")
night := time.Date(2026, 10, 10, 2, 15, 0, 0, time.UTC)
m.cachedStatus = &FullBackupStatus{DumpFiles: []DumpFileInfo{
{StackName: "adventurelog", DBType: DBTypePostgres, FileName: "adventurelog-postgres.sql", ModTime: night},
{StackName: "adventurelog", DBType: DBTypePostgres, FileName: "pre-restore-20260913T193505Z-adventurelog-postgres.sql", ModTime: night.AddDate(0, 0, -27), IsUndo: true},
{StackName: "adventurelog", DBType: DBTypePostgres, FileName: "pre-restore-20260928T021839Z-adventurelog-postgres.sql", ModTime: night.AddDate(0, 0, -12), IsUndo: true},
{StackName: "bookstack", DBType: DBTypeMariaDB, FileName: "bookstack-mariadb.sql", ModTime: night},
{StackName: "bookstack", DBType: DBTypeMariaDB, FileName: "pre-restore-20260930T113419Z-bookstack-mariadb.sql", ModTime: night.AddDate(0, 0, -10), IsUndo: true},
}}
st := m.GetFullStatus(time.Time{})
if st.LastDBDump == nil {
t.Fatal("no synthesised run")
}
per := map[string]int{}
for _, r := range st.LastDBDump.Results {
per[r.DB.StackName]++
if r.FilePath != "adventurelog-postgres.sql" && r.FilePath != "bookstack-mariadb.sql" {
t.Errorf("an undo copy is listed as a row of the run: %s", r.FilePath)
}
}
if per["adventurelog"] != 1 || per["bookstack"] != 1 || len(st.LastDBDump.Results) != 2 {
t.Errorf("want one row per database (2), got %d: %v", len(st.LastDBDump.Results), per)
}
if !st.LastDBDump.LastRun.Equal(night) {
t.Errorf("the run time must come from the real dumps: %v", st.LastDBDump.LastRun)
}
if len(st.DumpFiles) != 5 {
t.Errorf("the file list must keep every file, undo copies included (R-379): %d", len(st.DumpFiles))
}
}
// Only undo copies on disk: there is no run to show, so no synthesised run (a zero-time „last backup" would be a lie).
func TestSynthesisedRunAbsentWhenOnlyUndoCopies(t *testing.T) {
m, _ := newTestManager(t, "/srv/sys")
m.cachedStatus = &FullBackupStatus{DumpFiles: []DumpFileInfo{
{StackName: "bookstack", DBType: DBTypeMariaDB, FileName: "pre-restore-20260930T113419Z-bookstack-mariadb.sql", ModTime: time.Now(), IsUndo: true},
}}
if st := m.GetFullStatus(time.Time{}); st.LastDBDump != nil {
t.Errorf("a run was synthesised from undo copies alone: %+v", st.LastDBDump)
}
}
+5
View File
@@ -464,6 +464,11 @@ func (m *Manager) RunAllTier2() {
m.logger.Printf("[WARN] [backup] Tier 2 skipped for %s — the app is HELD; its copy is the restore point and is preserved", stack.Name) m.logger.Printf("[WARN] [backup] Tier 2 skipped for %s — the app is HELD; its copy is the restore point and is preserved", stack.Name)
continue continue
} }
// R-645: the mirror of an app not running its pinned version stays as it is.
if why, skip := m.versionSkip(stack.Name); skip {
m.logger.Printf("[WARN] [backup] Tier 2 skipped for %s — it is not running its pinned version (%s); the last good copy is kept (R-645)", stack.Name, why)
continue
}
runOne := m.perAppTier2 runOne := m.perAppTier2
if runOne == nil { if runOne == nil {
runOne = m.RunTier2 runOne = m.RunTier2
+1 -1
View File
@@ -440,7 +440,7 @@ func (m *Manager) RestoreTier2Files(stackName string) (filesRestored int, err er
if startErr != nil { if startErr != nil {
m.logger.Printf("[ERROR] [backup] failed to restart %s after Tier-2 file restore: %v", stackName, startErr) m.logger.Printf("[ERROR] [backup] failed to restart %s after Tier-2 file restore: %v", stackName, startErr)
} }
if healthErr := m.waitForHealthy(stackName, 90*time.Second); healthErr != nil { if healthErr := m.waitForHealthy(stackName, healthTimeout); healthErr != nil {
m.logger.Printf("[WARN] [backup] %s Tier-2 file restore done but health check failed: %v", stackName, healthErr) m.logger.Printf("[WARN] [backup] %s Tier-2 file restore done but health check failed: %v", stackName, healthErr)
} }
@@ -600,6 +600,39 @@ func (m *Manager) isHeld(stackName string) bool {
return m.updatingCheck != nil && m.updatingCheck(stackName) return m.updatingCheck != nil && m.updatingCheck(stackName)
} }
// versionSkip (R-645, 09 §3 decision 142) reports whether the night backup must leave an app alone
// because the version it is pinned to is NOT the version it runs, and names the difference.
//
// THE MEASURED CASE (9202, 2026-09-23): a failed update left docmost pinned to 0.96.0 while its running
// record said 0.95.0. Lifting the hold by hand (`--clear-restore-hold` + the restart) let the capture
// write the 0.96.0 definition — the version that had just failed — over the recovery unit the hold
// sentence named, within seconds. The hold is what kept the legs off the app (isHeld); once it is gone,
// this is what does. Where a leg also asks isHeld it asks this AFTER it, so a held app keeps its own line.
//
// Nil check or unknown ⇒ false (back the app up as before): a missing backup is the worse failure.
// Pinned by TestR645_HandLiftedHoldKeepsTheGoodUnit (r645_version_skip_test.go).
func (m *Manager) versionSkip(stackName string) (string, bool) {
if m == nil || m.versionCheck == nil {
return "", false
}
return m.versionCheck(stackName)
}
// VersionSkipFor is versionSkip for the backups page: the household's sentence in lang, or false.
func (m *Manager) VersionSkipFor(stackName, lang string) (bool, string) {
if _, skip := m.versionSkip(stackName); !skip {
return false, ""
}
return true, util.Text(lang, "backup.status.version_skip", stackName)
}
// SetVersionCheck wires the "is this app running its pinned version" question (stacks.Manager.PinNotRunning).
// INIT-ONLY, in main.go — pinned by TestR645_VersionCheckIsWiredAtStartup. The backup package cannot
// import stacks, which is why it is a seam.
func (m *Manager) SetVersionCheck(fn func(stackName string) (string, bool)) {
m.versionCheck = fn
}
// SetUpdatingCheck wires the "is a guarded update moving this app" question (stacks.Manager.IsUpdating). // SetUpdatingCheck wires the "is a guarded update moving this app" question (stacks.Manager.IsUpdating).
// INIT-ONLY, in main.go — pinned by TestSlice4_UpdatingCheckIsWiredAtStartup. The backup package cannot // INIT-ONLY, in main.go — pinned by TestSlice4_UpdatingCheckIsWiredAtStartup. The backup package cannot
// import stacks, which is why it is a seam. // import stacks, which is why it is a seam.
+59 -16
View File
@@ -5,13 +5,14 @@
// uses the passphrase to PULL its full controller.yaml from the hub (which mints the customer-scoped // uses the passphrase to PULL its full controller.yaml from the hub (which mints the customer-scoped
// hub api_key + identity + assets + backup + CF config), MERGES in the per-guest local_api block (the // hub api_key + identity + assets + backup + CF config), MERGES in the per-guest local_api block (the
// only thing the hub yaml lacks, because the hub must not know per-guest Proxmox internals), writes // only thing the hub yaml lacks, because the hub must not know per-guest Proxmox internals), writes
// it, and comes up CONFIGURED — skipping the setup wizard. // it, and comes up CONFIGURED (an unconfigured box waits and retries — the setup wizard is deleted, R-554).
// //
// This replaces the old "seed a configured yaml from the agent's HOST key" path, which made the // This replaces the old "seed a configured yaml from the agent's HOST key" path, which made the
// controller's hub reports 401 (the hub's /report needs the customer-scoped key, not the host key). // controller's hub reports 401 (the hub's /report needs the customer-scoped key, not the host key).
package bootstrap package bootstrap
import ( import (
"crypto/subtle"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -32,7 +33,7 @@ const DefaultMountPath = "/etc/felhom-bootstrap/bootstrap.json"
// SchemaV2 is the stable contract version the agent emits and the controller ingests. v2 changed the // SchemaV2 is the stable contract version the agent emits and the controller ingests. v2 changed the
// contract's MEANING (the controller pulls its config from the hub rather than seeding it from the // contract's MEANING (the controller pulls its config from the hub rather than seeding it from the
// agent's host key) and its field set, so it is a clean version bump with no v1 back-compat // agent's host key) and its field set, so it is a clean version bump with no v1 back-compat
// (pre-launch; zero v1 guests deployed). A non-v2 schema is rejected → setup mode. // (pre-launch; zero v1 guests deployed). A non-v2 schema is rejected → the box waits (R-554).
const SchemaV2 = "felhom.bootstrap/v2" const SchemaV2 = "felhom.bootstrap/v2"
// ErrPullTransient marks a pull failure as retryable (a boot-time network race reaching the hub). // ErrPullTransient marks a pull failure as retryable (a boot-time network race reaching the hub).
@@ -94,7 +95,7 @@ func Path() string {
// - Idempotent: if cfg is already configured (customer.id set), the existing controller.yaml is // - Idempotent: if cfg is already configured (customer.id set), the existing controller.yaml is
// NEVER clobbered and the hub is NEVER pulled — returns cfg unchanged. // NEVER clobbered and the hub is NEVER pulled — returns cfg unchanged.
// - Fail-safe: an absent/malformed bootstrap, a non-v2 schema, a missing required field, or a hub // - Fail-safe: an absent/malformed bootstrap, a non-v2 schema, a missing required field, or a hub
// pull that ultimately fails leaves cfg unchanged (the caller proceeds to setup mode). It logs // pull that ultimately fails leaves cfg unchanged (the caller serves the waiting page and retries, R-554). It logs
// and NEVER crashes — a hub outage at first boot must not brick the guest. // and NEVER crashes — a hub outage at first boot must not brick the guest.
// - On success: writes controller.yaml (0600, atomic), reloads it, and returns the reloaded cfg. // - On success: writes controller.yaml (0600, atomic), reloads it, and returns the reloaded cfg.
func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc) *config.Config { func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc) *config.Config {
@@ -110,37 +111,37 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
data, err := os.ReadFile(bpath) data, err := os.ReadFile(bpath)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
logger.Printf("[WARN] bootstrap: cannot read %s: %v — staying in setup", bpath, err) logger.Printf("[WARN] bootstrap: cannot read %s: %v — waiting for the configuration", bpath, err)
} }
return cfg // no bootstrap → normal setup return cfg // no bootstrap → the caller waits (WaitForConfig)
} }
var b Bootstrap var b Bootstrap
if err := json.Unmarshal(data, &b); err != nil { if err := json.Unmarshal(data, &b); err != nil {
logger.Printf("[WARN] bootstrap: %s is not valid JSON: %v — staying in setup", bpath, err) logger.Printf("[WARN] bootstrap: %s is not valid JSON: %v — waiting for the configuration", bpath, err)
return cfg return cfg
} }
if b.Schema != SchemaV2 { if b.Schema != SchemaV2 {
logger.Printf("[WARN] bootstrap: unsupported schema %q (want %q) — staying in setup", b.Schema, SchemaV2) logger.Printf("[WARN] bootstrap: unsupported schema %q (want %q) — waiting for the configuration", b.Schema, SchemaV2)
return cfg return cfg
} }
if b.Customer.ID == "" || b.Hub.URL == "" || b.Hub.RetrievalPassword == "" { if b.Customer.ID == "" || b.Hub.URL == "" || b.Hub.RetrievalPassword == "" {
logger.Printf("[WARN] bootstrap: %s missing customer.id / hub.url / hub.retrieval_password — staying in setup", bpath) logger.Printf("[WARN] bootstrap: %s missing customer.id / hub.url / hub.retrieval_password — waiting for the configuration", bpath)
return cfg return cfg
} }
if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" { if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" {
logger.Printf("[WARN] bootstrap: %s missing local_api.{endpoint,fingerprint,token} — staying in setup", bpath) logger.Printf("[WARN] bootstrap: %s missing local_api.{endpoint,fingerprint,token} — waiting for the configuration", bpath)
return cfg return cfg
} }
if pull == nil { if pull == nil {
logger.Printf("[WARN] bootstrap: no pull function wired — staying in setup") logger.Printf("[WARN] bootstrap: no pull function wired — waiting for the configuration")
return cfg return cfg
} }
// --- Pull the full controller.yaml from the hub, with bounded retry on transient errors only. --- // --- Pull the full controller.yaml from the hub, with bounded retry on transient errors only. ---
pulled, err := pullWithRetry(pull, b.Hub.URL, b.Customer.ID, b.Hub.RetrievalPassword, logger) pulled, err := pullWithRetry(pull, b.Hub.URL, b.Customer.ID, b.Hub.RetrievalPassword, logger)
if err != nil { if err != nil {
logger.Printf("[WARN] bootstrap: hub config pull failed for customer %s from %s: %v — staying in setup (manual setup wizard remains the fallback)", logger.Printf("[WARN] bootstrap: hub config pull failed for customer %s from %s: %v — waiting for the configuration (the waiting page retries every minute)",
b.Customer.ID, b.Hub.URL, err) b.Customer.ID, b.Hub.URL, err)
return cfg return cfg
} }
@@ -149,16 +150,16 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
// every field the hub emits is preserved (forward-compat with hub template changes). --- // every field the hub emits is preserved (forward-compat with hub template changes). ---
merged, err := mergeLocalAPI(pulled, b.LocalAPI) merged, err := mergeLocalAPI(pulled, b.LocalAPI)
if err != nil { if err != nil {
logger.Printf("[WARN] bootstrap: merging local_api into pulled config failed: %v — staying in setup", err) logger.Printf("[WARN] bootstrap: merging local_api into pulled config failed: %v — waiting for the configuration", err)
return cfg return cfg
} }
if err := writeFileAtomic(configPath, merged); err != nil { if err := writeFileAtomic(configPath, merged); err != nil {
logger.Printf("[WARN] bootstrap: could not write %s: %v — staying in setup", configPath, err) logger.Printf("[WARN] bootstrap: could not write %s: %v — waiting for the configuration", configPath, err)
return cfg return cfg
} }
reloaded, err := config.LoadPermissive(configPath) reloaded, err := config.LoadPermissive(configPath)
if err != nil { if err != nil {
logger.Printf("[WARN] bootstrap: wrote %s but reload failed: %v — staying in setup", configPath, err) logger.Printf("[WARN] bootstrap: wrote %s but reload failed: %v — waiting for the configuration", configPath, err)
return cfg return cfg
} }
logger.Printf("[INFO] bootstrap: pulled config from %s for %s, merged local_api (%s) — coming up configured", logger.Printf("[INFO] bootstrap: pulled config from %s for %s, merged local_api (%s) — coming up configured",
@@ -351,6 +352,11 @@ type EndpointDrift struct {
// situation than a moved address — fixing the endpoint alone would then fail closed on the pin — // situation than a moved address — fixing the endpoint alone would then fail closed on the pin —
// so it is surfaced, as a boolean, never as a value. // so it is surfaced, as a boolean, never as a value.
FingerprintAgrees bool FingerprintAgrees bool
// EndpointAgrees is true when only the TOKEN moved (R-270): the address is right, the agent
// answers 401, and before R-270 nothing named it. TokenAgrees is a BOOLEAN for the same reason
// FingerprintAgrees is one — the token value is never stored, logged or sent.
EndpointAgrees bool
TokenAgrees bool
} }
// DetectEndpointDrift compares controller.yaml's live local_api.endpoint against bootstrap.json's. // DetectEndpointDrift compares controller.yaml's live local_api.endpoint against bootstrap.json's.
@@ -362,7 +368,12 @@ type EndpointDrift struct {
// unprovisioned guest is not a drifted one; // unprovisioned guest is not a drifted one;
// - the bootstrap local_api block is incomplete (any of endpoint/fingerprint/token empty) — the // - the bootstrap local_api block is incomplete (any of endpoint/fingerprint/token empty) — the
// same completeness bar ensureLocalAPI applies before it will merge; // same completeness bar ensureLocalAPI applies before it will merge;
// - the endpoints agree. // - the endpoints agree AND the tokens agree.
//
// R-270: a token rotation that rewrote bootstrap.json but not controller.yaml left the channel at
// HTTP 401 across restarts, and this detector — endpoint-only — stayed silent. It now also compares
// the token (constant-time, as a boolean only) and names that case; it still reconciles nothing
// (R-78). Pinned by TestR270_TokenOnlyDriftIsNamedNotLeaked.
// //
// It reads two files and writes NOTHING. Emitting the ERROR here (rather than at the call site) // It reads two files and writes NOTHING. Emitting the ERROR here (rather than at the call site)
// keeps the diagnosis in one line of log even when the alert path is unavailable. // keeps the diagnosis in one line of log even when the alert path is unavailable.
@@ -381,7 +392,9 @@ func DetectEndpointDrift(configPath string, cfg *config.Config, logger *log.Logg
if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" { if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" {
return nil return nil
} }
if cfg.LocalAPI.Endpoint == b.LocalAPI.Endpoint { endpointAgrees := cfg.LocalAPI.Endpoint == b.LocalAPI.Endpoint
tokenAgrees := subtle.ConstantTimeCompare([]byte(cfg.LocalAPI.Token), []byte(b.LocalAPI.Token)) == 1
if endpointAgrees && tokenAgrees {
return nil return nil
} }
d := &EndpointDrift{ d := &EndpointDrift{
@@ -390,6 +403,18 @@ func DetectEndpointDrift(configPath string, cfg *config.Config, logger *log.Logg
ConfigEndpoint: cfg.LocalAPI.Endpoint, ConfigEndpoint: cfg.LocalAPI.Endpoint,
BootstrapEndpoint: b.LocalAPI.Endpoint, BootstrapEndpoint: b.LocalAPI.Endpoint,
FingerprintAgrees: cfg.LocalAPI.Fingerprint == b.LocalAPI.Fingerprint, FingerprintAgrees: cfg.LocalAPI.Fingerprint == b.LocalAPI.Fingerprint,
EndpointAgrees: endpointAgrees,
TokenAgrees: tokenAgrees,
}
if endpointAgrees {
if logger != nil {
logger.Printf("[ERROR] bootstrap: local_api TOKEN drift — %s and %s carry DIFFERENT tokens "+
"(endpoint %q agrees; pin agrees: %v). The controller presents %s's token, so the agent "+
"answers 401 if it was rotated. Not auto-corrected (R-78) — write the rotated token into "+
"%s too and restart the controller (R-270).",
d.ConfigPath, d.BootstrapPath, d.ConfigEndpoint, d.FingerprintAgrees, d.ConfigPath, d.ConfigPath)
}
return d
} }
if logger != nil { if logger != nil {
logger.Printf("[ERROR] bootstrap: local_api endpoint DRIFT — %s says %q but %s says %q; "+ logger.Printf("[ERROR] bootstrap: local_api endpoint DRIFT — %s says %q but %s says %q; "+
@@ -402,6 +427,12 @@ func DetectEndpointDrift(configPath string, cfg *config.Config, logger *log.Logg
// EnglishMessage is the operator-tier alert body (operator events are English by convention). // EnglishMessage is the operator-tier alert body (operator events are English by convention).
func (d *EndpointDrift) EnglishMessage() string { func (d *EndpointDrift) EnglishMessage() string {
if d.EndpointAgrees {
return fmt.Sprintf("local_api token drift: controller.yaml and bootstrap.json carry different tokens "+
"(endpoint %s agrees, pin agrees: %v) — the controller presents controller.yaml's token; after a "+
"rotation write it there too and restart the controller (R-270).",
d.ConfigEndpoint, d.FingerprintAgrees)
}
return fmt.Sprintf("local_api endpoint drift: controller.yaml=%s bootstrap.json=%s (pin agrees: %v) "+ return fmt.Sprintf("local_api endpoint drift: controller.yaml=%s bootstrap.json=%s (pin agrees: %v) "+
"— the controller is dialling controller.yaml's value; the agent may be listening on the other.", "— the controller is dialling controller.yaml's value; the agent may be listening on the other.",
d.ConfigEndpoint, d.BootstrapEndpoint, d.FingerprintAgrees) d.ConfigEndpoint, d.BootstrapEndpoint, d.FingerprintAgrees)
@@ -410,5 +441,17 @@ func (d *EndpointDrift) EnglishMessage() string {
// HungarianMessage is the customer-facing dashboard line, matching channelhealth's tone (short, // HungarianMessage is the customer-facing dashboard line, matching channelhealth's tone (short,
// no addresses — the operator gets those in the event and the log). // no addresses — the operator gets those in the event and the log).
func (d *EndpointDrift) HungarianMessage() string { func (d *EndpointDrift) HungarianMessage() string {
if d.EndpointAgrees {
return "A tárolókezelő ügynök hozzáférési kulcsa elavult a beállításokban."
}
return "A tárolókezelő ügynök címe elavult a beállításokban." return "A tárolókezelő ügynök címe elavult a beállításokban."
} }
// AlertKey is the dashboard banner's bundle key: the address case keeps its key; the token-only case
// (R-270) has its own, so the banner never claims the ADDRESS is stale when it is not.
func (d *EndpointDrift) AlertKey() string {
if d.EndpointAgrees {
return "alert.local_api_token_drift"
}
return "alert.endpoint_drift"
}
@@ -221,3 +221,41 @@ func TestScenarioC_AbsentBlockStillMerges(t *testing.T) {
t.Errorf("after a successful merge the two files agree — no drift: %+v", d) t.Errorf("after a successful merge the two files agree — no drift: %+v", d)
} }
} }
// R-270 — a token rotation that reached bootstrap.json but not controller.yaml left the channel at
// HTTP 401 across restarts, and the endpoint-only detector stayed silent (proved live 2026-08-09).
// The consequence pinned: the token-only divergence is DETECTED, NAMED as a token (not an address)
// on the banner key, nothing is written, and the token value appears in no output.
func TestR270_TokenOnlyDriftIsNamedNotLeaked(t *testing.T) {
cfgPath, cfg := writeDriftFixture(t, "169.254.253.1:8443", "169.254.253.1:8443", "aaaa1111", "tok-ROTATED")
before := sha(t, cfgPath)
var buf bytes.Buffer
d := DetectEndpointDrift(cfgPath, cfg, log.New(&buf, "", 0))
if d == nil {
t.Fatal("a token-only divergence must be DETECTED — this is the R-270 live shape (401 across restarts)")
}
if !d.EndpointAgrees || d.TokenAgrees {
t.Errorf("want EndpointAgrees=true TokenAgrees=false, got %+v", *d)
}
if d.AlertKey() != "alert.local_api_token_drift" {
t.Errorf("the banner must name the TOKEN, not the address: key %q", d.AlertKey())
}
if !strings.Contains(d.EnglishMessage(), "token drift") || !strings.Contains(buf.String(), "TOKEN drift") {
t.Errorf("operator message/log must name the token drift:\nmsg=%q\nlog=%q", d.EnglishMessage(), buf.String())
}
for _, secret := range []string{"tok-secret", "tok-ROTATED", "aaaa1111"} {
if strings.Contains(buf.String(), secret) || strings.Contains(d.EnglishMessage(), secret) || strings.Contains(d.HungarianMessage(), secret) {
t.Errorf("a secret value leaked: %q", secret)
}
}
if after := sha(t, cfgPath); !bytes.Equal(before, after) {
t.Error("controller.yaml was MODIFIED — detection must never write (R-78)")
}
// The address case keeps its own key (no regression of R-77's banner).
cfgPath2, cfg2 := writeDriftFixture(t, "192.168.0.87:8443", "169.254.253.1:8443", "aaaa1111", "tok-secret")
d2 := DetectEndpointDrift(cfgPath2, cfg2, log.New(io.Discard, "", 0))
if d2 == nil || d2.EndpointAgrees || !d2.TokenAgrees || d2.AlertKey() != "alert.endpoint_drift" {
t.Errorf("address drift must keep alert.endpoint_drift: %+v", d2)
}
}
+112
View File
@@ -0,0 +1,112 @@
package bootstrap
import (
"context"
"encoding/json"
"html"
"log"
"net"
"net/http"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// ── Not configured yet: wait for the hub (R-554, v0.308.0, `09` §3 decision 222) ───────────────
//
// Until v0.308.0 a box whose first boot could not ingest its configuration (no or bad bootstrap.json, a
// hub it could not reach, a failed write) entered the first-boot SETUP WIZARD — eight pages built for the
// retired docker-setup.sh install. The agent provisions every box now; the wizard is deleted. Such a box
// shows ONE page instead — „Felhom has not reached the central service yet", in both languages — and
// retries the ingestion every minute. When it succeeds the page goes away and the controller starts
// normally on the same listener. Pinned by TestWaitForConfig_ServesWaitingPageThenStarts.
// WaitRetryInterval is how often an unconfigured box retries the ingestion.
const WaitRetryInterval = time.Minute
// Unconfigured reports whether this controller has no customer id — the only state that waits.
func Unconfigured(cfg *config.Config) bool {
return cfg == nil || cfg.Customer.ID == ""
}
// WaitingHandler serves the waiting page on every path, and a health answer on /api/health (the
// container's health check must keep passing while the box waits — it is not broken, it is early).
func WaitingHandler(version string) http.Handler {
page := waitingPage()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/health" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true, "message": "felhom-controller is waiting for its configuration",
"waiting_for_hub": true, "setup_mode": true, "version": version,
})
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(page))
})
}
// waitingPage is the page in BOTH languages: an unconfigured box has no household setting to choose by.
func waitingPage() string {
var b strings.Builder
b.WriteString("<!DOCTYPE html>\n<html lang=\"hu\">\n<head>\n<meta charset=\"UTF-8\">\n")
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
b.WriteString("<meta http-equiv=\"refresh\" content=\"60\">\n<title>Felhom</title>\n")
b.WriteString("<style>body{font-family:system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;line-height:1.5}section{margin-bottom:2rem}</style>\n")
b.WriteString("</head>\n<body>\n")
bundle, err := i18n.Shared()
for _, lang := range i18n.Supported {
title, body := "Felhom", ""
if err == nil {
title, body = bundle.Msg(lang, "waiting.title"), bundle.Msg(lang, "waiting.body")
}
b.WriteString("<section lang=\"" + lang + "\">\n<h1>" + html.EscapeString(title) + "</h1>\n<p>" + html.EscapeString(body) + "</p>\n</section>\n")
}
b.WriteString("</body>\n</html>\n")
return b.String()
}
// WaitForConfig serves the waiting page on ln and retries MaybeIngest every `every` until the box is
// configured, then closes the page and returns the configured config. It returns nil when ctx ends first
// (the process is stopping). ln may be nil (the port could not be bound): the retries still run.
func WaitForConfig(ctx context.Context, ln net.Listener, configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc, every time.Duration, version string) *config.Config {
var srv *http.Server
if ln != nil {
srv = &http.Server{Handler: WaitingHandler(version), ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second}
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
logger.Printf("[ERROR] bootstrap: waiting page server: %v", err)
}
}()
logger.Printf("[INFO] bootstrap: not configured yet — serving the waiting page on %s, retrying every %s", ln.Addr(), every)
}
stop := func() {
if srv == nil {
return
}
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(sctx)
}
t := time.NewTicker(every)
defer t.Stop()
for attempt := 1; ; attempt++ {
select {
case <-ctx.Done():
stop()
return nil
case <-t.C:
}
next := MaybeIngest(configPath, cfg, logger, pull)
if !Unconfigured(next) {
logger.Printf("[INFO] bootstrap: configured after %d retries — closing the waiting page and starting", attempt)
stop()
return next
}
cfg = next
}
}
@@ -0,0 +1,100 @@
package bootstrap
import (
"context"
"errors"
"io"
"net"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// R-554 (`09` §3 decision 222): the setup wizard is deleted. A box whose first ingestion FAILED serves the
// waiting page — in both languages, on every path, with a passing health answer — and not a 404 or a
// wizard; it retries, and once the hub answers it returns the configured config and closes the page.
//
// RED-PROOF (REPORT): make WaitingHandler answer http.NotFound for "/" → "GET / = 404"; make the loop
// return the unchanged cfg on the first tick → "the box did not come up configured".
func TestWaitForConfig_ServesWaitingPageThenStarts(t *testing.T) {
dir := t.TempDir()
_, cfgPath := writeBootstrap(t, dir, goodBootstrapV2)
var calls atomic.Int32
pull := func(_, _, _ string) (string, error) {
if calls.Add(1) <= 5 {
return "", errors.New("hub answered 503") // permanent: no in-call retry, the loop retries
}
return hubYAML, nil
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := "http://" + ln.Addr().String()
// The first ingestion failed: the caller holds an unconfigured config.
start := MaybeIngest(cfgPath, config.Default(), testLogger(), pull)
if !Unconfigured(start) {
t.Fatal("setup: the first ingestion should have failed")
}
done := make(chan *config.Config, 1)
go func() {
done <- WaitForConfig(context.Background(), ln, cfgPath, start, testLogger(), pull, 150*time.Millisecond, "0.308.0")
}()
get := func(path string) (int, string) {
t.Helper()
resp, err := http.Get(addr + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
code, body := get("/")
if code != http.StatusOK {
t.Fatalf("GET / = %d — an unconfigured box must show the waiting page, not a 404", code)
}
for _, want := range []string{`lang="hu"`, "nem érte el a központot", `lang="en"`, "has not reached the central service yet", "support@felhom.eu"} {
if !strings.Contains(body, want) {
t.Errorf("the waiting page does not carry %q", want)
}
}
if strings.Contains(strings.ToLower(body), "varázsló") || strings.Contains(body, "8081") {
t.Error("the waiting page still points at the setup wizard")
}
if code, _ := get("/backups/restore"); code != http.StatusOK {
t.Errorf("GET /backups/restore = %d — every path shows the waiting page", code)
}
if code, body := get("/api/health"); code != http.StatusOK || !strings.Contains(body, `"waiting_for_hub":true`) {
t.Errorf("health while waiting: %d %s", code, body)
}
select {
case got := <-done:
if got == nil || Unconfigured(got) || got.Customer.ID != "cust-8200" {
t.Fatalf("the box did not come up configured: %+v", got)
}
case <-time.After(10 * time.Second):
t.Fatal("WaitForConfig never returned although the hub answered")
}
if _, err := http.Get(addr + "/"); err == nil {
t.Error("the waiting page is still served after the box was configured")
}
}
// A stop signal while waiting returns nil (the process exits); it never pretends to be configured.
func TestWaitForConfig_StopReturnsNil(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if got := WaitForConfig(ctx, nil, "", config.Default(), testLogger(), nil, time.Hour, "x"); got != nil {
t.Fatalf("a stopped wait returned a config: %+v", got)
}
}
+1 -3
View File
@@ -164,12 +164,11 @@ type PathsConfig struct {
StacksDir string `yaml:"stacks_dir"` StacksDir string `yaml:"stacks_dir"`
DataDir string `yaml:"data_dir"` DataDir string `yaml:"data_dir"`
SystemDataPath string `yaml:"system_data_path"` SystemDataPath string `yaml:"system_data_path"`
HDDPath string `yaml:"hdd_path"`
} }
type WebConfig struct { type WebConfig struct {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode) SetupListen string `yaml:"setup_listen"` // UNUSED since v0.308.0 (R-554: the setup wizard is deleted); kept so an existing controller.yaml still loads
PasswordHash string `yaml:"password_hash"` PasswordHash string `yaml:"password_hash"`
SessionSecret string `yaml:"session_secret"` SessionSecret string `yaml:"session_secret"`
@@ -450,7 +449,6 @@ func applyEnvOverrides(cfg *Config) {
envStr("FELHOM_WEB_LISTEN", &cfg.Web.Listen) envStr("FELHOM_WEB_LISTEN", &cfg.Web.Listen)
envStr("FELHOM_WEB_PASSWORD_HASH", &cfg.Web.PasswordHash) envStr("FELHOM_WEB_PASSWORD_HASH", &cfg.Web.PasswordHash)
envStr("FELHOM_PATHS_STACKS_DIR", &cfg.Paths.StacksDir) envStr("FELHOM_PATHS_STACKS_DIR", &cfg.Paths.StacksDir)
envStr("FELHOM_PATHS_HDD_PATH", &cfg.Paths.HDDPath)
envStr("FELHOM_LOGGING_LEVEL", &cfg.Logging.Level) envStr("FELHOM_LOGGING_LEVEL", &cfg.Logging.Level)
envStr("FELHOM_MONITORING_SYSTEM_HEALTH_INTERVAL", &cfg.Monitoring.SystemHealthInterval) envStr("FELHOM_MONITORING_SYSTEM_HEALTH_INTERVAL", &cfg.Monitoring.SystemHealthInterval)
} }
@@ -0,0 +1,27 @@
package config
import (
"path/filepath"
"testing"
)
// R-492 — the global paths.hdd_path was deleted (it was empty on every box and every reader already
// fell back to the storage registry). The consequence that matters is the OLD box: a controller.yaml
// written before the deletion that still carries `hdd_path` (empty or not) must keep loading, with
// every other field intact — a config that fails to parse is a controller that does not start.
// RED-PROOF (REPORT): switch loadAndParse to a strict decoder (yaml KnownFields) and this fails.
func TestR492_OldConfigWithHDDPathStillLoads(t *testing.T) {
for _, v := range []string{`""`, `/mnt/legacy_hdd`} {
y := minimalYAML(bcryptHash) + "paths:\n stacks_dir: /opt/stacks\n hdd_path: " + v + "\n"
cfg, err := LoadFromBytes([]byte(y))
if err != nil {
t.Fatalf("hdd_path=%s: an old config no longer loads: %v", v, err)
}
if cfg.Paths.StacksDir != filepath.FromSlash("/opt/stacks") && cfg.Paths.StacksDir != "/opt/stacks" {
t.Errorf("hdd_path=%s: neighbouring field lost: stacks_dir=%q", v, cfg.Paths.StacksDir)
}
if cfg.Customer.ID != "demo" {
t.Errorf("hdd_path=%s: customer.id lost: %q", v, cfg.Customer.ID)
}
}
}
+153
View File
@@ -0,0 +1,153 @@
// Package crashboot decides how long the controller's app mails wait after the controller starts
// (R-856, `09` §3 decision 143, extending decision 129).
//
// After a NORMAL start the dead-app check waits NormalGrace (90 s) — apps legitimately take 30–60 s
// to come up. After a CRASH boot of the host (a kernel crash, a power cut or a hard reset — the crash
// guard cannot tell them apart, `11` §5.9) the apps come up slower and the hub already tells the
// household „restarted after an unexpected stop"; the 2026-10-04 crash-guard test on demo-hp then
// mailed `app_start_failed` and `app_stopped_unhealthy` 3.5 and 9 minutes after the boot, for apps
// that were still coming up. So after a crash boot the wait is CrashGrace (about 15 minutes).
//
// THE FACT comes from the host's crash guard (`felhom-crash-guard`, state.json `last_boot_unclean` +
// `last_boot_at`), through the agent's local API. The guest cannot see the host's state file, so it
// is a Probe seam.
//
// UNKNOWN IS A NORMAL BOOT. An agent that predates the route (404), an unreachable agent, a box with
// no crash guard, an unparseable time: all keep today's 90 s. A longer silence must never come from
// a guess — it would hide a real outage for 15 minutes on every box whose fact we cannot read.
package crashboot
import (
"context"
"log"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
const (
// NormalGrace is the dead-app boot grace after an ordinary start (the pre-R-856 deadAppBootGrace).
NormalGrace = 90 * time.Second
// CrashGrace is the wait after a crash boot — „about 15 minutes" (decision 143). The 2026-10-04
// mails came at 3.5 and 9 minutes; 15 covers both with room.
CrashGrace = 15 * time.Minute
// BootWindow bounds how recent the host's unclean boot must be for THIS controller start to be the
// one that followed it. A controller restarted days after a crash (a self-update, a kill) is a
// normal start; a guest starts its controller well within this window after a host boot.
BootWindow = 30 * time.Minute
probeTimeout = 5 * time.Second
)
// Fact is what the host's crash guard says about its most recent boot.
type Fact struct {
Known bool // false: no crash guard, no state, or an agent that cannot say
Unclean bool // the most recent host boot followed an unclean stop
BootAt time.Time // when that boot happened
}
// Probe asks the host for the fact. An error means unknown.
type Probe func(ctx context.Context) (Fact, error)
// Grace answers "must the app mails still wait?" for one controller run.
type Grace struct {
start time.Time
probe Probe
logger *log.Logger
mu sync.Mutex
resolved bool
crashBoot bool
}
// New builds the grace for a controller that started at start. probe may be nil (no agent): unknown.
func New(start time.Time, probe Probe, logger *log.Logger) *Grace {
return &Grace{start: start, probe: probe, logger: logger}
}
// Within reports whether now is still inside the boot grace, i.e. the app mails must wait.
//
// The fact is asked for while the normal grace runs (the agent may come up a few seconds after the
// controller) and once more at its end; whatever is known then decides, and unknown resolves to a
// normal boot. Once resolved it is never asked again.
func (g *Grace) Within(now time.Time) bool {
elapsed := now.Sub(g.start)
return elapsed < g.duration(elapsed)
}
// Duration is the grace this run uses (resolving it if it can). For logs and tests.
func (g *Grace) Duration(now time.Time) time.Duration {
return g.duration(now.Sub(g.start))
}
func (g *Grace) duration(elapsed time.Duration) time.Duration {
g.mu.Lock()
defer g.mu.Unlock()
if !g.resolved {
g.tryResolve(elapsed >= NormalGrace)
}
if g.crashBoot {
return CrashGrace
}
return NormalGrace
}
// tryResolve asks the probe once. final: the normal grace is over, so an unknown answer is final too.
func (g *Grace) tryResolve(final bool) {
if g.probe == nil {
g.resolve(false, "no agent to ask — a normal boot")
return
}
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
f, err := g.probe(ctx)
cancel()
switch {
case err != nil || !f.Known:
if final {
why := "the host's crash guard has no record"
if err != nil {
why = "the host's crash guard could not be read (" + err.Error() + ")"
}
g.resolve(false, why+" — treated as a normal boot")
}
case !f.Unclean:
g.resolve(false, "the host's last boot was clean")
case f.BootAt.IsZero() || g.start.Sub(f.BootAt) > BootWindow:
g.resolve(false, "the host's last unclean boot ("+f.BootAt.UTC().Format(time.RFC3339)+") is not the one this start followed")
default:
g.crashBoot = true
g.resolve(true, "the host's last boot ("+f.BootAt.UTC().Format(time.RFC3339)+") followed an UNCLEAN stop")
}
}
func (g *Grace) resolve(crash bool, why string) {
g.resolved = true
if g.logger == nil {
return
}
d := NormalGrace
if crash {
d = CrashGrace
}
g.logger.Printf("[INFO] [deadapp] boot grace %s: %s (R-856)", d, why)
}
// AgentProbe adapts the agent's GET /host/crash-guard (agentapi.Client.CrashGuard) to a Probe. A
// state the host has not written (Present=false) is unknown; an unparseable boot time is a known
// unclean boot with no time, which New's window check reads as NOT this start's — a normal boot.
func AgentProbe(get func(ctx context.Context) (agentapi.CrashGuardState, error)) Probe {
return func(ctx context.Context) (Fact, error) {
st, err := get(ctx)
if err != nil {
return Fact{}, err
}
if !st.Present {
return Fact{}, nil
}
f := Fact{Known: true, Unclean: st.LastBootUnclean}
if t, perr := time.Parse(time.RFC3339, st.LastBootAt); perr == nil {
f.BootAt = t
}
return f, nil
}
}
@@ -0,0 +1,106 @@
package crashboot
import (
"bytes"
"context"
"errors"
"log"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-856 (`09` §3 decision 143): after a CRASH boot the app mails wait about 15 minutes; a normal boot
// keeps 90 s; unknown is a normal boot.
//
// COMPANION RED-PROOF: in tryResolve's default branch, leave g.crashBoot false — the crash-boot test
// then reads Within(start+10m) = false (mails would go out at 10 minutes), and fails.
var start = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
func fixed(f Fact, err error) Probe {
return func(context.Context) (Fact, error) { return f, err }
}
// The 2026-10-04 shape: the host crashed, came back, and the controller started ~1 minute later. The
// mails came at 3.5 and 9 minutes after the boot — both must now be inside the grace.
func TestR856_CrashBootHoldsTheMailsForTheLongGrace(t *testing.T) {
var logs bytes.Buffer
g := New(start, fixed(Fact{Known: true, Unclean: true, BootAt: start.Add(-time.Minute)}, nil), log.New(&logs, "", 0))
for _, at := range []time.Duration{30 * time.Second, 100 * time.Second, 210 * time.Second, 9 * time.Minute, 14 * time.Minute} {
if !g.Within(start.Add(at)) {
t.Errorf("crash boot: at +%s the mails must still wait (grace %s)", at, g.Duration(start.Add(at)))
}
}
if g.Within(start.Add(CrashGrace + time.Second)) {
t.Error("crash boot: after the long grace the mails must flow again")
}
if !strings.Contains(logs.String(), "boot grace 15m0s") || !strings.Contains(logs.String(), "UNCLEAN") {
t.Errorf("the decision must be logged once, positively; log:\n%s", logs.String())
}
}
func TestR856_NormalBootKeeps90s(t *testing.T) {
for name, p := range map[string]Probe{
"clean boot": fixed(Fact{Known: true, Unclean: false, BootAt: start.Add(-time.Minute)}, nil),
"old unclean boot": fixed(Fact{Known: true, Unclean: true, BootAt: start.Add(-48 * time.Hour)}, nil),
"unclean, no time": fixed(Fact{Known: true, Unclean: true}, nil),
"agent predates it": fixed(Fact{}, errors.New("agentapi: GET /host/crash-guard: HTTP 404")),
"no crash guard": fixed(Fact{Known: false}, nil),
"no agent (nil)": nil,
} {
g := New(start, p, nil)
if !g.Within(start.Add(30 * time.Second)) {
t.Errorf("%s: inside the normal grace the mails wait", name)
}
if g.Within(start.Add(NormalGrace + time.Second)) {
t.Errorf("%s: a normal or unknown boot must keep today's 90 s grace, got %s", name, g.Duration(start.Add(NormalGrace+time.Second)))
}
}
}
// The agent comes up a few seconds after the controller: an error early in the normal grace is not
// final; the fact read later in it still decides.
func TestR856_FactReadLateInTheNormalGraceStillCounts(t *testing.T) {
calls := 0
g := New(start, func(context.Context) (Fact, error) {
calls++
if calls == 1 {
return Fact{}, errors.New("connection refused")
}
return Fact{Known: true, Unclean: true, BootAt: start.Add(-2 * time.Minute)}, nil
}, nil)
g.Within(start.Add(30 * time.Second))
if !g.Within(start.Add(5 * time.Minute)) {
t.Fatal("a crash boot learned on the second ask must still hold the mails")
}
g.Within(start.Add(6 * time.Minute))
if calls != 2 {
t.Errorf("once resolved the fact is never asked again, asked %d times", calls)
}
}
// The agent's answer, end to end through the adapter: the state.json shape the crash guard writes.
func TestR856_AgentProbeReadsTheCrashGuardState(t *testing.T) {
boot := start.Add(-time.Minute).Format("2006-01-02T15:04:05Z")
g := New(start, AgentProbe(func(context.Context) (agentapi.CrashGuardState, error) {
return agentapi.CrashGuardState{Present: true, LastBootAt: boot, LastBootUnclean: true}, nil
}), nil)
if !g.Within(start.Add(10 * time.Minute)) {
t.Error("an unclean boot read through the agent must hold the mails")
}
g = New(start, AgentProbe(func(context.Context) (agentapi.CrashGuardState, error) {
return agentapi.CrashGuardState{Present: false, LastBootUnclean: true}, nil
}), nil)
if g.Within(start.Add(NormalGrace + time.Second)) {
t.Error("a host with no crash-guard state is unknown — a normal boot")
}
g = New(start, AgentProbe(func(context.Context) (agentapi.CrashGuardState, error) {
return agentapi.CrashGuardState{}, &agentapi.StatusError{Path: "/host/crash-guard", Code: 404}
}), nil)
if g.Within(start.Add(NormalGrace + time.Second)) {
t.Error("an agent that predates the route (404) is unknown — a normal boot")
}
}
@@ -75,6 +75,12 @@ func TestR650_NoBareDockerExec(t *testing.T) {
n := 0 n := 0
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error { err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
// Another package's test, running in parallel, may create and rename a scratch file (for example
// internal/stacks/update-journal.json.tmp) between the directory read and the lstat. A file that vanished
// is not a Go source file; skip it. Seen 2026-10-08 in a full `go test ./...`.
if os.IsNotExist(err) && !strings.HasSuffix(p, ".go") {
return nil
}
return err return err
} }
if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
+61 -27
View File
@@ -27,6 +27,8 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
) )
// ── Thresholds ─────────────────────────────────────────────────────────────────────────────────── // ── Thresholds ───────────────────────────────────────────────────────────────────────────────────
@@ -120,6 +122,11 @@ type Usage struct {
type Target struct { type Target struct {
Path string Path string
Label string Label string
// LabelKey, when set, names the target in the household's language from the bundle (the two
// built-in areas: „Alkalmazások területe", „Rendszer- és mentési terület"). Label stays the
// Hungarian name for the log and the event details. A household's own drive label has no key —
// the household named it, in whatever language it wished.
LabelKey string
} }
// Event is one crossing, handed to the notify seam. // Event is one crossing, handed to the notify seam.
@@ -127,7 +134,10 @@ type Event struct {
Target Target Target Target
Band Band Band Band
Usage Usage Usage Usage
Message string // the Hungarian customer text, already rendered Message string // the Hungarian customer text, already rendered (the wire's `message`)
// MessageHousehold is the same sentence in the household's language (the wire's
// `message_customer`, R-558); "" for a Hungarian household, which sends nothing extra.
MessageHousehold string
} }
// Watcher holds the persisted per-filesystem band and emits on escalation only. // Watcher holds the persisted per-filesystem band and emits on escalation only.
@@ -142,9 +152,12 @@ type Watcher struct {
usage func(path string) *Usage usage func(path string) *Usage
// notify pushes the customer event. Nil-safe. // notify pushes the customer event. Nil-safe.
notify func(Event) notify func(Event)
// extra appends a sentence to the warning for one target — the kept folders on it the household // extra appends a sentence to the warning for one target, in a language — the kept folders on it
// can delete (`09` §3 decision 36). Nil-safe; "" appends nothing. // the household can delete (`09` §3 decision 36). Nil-safe; "" appends nothing.
extra func(Target) string extra func(t Target, lang string) string
// lang returns the household's language (R-521 F7: the warning reaches an English household in
// English). Nil-safe: nil means Hungarian only.
lang func() string
mu sync.Mutex mu sync.Mutex
bands map[string]Band bands map[string]Band
@@ -168,7 +181,10 @@ func New(statePath string, logger *log.Logger, targets func() []Target, usage fu
func (w *Watcher) SetNotify(fn func(Event)) { w.notify = fn } func (w *Watcher) SetNotify(fn func(Event)) { w.notify = fn }
// SetExtra wires the kept-data sentence (main.go). INIT-ONLY. // SetExtra wires the kept-data sentence (main.go). INIT-ONLY.
func (w *Watcher) SetExtra(fn func(Target) string) { w.extra = fn } func (w *Watcher) SetExtra(fn func(t Target, lang string) string) { w.extra = fn }
// SetLang wires the household's language (main.go: settings.GetLanguage). INIT-ONLY.
func (w *Watcher) SetLang(fn func() string) { w.lang = fn }
// classify decides the band from a reading AND the previous band, which is what makes the hysteresis // classify decides the band from a reading AND the previous band, which is what makes the hysteresis
// work: between the clear and warn thresholds the previous band is HELD rather than recomputed. // work: between the clear and warn thresholds the previous band is HELD rather than recomputed.
@@ -236,17 +252,18 @@ func (w *Watcher) Check() error {
continue continue
} }
msg := Message(t, *u, next) msg := w.messageWithExtra(i18n.Default, t, *u, next)
if w.extra != nil { household := ""
if x := w.extra(t); x != "" { if w.lang != nil {
msg += " " + x if lang := i18n.Normalize(w.lang()); lang != i18n.Default {
household = w.messageWithExtra(lang, t, *u, next)
} }
} }
w.logger.Printf("[WARN] [fillwatch] %s (%q): %s → %s — %.0f%% used, %.1f GB free of %.1f GB; notifying the customer", w.logger.Printf("[WARN] [fillwatch] %s (%q): %s → %s — %.0f%% used, %.1f GB free of %.1f GB; notifying the customer",
t.Path, t.Label, prev, next, u.UsedPercent, u.AvailGB, u.TotalGB) t.Path, t.Label, prev, next, u.UsedPercent, u.AvailGB, u.TotalGB)
emitted++ emitted++
if w.notify != nil { if w.notify != nil {
w.notify(Event{Target: t, Band: next, Usage: *u, Message: msg}) w.notify(Event{Target: t, Band: next, Usage: *u, Message: msg, MessageHousehold: household})
} }
} }
@@ -273,6 +290,17 @@ func (w *Watcher) Check() error {
return w.saveLocked() return w.saveLocked()
} }
// messageWithExtra is the warning in one language, with the kept-data sentence when there is one.
func (w *Watcher) messageWithExtra(lang string, t Target, u Usage, band Band) string {
msg := MessageLang(lang, t, u, band)
if w.extra != nil {
if x := w.extra(t, lang); x != "" {
msg += " " + x
}
}
return msg
}
// bandSummaryLocked renders the current per-path bands for the run summary, deterministically. Caller // bandSummaryLocked renders the current per-path bands for the run summary, deterministically. Caller
// holds w.mu. // holds w.mu.
func (w *Watcher) bandSummaryLocked() string { func (w *Watcher) bandSummaryLocked() string {
@@ -376,35 +404,41 @@ func (w *Watcher) saveLocked() error {
// these two types: `FormatCustomerEmail` PREFERS the entry over the message, so a static template // these two types: `FormatCustomerEmail` PREFERS the entry over the message, so a static template
// would discard the label and the figures — exactly why `offbox_enlarge_blocked` and // would discard the label and the figures — exactly why `offbox_enlarge_blocked` and
// `disk_health_degraded` have none either. // `disk_health_degraded` have none either.
func Message(t Target, u Usage, band Band) string { func Message(t Target, u Usage, band Band) string { return MessageLang(i18n.Default, t, u, band) }
// MessageLang is Message in a language, from the bundle keys `event.fill.critical` /
// `event.fill.warning` (R-521 F7). The Hungarian is the literal these keys replaced minus its formal
// „Kérjük," opening (the R-516 te-form gate); TestMessageHungarianWireText pins the exact bytes.
func MessageLang(lang string, t Target, u Usage, band Band) string {
b, err := i18n.Shared()
if err != nil {
// The bundle is embedded: this is a broken build. Say it in the log and send the figures bare
// rather than nothing — an event with a visible fault reaches the operator and gets fixed.
return fmt.Sprintf("%s: %s, %s (%v)", t.Path, gbIn(lang, u.AvailGB), percent(u.UsedPercent), err)
}
name := t.Label name := t.Label
if t.LabelKey != "" && b.Has(i18n.Normalize(lang), t.LabelKey) {
name = b.Msg(lang, t.LabelKey)
}
if name == "" { if name == "" {
name = t.Path name = t.Path
} }
if band == BandCritical { if band == BandCritical {
return fmt.Sprintf( return b.Msgf(lang, "event.fill.critical", name, gbIn(lang, u.AvailGB), percent(u.UsedPercent))
"A(z) „%s” tároló kritikusan megtelt: %s szabad hely maradt (%s foglalt). "+
"A biztonsági mentések és az alkalmazások írásai bármikor meghiúsulhatnak. "+
"Kérjük, mielőbb szabadíts fel helyet: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót.",
name, hunGB(u.AvailGB), hunPercent(u.UsedPercent))
} }
return fmt.Sprintf( return b.Msgf(lang, "event.fill.warning", name, percent(u.UsedPercent), gbIn(lang, u.AvailGB))
"A(z) „%s” tároló %s foglalt — %s szabad hely maradt. "+
"Kérjük, szabadíts fel helyet, mielőtt megtelik: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót. "+
"Ha megtelik, a biztonsági mentések meghiúsulnak.",
name, hunPercent(u.UsedPercent), hunGB(u.AvailGB))
} }
// hunGB formats a GB figure the Hungarian way — decimal COMMA, one decimal place. A "4.2 GB" in a // gbIn formats a GB figure with lang's decimal separator, one decimal place — a "4.2 GB" in a
// Hungarian sentence reads as a typo to the customer. // Hungarian sentence reads as a typo to the customer, and "4,2 GB" in an English one.
func hunGB(gb float64) string { func gbIn(lang string, gb float64) string {
if gb >= 1024 { if gb >= 1024 {
return strings.Replace(fmt.Sprintf("%.1f TB", gb/1024), ".", ",", 1) return i18n.FormatDecimal(lang, gb/1024, 1) + " TB"
} }
return strings.Replace(fmt.Sprintf("%.1f GB", gb), ".", ",", 1) return i18n.FormatDecimal(lang, gb, 1) + " GB"
} }
func hunPercent(p float64) string { return fmt.Sprintf("%.0f%%", p) } func percent(p float64) string { return fmt.Sprintf("%.0f%%", p) }
// SortTargets gives Check a deterministic order, so a multi-filesystem crossing produces its events // SortTargets gives Check a deterministic order, so a multi-filesystem crossing produces its events
// in a stable sequence (tests, and a readable log). // in a stable sequence (tests, and a readable log).
@@ -273,7 +273,7 @@ func TestCustomerMessageNamesTheDriveTheSpaceAndTheAction(t *testing.T) {
if !strings.Contains(msg, "87%") { if !strings.Contains(msg, "87%") {
t.Fatalf("the message does not give the fill percentage. Got: %s", msg) t.Fatalf("the message does not give the fill percentage. Got: %s", msg)
} }
if !strings.Contains(msg, "szabadíts fel helyet") && !strings.Contains(msg, "szabadíts fel helyet:") { if !strings.Contains(strings.ToLower(msg), "szabadíts fel helyet") { // v0.309.0: the sentence may open with it (R-516 dropped „Kérjük,")
t.Fatalf("the message does not tell the customer WHAT TO DO. Got: %s", msg) t.Fatalf("the message does not tell the customer WHAT TO DO. Got: %s", msg)
} }
// Design-system rule: no emoji in customer copy. // Design-system rule: no emoji in customer copy.
@@ -15,7 +15,7 @@ func TestFillwatch_ExtraSentenceRidesTheWarning(t *testing.T) {
func() []Target { return []Target{{Path: "/mnt/d", Label: "HDD"}} }, func() []Target { return []Target{{Path: "/mnt/d", Label: "HDD"}} },
func(string) *Usage { return &Usage{UsedPercent: 97, AvailGB: 3, UsedGB: 97, TotalGB: 100} }) func(string) *Usage { return &Usage{UsedPercent: 97, AvailGB: 3, UsedGB: 97, TotalGB: 100} })
w.SetNotify(func(e Event) { got = append(got, e) }) w.SetNotify(func(e Event) { got = append(got, e) })
w.SetExtra(func(tg Target) string { w.SetExtra(func(tg Target, _ string) string {
if tg.Path == "/mnt/d" { if tg.Path == "/mnt/d" {
return "KEPT: Nextcloud (126.0 MB)." return "KEPT: Nextcloud (126.0 MB)."
} }
@@ -0,0 +1,76 @@
package fillwatch
import (
"io"
"log"
"path/filepath"
"strings"
"testing"
)
// R-521 F7 — the fill warning moved from a Go literal to the bundle (`event.fill.*`) so an English
// household is told in English. The Hungarian wire text is pinned to the byte: it is the pre-bundle
// literal (this test ran green against 68294ec's fillwatch.go with these strings carrying „Kérjük, ")
// minus that one formal opening, which the R-516 te-form gate refuses in the bundle.
func TestMessageHungarianWireText(t *testing.T) {
cases := []struct {
name string
got string
want string
}{
{"warning", Message(photos, Usage{UsedPercent: 87, AvailGB: 4.2, TotalGB: 100}, BandWarning),
"A(z) „Fotók” tároló 87% foglalt — 4,2 GB szabad hely maradt. Szabadíts fel helyet, mielőtt megtelik: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót. Ha megtelik, a biztonsági mentések meghiúsulnak."},
{"critical", Message(photos, Usage{UsedPercent: 96, AvailGB: 1.4, TotalGB: 100}, BandCritical),
"A(z) „Fotók” tároló kritikusan megtelt: 1,4 GB szabad hely maradt (96% foglalt). A biztonsági mentések és az alkalmazások írásai bármikor meghiúsulhatnak. Szabadíts fel helyet mielőbb: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót."},
{"terabytes", Message(Target{Path: "/mnt/big", Label: "Média"}, Usage{UsedPercent: 88, AvailGB: 2048, TotalGB: 16000}, BandWarning),
"A(z) „Média” tároló 88% foglalt — 2,0 TB szabad hely maradt. Szabadíts fel helyet, mielőtt megtelik: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót. Ha megtelik, a biztonsági mentések meghiúsulnak."},
{"no label", Message(Target{Path: "/mnt/sys_drive"}, Usage{UsedPercent: 90, AvailGB: 1.5}, BandWarning),
"A(z) „/mnt/sys_drive” tároló 90% foglalt — 1,5 GB szabad hely maradt. Szabadíts fel helyet, mielőtt megtelik: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót. Ha megtelik, a biztonsági mentések meghiúsulnak."},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s: Hungarian changed.\n got: %q\n want: %q", c.name, c.got, c.want)
}
}
}
// The household half: an English household gets the same warning in English, the built-in area named
// in English, the figure with a decimal point; a Hungarian household sends nothing extra.
func TestR521F7_HouseholdLanguageRidesTheEvent(t *testing.T) {
root := Target{Path: "/", Label: "Alkalmazások területe", LabelKey: "event.fill.label_apps"}
for _, lang := range []string{"en", "hu"} {
t.Run(lang, func(t *testing.T) {
var got []Event
w := New(filepath.Join(t.TempDir(), "s.json"), log.New(io.Discard, "", 0),
func() []Target { return []Target{root} },
func(string) *Usage { return &Usage{UsedPercent: 95, AvailGB: 3.5, UsedGB: 65, TotalGB: 68.7} })
w.SetNotify(func(e Event) { got = append(got, e) })
w.SetLang(func() string { return lang })
w.SetExtra(func(_ Target, l string) string { return "EXTRA-" + l })
if err := w.Check(); err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("events = %d, want 1", len(got))
}
e := got[0]
if !strings.Contains(e.Message, "Alkalmaz") || !strings.Contains(e.Message, "3,5 GB") || !strings.HasSuffix(e.Message, " EXTRA-hu") {
t.Errorf("Hungarian message = %q", e.Message)
}
if lang == "hu" {
if e.MessageHousehold != "" {
t.Errorf("a Hungarian household sends nothing extra; got %q", e.MessageHousehold)
}
return
}
for _, want := range []string{"App area", "critically full", "3.5 GB", "95%", " EXTRA-en"} {
if !strings.Contains(e.MessageHousehold, want) {
t.Errorf("English message %q lacks %q", e.MessageHousehold, want)
}
}
if strings.Contains(e.MessageHousehold, "Alkalmaz") {
t.Errorf("English message carries the Hungarian area name: %q", e.MessageHousehold)
}
})
}
}
+86
View File
@@ -0,0 +1,86 @@
package i18n
import (
"strconv"
"strings"
"time"
)
// ── Dates and numbers per language (R-562, v0.308.0, `09` §3 decision 223) ────────────────────────
//
// The ONE place the dashboard decides how a date, a time or a decimal number looks:
//
// Hungarian 2026. 10. 10. 15:04 1,5 GB (the Hungarian date order with its dots; decimal COMMA)
// English 2026-10-10 15:04 1.5 GB (ISO-like; decimal point)
//
// Every function takes the time ALREADY in the zone it is shown in (the dashboard shows Budapest
// local time; the callers convert). An unknown language formats as Hungarian, like every other
// fallback in this package. Pinned by TestFormat_* in format_test.go.
// FormatDateTime renders a date with hour and minute.
func FormatDateTime(lang string, t time.Time) string {
if Normalize(lang) == "en" {
return t.Format("2006-01-02 15:04")
}
return t.Format("2006. 01. 02. 15:04")
}
// FormatDate renders a date alone.
func FormatDate(lang string, t time.Time) string {
if Normalize(lang) == "en" {
return t.Format("2006-01-02")
}
return t.Format("2006. 01. 02.")
}
// FormatMonthDayTime renders a date in the current year: month, day, hour and minute.
func FormatMonthDayTime(lang string, t time.Time) string {
if Normalize(lang) == "en" {
return t.Format("01-02 15:04")
}
return t.Format("01. 02. 15:04")
}
// FormatDecimal renders v with prec digits after the decimal separator of lang.
func FormatDecimal(lang string, v float64, prec int) string {
s := strconv.FormatFloat(v, 'f', prec, 64)
if Normalize(lang) == "en" {
return s
}
return strings.Replace(s, ".", ",", 1)
}
// LocalizeDecimals rewrites the decimal POINT of every number in an already formatted short string
// ("1.5 GB", "Immich (95.0 GB)") into lang's separator. Only a point with a digit on BOTH sides is
// touched, so a sentence's full stop, a version ("0.307.0" — see below) or an address is not a target
// by accident: a dot preceded by a digit and followed by a digit is rewritten only when the number it
// belongs to has exactly ONE dot. English returns s unchanged.
//
// For strings another package formatted with %.1f (appbackup.HumanizeBytes and its siblings) and
// handed to a page as data. A new formatter should call FormatDecimal instead.
func LocalizeDecimals(lang, s string) string {
if Normalize(lang) == "en" || !strings.Contains(s, ".") {
return s
}
b := []byte(s)
isDigit := func(c byte) bool { return c >= '0' && c <= '9' }
for i := 1; i < len(b)-1; i++ {
if b[i] != '.' || !isDigit(b[i-1]) || !isDigit(b[i+1]) {
continue
}
// The whole digit-and-dot run around this dot: more than one dot is a version or an address.
start, end := i, i
for start > 0 && (isDigit(b[start-1]) || b[start-1] == '.') {
start--
}
for end < len(b)-1 && (isDigit(b[end+1]) || b[end+1] == '.') {
end++
}
if strings.Count(string(b[start:end+1]), ".") != 1 {
i = end
continue
}
b[i] = ','
}
return string(b)
}
+44
View File
@@ -0,0 +1,44 @@
package i18n
import (
"testing"
"time"
)
// R-562 (`09` §3 decision 223): Hungarian dates and numbers in Hungarian, ISO-like in English.
func TestFormat_DatesPerLanguage(t *testing.T) {
at := time.Date(2026, 10, 10, 15, 4, 0, 0, time.UTC)
cases := []struct{ name, got, want string }{
{"hu datetime", FormatDateTime("hu", at), "2026. 10. 10. 15:04"},
{"en datetime", FormatDateTime("en", at), "2026-10-10 15:04"},
{"hu date", FormatDate("hu", at), "2026. 10. 10."},
{"en date", FormatDate("en", at), "2026-10-10"},
{"hu month-day", FormatMonthDayTime("hu", at), "10. 10. 15:04"},
{"en month-day", FormatMonthDayTime("en", at), "10-10 15:04"},
{"unknown language falls back to Hungarian", FormatDateTime("xx", at), "2026. 10. 10. 15:04"},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s: got %q, want %q", c.name, c.got, c.want)
}
}
}
func TestFormat_NumbersPerLanguage(t *testing.T) {
cases := []struct{ name, got, want string }{
{"hu decimal comma", FormatDecimal("hu", 1.53, 1), "1,5"},
{"en decimal point", FormatDecimal("en", 1.53, 1), "1.5"},
{"hu integer has no separator", FormatDecimal("hu", 12, 0), "12"},
{"hu size string", LocalizeDecimals("hu", "1.5 GB"), "1,5 GB"},
{"hu list of sizes", LocalizeDecimals("hu", "Immich (95.0 GB), Nextcloud (34.0 GB)"), "Immich (95,0 GB), Nextcloud (34,0 GB)"},
{"en untouched", LocalizeDecimals("en", "1.5 GB"), "1.5 GB"},
{"hu sentence full stop untouched", LocalizeDecimals("hu", "Kész. 3 elem."), "Kész. 3 elem."},
{"hu version untouched", LocalizeDecimals("hu", "v0.307.0 és 1.5 GB"), "v0.307.0 és 1,5 GB"},
{"hu address untouched", LocalizeDecimals("hu", "192.168.0.180"), "192.168.0.180"},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s: got %q, want %q", c.name, c.got, c.want)
}
}
}
+118 -18
View File
@@ -13,6 +13,7 @@
"alert.deadapp.single": "An installed app is not running: %s", "alert.deadapp.single": "An installed app is not running: %s",
"alert.deadapp.single_state": "An installed app is not running: %s (%s)", "alert.deadapp.single_state": "An installed app is not running: %s (%s)",
"alert.endpoint_drift": "The storage agent's address in the settings is out of date.", "alert.endpoint_drift": "The storage agent's address in the settings is out of date.",
"alert.local_api_token_drift": "The access key of the storage agent in the settings is out of date.",
"alert.hub.disabled": "The hub connection is off — central monitoring is not running", "alert.hub.disabled": "The hub connection is off — central monitoring is not running",
"alert.hub.unreachable": "The hub cannot be reached — last error: %s", "alert.hub.unreachable": "The hub cannot be reached — last error: %s",
"alert.link.monitoring": "System monitor", "alert.link.monitoring": "System monitor",
@@ -74,6 +75,7 @@
"app_export.titkositasi_jelszo": "Encryption password", "app_export.titkositasi_jelszo": "Encryption password",
"app_export.valassz_tarolot": "Choose a storage...", "app_export.valassz_tarolot": "Choose a storage...",
"app_export.valaszthato_tartalom": "Optional content:", "app_export.valaszthato_tartalom": "Optional content:",
"app_export.network_drive_needs_password": "A bundle can go onto a network drive (NAS) only with a password, because it also holds the passwords of the app. Set a password, or choose an attached drive.",
"app_import.a_feltoltes_megszakadt_ellenorizze_a": "The upload stopped — check the connection, then try again.", "app_import.a_feltoltes_megszakadt_ellenorizze_a": "The upload stopped — check the connection, then try again.",
"app_import.adatbazis": "Database:", "app_import.adatbazis": "Database:",
"app_import.adatok": "Data:", "app_import.adatok": "Data:",
@@ -155,6 +157,7 @@
"app_info.mentesek": "Backups", "app_info.mentesek": "Backups",
"app_info.mire_hasznalhato": "What is it for?", "app_info.mire_hasznalhato": "What is it for?",
"app_info.naplo": "Log", "app_info.naplo": "Log",
"app_info.hold_log_kept": "The app's own log from before it was stopped was kept — it shows why the new version did not start.",
"app_info.nincs_folyamatban_athelyezes": "No move in progress.", "app_info.nincs_folyamatban_athelyezes": "No move in progress.",
"app_info.regi_adatok_torlese": "Deleting old data", "app_info.regi_adatok_torlese": "Deleting old data",
"app_info.telepites": "Install", "app_info.telepites": "Install",
@@ -170,6 +173,7 @@
"backup.guest.err.unavailable": "the system backup is not available on this machine", "backup.guest.err.unavailable": "the system backup is not available on this machine",
"backup.removed_app": "Removed app — its backup is here and can be restored", "backup.removed_app": "Removed app — its backup is here and can be restored",
"backup.status.db_failed": "The database backup failed", "backup.status.db_failed": "The database backup failed",
"backup.status.version_skip": "%s is not running the version it is set to, so the night backup leaves it out and your last good backup stays as it is. Get in touch with us",
"backup.status.ok": "App data backup is fine", "backup.status.ok": "App data backup is fine",
"backup.target.absent": "The drive for the system backup cannot be reached — until you reconnect it, the full system backup is not made.", "backup.target.absent": "The drive for the system backup cannot be reached — until you reconnect it, the full system backup is not made.",
"backup.target.degraded": "The system backup is currently on the same disk as the system — so it protects against corrupted files, but not against a disk failure. Attach a second drive for full protection.", "backup.target.degraded": "The system backup is currently on the same disk as the system — so it protects against corrupted files, but not against a disk failure. Attach a second drive for full protection.",
@@ -190,7 +194,7 @@
"backups.a_kijeloles_nem_sikerult": "The drive could not be assigned:", "backups.a_kijeloles_nem_sikerult": "The drive could not be assigned:",
"backups.a_meghajto_kijelolve": "The drive is assigned.", "backups.a_meghajto_kijelolve": "The drive is assigned.",
"backups.a_meghajto_kijelolve_a_beallitas": "The drive is assigned. The setting takes effect after the host agent next restarts.", "backups.a_meghajto_kijelolve_a_beallitas": "The drive is assigned. The setting takes effect after the host agent next restarts.",
"backups.a_mentes_alatt_az_alkalmazasok": "Every app stops for as long as the full system backup takes. That means minutes, not seconds: about 6 minutes on a box with 9 apps, about 8 minutes on a box with 12 — longer with more data, and longer when the off-site copy is made in the same run.", "backups.a_mentes_alatt_az_alkalmazasok": "Every app stops briefly for the backup: about 1–1.5 minutes, longer with more apps. Then the apps run again while the copy finishes. This button makes the local copy; the off-site copy follows with the next nightly backup.",
"backups.a_mentes_sikertelen": "The backup failed.", "backups.a_mentes_sikertelen": "The backup failed.",
"backups.a_mentesek_egymas_utan_futnak": "The backups run one after the other: database backup, local copy, remote backup, then the full system backup.", "backups.a_mentesek_egymas_utan_futnak": "The backups run one after the other: database backup, local copy, remote backup, then the full system backup.",
"backups.a_rendszermentes_elkeszult": "The system backup is done.", "backups.a_rendszermentes_elkeszult": "The system backup is done.",
@@ -204,7 +208,7 @@
"backups.beagyazott_db_k_a_kotetmentesben": "embedded databases, inside the volume backup", "backups.beagyazott_db_k_a_kotetmentesben": "embedded databases, inside the volume backup",
"backups.biztonsagi_mentes": "Backup", "backups.biztonsagi_mentes": "Backup",
"backups.db_mentesek": "Database backups", "backups.db_mentesek": "Database backups",
"backups.elinditod_a_teljes_rendszermentest_most": "Start the full system backup now? Every app stops for as long as the full system backup takes. That means minutes, not seconds: about 6 minutes on a box with 9 apps, about 8 minutes on a box with 12 — longer with more data, and longer when the off-site copy is made in the same run.", "backups.elinditod_a_teljes_rendszermentest_most": "Start the full system backup now? Every app stops briefly for the backup: about 1–1.5 minutes, longer with more apps. Then the apps run again while the copy finishes. This button makes the local copy; the off-site copy follows with the next nightly backup.",
"backups.esedekes": "Due", "backups.esedekes": "Due",
"backups.fajl": "{{$n := len .Backup.DumpFiles}}{{$n}} {{if eq $n 1}}file{{else}}files{{end}}", "backups.fajl": "{{$n := len .Backup.DumpFiles}}{{$n}} {{if eq $n 1}}file{{else}}files{{end}}",
"backups.helyi_masolat": "Local copy: <strong>{{.BackupLegTier2}}</strong>", "backups.helyi_masolat": "Local copy: <strong>{{.BackupLegTier2}}</strong>",
@@ -627,7 +631,7 @@
"common.magyar_felulet": "Hungarian interface", "common.magyar_felulet": "Hungarian interface",
"common.megsem": "Cancel", "common.megsem": "Cancel",
"common.pi_kompatibilis": "Runs on Pi", "common.pi_kompatibilis": "Runs on Pi",
"common.rendszer_gyoker": "System (/)", "common.rendszer_gyoker": "System",
"common.torles": "Delete", "common.torles": "Delete",
"common.ugyfel_azonosito": "Customer ID", "common.ugyfel_azonosito": "Customer ID",
"common.ujrainditas": "Restart", "common.ujrainditas": "Restart",
@@ -902,6 +906,8 @@
"dashboard.telepites": "Install", "dashboard.telepites": "Install",
"dashboard.telepitett_alkalmazasok": "Installed apps", "dashboard.telepitett_alkalmazasok": "Installed apps",
"dashboard.url_nem_elerheto": "URL not reachable", "dashboard.url_nem_elerheto": "URL not reachable",
"dashboard.tunnel_offline": "Not reachable from outside",
"dashboard.tunnel_offline_title": "The secure internet connection is not connected now, so your apps cannot be reached from outside. Check the internet connection.",
"dashboard.utolso_mentes": "Last backup:", "dashboard.utolso_mentes": "Last backup:",
"dashboard.vedett": "Protected", "dashboard.vedett": "Protected",
"dashboard.vezerlopult": "Dashboard", "dashboard.vezerlopult": "Dashboard",
@@ -922,7 +928,6 @@
"debug.bejegyzes": "entries", "debug.bejegyzes": "entries",
"debug.betoltes": "Loading...", "debug.betoltes": "Loading...",
"debug.cimke": "Label", "debug.cimke": "Label",
"debug.controller_ujraindulasa": "Controller restarting...",
"debug.cpu_atlag": "CPU (avg)", "debug.cpu_atlag": "CPU (avg)",
"debug.csak_cross_drive": "Cross-drive only", "debug.csak_cross_drive": "Cross-drive only",
"debug.csak_db_dump": "DB dump only", "debug.csak_db_dump": "DB dump only",
@@ -932,7 +937,6 @@
"debug.db": "items", "debug.db": "items",
"debug.debug_mod": "Debug mode", "debug.debug_mod": "Debug mode",
"debug.debug_mod_aktiv_ez_az": "Debug mode on — this page is only for development and troubleshooting", "debug.debug_mod_aktiv_ez_az": "Debug mode on — this page is only for development and troubleshooting",
"debug.dr_telepito_varazslo": "DR / Setup wizard",
"debug.dry_run_frissites": "Dry-run update", "debug.dry_run_frissites": "Dry-run update",
"debug.elavult_temp_fajlok": "Stale temp files", "debug.elavult_temp_fajlok": "Stale temp files",
"debug.eleres": "Path", "debug.eleres": "Path",
@@ -941,8 +945,6 @@
"debug.ertesites_teszt": "Notification test", "debug.ertesites_teszt": "Notification test",
"debug.esemeny_tipus": "Event type:", "debug.esemeny_tipus": "Event type:",
"debug.export_konyvtarak": "Export folders", "debug.export_konyvtarak": "Export folders",
"debug.ez_torli_az_ugyfel_azonositot": "This deletes the customer ID and restarts the controller in setup mode. The configuration files stay on the drives.",
"debug.figyelem_ez_ujrainditja_a_controllert": "WARNING! This restarts the controller in setup mode. Continue?",
"debug.figyelmeztetesek": "Warnings", "debug.figyelmeztetesek": "Warnings",
"debug.folyamatban": "In progress...", "debug.folyamatban": "In progress...",
"debug.frissites": "Refresh", "debug.frissites": "Refresh",
@@ -966,8 +968,6 @@
"debug.igen": "Yes", "debug.igen": "Yes",
"debug.igen_2": "yes", "debug.igen_2": "yes",
"debug.inaktiv": "Inactive", "debug.inaktiv": "Inactive",
"debug.irja_be_reset": "Type: RESET",
"debug.irja_be_reset_a_megerositeshez": "Type \"RESET\" to confirm:",
"debug.jelenlegi": "Current:", "debug.jelenlegi": "Current:",
"debug.katalogus_limit": "Catalog limit", "debug.katalogus_limit": "Catalog limit",
"debug.kattintson_a_gombra_a_telemetria": "Click the button to run telemetry.", "debug.kattintson_a_gombra_a_telemetria": "Click the button to run telemetry.",
@@ -1022,7 +1022,6 @@
"debug.titkositott": "encrypted", "debug.titkositott": "encrypted",
"debug.torles": "Clear", "debug.torles": "Clear",
"debug.ugynok": "Agent", "debug.ugynok": "Agent",
"debug.ujrainditas_setup_modban": "Restart in setup mode",
"debug.utemezo": "Scheduler", "debug.utemezo": "Scheduler",
"debug.utolso_db_dump": "Last DB dump:", "debug.utolso_db_dump": "Last DB dump:",
"debug.utolso_esemenyek": "Recent events", "debug.utolso_esemenyek": "Recent events",
@@ -1033,8 +1032,34 @@
"debug.utvonal": "Path", "debug.utvonal": "Path",
"debug.varakozik": "Waiting", "debug.varakozik": "Waiting",
"debug.verzio": "Version", "debug.verzio": "Version",
"debug.veszhelyzet_szimulacio": "Emergency simulation",
"debug.vezerlo": "Controller", "debug.vezerlo": "Controller",
"debug.api.invalid_request": "Invalid request",
"debug.api.notifier_unconfigured": "Notifier is not configured",
"debug.api.event_sent": "Event sent (HTTP %d)",
"debug.api.backup_unconfigured": "Backup manager is not configured",
"debug.api.db_dump_started": "DB dump started",
"debug.api.crossdrive_none": "No installed app has a second-drive backup that could run",
"debug.api.crossdrive_started": "Cross-drive backup started; apps: %d",
"debug.api.not_wired": "Not wired",
"debug.api.proof_none_due": "No app is due — every latest backup is already checked",
"debug.api.proof_not_run": "The restore did not run — nothing was learned about the backup",
"debug.api.proof_ok": "The backup contains the app’s data",
"debug.api.proof_unknown": "Cannot be judged — the backup does not carry enough information",
"debug.api.proof_missing": "The backup is readable but does not contain the app’s data",
"debug.api.integrity_unreachable": "The repository cannot be reached — the check did not run",
"debug.api.integrity_ok": "The check completed without problems",
"debug.api.integrity_failed": "The check found an error in the repository",
"debug.api.hub_report_sent": "Hub report sent",
"debug.api.hub_reachable": "Hub reachable (HTTP %d, %dms)",
"debug.api.prefs_synced": "Preferences synchronised",
"debug.api.gitea_reachable": "Gitea reachable (HTTP %d, %dms)",
"debug.api.telemetry_collected": "Telemetry collected: apps %d, errors %d, warnings %d (%dms)",
"debug.api.selfupdate_unconfigured": "Self-update is not configured",
"debug.api.agent_unconfigured": "The agent is not configured on this system.",
"debug.api.agent_logs_unsupported": "The agent’s log view becomes available after the agent’s next update.",
"debug.api.bundles_found": "Bundles found: %d",
"debug.api.no_temp_files": "No temp file to remove",
"debug.api.temp_files_removed": "Temp files removed: %d/%d",
"deploy.a_celalkalmazas_nem_fut": "The target app is not running", "deploy.a_celalkalmazas_nem_fut": "The target app is not running",
"deploy.a_celalkalmazas_nincs_telepitve": "The target app is not installed", "deploy.a_celalkalmazas_nincs_telepitve": "The target app is not installed",
"deploy.a_ket_jelszo_nem_egyezik": "The two passwords do not match:", "deploy.a_ket_jelszo_nem_egyezik": "The two passwords do not match:",
@@ -1228,6 +1253,8 @@
"err.backup.az_offsite_tarolo_nincs_elarvult_allapotban": "the off-site store is not orphaned", "err.backup.az_offsite_tarolo_nincs_elarvult_allapotban": "the off-site store is not orphaned",
"err.backup.db_restore_and_rollback_failed": "putting back the database of %s failed, and the earlier state could not be restored either. We have left the app STOPPED, for safety, so your data cannot be damaged further. Get in touch with us — %s", "err.backup.db_restore_and_rollback_failed": "putting back the database of %s failed, and the earlier state could not be restored either. We have left the app STOPPED, for safety, so your data cannot be damaged further. Get in touch with us — %s",
"err.backup.db_restore_failed_rolled_back": "putting back the database of %s failed — your data is back as it was before the restore, and the app is still running. If you want to try again, get in touch with us first", "err.backup.db_restore_failed_rolled_back": "putting back the database of %s failed — your data is back as it was before the restore, and the app is still running. If you want to try again, get in touch with us first",
"err.backup.db_restore_failed_held_mixed": "putting back the database of %s failed. The database is as it was before the restore, but the files and other data of the app already come from the backup, so they do not match. We have left the app STOPPED, for safety. This needs our help: get in touch with us",
"err.backup.restore_failed_held_mixed": "restoring %s stopped half-way. Some of the files or data of the app already come from the backup and some do not, so they do not match. We have left the app STOPPED, for safety. This needs our help: get in touch with us",
"err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar": "another backup or restore is already running", "err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar": "another backup or restore is already running",
"err.backup.ennek_az_alkalmazasnak_az_adatai_nem": "this app’s data cannot be restored from this copy", "err.backup.ennek_az_alkalmazasnak_az_adatai_nem": "this app’s data cannot be restored from this copy",
"err.backup.ervenytelen_alkalmazasnev": "that app name is not valid", "err.backup.ervenytelen_alkalmazasnev": "that app name is not valid",
@@ -1396,6 +1423,20 @@
"event.crossdrive_completed": "Second backup copy finished: %s", "event.crossdrive_completed": "Second backup copy finished: %s",
"event.crossdrive_failed": "Second backup copy failed: %s", "event.crossdrive_failed": "Second backup copy failed: %s",
"event.db_dump_completed": "Database backup finished", "event.db_dump_completed": "Database backup finished",
"event.db_dump_failed": "Database backup failed",
"event.offbox_backup_failed": "Off-box (NAS) backup failed",
"event.offbox_enlarge_blocked": "The full remote backup of %s (~%s) would go over the storage quota (%d/%d GB). The configuration and the database are still backed up; contact us for a larger quota.",
"event.backup_integrity_failed": "The check of your remote backup found an error in the store. Some of the backups may be damaged. Do not delete anything, and contact us.",
"event.backup_integrity_ok": "The check of your remote backup finished without errors. (%s)",
"event.backup_integrity_ok_subset": "The check of your remote backup finished without errors. (%s, re-reading %s of the stored data)",
"event.appstop.failed": "%s was interrupted by a controller restart and %d of %d app(s) could NOT be restarted",
"event.appstop.failed_held": "%s was interrupted by a controller restart and %d of %d app(s) could NOT be restarted (a further %d are held by an absent drive and are not counted as failures)",
"event.appstop.held": "%s was interrupted by a controller restart — %d app(s) are left stopped and HELD: their data drive is not available, so the drive gate restarts them when it returns",
"event.appstop.restarted": "%s was interrupted by a controller restart — %d app(s) were left stopped and have been restarted",
"event.appstop.restarted_held": "%s was interrupted by a controller restart — %d app(s) were left stopped and have been restarted; %d more are held by an absent drive",
"appstop.reason.volume_dump": "an app-data backup (volume dump)",
"appstop.reason.offbox_reconstitute": "an off-site restore",
"appstop.reason.app_export": "an app export",
"event.disaster_recovery_completed": "Disaster recovery finished (%d succeeded, %d failed)", "event.disaster_recovery_completed": "Disaster recovery finished (%d succeeded, %d failed)",
"event.disaster_recovery_started": "Disaster recovery started (%d app(s))", "event.disaster_recovery_started": "Disaster recovery started (%d app(s))",
"event.health_critical": "System health is critical (was: %s)", "event.health_critical": "System health is critical (was: %s)",
@@ -1403,6 +1444,10 @@
"event.health_recovered": "System health is back to normal: %s (was: %s)", "event.health_recovered": "System health is back to normal: %s (was: %s)",
"event.storage_disconnected": "Drive disconnected unexpectedly: %s", "event.storage_disconnected": "Drive disconnected unexpectedly: %s",
"event.storage_reconnected": "Drive reconnected: %s", "event.storage_reconnected": "Drive reconnected: %s",
"event.fill.critical": "The storage “%s” is critically full: %s of free space is left (%s used). Backups and app writes can fail at any moment. Free up space soon: delete files you do not need, or connect a new drive.",
"event.fill.warning": "The storage “%s” is %s full — %s of free space is left. Free up space before it fills: delete files you do not need, or connect a new drive. If it fills, backups will fail.",
"event.fill.label_apps": "App area",
"event.fill.label_system": "System and backup area",
"flash.backup.not_configured": "Backup is not set up", "flash.backup.not_configured": "Backup is not set up",
"flash.backup.window_invalid_time": "That is not a valid time. Use the HH:MM format (02:30, for example).", "flash.backup.window_invalid_time": "That is not a valid time. Use the HH:MM format (02:30, for example).",
"flash.backup.window_save_failed": "The backup window could not be saved.", "flash.backup.window_save_failed": "The backup window could not be saved.",
@@ -1456,6 +1501,7 @@
"flash.share.disabled": "Sharing is off.", "flash.share.disabled": "Sharing is off.",
"flash.share.enable_failed": "Sharing could not be turned on.", "flash.share.enable_failed": "Sharing could not be turned on.",
"flash.share.enabled": "Sharing is on.", "flash.share.enabled": "Sharing is on.",
"flash.share.wrong_password": "Wrong password",
"flash.share.not_on": "Sharing is not on.", "flash.share.not_on": "Sharing is not on.",
"flash.share.pw_clear_failed": "The password could not be removed.", "flash.share.pw_clear_failed": "The password could not be removed.",
"flash.share.pw_cleared": "The share password is removed.", "flash.share.pw_cleared": "The share password is removed.",
@@ -1514,6 +1560,19 @@
"func.time.tomorrow_at": "tomorrow %s", "func.time.tomorrow_at": "tomorrow %s",
"func.time.yesterday": "yesterday", "func.time.yesterday": "yesterday",
"health.no_probe_container": "No health check ran: no matching container.", "health.no_probe_container": "No health check ran: no matching container.",
"health.system_disk_high": "System storage is filling up: %.0f%% used",
"health.system_disk_critical": "System storage is almost full: %.0f%% used",
"health.data_disk_high": "The data drive is filling up: %.0f%% used",
"health.data_disk_critical": "The data drive is almost full: %.0f%% used",
"health.memory_high": "Memory usage high: %.0f%%",
"health.cpu_high": "CPU usage high: %.0f%%",
"health.temperature_high": "Temperature high: %.0f°C (%s)",
"health.docker_unreachable": "Docker is not reachable: %s",
"health.protected_container_down": "A protected system service is not running: %s",
"health.storage_unavailable": "Storage not reachable: %s",
"health.storage_not_separate": "The storage (%s) is not on a separate drive — data is written to the system drive",
"health.storage_usage_high": "Storage usage high: %s (%.0f%%)",
"health.storage_almost_full": "Storage almost full: %s (%.0f%%)",
"hold.copy_holds.db_only": "holds only the settings and the database, not the files", "hold.copy_holds.db_only": "holds only the settings and the database, not the files",
"hold.copy_holds.files": "holds the settings, the database and the files", "hold.copy_holds.files": "holds the settings, the database and the files",
"hold.copy_holds.volumes": "holds the settings, the database and the data volumes", "hold.copy_holds.volumes": "holds the settings, the database and the data volumes",
@@ -1622,6 +1681,7 @@
"layout.nyelv": "Language", "layout.nyelv": "Language",
"layout.rendszer": "System", "layout.rendszer": "System",
"layout.rendszermonitor": "System monitor", "layout.rendszermonitor": "System monitor",
"layout.hibakereses": "Debug",
"layout.sablonok_frissitve": "Templates refreshed", "layout.sablonok_frissitve": "Templates refreshed",
"layout.sikeresen_eltavolitva": "Removed!", "layout.sikeresen_eltavolitva": "Removed!",
"layout.sikeresen_torolve": "Deleted!", "layout.sikeresen_torolve": "Deleted!",
@@ -1716,6 +1776,7 @@
"note.offsite.whole_unit_gap": "(the whole app — it has no local backup unit)", "note.offsite.whole_unit_gap": "(the whole app — it has no local backup unit)",
"note.reconstitute.copy_latest": "latest", "note.reconstitute.copy_latest": "latest",
"note.reconstitute.held": "restoring the data of %s stopped at %s, and the earlier state could not be put back either. The app stays stopped, for safety, so your data cannot be damaged further. Get in touch with us", "note.reconstitute.held": "restoring the data of %s stopped at %s, and the earlier state could not be put back either. The app stays stopped, for safety, so your data cannot be damaged further. Get in touch with us",
"note.reconstitute.held_mixed": "restoring %s stopped half-way at %s: part of its data comes from the backup and part does not. The app stays stopped, for safety. This needs our help: get in touch with us",
"note.restore.whole_files": "The drive's files: %d brought back, %d replaced (the older live copy was kept beside it, ending in .felhom-…), %d newer copies left untouched, %d unchanged. No file was deleted.", "note.restore.whole_files": "The drive's files: %d brought back, %d replaced (the older live copy was kept beside it, ending in .felhom-…), %d newer copies left untouched, %d unchanged. No file was deleted.",
"note.restore.all_files_present": "Every file that was checked is in place.", "note.restore.all_files_present": "Every file that was checked is in place.",
"note.restore.and_database": " and the database", "note.restore.and_database": " and the database",
@@ -1818,7 +1879,7 @@
"recovery.adatok": "Retrieve", "recovery.adatok": "Retrieve",
"recovery.adatok_visszaszerzese_felhom": "Retrieve data — Felhom", "recovery.adatok_visszaszerzese_felhom": "Retrieve data — Felhom",
"recovery.alkalmazas": "App", "recovery.alkalmazas": "App",
"recovery.amelyet_zartunk_le": ", which we\n sealed on <strong>{{.}}</strong>", "recovery.amelyet_zartunk_le": ", which we\n sealed on <strong>{{fmtTimeStr .}}</strong>",
"recovery.biztosan_nem_kered_vissza_a": "Are you sure you do not want the earlier backups back?", "recovery.biztosan_nem_kered_vissza_a": "Are you sure you do not want the earlier backups back?",
"recovery.elerhetok": "are available", "recovery.elerhetok": "are available",
"recovery.ez_a_lehetoseg_akkor_valik": "this option becomes available once the machine has reconnected to the off-site\n storage. Until then your backups stay untouched.", "recovery.ez_a_lehetoseg_akkor_valik": "this option becomes available once the machine has reconnected to the off-site\n storage. Until then your backups stay untouched.",
@@ -1837,12 +1898,14 @@
"recovery.meret": "Size", "recovery.meret": "Size",
"recovery.most_nem": "Not now", "recovery.most_nem": "Not now",
"recovery.nem_kerem_vissza_a_korabbi": "<a href=\"/recovery?setaside=1\">I do not want the earlier data back</a>.", "recovery.nem_kerem_vissza_a_korabbi": "<a href=\"/recovery?setaside=1\">I do not want the earlier data back</a>.",
"recovery.older_unchecked": "This code did not open the package we keep for this machine now. We also keep earlier packages for this machine, but we could not try all of them with your code — so we do not know whether your code is wrong. It can be a typing mistake: check that you typed all ten words exactly, separated by spaces, and try again. Or your code can belong to an earlier package. If it still does not work, keep your code and contact Felhom support: tell them that your recovery code did not open your backups. Nothing has changed.",
"recovery.semmi_nem_valtozott_varj_nehany": "Nothing changed. Wait a few seconds and try again — you will still need your code,", "recovery.semmi_nem_valtozott_varj_nehany": "Nothing changed. Wait a few seconds and try again — you will still need your code,",
"recovery.tiz_szo_szokozokkel_elvalasztva": "ten words, separated by spaces", "recovery.tiz_szo_szokozokkel_elvalasztva": "ten words, separated by spaces",
"recovery.tovabb_a_visszaallitashoz": "Continue to restore", "recovery.tovabb_a_visszaallitashoz": "Continue to restore",
"recovery.ugyhogy_tartsd_keznel": "so keep it at hand.", "recovery.ugyhogy_tartsd_keznel": "so keep it at hand.",
"recovery.vissza_a_kezdolapra": "Back to the home page", "recovery.vissza_a_kezdolapra": "Back to the home page",
"recovery.visszaszerzese": "your data", "recovery.visszaszerzese": "your data",
"recovery_info.body": "Felhom — Emergency information\n==============================\n\nCustomer ID: %s\nHub URL: %s\nRetrieval password: %s\n\nSupport:\n E-mail: %s\n Web: %s\n\nIf you need to restore something:\n 1. Open your dashboard in your browser: %s\n 2. Restore the data of an app on the Backup → Restore page:\n %s\n 3. If your machine was reinstalled, the dashboard offers to bring back\n your earlier off-site backups (Retrieve data: %s).\n For this you need your recovery code: the ten words you received\n at installation. Nobody can replace this code, so keep it in a\n safe place.\n 4. If you get stuck, write to us at the address above.\n\nGenerated: %s\nController version: %s\n",
"restore.refuse.files.head": "This backup does not hold the files of the app, so we do not restore the database over them — the files stay where they are. ", "restore.refuse.files.head": "This backup does not hold the files of the app, so we do not restore the database over them — the files stay where they are. ",
"restore.refuse.files.none": "There is no copy of these files at the moment — turn on the remote backup, or connect a second drive.", "restore.refuse.files.none": "There is no copy of these files at the moment — turn on the remote backup, or connect a second drive.",
"restore.refuse.files.offsite": "The files can be restored from the remote copy: Backup → Restore, “Full restore (files + database)”.", "restore.refuse.files.offsite": "The files can be restored from the remote copy: Backup → Restore, “Full restore (files + database)”.",
@@ -1875,6 +1938,8 @@
"settings_notifications.beallitasok_ertesitesek": "Settings — Notifications", "settings_notifications.beallitasok_ertesitesek": "Settings — Notifications",
"settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "After turning it on, allow sending e-mail for each app too (on the app’s page).", "settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "After turning it on, allow sending e-mail for each app too (on the app’s page).",
"settings_notifications.biztonsagi_mentes_sikertelen": "Backup failed", "settings_notifications.biztonsagi_mentes_sikertelen": "Backup failed",
"settings_notifications.clear_confirm": "Yes, remove the address",
"settings_notifications.clear_warning": "If you remove the notification e-mail address, you will get no more notification e-mails. Nothing is saved until you confirm.",
"settings_notifications.e_mail_cim": "E-mail address", "settings_notifications.e_mail_cim": "E-mail address",
"settings_notifications.elvart_adatbazismentes_elmaradt": "Expected database backup missed", "settings_notifications.elvart_adatbazismentes_elmaradt": "Expected database backup missed",
"settings_notifications.elvart_fajlmentes_elmaradt": "Expected file backup missed", "settings_notifications.elvart_fajlmentes_elmaradt": "Expected file backup missed",
@@ -1882,7 +1947,6 @@
"settings_notifications.ertesitesi_szunet": "Notification pause", "settings_notifications.ertesitesi_szunet": "Notification pause",
"settings_notifications.ez_jelenik_meg_a_kimeno": "This appears as the sender of outgoing e-mails, next to the app name.", "settings_notifications.ez_jelenik_meg_a_kimeno": "This appears as the sender of outgoing e-mails, next to the app name.",
"settings_notifications.felado_neve_opcionalis": "Sender name (optional)", "settings_notifications.felado_neve_opcionalis": "Sender name (optional)",
"settings_notifications.hibak_es_figyelmeztetesek": "Errors and warnings:",
"settings_notifications.lemez_beteloben_90_felett": "Disk filling up (over 90%)", "settings_notifications.lemez_beteloben_90_felett": "Disk filling up (over 90%)",
"settings_notifications.lemez_megtelt_vagy_meghibasodott": "Disk full or failed", "settings_notifications.lemez_megtelt_vagy_meghibasodott": "Disk full or failed",
"settings_notifications.masodlagos_mentes_sikertelen": "Secondary backup failed", "settings_notifications.masodlagos_mentes_sikertelen": "Secondary backup failed",
@@ -1896,9 +1960,19 @@
"settings_notifications.rendszer_allapot_helyreallt": "System health recovered", "settings_notifications.rendszer_allapot_helyreallt": "System health recovered",
"settings_notifications.rendszer_allapot_kritikus": "System health critical", "settings_notifications.rendszer_allapot_kritikus": "System health critical",
"settings_notifications.szerver_nem_elerheto": "Server not reachable", "settings_notifications.szerver_nem_elerheto": "Server not reachable",
"settings_notifications.tajekoztato": "For information:",
"settings_notifications.tavoli_mentes_tarhelykeret_figyelmeztete": "Remote backup — storage quota warning", "settings_notifications.tavoli_mentes_tarhelykeret_figyelmeztete": "Remote backup — storage quota warning",
"settings_notifications.teszt_email_kuldese": "Send test e-mail", "settings_notifications.teszt_email_kuldese": "Send test e-mail",
"settings_notifications.groups_label": "What should we e-mail you about?",
"settings_notifications.group_machine": "My machine and drives",
"settings_notifications.group_machine_desc": "You get an e-mail when a drive is unplugged or back, filling up, full or failing, or the off-site backup is short of space. These need you.",
"settings_notifications.group_apps": "My apps",
"settings_notifications.group_apps_desc": "You get an e-mail when an app is not running or we stopped it because it kept crashing, and when an update was undone or failed.",
"settings_notifications.group_handled": "What we handle",
"settings_notifications.group_handled_desc": "You get an e-mail when a backup failed or did not run, or the machine cannot be reached. We see these too and deal with them; you need not do anything.",
"settings_notifications.group_custom": "custom",
"settings_notifications.group_custom_hint": "This group has your own selection. Switching it on turns on all of its notifications.",
"settings_notifications.reszletek": "Details",
"settings_notifications.reszletek_hint": "Here you can switch each notification on or off one by one.",
"settings_security.a_cloudflare_api_token_nincs": "The Cloudflare API token is not configured. Ask the operator to set it up.<br>\n <small>The token needs the <strong>Zone WAF:Edit</strong> permission.</small>", "settings_security.a_cloudflare_api_token_nincs": "The Cloudflare API token is not configured. Ask the operator to set it up.<br>\n <small>The token needs the <strong>Zone WAF:Edit</strong> permission.</small>",
"settings_security.a_helyi_halozati_hozzaferes_mindig": "Access from the local network is always allowed (it does not pass through Cloudflare).", "settings_security.a_helyi_halozati_hozzaferes_mindig": "Access from the local network is always allowed (it does not pass through Cloudflare).",
"settings_security.a_jelszavas_vedelem_nincs_beallitva": "Password protection is not set up. Ask the operator to set it up.", "settings_security.a_jelszavas_vedelem_nincs_beallitva": "Password protection is not set up. Ask the operator to set it up.",
@@ -1942,7 +2016,7 @@
"settings_security.uj_jelszo": "New password", "settings_security.uj_jelszo": "New password",
"settings_security.uj_jelszo_megerositese": "Confirm new password", "settings_security.uj_jelszo_megerositese": "Confirm new password",
"settings_security.utolso": "Last:", "settings_security.utolso": "Last:",
"settings_security.utolso_szinkronizalas": "Last sync: {{.GeoLastSync}}", "settings_security.utolso_szinkronizalas": "Last sync: {{fmtTimeStr .GeoLastSync}}",
"settings_security.veszhelyzeti_informaciok": "Emergency information", "settings_security.veszhelyzeti_informaciok": "Emergency information",
"settings_security.visszaallitasi_jelszo": "Retrieval password", "settings_security.visszaallitasi_jelszo": "Retrieval password",
"settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "The host agent is not reachable right now — try again later.", "settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "The host agent is not reachable right now — try again later.",
@@ -2183,7 +2257,7 @@
"storage.hiba": "Error:", "storage.hiba": "Error:",
"storage.host_tarhely_rendszer_sablonok_iso": "Host storage — system templates, ISOs, host-level backups. It holds no app data.", "storage.host_tarhely_rendszer_sablonok_iso": "Host storage — system templates, ISOs, host-level backups. It holds no app data.",
"storage.hozzaadas": "Add", "storage.hozzaadas": "Add",
"storage.idopont": "Time: {{.DecommissionedAt}}", "storage.idopont": "Time: {{fmtTimeStr .DecommissionedAt}}",
"storage.inaktiv": "Inactive", "storage.inaktiv": "Inactive",
"storage.ismeretlen": "unknown", "storage.ismeretlen": "unknown",
"storage.kesz": "Done", "storage.kesz": "Done",
@@ -2205,7 +2279,7 @@
"storage.levalasztas": "Disconnect", "storage.levalasztas": "Disconnect",
"storage.levalasztas_folyamatban": "Disconnecting…", "storage.levalasztas_folyamatban": "Disconnecting…",
"storage.levalasztva": "Disconnected", "storage.levalasztva": "Disconnected",
"storage.levalasztva_2": "Disconnected: {{.DisconnectedAt}}", "storage.levalasztva_2": "Disconnected: {{fmtTimeStr .DisconnectedAt}}",
"storage.majd_a_z": "then", "storage.majd_a_z": "then",
"storage.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Add already mounted storage by hand", "storage.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Add already mounted storage by hand",
"storage.megerosites": "Confirm", "storage.megerosites": "Confirm",
@@ -2491,6 +2565,28 @@
"err.kept.not_listed": "This is not kept data, so nothing was touched.", "err.kept.not_listed": "This is not kept data, so nothing was touched.",
"err.kept.occupied": "The app's folder already holds data, so the kept files were not put back. Nothing changed.", "err.kept.occupied": "The app's folder already holds data, so the kept files were not put back. Nothing changed.",
"api.kept.busy": "A backup or a restore is running now. Try again when it has finished.", "api.kept.busy": "A backup or a restore is running now. Try again when it has finished.",
"api.escrow.dashboard_password_missing": "The dashboard password is not set — set a password first.",
"api.escrow.too_many_attempts": "Too many failed attempts, try again in 1 minute.",
"api.escrow.stage_failed": "Preparing the remote backup password for escrow failed — the process did not start. Try again.",
"api.escrow.already_running": "A code is already being created — wait until it finishes.",
"api.escrow.code_not_shown": "The code was created but not shown — for security it cannot be fetched again. Start the process again: the new code replaces the old one.",
"api.escrow.no_active_job": "There is no active recovery process — start creating the code first.",
"api.share.form_invalid": "Invalid form — reload the page.",
"api.share.too_many_attempts": "Too many failed attempts, try again in 1 minute",
"api.export.upload_size_mismatch": "The uploaded size does not match — try again.",
"api.netstorage.uid_range": "The user id (uid) of the app is invalid. Enter a value between 1 and 65533 — most media apps use 1000.",
"api.netstorage.remove_in_use": "The storage cannot be removed while an app uses it: %s. Remove or move the app first.",
"api.netstorage.add.unreachable": "The server cannot be reached (%s). Check the IP address and that the NAS is switched on.",
"api.netstorage.add.flat_name_tip": " Tip: »%s« looks like a Windows network name — use the IP address of the device.",
"api.netstorage.add.nfs_export": "The share was not found, or the NAS does not allow this machine access. Check the export path and that the NAS allows the IP address of the Felhom machine.",
"api.netstorage.add.smb_auth": "Wrong SMB user name or password.",
"api.netstorage.add.smb_share": "The SMB share was not found on the server. Check the share name.",
"api.netstorage.add.timeout": "Timed out while mounting — the server is reachable on the network, but the share cannot be mounted. Check the NFS/SMB service of the NAS.",
"api.netstorage.add.not_writable": "The share can be mounted, but apps cannot write to it. For NFS, turn on „map all users” (all squash) for the share on the NAS — or set the owner of the files to id %d. For SMB, check that the user may write to the share.",
"api.netstorage.add.not_network_fs": "The network storage was not mounted correctly inside the system. Try connecting again; if it happens again, tell the operator.",
"api.netstorage.add.probe_io": "Write error on the share (the data could not be read back intact). Check the share and the network.",
"api.netstorage.add.busy": "A connection is already in progress. Wait until it finishes.",
"api.netstorage.add.failed": "Mounting failed. Details below.",
"page.title.kept_data": "Kept data", "page.title.kept_data": "Kept data",
"note.restore.older_version": "%s is back from the backup of %s, at version %s. An update is available: the box brings it up to date one step at a time.", "note.restore.older_version": "%s is back from the backup of %s, at version %s. An update is available: the box brings it up to date one step at a time.",
"note.restore.older_version_no_step": "%s is back from the backup of %s, at version %s. No tested update step leads on from this version, so the box does not update it by itself.", "note.restore.older_version_no_step": "%s is back from the backup of %s, at version %s. No tested update step leads on from this version, so the box does not update it by itself.",
@@ -2524,7 +2620,9 @@
"app_info.close_signup_text": "Anyone who finds this app's address can sign up. Close sign-up now — you can still add your family afterwards.", "app_info.close_signup_text": "Anyone who finds this app's address can sign up. Close sign-up now — you can still add your family afterwards.",
"app_info.close_signup_btn": "Close sign-up now", "app_info.close_signup_btn": "Close sign-up now",
"app_info.signup_native_failed": "The app's own sign-up switch could not be closed. The address block still protects it.", "app_info.signup_native_failed": "The app's own sign-up switch could not be closed. The address block still protects it.",
"app_info.signup_native_open_failed": "The app's own sign-up switch could not be opened for the 15 minutes, so it stays closed. A new family member cannot sign up in the app itself. Try again later.",
"app_info.signup_window_restart": "The app restarts for this, and once more when the 15 minutes end.", "app_info.signup_window_restart": "The app restarts for this, and once more when the 15 minutes end.",
"app_info.restarts_once": "For this the app restarts once and is briefly unavailable meanwhile.",
"err.setup_gate.close_signup_not_offered": "There is nothing to close on this app.", "err.setup_gate.close_signup_not_offered": "There is nothing to close on this app.",
"backups_offsite.offer_text": "Some apps have no off-site copy. Turn it on for all of them?", "backups_offsite.offer_text": "Some apps have no off-site copy. Turn it on for all of them?",
"backups_offsite.offer_all": "Yes, all of them", "backups_offsite.offer_all": "Yes, all of them",
@@ -2566,5 +2664,7 @@
"family_gate.pw_once": "The password shows only now. Write it down, or give it to the family member:", "family_gate.pw_once": "The password shows only now. Write it down, or give it to the family member:",
"family_gate.confirm_remove": "Remove them? Their sign-ins end at once.", "family_gate.confirm_remove": "Remove them? Their sign-ins end at once.",
"family_gate.confirm_reset": "Give a new password? The old one stops at once.", "family_gate.confirm_reset": "Give a new password? The old one stops at once.",
"family_gate.error": "That did not work. Try again." "family_gate.error": "That did not work. Try again.",
"waiting.body": "This machine has just started for the first time, but it could not download its settings from the Felhom central service yet. It tries again every minute; once it succeeds, this page turns into your dashboard by itself. If you still see this in an hour, write to us: support@felhom.eu",
"waiting.title": "Felhom has not reached the central service yet"
} }
+179 -79
View File
@@ -7,10 +7,11 @@
"alert.agent_channel.unknown": "A tárolókezelő ügynök nem elérhető.", "alert.agent_channel.unknown": "A tárolókezelő ügynök nem elérhető.",
"alert.agent_channel.unreachable": "A tárolókezelő ügynök nem elérhető.", "alert.agent_channel.unreachable": "A tárolókezelő ügynök nem elérhető.",
"alert.backup.disabled": "A biztonsági mentés nincs bekapcsolva", "alert.backup.disabled": "A biztonsági mentés nincs bekapcsolva",
"alert.deadapp.group": "%d telepített alkalmazás nem fut — nézze meg a rendszermonitort", "alert.deadapp.group": "%d telepített alkalmazás nem fut — nézd meg a rendszermonitort",
"alert.deadapp.single": "Telepített alkalmazás nem fut: %s", "alert.deadapp.single": "Telepített alkalmazás nem fut: %s",
"alert.deadapp.single_state": "Telepített alkalmazás nem fut: %s (%s)", "alert.deadapp.single_state": "Telepített alkalmazás nem fut: %s (%s)",
"alert.endpoint_drift": "A tárolókezelő ügynök címe elavult a beállításokban.", "alert.endpoint_drift": "A tárolókezelő ügynök címe elavult a beállításokban.",
"alert.local_api_token_drift": "A tárolókezelő ügynök hozzáférési kulcsa elavult a beállításokban.",
"alert.hub.disabled": "Hub kapcsolat kikapcsolva — a központi monitoring nem aktív", "alert.hub.disabled": "Hub kapcsolat kikapcsolva — a központi monitoring nem aktív",
"alert.hub.unreachable": "Hub nem elérhető — utolsó hiba: %s", "alert.hub.unreachable": "Hub nem elérhető — utolsó hiba: %s",
"alert.link.monitoring": "Rendszermonitor", "alert.link.monitoring": "Rendszermonitor",
@@ -25,9 +26,9 @@
"api.backup.tier2_started": "2. mentés elindítva", "api.backup.tier2_started": "2. mentés elindítva",
"api.config.applied": "Konfiguráció alkalmazva — a vezérlő újraindul.", "api.config.applied": "Konfiguráció alkalmazva — a vezérlő újraindul.",
"api.config.unchanged": "A konfiguráció változatlan — nincs szükség újraindításra.", "api.config.unchanged": "A konfiguráció változatlan — nincs szükség újraindításra.",
"api.deploy.netstorage_unreachable": "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.", "api.deploy.netstorage_unreachable": "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áld újra pár perc múlva, vagy jelezd az üzemeltetőnek.",
"api.deploy.hdd_path_inside_drive": "Az alkalmazás adatainak helye egy meghajtó lehet, nem egy mappa a meghajtón belül. Válaszd ezt: %s.", "api.deploy.hdd_path_inside_drive": "Az alkalmazás adatainak helye egy meghajtó lehet, nem egy mappa a meghajtón belül. Válaszd ezt: %s.",
"api.deploy.no_space": "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.", "api.deploy.no_space": "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íts fel helyet, vagy bővítsd a tárhelyet.",
"api.deploy.not_installable": "Ez az alkalmazás jelenleg nem telepíthető.", "api.deploy.not_installable": "Ez az alkalmazás jelenleg nem telepíthető.",
"api.deploy.started": "Telepítés elindítva – az állapot a kártyán követhető", "api.deploy.started": "Telepítés elindítva – az állapot a kártyán követhető",
"api.geo.app_removed": "Alkalmazás geo-korlátozás eltávolítva", "api.geo.app_removed": "Alkalmazás geo-korlátozás eltávolítva",
@@ -70,7 +71,8 @@
"app_export.titkositasi_jelszo": "Titkosítási jelszó", "app_export.titkositasi_jelszo": "Titkosítási jelszó",
"app_export.valassz_tarolot": "Válassz tárolót...", "app_export.valassz_tarolot": "Válassz tárolót...",
"app_export.valaszthato_tartalom": "Választható tartalom:", "app_export.valaszthato_tartalom": "Választható tartalom:",
"app_import.a_feltoltes_megszakadt_ellenorizze_a": "A feltöltés megszakadt — ellenőrizze a kapcsolatot, majd próbálja újra.", "app_export.network_drive_needs_password": "Hálózati tárhelyre (NAS) csak jelszóval védett csomagot menthetsz, mert a csomag az alkalmazás jelszavait is tartalmazza. Adj meg jelszót, vagy válassz csatlakoztatott meghajtót.",
"app_import.a_feltoltes_megszakadt_ellenorizze_a": "A feltöltés megszakadt — ellenőrizd a kapcsolatot, majd próbáld újra.",
"app_import.adatbazis": "Adatbázis:", "app_import.adatbazis": "Adatbázis:",
"app_import.adatok": "Adatok:", "app_import.adatok": "Adatok:",
"app_import.alkalmazas": "Alkalmazás", "app_import.alkalmazas": "Alkalmazás",
@@ -89,7 +91,7 @@
"app_import.exportalj_egy_alkalmazast_az_alkalmazas": "Exportálj egy alkalmazást az alkalmazás oldaláról, tölts fel egy .fab fájlt itt fent, vagy másolj egyet a <code>{tároló}/felhom-data/exports/</code> könyvtárba.", "app_import.exportalj_egy_alkalmazast_az_alkalmazas": "Exportálj egy alkalmazást az alkalmazás oldaláról, tölts fel egy .fab fájlt itt fent, vagy másolj egyet a <code>{tároló}/felhom-data/exports/</code> könyvtárba.",
"app_import.exportalva": "Exportálva:", "app_import.exportalva": "Exportálva:",
"app_import.ez_a_csomag_jelszoval_vedett": "Ez a csomag jelszóval védett. Kérlek add meg a jelszót:", "app_import.ez_a_csomag_jelszoval_vedett": "Ez a csomag jelszóval védett. Kérlek add meg a jelszót:",
"app_import.fab_csomag_feltoltese_huzza_ide": ".fab csomag feltöltése &mdash; húzza ide, vagy válassza ki a fájlt", "app_import.fab_csomag_feltoltese_huzza_ide": ".fab csomag feltöltése &mdash; húzd ide, vagy válaszd ki a fájlt",
"app_import.fajl_kivalasztasa": "Fájl kiválasztása", "app_import.fajl_kivalasztasa": "Fájl kiválasztása",
"app_import.feltoltes": "Feltöltés:", "app_import.feltoltes": "Feltöltés:",
"app_import.feltoltes_0": "Feltöltés: 0%", "app_import.feltoltes_0": "Feltöltés: 0%",
@@ -151,6 +153,7 @@
"app_info.mentesek": "Mentések", "app_info.mentesek": "Mentések",
"app_info.mire_hasznalhato": "Mire használható?", "app_info.mire_hasznalhato": "Mire használható?",
"app_info.naplo": "Napló", "app_info.naplo": "Napló",
"app_info.hold_log_kept": "Az alkalmazás saját naplója megmaradt a leállítás előttről — ebből látszik, miért nem indult el az új változat.",
"app_info.nincs_folyamatban_athelyezes": "Nincs folyamatban áthelyezés.", "app_info.nincs_folyamatban_athelyezes": "Nincs folyamatban áthelyezés.",
"app_info.regi_adatok_torlese": "Régi adatok törlése", "app_info.regi_adatok_torlese": "Régi adatok törlése",
"app_info.telepites": "Telepítés", "app_info.telepites": "Telepítés",
@@ -166,6 +169,7 @@
"backup.guest.err.unavailable": "a rendszermentés nem érhető el ezen a gépen", "backup.guest.err.unavailable": "a rendszermentés nem érhető el ezen a gépen",
"backup.removed_app": "Eltávolított alkalmazás — a mentése megvan, visszaállítható", "backup.removed_app": "Eltávolított alkalmazás — a mentése megvan, visszaállítható",
"backup.status.db_failed": "Adatbázis mentés sikertelen", "backup.status.db_failed": "Adatbázis mentés sikertelen",
"backup.status.version_skip": "A(z) %s nem a beállított verzióval fut, ezért az éjszakai mentés kihagyja, és a legutóbbi jó mentésed változatlan marad. Vedd fel velünk a kapcsolatot",
"backup.status.ok": "Alkalmazás-adat mentés rendben", "backup.status.ok": "Alkalmazás-adat mentés rendben",
"backup.target.absent": "A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el.", "backup.target.absent": "A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el.",
"backup.target.degraded": "A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — így hibás fájlok ellen véd, lemezhiba ellen nem. Csatlakoztass egy második meghajtót a teljes védelemhez.", "backup.target.degraded": "A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — így hibás fájlok ellen véd, lemezhiba ellen nem. Csatlakoztass egy második meghajtót a teljes védelemhez.",
@@ -186,7 +190,7 @@
"backups.a_kijeloles_nem_sikerult": "A kijelölés nem sikerült:", "backups.a_kijeloles_nem_sikerult": "A kijelölés nem sikerült:",
"backups.a_meghajto_kijelolve": "A meghajtó kijelölve.", "backups.a_meghajto_kijelolve": "A meghajtó kijelölve.",
"backups.a_meghajto_kijelolve_a_beallitas": "A meghajtó kijelölve. A beállítás a host-ügynök következő újraindulása után lép életbe.", "backups.a_meghajto_kijelolve_a_beallitas": "A meghajtó kijelölve. A beállítás a host-ügynök következő újraindulása után lép életbe.",
"backups.a_mentes_alatt_az_alkalmazasok": "A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.", "backups.a_mentes_alatt_az_alkalmazasok": "Mentés közben minden alkalmazás rövid időre leáll: kb. 1–1,5 percre, több alkalmazásnál tovább. Utána újra futnak, amíg a másolás befejeződik. A gomb a helyi másolatot készíti el; a távoli másolat a következő éjszakai mentéssel készül.",
"backups.a_mentes_sikertelen": "A mentés sikertelen.", "backups.a_mentes_sikertelen": "A mentés sikertelen.",
"backups.a_mentesek_egymas_utan_futnak": "A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.", "backups.a_mentesek_egymas_utan_futnak": "A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.",
"backups.a_rendszermentes_elkeszult": "A rendszermentés elkészült.", "backups.a_rendszermentes_elkeszult": "A rendszermentés elkészült.",
@@ -200,7 +204,7 @@
"backups.beagyazott_db_k_a_kotetmentesben": "beágyazott DB-k a kötetmentésben", "backups.beagyazott_db_k_a_kotetmentesben": "beágyazott DB-k a kötetmentésben",
"backups.biztonsagi_mentes": "Biztonsági mentés", "backups.biztonsagi_mentes": "Biztonsági mentés",
"backups.db_mentesek": "DB mentések", "backups.db_mentesek": "DB mentések",
"backups.elinditod_a_teljes_rendszermentest_most": "Elindítod a teljes rendszermentést most? A mentés alatt minden alkalmazás leáll, amíg a teljes rendszer mentése tart. Ez perceket jelent, nem másodperceket: egy 9 alkalmazásos gépen kb. 6 perc, egy 12 alkalmazásos gépen kb. 8 perc volt — több adatnál, és ha a távoli másolat is ugyanekkor készül, tovább.", "backups.elinditod_a_teljes_rendszermentest_most": "Elindítod a teljes rendszermentést most? Mentés közben minden alkalmazás rövid időre leáll: kb. 1–1,5 percre, több alkalmazásnál tovább. Utána újra futnak, amíg a másolás befejeződik. A gomb a helyi másolatot készíti el; a távoli másolat a következő éjszakai mentéssel készül.",
"backups.esedekes": "Esedékes", "backups.esedekes": "Esedékes",
"backups.fajl": "{{len .Backup.DumpFiles}} fájl", "backups.fajl": "{{len .Backup.DumpFiles}} fájl",
"backups.helyi_masolat": "Helyi másolat: <strong>{{.BackupLegTier2}}</strong>", "backups.helyi_masolat": "Helyi másolat: <strong>{{.BackupLegTier2}}</strong>",
@@ -302,8 +306,8 @@
"backups_apps.utolso_adatbazis_mentes": "Utolsó adatbázis mentés:", "backups_apps.utolso_adatbazis_mentes": "Utolsó adatbázis mentés:",
"backups_apps.utolso_sikeres": "Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}", "backups_apps.utolso_sikeres": "Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}",
"backups_apps.visszaallitas_a_mentesbol": "Visszaállítás a mentésből", "backups_apps.visszaallitas_a_mentesbol": "Visszaállítás a mentésből",
"backups_apps.visszaallitja_a_hianyzo_fajlokat_a": "Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{fmtTimeStr .Tier2LastRun}}", "backups_apps.visszaallitja_a_hianyzo_fajlokat_a": "Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{fmtTimeStr .Tier2LastRun}}",
"backups_apps.visszaallitja_legutobbi_sikeres_masolat": "Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: {{fmtTimeStr .Tier2LastSuccess}}.", "backups_apps.visszaallitja_legutobbi_sikeres_masolat": "Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: {{fmtTimeStr .Tier2LastSuccess}}.",
"backups_escrow.1_elofeltetelek_ellenorzese": "1. Előfeltételek ellenőrzése", "backups_escrow.1_elofeltetelek_ellenorzese": "1. Előfeltételek ellenőrzése",
"backups_escrow.2_fontos_tudnivalok": "2. Fontos tudnivalók", "backups_escrow.2_fontos_tudnivalok": "2. Fontos tudnivalók",
"backups_escrow.3_megerosites": "3. Megerősítés", "backups_escrow.3_megerosites": "3. Megerősítés",
@@ -315,7 +319,7 @@
"backups_escrow.a_folyamat_a_piros_feltetelek": "A folyamat a piros feltételek teljesüléséig nem indítható.", "backups_escrow.a_folyamat_a_piros_feltetelek": "A folyamat a piros feltételek teljesüléséig nem indítható.",
"backups_escrow.a_folyamat_inditasa_nem_sikerult": "A folyamat indítása nem sikerült.", "backups_escrow.a_folyamat_inditasa_nem_sikerult": "A folyamat indítása nem sikerült.",
"backups_escrow.a_folytatashoz_adja_meg_a": "A folytatáshoz add meg a bejelentkezési jelszavadat", "backups_escrow.a_folytatashoz_adja_meg_a": "A folytatáshoz add meg a bejelentkezési jelszavadat",
"backups_escrow.a_funkciohoz_az_ugynok_frissitese": "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik. Próbálja újra később.", "backups_escrow.a_funkciohoz_az_ugynok_frissitese": "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik. Próbáld újra később.",
"backups_escrow.a_funkciohoz_az_ugynok_frissitese_2": "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.", "backups_escrow.a_funkciohoz_az_ugynok_frissitese_2": "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.",
"backups_escrow.a_helyreallitasi_kod_a_mentesei": "A helyreállítási kód a mentéseid utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer\n sehol nem tárolja, és a Felhom sem ismeri. Ha a szerver megsemmisül, a távoli mentések CSAK\n ezzel a kóddal állíthatók vissza. Írd fel papírra, vagy mentsd jelszókezelőbe — de NE ezen a\n szerveren tárold.", "backups_escrow.a_helyreallitasi_kod_a_mentesei": "A helyreállítási kód a mentéseid utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer\n sehol nem tárolja, és a Felhom sem ismeri. Ha a szerver megsemmisül, a távoli mentések CSAK\n ezzel a kóddal állíthatók vissza. Írd fel papírra, vagy mentsd jelszókezelőbe — de NE ezen a\n szerveren tárold.",
"backups_escrow.a_kod_lekerese_nem_sikerult": "A kód lekérése nem sikerült.", "backups_escrow.a_kod_lekerese_nem_sikerult": "A kód lekérése nem sikerült.",
@@ -372,13 +376,13 @@
"backups_remote.a_korabbi_mentesek_torlese_folyamatban": "A korábbi mentések törlése folyamatban", "backups_remote.a_korabbi_mentesek_torlese_folyamatban": "A korábbi mentések törlése folyamatban",
"backups_remote.a_korabbi_tavoli_menteseid_torlese": "A korábbi távoli mentéseid törlése megtörtént. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítása még folyamatban van.", "backups_remote.a_korabbi_tavoli_menteseid_torlese": "A korábbi távoli mentéseid törlése megtörtént. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítása még folyamatban van.",
"backups_remote.a_kulcsot_0600_as_fajlba": "A kulcsot 0600-as fájlba írjuk; sosem naplózzuk és nem tároljuk a beállításokban.", "backups_remote.a_kulcsot_0600_as_fajlba": "A kulcsot 0600-as fájlba írjuk; sosem naplózzuk és nem tároljuk a beállításokban.",
"backups_remote.a_letetben_levo_helyreallitasi_csomag": "A letétben lévő helyreállítási csomag nem fedi a jelenlegi távoli mentési jelszót. Hozzon létre új helyreállítási kódot.", "backups_remote.a_letetben_levo_helyreallitasi_csomag": "A letétben lévő helyreállítási csomag nem fedi a jelenlegi távoli mentési jelszót. Hozz létre új helyreállítási kódot.",
"backups_remote.a_megerosites_nem_erkezett_meg": "A megerősítés nem érkezett meg a várt időn belül. Ellenőrizze az internetkapcsolatot, majd készítsen új helyreállítási kódot.", "backups_remote.a_megerosites_nem_erkezett_meg": "A megerősítés nem érkezett meg a várt időn belül. Ellenőrizd az internetkapcsolatot, majd készíts új helyreállítási kódot.",
"backups_remote.a_mentendo_adatok_felmerese": "— a mentendő adatok felmérése…", "backups_remote.a_mentendo_adatok_felmerese": "— a mentendő adatok felmérése…",
"backups_remote.a_pillanatkepek_szama_meg_ismeretlen": "a pillanatképek száma még ismeretlen", "backups_remote.a_pillanatkepek_szama_meg_ismeretlen": "a pillanatképek száma még ismeretlen",
"backups_remote.a_regi_elozmeny_felreteve_marad": "A régi előzmény <strong>félretéve marad a tárhelyen — nem töröljük</strong>. Új, üres tárolót hozunk létre, és a következő mentés oda készül.", "backups_remote.a_regi_elozmeny_felreteve_marad": "A régi előzmény <strong>félretéve marad a tárhelyen — nem töröljük</strong>. Új, üres tárolót hozunk létre, és a következő mentés oda készül.",
"backups_remote.a_tavoli_mentes_ki_van": "A távoli mentés ki van kapcsolva", "backups_remote.a_tavoli_mentes_ki_van": "A távoli mentés ki van kapcsolva",
"backups_remote.a_tavoli_mentesek_csak_akkor": "A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után, ha létrehozza a helyreállítási kódot.", "backups_remote.a_tavoli_mentesek_csak_akkor": "A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után, ha létrehozod a helyreállítási kódot.",
"backups_remote.a_tavoli_tarhelyen_levo_mentesek": "A távoli tárhelyen lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Emiatt új mentés jelenleg nem írható a tárolóba. A meglévő mentések nem sérültek. Azt viszont ez a gép nem tudja megállapítani, hogy később megnyithatók-e — ez attól függ, megvan-e még a hozzájuk tartozó kulcs. Ha szükséged van rájuk, írj nekünk.", "backups_remote.a_tavoli_tarhelyen_levo_mentesek": "A távoli tárhelyen lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Emiatt új mentés jelenleg nem írható a tárolóba. A meglévő mentések nem sérültek. Azt viszont ez a gép nem tudja megállapítani, hogy később megnyithatók-e — ez attól függ, megvan-e még a hozzájuk tartozó kulcs. Ha szükséged van rájuk, írj nekünk.",
"backups_remote.a_tavoli_tarolo_masik_kulccsal": "A távoli tároló másik kulccsal készült mentéseket tartalmaz", "backups_remote.a_tavoli_tarolo_masik_kulccsal": "A távoli tároló másik kulccsal készült mentéseket tartalmaz",
"backups_remote.a_teljes_mentes_tullepne_a": "A teljes mentés túllépné a tárhelykeretet — csak a konfiguráció és az adatbázis kerül mentésre.", "backups_remote.a_teljes_mentes_tullepne_a": "A teljes mentés túllépné a tárhelykeretet — csak a konfiguráció és az adatbázis kerül mentésre.",
@@ -507,8 +511,8 @@
"backups_restore.teljes_visszaallitas_adatbazis_konfigura": "Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok a kiválasztott pillanatképből.", "backups_restore.teljes_visszaallitas_adatbazis_konfigura": "Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok a kiválasztott pillanatképből.",
"backups_restore.uresen_hagyva_a_csomag_titkositas": "Üresen hagyva a csomag titkosítás nélkül készül.", "backups_restore.uresen_hagyva_a_csomag_titkositas": "Üresen hagyva a csomag titkosítás nélkül készül.",
"backups_restore.utolso_megerosites_a_masolat_veglegesen": "UTOLSÓ MEGERŐSÍTÉS: a másolat véglegesen törlődik.", "backups_restore.utolso_megerosites_a_masolat_veglegesen": "UTOLSÓ MEGERŐSÍTÉS: a másolat véglegesen törlődik.",
"backups_restore.valasszon": "— Válasszon —", "backups_restore.valasszon": "— Válassz —",
"backups_restore.valasszon_alkalmazast": "— Válasszon alkalmazást —", "backups_restore.valasszon_alkalmazast": "— Válassz alkalmazást —",
"backups_restore.valaszd_ki_az_alkalmazast_es": "Válaszd ki az alkalmazást, és a következő oldalon döntsd el, mit szeretnél: ellenőrzést külön mappába, csak a hiányzó fájlok visszahozását, vagy teljes visszaállítást. Egyik sem indul el kérdés nélkül.", "backups_restore.valaszd_ki_az_alkalmazast_es": "Válaszd ki az alkalmazást, és a következő oldalon döntsd el, mit szeretnél: ellenőrzést külön mappába, csak a hiányzó fájlok visszahozását, vagy teljes visszaállítást. Egyik sem indul el kérdés nélkül.",
"backups_restore.vasarnap": "vasárnap", "backups_restore.vasarnap": "vasárnap",
"backups_restore.visszaallitas": "Visszaállítás", "backups_restore.visszaallitas": "Visszaállítás",
@@ -560,7 +564,7 @@
"backups_restore_wizard.vegrehajtas": "Végrehajtás", "backups_restore_wizard.vegrehajtas": "Végrehajtás",
"backups_restore_wizard.vissza": "← Vissza", "backups_restore_wizard.vissza": "← Vissza",
"backups_restore_wizard.visszaallitas": "Visszaállítás — {{.AppDisplayName}}", "backups_restore_wizard.visszaallitas": "Visszaállítás — {{.AppDisplayName}}",
"backups_shared.empty_body": "A biztonsági mentés funkció nem aktív.<br>\n Kérjük, vegye fel a kapcsolatot a Felhom csapattal a beállításhoz.", "backups_shared.empty_body": "A biztonsági mentés funkció nem aktív.<br>\n A beállításhoz vedd fel a kapcsolatot a Felhom csapattal.",
"backups_shared.empty_title": "Biztonsági mentés nincs beállítva", "backups_shared.empty_title": "Biztonsági mentés nincs beállítva",
"backups_shared.fajl_visszaallitas": "Fájl-visszaállítás", "backups_shared.fajl_visszaallitas": "Fájl-visszaállítás",
"backups_shared.folyamatban": "folyamatban", "backups_shared.folyamatban": "folyamatban",
@@ -622,7 +626,7 @@
"common.magyar_felulet": "Magyar felület", "common.magyar_felulet": "Magyar felület",
"common.megsem": "Mégsem", "common.megsem": "Mégsem",
"common.pi_kompatibilis": "Pi kompatibilis", "common.pi_kompatibilis": "Pi kompatibilis",
"common.rendszer_gyoker": "Rendszer (/)", "common.rendszer_gyoker": "Rendszer",
"common.torles": "Törlés", "common.torles": "Törlés",
"common.ugyfel_azonosito": "Ügyfél azonosító", "common.ugyfel_azonosito": "Ügyfél azonosító",
"common.ujrainditas": "Újraindítás", "common.ujrainditas": "Újraindítás",
@@ -870,7 +874,7 @@
"dashboard.adatbazisok": "Adatbázisok:", "dashboard.adatbazisok": "Adatbázisok:",
"dashboard.az_alkalmazas_adattaroloja_nem_elerheto": "Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre.", "dashboard.az_alkalmazas_adattaroloja_nem_elerheto": "Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre.",
"dashboard.az_alkalmazas_egyik_folyamatat_a": "Az alkalmazás egyik folyamatát a rendszer leállította, mert elfogyott a memóriája. Lehet, hogy egy feladat nem fejeződött be. Indítsd újra az alkalmazást; ha ismétlődik, jelezd az üzemeltetőnek.", "dashboard.az_alkalmazas_egyik_folyamatat_a": "Az alkalmazás egyik folyamatát a rendszer leállította, mert elfogyott a memóriája. Lehet, hogy egy feladat nem fejeződött be. Indítsd újra az alkalmazást; ha ismétlődik, jelezd az üzemeltetőnek.",
"dashboard.az_alkalmazas_kornyezeteben_a_halozati": "Az alkalmazás környezetében a hálózati tárhely helyén üres helyi könyvtár van — az adatok nem a NAS-ra kerülnek. Jelezze az üzemeltetőnek.", "dashboard.az_alkalmazas_kornyezeteben_a_halozati": "Az alkalmazás környezetében a hálózati tárhely helyén üres helyi könyvtár van — az adatok nem a NAS-ra kerülnek. Jelezd az üzemeltetőnek.",
"dashboard.biztonsagi_mentes": "Biztonsági mentés", "dashboard.biztonsagi_mentes": "Biztonsági mentés",
"dashboard.fogyoban_a_hely": "Fogyóban a hely", "dashboard.fogyoban_a_hely": "Fogyóban a hely",
"dashboard.futo_alkalmazas": "Futó alkalmazás", "dashboard.futo_alkalmazas": "Futó alkalmazás",
@@ -897,6 +901,8 @@
"dashboard.telepites": "Telepítés", "dashboard.telepites": "Telepítés",
"dashboard.telepitett_alkalmazasok": "Telepített alkalmazások", "dashboard.telepitett_alkalmazasok": "Telepített alkalmazások",
"dashboard.url_nem_elerheto": "URL nem elérhető", "dashboard.url_nem_elerheto": "URL nem elérhető",
"dashboard.tunnel_offline": "Kívülről nem érhető el",
"dashboard.tunnel_offline_title": "A biztonságos internetkapcsolat most nem kapcsolódik, ezért az alkalmazásaid kívülről nem érhetők el. Ellenőrizd az internetkapcsolatot.",
"dashboard.utolso_mentes": "Utolsó mentés:", "dashboard.utolso_mentes": "Utolsó mentés:",
"dashboard.vedett": "Védett", "dashboard.vedett": "Védett",
"dashboard.vezerlopult": "Vezérlőpult", "dashboard.vezerlopult": "Vezérlőpult",
@@ -917,7 +923,6 @@
"debug.bejegyzes": "bejegyzés", "debug.bejegyzes": "bejegyzés",
"debug.betoltes": "Betöltés...", "debug.betoltes": "Betöltés...",
"debug.cimke": "Cimke", "debug.cimke": "Cimke",
"debug.controller_ujraindulasa": "Controller újraindulása...",
"debug.cpu_atlag": "CPU (átlag)", "debug.cpu_atlag": "CPU (átlag)",
"debug.csak_cross_drive": "Csak cross-drive", "debug.csak_cross_drive": "Csak cross-drive",
"debug.csak_db_dump": "Csak DB dump", "debug.csak_db_dump": "Csak DB dump",
@@ -927,7 +932,6 @@
"debug.db": "db", "debug.db": "db",
"debug.debug_mod": "Debug mód", "debug.debug_mod": "Debug mód",
"debug.debug_mod_aktiv_ez_az": "Debug mód aktív — ez az oldal csak fejlesztéshez és hibaelhárításhoz", "debug.debug_mod_aktiv_ez_az": "Debug mód aktív — ez az oldal csak fejlesztéshez és hibaelhárításhoz",
"debug.dr_telepito_varazslo": "DR / Telepítő varázsló",
"debug.dry_run_frissites": "Dry-run frissítés", "debug.dry_run_frissites": "Dry-run frissítés",
"debug.elavult_temp_fajlok": "Elavult temp fájlok", "debug.elavult_temp_fajlok": "Elavult temp fájlok",
"debug.eleres": "Elérés", "debug.eleres": "Elérés",
@@ -936,8 +940,6 @@
"debug.ertesites_teszt": "Értesítés teszt", "debug.ertesites_teszt": "Értesítés teszt",
"debug.esemeny_tipus": "Esemény típus:", "debug.esemeny_tipus": "Esemény típus:",
"debug.export_konyvtarak": "Export könyvtárak", "debug.export_konyvtarak": "Export könyvtárak",
"debug.ez_torli_az_ugyfel_azonositot": "Ez törli az ügyfél azonosítót és újraindítja a controllert setup módba. A konfigurációs fájlok megmaradnak a meghajtókon.",
"debug.figyelem_ez_ujrainditja_a_controllert": "FIGYELEM! Ez újraindítja a controllert setup módba. Biztosan folytatja?",
"debug.figyelmeztetesek": "Figyelmeztetések", "debug.figyelmeztetesek": "Figyelmeztetések",
"debug.folyamatban": "Folyamatban...", "debug.folyamatban": "Folyamatban...",
"debug.frissites": "Frissítés", "debug.frissites": "Frissítés",
@@ -961,11 +963,9 @@
"debug.igen": "Igen", "debug.igen": "Igen",
"debug.igen_2": "igen", "debug.igen_2": "igen",
"debug.inaktiv": "Inaktív", "debug.inaktiv": "Inaktív",
"debug.irja_be_reset": "Írja be: RESET",
"debug.irja_be_reset_a_megerositeshez": "Írja be \"RESET\" a megerősítéshez:",
"debug.jelenlegi": "Jelenlegi:", "debug.jelenlegi": "Jelenlegi:",
"debug.katalogus_limit": "Katalógus limit", "debug.katalogus_limit": "Katalógus limit",
"debug.kattintson_a_gombra_a_telemetria": "Kattintson a gombra a telemetria futtatásához.", "debug.kattintson_a_gombra_a_telemetria": "Kattints a gombra a telemetria futtatásához.",
"debug.kereses": "Keresés...", "debug.kereses": "Keresés...",
"debug.kesz": "Kész", "debug.kesz": "Kész",
"debug.kimenet": "Kimenet", "debug.kimenet": "Kimenet",
@@ -1017,7 +1017,6 @@
"debug.titkositott": "titkosított", "debug.titkositott": "titkosított",
"debug.torles": "Törlés", "debug.torles": "Törlés",
"debug.ugynok": "Ügynök", "debug.ugynok": "Ügynök",
"debug.ujrainditas_setup_modban": "Újraindítás setup módban",
"debug.utemezo": "Ütemező", "debug.utemezo": "Ütemező",
"debug.utolso_db_dump": "Utolsó DB dump:", "debug.utolso_db_dump": "Utolsó DB dump:",
"debug.utolso_esemenyek": "Utolsó események", "debug.utolso_esemenyek": "Utolsó események",
@@ -1028,15 +1027,41 @@
"debug.utvonal": "Útvonal", "debug.utvonal": "Útvonal",
"debug.varakozik": "Várakozik", "debug.varakozik": "Várakozik",
"debug.verzio": "Verzió", "debug.verzio": "Verzió",
"debug.veszhelyzet_szimulacio": "Vészhelyzet szimuláció",
"debug.vezerlo": "Vezérlő", "debug.vezerlo": "Vezérlő",
"debug.api.invalid_request": "Érvénytelen kérés",
"debug.api.notifier_unconfigured": "Notifier nincs konfigurálva",
"debug.api.event_sent": "Esemény elküldve (HTTP %d)",
"debug.api.backup_unconfigured": "Backup manager nincs konfigurálva",
"debug.api.db_dump_started": "DB dump elindítva",
"debug.api.crossdrive_none": "Nincs olyan telepített alkalmazás, amelynek 2. mentése futtatható lenne",
"debug.api.crossdrive_started": "Cross-drive mentés elindítva %d alkalmazásra",
"debug.api.not_wired": "Nem bekötött",
"debug.api.proof_none_due": "Nincs esedékes alkalmazás — minden legújabb mentés már ellenőrizve",
"debug.api.proof_not_run": "A visszaállítás nem futott le — a mentésről semmi nem derült ki",
"debug.api.proof_ok": "A mentés tartalmazza az alkalmazás adatait",
"debug.api.proof_unknown": "Nem megítélhető — a mentés nem hordoz elég információt",
"debug.api.proof_missing": "A mentés olvasható, de nem tartalmazza az alkalmazás adatait",
"debug.api.integrity_unreachable": "A tároló nem érhető el — az ellenőrzés nem futott le",
"debug.api.integrity_ok": "Az ellenőrzés rendben lezajlott",
"debug.api.integrity_failed": "Az ellenőrzés hibát talált a tárolóban",
"debug.api.hub_report_sent": "Hub jelentés elküldve",
"debug.api.hub_reachable": "Hub elérhető (HTTP %d, %dms)",
"debug.api.prefs_synced": "Preferenciák szinkronizálva",
"debug.api.gitea_reachable": "Gitea elérhető (HTTP %d, %dms)",
"debug.api.telemetry_collected": "Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)",
"debug.api.selfupdate_unconfigured": "Self-update nincs konfigurálva",
"debug.api.agent_unconfigured": "Az ügynök nincs konfigurálva ezen a rendszeren.",
"debug.api.agent_logs_unsupported": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.",
"debug.api.bundles_found": "%d csomag található",
"debug.api.no_temp_files": "Nincs eltávolítandó temp fájl",
"debug.api.temp_files_removed": "%d/%d temp fájl eltávolítva",
"deploy.a_celalkalmazas_nem_fut": "A célalkalmazás nem fut", "deploy.a_celalkalmazas_nem_fut": "A célalkalmazás nem fut",
"deploy.a_celalkalmazas_nincs_telepitve": "A célalkalmazás nincs telepítve", "deploy.a_celalkalmazas_nincs_telepitve": "A célalkalmazás nincs telepítve",
"deploy.a_ket_jelszo_nem_egyezik": "A két jelszó nem egyezik:", "deploy.a_ket_jelszo_nem_egyezik": "A két jelszó nem egyezik:",
"deploy.a_kivalasztott_meghajton_az_alkalmazas": "A kiválasztott meghajtón az alkalmazás <strong>fájljai</strong> (média, dokumentumok) tárolódnak.\n Az <strong>adatbázis a gyors belső SSD-n</strong> fut — és az alkalmazással együtt készül róla biztonsági mentés.", "deploy.a_kivalasztott_meghajton_az_alkalmazas": "A kiválasztott meghajtón az alkalmazás <strong>fájljai</strong> (média, dokumentumok) tárolódnak.\n Az <strong>adatbázis a gyors belső SSD-n</strong> fut — és az alkalmazással együtt készül róla biztonsági mentés.",
"deploy.a_kivalasztott_tarhely_majdnem_megtelt": "A kiválasztott tárhely majdnem megtelt.", "deploy.a_kivalasztott_tarhely_majdnem_megtelt": "A kiválasztott tárhely majdnem megtelt.",
"deploy.a_kontener_leallt": "A konténer leállt", "deploy.a_kontener_leallt": "A konténer leállt",
"deploy.a_kontener_leallt_ellenorizze_a": "A konténer leállt. Ellenőrizze a naplókat.", "deploy.a_kontener_leallt_ellenorizze_a": "A konténer leállt. Ellenőrizd a naplókat.",
"deploy.a_lekeres_nem_sikerult": "A lekérés nem sikerült.", "deploy.a_lekeres_nem_sikerult": "A lekérés nem sikerült.",
"deploy.a_mentes_ujrainditja_az_alkalmazast": "A mentés újraindítja az alkalmazást.", "deploy.a_mentes_ujrainditja_az_alkalmazast": "A mentés újraindítja az alkalmazást.",
"deploy.a_telepites_idotullepesbe_futott_az": "A telepítés időtúllépésbe futott. Az alkalmazás még indulhat.", "deploy.a_telepites_idotullepesbe_futott_az": "A telepítés időtúllépésbe futott. Az alkalmazás még indulhat.",
@@ -1082,7 +1107,7 @@
"deploy.ez_az_alkalmazas_mar_telepitve": "Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók.", "deploy.ez_az_alkalmazas_mar_telepitve": "Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók.",
"deploy.ez_az_alkalmazas_tud_emailt": "Ez az alkalmazás tud emailt küldeni (pl. jelszó-visszaállítás, meghívók) a Felhom-on keresztül,\n külön szolgáltató beállítása nélkül. A feladó címe: <strong>{{.AppEmailFromAddress}}</strong>.", "deploy.ez_az_alkalmazas_tud_emailt": "Ez az alkalmazás tud emailt küldeni (pl. jelszó-visszaállítás, meghívók) a Felhom-on keresztül,\n külön szolgáltató beállítása nélkül. A feladó címe: <strong>{{.AppEmailFromAddress}}</strong>.",
"deploy.ez_normalis_az_elso_percekben": "Ez normális az első percekben — az alkalmazás már elérhető lehet.", "deploy.ez_normalis_az_elso_percekben": "Ez normális az első percekben — az alkalmazás már elérhető lehet.",
"deploy.ezek_az_ertekek_a_telepitessel": "Ezek az értékek a telepítéssel együtt mentésre kerülnek. Jegyezze fel a szükséges jelszavakat!", "deploy.ezek_az_ertekek_a_telepitessel": "Ezek az értékek a telepítéssel együtt mentésre kerülnek. Jegyezd fel a szükséges jelszavakat!",
"deploy.ezek_az_ertekek_automatikusan_jottek": "Ezek az értékek automatikusan jöttek létre a telepítéskor.", "deploy.ezek_az_ertekek_automatikusan_jottek": "Ezek az értékek automatikusan jöttek létre a telepítéskor.",
"deploy.figyelem_a_muvelet_visszavonhatatlan_elo": "— FIGYELEM: a művelet visszavonhatatlan! Előtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.", "deploy.figyelem_a_muvelet_visszavonhatatlan_elo": "— FIGYELEM: a művelet visszavonhatatlan! Előtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.",
"deploy.foldrajzi_korlatozas": "Földrajzi korlátozás", "deploy.foldrajzi_korlatozas": "Földrajzi korlátozás",
@@ -1127,12 +1152,12 @@
"deploy.n_nnehany_hiba_tortent_n": "\\n\\nNéhány hiba történt:\\n", "deploy.n_nnehany_hiba_tortent_n": "\\n\\nNéhány hiba történt:\\n",
"deploy.naplok_megtekintese": "Naplók megtekintése", "deploy.naplok_megtekintese": "Naplók megtekintése",
"deploy.nem_elerheto": "{{$.CurrentHDDPath}} (nem elérhető)", "deploy.nem_elerheto": "{{$.CurrentHDDPath}} (nem elérhető)",
"deploy.nhasznalja_a_generalas_gombot_vagy": "\\nHasználja a Generálás gombot vagy írjon be egy jelszót.", "deploy.nhasznalja_a_generalas_gombot_vagy": "\\nHasználd a Generálás gombot vagy írj be egy jelszót.",
"deploy.nincs_eleg_memoria": "Nincs elég memória", "deploy.nincs_eleg_memoria": "Nincs elég memória",
"deploy.nincs_eleg_memoria_foglalas_telepites": "Nincs elég memória! Foglalás telepítés után: {{.AfterMB}} MB / {{.UsableMB}} MB", "deploy.nincs_eleg_memoria_foglalas_telepites": "Nincs elég memória! Foglalás telepítés után: {{.AfterMB}} MB / {{.UsableMB}} MB",
"deploy.nincs_eleg_szabad_tarhely": "Nincs elég szabad tárhely", "deploy.nincs_eleg_szabad_tarhely": "Nincs elég szabad tárhely",
"deploy.nincs_eleg_szabad_tarhely_a": "Nincs elég szabad tárhely a telepítéshez. Jelenleg {{.DockerFreeHuman}} szabad, és a rendszer\n {{.DockerReserveHuman}} tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében.\n A telepítés ezért átmenetileg le van tiltva — szabadítson fel helyet, vagy bővítse a tárhelyet.", "deploy.nincs_eleg_szabad_tarhely_a": "Nincs elég szabad tárhely a telepítéshez. Jelenleg {{.DockerFreeHuman}} szabad, és a rendszer\n {{.DockerReserveHuman}} tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében.\n A telepítés ezért átmenetileg le van tiltva — szabadíts fel helyet, vagy bővítsd a tárhelyet.",
"deploy.nincs_regisztralt_adattarolo_adja_meg": "Nincs regisztrált adattároló — adja meg kézzel az útvonalat", "deploy.nincs_regisztralt_adattarolo_adja_meg": "Nincs regisztrált adattároló — add meg kézzel az útvonalat",
"deploy.nincs_talalat": "Nincs találat", "deploy.nincs_talalat": "Nincs találat",
"deploy.nincs_telepitve": "Nincs telepítve", "deploy.nincs_telepitve": "Nincs telepítve",
"deploy.opcionalis_beallitasok": "Opcionális beállítások", "deploy.opcionalis_beallitasok": "Opcionális beállítások",
@@ -1223,6 +1248,8 @@
"err.backup.az_offsite_tarolo_nincs_elarvult_allapotban": "az offsite tároló nincs elárvult állapotban", "err.backup.az_offsite_tarolo_nincs_elarvult_allapotban": "az offsite tároló nincs elárvult állapotban",
"err.backup.db_restore_and_rollback_failed": "a(z) %s adatbázisának visszaállítása sikertelen, és a korábbi állapot visszatöltése sem sikerült. Az alkalmazást biztonsági okból LEÁLLÍTVA hagytuk, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot — %s", "err.backup.db_restore_and_rollback_failed": "a(z) %s adatbázisának visszaállítása sikertelen, és a korábbi állapot visszatöltése sem sikerült. Az alkalmazást biztonsági okból LEÁLLÍTVA hagytuk, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot — %s",
"err.backup.db_restore_failed_rolled_back": "a(z) %s adatbázisának visszaállítása sikertelen — az adataid visszakerültek a visszaállítás előtti állapotba, az alkalmazás fut tovább. Ha újra megpróbálnád, előbb vedd fel velünk a kapcsolatot", "err.backup.db_restore_failed_rolled_back": "a(z) %s adatbázisának visszaállítása sikertelen — az adataid visszakerültek a visszaállítás előtti állapotba, az alkalmazás fut tovább. Ha újra megpróbálnád, előbb vedd fel velünk a kapcsolatot",
"err.backup.db_restore_failed_held_mixed": "a(z) %s adatbázisának visszaállítása sikertelen. Az adatbázis a visszaállítás előtti állapotban van, de az alkalmazás fájljai és többi adata már a mentésből származnak, ezért nem illenek össze. Az alkalmazást biztonsági okból LEÁLLÍTVA hagytuk. Ehhez segítség kell: vedd fel velünk a kapcsolatot",
"err.backup.restore_failed_held_mixed": "a(z) %s visszaállítása félúton megszakadt. Az alkalmazás egyes fájljai vagy adatai már a mentésből származnak, mások nem, ezért nem illenek össze. Az alkalmazást biztonsági okból LEÁLLÍTVA hagytuk. Ehhez segítség kell: vedd fel velünk a kapcsolatot",
"err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar": "egy másik mentési/visszaállítási művelet már fut", "err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar": "egy másik mentési/visszaállítási művelet már fut",
"err.backup.ennek_az_alkalmazasnak_az_adatai_nem": "ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza", "err.backup.ennek_az_alkalmazasnak_az_adatai_nem": "ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza",
"err.backup.ervenytelen_alkalmazasnev": "érvénytelen alkalmazásnév", "err.backup.ervenytelen_alkalmazasnev": "érvénytelen alkalmazásnév",
@@ -1272,7 +1299,7 @@
"err.selfupdate.docker_pull_hianyos_registry_hitelesito_adatok": "docker pull: hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)", "err.selfupdate.docker_pull_hianyos_registry_hitelesito_adatok": "docker pull: hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)",
"err.selfupdate.frissites_mar_folyamatban": "Frissítés már folyamatban", "err.selfupdate.frissites_mar_folyamatban": "Frissítés már folyamatban",
"err.selfupdate.hianyos_registry_hitelesito_adatok_felhasznalonev_es": "hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)", "err.selfupdate.hianyos_registry_hitelesito_adatok_felhasznalonev_es": "hiányos registry hitelesítő adatok (felhasználónév és token együtt szükséges)",
"err.selfupdate.mentes_fut_probalja_kesobb": "Mentés fut, próbálja később", "err.selfupdate.mentes_fut_probalja_kesobb": "Mentés fut, próbáld később",
"err.selfupdate.nincs_elerheto_frissites": "Nincs elérhető frissítés", "err.selfupdate.nincs_elerheto_frissites": "Nincs elérhető frissítés",
"err.settings.a_halozati_tarhely_nem_lehet_a": "a hálózati tárhely nem lehet a rendszermentés célja: %s", "err.settings.a_halozati_tarhely_nem_lehet_a": "a hálózati tárhely nem lehet a rendszermentés célja: %s",
"err.settings.a_kiszolgalo_neve_csak_betut_szamot": "a kiszolgáló neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat", "err.settings.a_kiszolgalo_neve_csak_betut_szamot": "a kiszolgáló neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat",
@@ -1310,7 +1337,7 @@
"err.stacks.ellenorzes_sikertelen_appdata": "ellenőrzés sikertelen (%s appdata): %s", "err.stacks.ellenorzes_sikertelen_appdata": "ellenőrzés sikertelen (%s appdata): %s",
"err.stacks.ellenorzes_sikertelen_mentes": "ellenőrzés sikertelen (%s mentés): %s", "err.stacks.ellenorzes_sikertelen_mentes": "ellenőrzés sikertelen (%s mentés): %s",
"err.stacks.field_required": "a(z) %q (%s) mező kitöltése kötelező", "err.stacks.field_required": "a(z) %q (%s) mező kitöltése kötelező",
"err.stacks.field_required_password": "a(z) %q mező kitöltése kötelező — használja a Generálás gombot vagy írjon be egy jelszót", "err.stacks.field_required_password": "a(z) %q mező kitöltése kötelező — használd a Generálás gombot vagy írj be egy jelszót",
"err.stacks.forras_nevter_olvasasa_sikertelen": "forrás névtér olvasása sikertelen: %s", "err.stacks.forras_nevter_olvasasa_sikertelen": "forrás névtér olvasása sikertelen: %s",
"err.stacks.forras_torlese_sikertelen": "forrás törlése sikertelen (%s): %s", "err.stacks.forras_torlese_sikertelen": "forrás törlése sikertelen (%s): %s",
"err.stacks.forras_torlese_sikertelen_appdata": "forrás törlése sikertelen (%s appdata): %s", "err.stacks.forras_torlese_sikertelen_appdata": "forrás törlése sikertelen (%s appdata): %s",
@@ -1344,7 +1371,7 @@
"err.web.ervenytelen_csatlakoztatasi_nev_csak_betuk_szamok": "érvénytelen csatlakoztatási név (csak betűk, számok, _ és - engedélyezett)", "err.web.ervenytelen_csatlakoztatasi_nev_csak_betuk_szamok": "érvénytelen csatlakoztatási név (csak betűk, számok, _ és - engedélyezett)",
"err.web.ez_a_mappa_nem_oszthato_meg": "Ez a mappa nem osztható meg.", "err.web.ez_a_mappa_nem_oszthato_meg": "Ez a mappa nem osztható meg.",
"err.web.ez_az_egyetlen_hasznalhato_tarhely_a": "ez az egyetlen használható tárhely — a leszerelés megtagadva (előbb adj hozzá vagy állíts be másik alapértelmezett meghajtót)", "err.web.ez_az_egyetlen_hasznalhato_tarhely_a": "ez az egyetlen használható tárhely — a leszerelés megtagadva (előbb adj hozzá vagy állíts be másik alapértelmezett meghajtót)",
"err.web.formazas_kesz_de_az_uj_fajlrendszer": "formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót", "err.web.formazas_kesz_de_az_uj_fajlrendszer": "formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsd az oldalt, és használd a Csatolás funkciót",
"err.web.formazas_sikertelen": "formázás sikertelen: %s", "err.web.formazas_sikertelen": "formázás sikertelen: %s",
"err.web.formazas_sikertelen_2": "formázás sikertelen: %s", "err.web.formazas_sikertelen_2": "formázás sikertelen: %s",
"err.web.leszereles_visszavonasa_sikertelen": "leszerelés visszavonása sikertelen: %s", "err.web.leszereles_visszavonasa_sikertelen": "leszerelés visszavonása sikertelen: %s",
@@ -1387,6 +1414,20 @@
"event.crossdrive_completed": "Másodlagos mentés elkészült: %s", "event.crossdrive_completed": "Másodlagos mentés elkészült: %s",
"event.crossdrive_failed": "Másodlagos mentés sikertelen: %s", "event.crossdrive_failed": "Másodlagos mentés sikertelen: %s",
"event.db_dump_completed": "Adatbázis mentés elkészült", "event.db_dump_completed": "Adatbázis mentés elkészült",
"event.db_dump_failed": "Adatbázis mentés sikertelen",
"event.offbox_backup_failed": "Off-box (NAS) mentés sikertelen",
"event.offbox_enlarge_blocked": "A(z) %s teljes távoli mentése (~%s) túllépné a tárhelykeretet (%d/%d GB). A konfiguráció és az adatbázis továbbra is mentésre kerül; nagyobb kerethez vedd fel velünk a kapcsolatot.",
"event.backup_integrity_failed": "A távoli mentés ellenőrzése hibát talált a tárolóban. A mentések egy része sérült lehet. Ne törölj semmit, és vedd fel velünk a kapcsolatot.",
"event.backup_integrity_ok": "A távoli mentés ellenőrzése rendben lezajlott. (%s)",
"event.backup_integrity_ok_subset": "A távoli mentés ellenőrzése rendben lezajlott. (%s, a mentett adatok %s-át újraolvasva)",
"event.appstop.failed": "Megszakadt %s, mert a vezérlő újraindult. Nem sikerült újraindítani %d alkalmazást (összesen %d állt le).",
"event.appstop.failed_held": "Megszakadt %s, mert a vezérlő újraindult. Nem sikerült újraindítani %d alkalmazást (összesen %d állt le). További %d alkalmazás azért áll, mert nem érhető el az adatmeghajtója; ezek nem számítanak hibának.",
"event.appstop.held": "Megszakadt %s, mert a vezérlő újraindult. %d alkalmazás leállítva maradt, mert nem érhető el az adatmeghajtója; amikor a meghajtó visszatér, újraindulnak.",
"event.appstop.restarted": "Megszakadt %s, mert a vezérlő újraindult. %d leállva maradt alkalmazás újraindult.",
"event.appstop.restarted_held": "Megszakadt %s, mert a vezérlő újraindult. %d leállva maradt alkalmazás újraindult; további %d a hiányzó adatmeghajtó miatt vár.",
"appstop.reason.volume_dump": "egy alkalmazásadat-mentés (kötetmentés)",
"appstop.reason.offbox_reconstitute": "egy távoli visszaállítás",
"appstop.reason.app_export": "egy alkalmazás-exportálás",
"event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)", "event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)",
"event.disaster_recovery_started": "Katasztrófa helyreállítás elindítva (%d alkalmazás)", "event.disaster_recovery_started": "Katasztrófa helyreállítás elindítva (%d alkalmazás)",
"event.health_critical": "Rendszer állapot kritikus (volt: %s)", "event.health_critical": "Rendszer állapot kritikus (volt: %s)",
@@ -1394,11 +1435,15 @@
"event.health_recovered": "Rendszer állapot helyreállt: %s (volt: %s)", "event.health_recovered": "Rendszer állapot helyreállt: %s (volt: %s)",
"event.storage_disconnected": "Meghajtó váratlanul leválasztva: %s", "event.storage_disconnected": "Meghajtó váratlanul leválasztva: %s",
"event.storage_reconnected": "Meghajtó újra csatlakoztatva: %s", "event.storage_reconnected": "Meghajtó újra csatlakoztatva: %s",
"event.fill.critical": "A(z) „%s” tároló kritikusan megtelt: %s szabad hely maradt (%s foglalt). A biztonsági mentések és az alkalmazások írásai bármikor meghiúsulhatnak. Szabadíts fel helyet mielőbb: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót.",
"event.fill.warning": "A(z) „%s” tároló %s foglalt — %s szabad hely maradt. Szabadíts fel helyet, mielőtt megtelik: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót. Ha megtelik, a biztonsági mentések meghiúsulnak.",
"event.fill.label_apps": "Alkalmazások területe",
"event.fill.label_system": "Rendszer- és mentési terület",
"flash.backup.not_configured": "Mentés nincs beállítva", "flash.backup.not_configured": "Mentés nincs beállítva",
"flash.backup.window_invalid_time": "Érvénytelen időpont. Használja a ÓÓ:PP formátumot (például 02:30).", "flash.backup.window_invalid_time": "Érvénytelen időpont. Használd az ÓÓ:PP formátumot (például 02:30).",
"flash.backup.window_save_failed": "A mentési időablak mentése nem sikerült.", "flash.backup.window_save_failed": "A mentési időablak mentése nem sikerült.",
"flash.backup.window_updated": "Mentési időablak frissítve.", "flash.backup.window_updated": "Mentési időablak frissítve.",
"flash.login.password_changed": "Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.", "flash.login.password_changed": "Jelszó sikeresen módosítva. Jelentkezz be az új jelszóval.",
"flash.offbox.app_missing": "Hiányzó alkalmazás.", "flash.offbox.app_missing": "Hiányzó alkalmazás.",
"flash.offbox.app_setting_updated": "A távoli mentés beállítása frissítve.", "flash.offbox.app_setting_updated": "A távoli mentés beállítása frissítve.",
"flash.offbox.config_invalid": "Érvénytelen beállítás: %s", "flash.offbox.config_invalid": "Érvénytelen beállítás: %s",
@@ -1447,6 +1492,7 @@
"flash.share.disabled": "A megosztás kikapcsolva.", "flash.share.disabled": "A megosztás kikapcsolva.",
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
"flash.share.enabled": "A megosztás bekapcsolva.", "flash.share.enabled": "A megosztás bekapcsolva.",
"flash.share.wrong_password": "Hibás jelszó",
"flash.share.not_on": "A megosztás nincs bekapcsolva.", "flash.share.not_on": "A megosztás nincs bekapcsolva.",
"flash.share.pw_clear_failed": "A jelszó törlése nem sikerült.", "flash.share.pw_clear_failed": "A jelszó törlése nem sikerült.",
"flash.share.pw_cleared": "A megosztási jelszó törölve.", "flash.share.pw_cleared": "A megosztási jelszó törölve.",
@@ -1502,6 +1548,19 @@
"func.time.tomorrow_at": "holnap %s", "func.time.tomorrow_at": "holnap %s",
"func.time.yesterday": "tegnap", "func.time.yesterday": "tegnap",
"health.no_probe_container": "Nem futott egészségellenőrzés: nincs hozzá tartozó konténer.", "health.no_probe_container": "Nem futott egészségellenőrzés: nincs hozzá tartozó konténer.",
"health.system_disk_high": "A rendszer tárhelye fogy: %.0f%% foglalt",
"health.system_disk_critical": "A rendszer tárhelye majdnem megtelt: %.0f%% foglalt",
"health.data_disk_high": "Az adatmeghajtó tárhelye fogy: %.0f%% foglalt",
"health.data_disk_critical": "Az adatmeghajtó majdnem megtelt: %.0f%% foglalt",
"health.memory_high": "A memóriahasználat magas: %.0f%%",
"health.cpu_high": "A processzor terhelése magas: %.0f%%",
"health.temperature_high": "A hőmérséklet magas: %.0f°C (%s)",
"health.docker_unreachable": "A Docker nem érhető el: %s",
"health.protected_container_down": "Egy védett rendszerszolgáltatás nem fut: %s",
"health.storage_unavailable": "Adattároló nem elérhető: %s",
"health.storage_not_separate": "Az adattároló (%s) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak",
"health.storage_usage_high": "Adattároló használat magas: %s (%.0f%%)",
"health.storage_almost_full": "Adattároló majdnem megtelt: %s (%.0f%%)",
"hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem", "hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem",
"hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza", "hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza",
"hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza", "hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza",
@@ -1522,7 +1581,7 @@
"launcher.a_megosztas_jelszoval_vedett": "A megosztás jelszóval védett.", "launcher.a_megosztas_jelszoval_vedett": "A megosztás jelszóval védett.",
"launcher.alkalmazasok_telepitese": "Alkalmazások telepítése", "launcher.alkalmazasok_telepitese": "Alkalmazások telepítése",
"launcher.az_otthoni_alkalmazasok_egy_helyen": "Az otthoni alkalmazások egy helyen.", "launcher.az_otthoni_alkalmazasok_egy_helyen": "Az otthoni alkalmazások egy helyen.",
"launcher.biztosan_kikapcsolja_a_megosztast_a": "Biztosan kikapcsolja a megosztást? A link azonnal érvénytelenné válik.", "launcher.biztosan_kikapcsolja_a_megosztast_a": "Biztosan kikapcsolod a megosztást? A link azonnal érvénytelenné válik.",
"launcher.egy_megoszthato_link_amely_csak": "Egy megosztható link, amely csak megmutatja az alkalmazásokat és új lapon megnyitja őket. Fiók nélkül, felügyeleti hozzáférés nélkül — minden alkalmazás a saját bejelentkezése mögött marad.", "launcher.egy_megoszthato_link_amely_csak": "Egy megosztható link, amely csak megmutatja az alkalmazásokat és új lapon megnyitja őket. Fiók nélkül, felügyeleti hozzáférés nélkül — minden alkalmazás a saját bejelentkezése mögött marad.",
"launcher.inditopult": "Indítópult", "launcher.inditopult": "Indítópult",
"launcher.inditopult_2": "Indítópult — {{.Domain}}", "launcher.inditopult_2": "Indítópult — {{.Domain}}",
@@ -1538,7 +1597,7 @@
"launcher.megosztas_bekapcsolasa": "Megosztás bekapcsolása", "launcher.megosztas_bekapcsolasa": "Megosztás bekapcsolása",
"launcher.megosztas_kikapcsolasa": "Megosztás kikapcsolása", "launcher.megosztas_kikapcsolasa": "Megosztás kikapcsolása",
"launcher.megosztasi_link": "Megosztási link", "launcher.megosztasi_link": "Megosztási link",
"launcher.olvassa_be_telefonnal_a_gyors": "Olvassa be telefonnal a gyors megnyitáshoz.", "launcher.olvassa_be_telefonnal_a_gyors": "Olvasd be telefonnal a gyors megnyitáshoz.",
"launcher.qr_kod_a_megosztasi_linkhez": "QR-kód a megosztási linkhez", "launcher.qr_kod_a_megosztasi_linkhez": "QR-kód a megosztási linkhez",
"launcher.uj_link_keszitese": "Új link készítése", "launcher.uj_link_keszitese": "Új link készítése",
"launcher.uj_link_keszitese_a_regi": "Új link készítése? A régi link és minden korábbi belépés érvénytelenné válik.", "launcher.uj_link_keszitese_a_regi": "Új link készítése? A régi link és minden korábbi belépés érvénytelenné válik.",
@@ -1579,7 +1638,7 @@
"layout.felhasznaloi_adatok_torlese_a_merevlemez": "Felhasználói adatok törlése a merevlemezről", "layout.felhasznaloi_adatok_torlese_a_merevlemez": "Felhasználói adatok törlése a merevlemezről",
"layout.folyamatban": "Folyamatban...", "layout.folyamatban": "Folyamatban...",
"layout.frissites": "↻ Frissítés...", "layout.frissites": "↻ Frissítés...",
"layout.ha_ujratelepiti_az_alkalmazast_az": "Ha újratelepíti az alkalmazást, az adatokat újra importálnia kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.", "layout.ha_ujratelepiti_az_alkalmazast_az": "Ha újratelepíted az alkalmazást, az adatokat újra importálnod kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.",
"layout.halozati_hiba": "Hálózati hiba:", "layout.halozati_hiba": "Hálózati hiba:",
"layout.halozati_megosztas": "Hálózati megosztás", "layout.halozati_megosztas": "Hálózati megosztás",
"layout.halozati_tarhely": "Hálózati tárhely", "layout.halozati_tarhely": "Hálózati tárhely",
@@ -1610,6 +1669,7 @@
"layout.nyelv": "Nyelv", "layout.nyelv": "Nyelv",
"layout.rendszer": "Rendszer", "layout.rendszer": "Rendszer",
"layout.rendszermonitor": "Rendszermonitor", "layout.rendszermonitor": "Rendszermonitor",
"layout.hibakereses": "Hibakeresés",
"layout.sablonok_frissitve": "Sablonok frissítve", "layout.sablonok_frissitve": "Sablonok frissítve",
"layout.sikeresen_eltavolitva": "Sikeresen eltávolítva!", "layout.sikeresen_eltavolitva": "Sikeresen eltávolítva!",
"layout.sikeresen_torolve": "Sikeresen törölve!", "layout.sikeresen_torolve": "Sikeresen törölve!",
@@ -1704,6 +1764,7 @@
"note.offsite.whole_unit_gap": "(a teljes alkalmazás — nincs helyi mentési egysége)", "note.offsite.whole_unit_gap": "(a teljes alkalmazás — nincs helyi mentési egysége)",
"note.reconstitute.copy_latest": "legutóbbi", "note.reconstitute.copy_latest": "legutóbbi",
"note.reconstitute.held": "a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot", "note.reconstitute.held": "a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot",
"note.reconstitute.held_mixed": "a(z) %s visszaállítása %s-kor félúton megszakadt: az adatai részben a mentésből származnak, részben nem. Az alkalmazás biztonsági okból leállítva marad. Ehhez segítség kell: vedd fel velünk a kapcsolatot",
"note.restore.whole_files": "A meghajtó fájljai: %d visszahozva, %d kicserélve (a régebbi élő példány megmaradt mellette, .felhom-… végződéssel), %d újabb példány érintetlenül hagyva, %d változatlan. Fájl nem lett törölve.", "note.restore.whole_files": "A meghajtó fájljai: %d visszahozva, %d kicserélve (a régebbi élő példány megmaradt mellette, .felhom-… végződéssel), %d újabb példány érintetlenül hagyva, %d változatlan. Fájl nem lett törölve.",
"note.restore.all_files_present": "Minden vizsgált fájl megvan a helyén.", "note.restore.all_files_present": "Minden vizsgált fájl megvan a helyén.",
"note.restore.and_database": " és az adatbázis", "note.restore.and_database": " és az adatbázis",
@@ -1806,7 +1867,7 @@
"recovery.adatok": "Adatok", "recovery.adatok": "Adatok",
"recovery.adatok_visszaszerzese_felhom": "Adatok visszaszerzése — Felhom", "recovery.adatok_visszaszerzese_felhom": "Adatok visszaszerzése — Felhom",
"recovery.alkalmazas": "Alkalmazás", "recovery.alkalmazas": "Alkalmazás",
"recovery.amelyet_zartunk_le": ", amelyet\n <strong>{{.}}</strong> zártunk le", "recovery.amelyet_zartunk_le": ", amelyet\n <strong>{{fmtTimeStr .}}</strong> zártunk le",
"recovery.biztosan_nem_kered_vissza_a": "Biztosan nem kéred vissza a korábbi mentéseket?", "recovery.biztosan_nem_kered_vissza_a": "Biztosan nem kéred vissza a korábbi mentéseket?",
"recovery.elerhetok": "elérhetők", "recovery.elerhetok": "elérhetők",
"recovery.ez_a_lehetoseg_akkor_valik": "ez a lehetőség akkor válik elérhetővé, ha a gép már újra kapcsolódott a házon kívüli\n tárhelyhez. Addig a mentéseid érintetlenül megmaradnak.", "recovery.ez_a_lehetoseg_akkor_valik": "ez a lehetőség akkor válik elérhetővé, ha a gép már újra kapcsolódott a házon kívüli\n tárhelyhez. Addig a mentéseid érintetlenül megmaradnak.",
@@ -1825,12 +1886,14 @@
"recovery.meret": "Méret", "recovery.meret": "Méret",
"recovery.most_nem": "Most nem", "recovery.most_nem": "Most nem",
"recovery.nem_kerem_vissza_a_korabbi": "<a href=\"/recovery?setaside=1\">nem kérem vissza a korábbi adatokat</a>.", "recovery.nem_kerem_vissza_a_korabbi": "<a href=\"/recovery?setaside=1\">nem kérem vissza a korábbi adatokat</a>.",
"recovery.older_unchecked": "Ez a kód nem nyitotta meg azt a csomagot, amit most őrzünk ehhez a géphez. Ehhez a géphez korábbi csomagokat is őrzünk, de ezeket nem tudtuk mind kipróbálni a kódoddal — ezért nem tudjuk, hogy a kódod hibás-e. Lehet elgépelés: ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad-e be, és próbáld újra. Lehet az is, hogy a kódod egy korábbi csomaghoz tartozik. Ha újrapróbálod és úgy sem megy, a kódodat tedd el, és keresd a Felhom ügyfélszolgálatát: írd meg, hogy a helyreállítási kódod nem nyitotta meg a mentéseidet. Semmi nem változott.",
"recovery.semmi_nem_valtozott_varj_nehany": "Semmi nem változott. Várj néhány másodpercet, és próbáld újra — a kódodra továbbra is szükséged lesz,", "recovery.semmi_nem_valtozott_varj_nehany": "Semmi nem változott. Várj néhány másodpercet, és próbáld újra — a kódodra továbbra is szükséged lesz,",
"recovery.tiz_szo_szokozokkel_elvalasztva": "tíz szó, szóközökkel elválasztva", "recovery.tiz_szo_szokozokkel_elvalasztva": "tíz szó, szóközökkel elválasztva",
"recovery.tovabb_a_visszaallitashoz": "Tovább a visszaállításhoz", "recovery.tovabb_a_visszaallitashoz": "Tovább a visszaállításhoz",
"recovery.ugyhogy_tartsd_keznel": "úgyhogy tartsd kéznél.", "recovery.ugyhogy_tartsd_keznel": "úgyhogy tartsd kéznél.",
"recovery.vissza_a_kezdolapra": "Vissza a kezdőlapra", "recovery.vissza_a_kezdolapra": "Vissza a kezdőlapra",
"recovery.visszaszerzese": "visszaszerzése", "recovery.visszaszerzese": "visszaszerzése",
"recovery_info.body": "Felhom — Vészhelyzeti információk\n=================================\n\nÜgyfél azonosító: %s\nHub URL: %s\nVisszaállítási jelszó: %s\n\nTámogatás:\n E-mail: %s\n Web: %s\n\nHa vissza kell állítanod valamit:\n 1. Nyisd meg a vezérlőpultot a böngésződben: %s\n 2. Egy alkalmazás adatait a Biztonsági mentés → Visszaállítás oldalon\n állíthatod vissza: %s\n 3. Ha a gépedet újratelepítettük, a vezérlőpult felajánlja a korábbi,\n házon kívüli mentéseid visszaszerzését (Adatok visszaszerzése: %s).\n Ehhez a helyreállítási kódodra lesz szükséged: a tíz szóra, amelyet\n a telepítéskor kaptál. Ezt a kódot senki nem tudja pótolni, ezért\n őrizd meg biztonságos helyen.\n 4. Ha elakadsz, írj nekünk a fenti címre.\n\nGenerálva: %s\nController verzió: %s\n",
"restore.refuse.files.head": "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. ", "restore.refuse.files.head": "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. ",
"restore.refuse.files.none": "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót.", "restore.refuse.files.none": "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót.",
"restore.refuse.files.offsite": "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”.", "restore.refuse.files.offsite": "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”.",
@@ -1863,6 +1926,8 @@
"settings_notifications.beallitasok_ertesitesek": "Beállítások — Értesítések", "settings_notifications.beallitasok_ertesitesek": "Beállítások — Értesítések",
"settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "Bekapcsolás után az egyes alkalmazásoknál is engedélyezni kell az email-küldést (az alkalmazás oldalán).", "settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "Bekapcsolás után az egyes alkalmazásoknál is engedélyezni kell az email-küldést (az alkalmazás oldalán).",
"settings_notifications.biztonsagi_mentes_sikertelen": "Biztonsági mentés sikertelen", "settings_notifications.biztonsagi_mentes_sikertelen": "Biztonsági mentés sikertelen",
"settings_notifications.clear_confirm": "Igen, törlöm a címet",
"settings_notifications.clear_warning": "Ha törlöd az értesítési e-mail-címet, nem kapsz több értesítő levelet. A mentés csak akkor történik meg, ha megerősíted.",
"settings_notifications.e_mail_cim": "E-mail cím", "settings_notifications.e_mail_cim": "E-mail cím",
"settings_notifications.elvart_adatbazismentes_elmaradt": "Elvárt adatbázismentés elmaradt", "settings_notifications.elvart_adatbazismentes_elmaradt": "Elvárt adatbázismentés elmaradt",
"settings_notifications.elvart_fajlmentes_elmaradt": "Elvárt fájlmentés elmaradt", "settings_notifications.elvart_fajlmentes_elmaradt": "Elvárt fájlmentés elmaradt",
@@ -1870,7 +1935,6 @@
"settings_notifications.ertesitesi_szunet": "Értesítési szünet", "settings_notifications.ertesitesi_szunet": "Értesítési szünet",
"settings_notifications.ez_jelenik_meg_a_kimeno": "Ez jelenik meg a kimenő emailek feladójaként az alkalmazás neve mellett.", "settings_notifications.ez_jelenik_meg_a_kimeno": "Ez jelenik meg a kimenő emailek feladójaként az alkalmazás neve mellett.",
"settings_notifications.felado_neve_opcionalis": "Feladó neve (opcionális)", "settings_notifications.felado_neve_opcionalis": "Feladó neve (opcionális)",
"settings_notifications.hibak_es_figyelmeztetesek": "Hibák és figyelmeztetések:",
"settings_notifications.lemez_beteloben_90_felett": "Lemez betelőben (90% felett)", "settings_notifications.lemez_beteloben_90_felett": "Lemez betelőben (90% felett)",
"settings_notifications.lemez_megtelt_vagy_meghibasodott": "Lemez megtelt vagy meghibásodott", "settings_notifications.lemez_megtelt_vagy_meghibasodott": "Lemez megtelt vagy meghibásodott",
"settings_notifications.masodlagos_mentes_sikertelen": "Másodlagos mentés sikertelen", "settings_notifications.masodlagos_mentes_sikertelen": "Másodlagos mentés sikertelen",
@@ -1884,14 +1948,24 @@
"settings_notifications.rendszer_allapot_helyreallt": "Rendszer állapot helyreállt", "settings_notifications.rendszer_allapot_helyreallt": "Rendszer állapot helyreállt",
"settings_notifications.rendszer_allapot_kritikus": "Rendszer állapot kritikus", "settings_notifications.rendszer_allapot_kritikus": "Rendszer állapot kritikus",
"settings_notifications.szerver_nem_elerheto": "Szerver nem elérhető", "settings_notifications.szerver_nem_elerheto": "Szerver nem elérhető",
"settings_notifications.tajekoztato": "Tájékoztató:",
"settings_notifications.tavoli_mentes_tarhelykeret_figyelmeztete": "Távoli mentés — tárhelykeret-figyelmeztetés", "settings_notifications.tavoli_mentes_tarhelykeret_figyelmeztete": "Távoli mentés — tárhelykeret-figyelmeztetés",
"settings_notifications.teszt_email_kuldese": "Teszt email küldése", "settings_notifications.teszt_email_kuldese": "Teszt email küldése",
"settings_notifications.groups_label": "Miről küldjünk levelet?",
"settings_notifications.group_machine": "A gépem és a meghajtóim",
"settings_notifications.group_machine_desc": "Levelet kapsz, ha egy meghajtó leválik vagy visszakerül, betelőben van, megtelt vagy meghibásodott, vagy a távoli mentésnek kevés a helye. Ezekkel neked van teendőd.",
"settings_notifications.group_apps": "Az alkalmazásaim",
"settings_notifications.group_apps_desc": "Levelet kapsz, ha egy alkalmazás nem fut, vagy leállítottuk, mert újra és újra összeomlott, és ha egy frissítést visszavontunk vagy nem sikerült.",
"settings_notifications.group_handled": "Amit mi intézünk",
"settings_notifications.group_handled_desc": "Levelet kapsz, ha egy mentés nem sikerült vagy elmaradt, vagy a gép nem elérhető. Ezeket mi is látjuk és intézzük, neked nincs teendőd.",
"settings_notifications.group_custom": "egyedi",
"settings_notifications.group_custom_hint": "Ennél a csoportnál egyedi beállításod van. Ha bekapcsolod, minden értesítése bekapcsol.",
"settings_notifications.reszletek": "Részletek",
"settings_notifications.reszletek_hint": "Itt egyenként is be- és kikapcsolhatod az értesítéseket.",
"settings_security.a_cloudflare_api_token_nincs": "A Cloudflare API token nincs konfigurálva. Kérd az üzemeltetőt a beállításhoz.<br>\n <small>A tokennek <strong>Zone WAF:Edit</strong> jogosultsággal kell rendelkeznie.</small>", "settings_security.a_cloudflare_api_token_nincs": "A Cloudflare API token nincs konfigurálva. Kérd az üzemeltetőt a beállításhoz.<br>\n <small>A tokennek <strong>Zone WAF:Edit</strong> jogosultsággal kell rendelkeznie.</small>",
"settings_security.a_helyi_halozati_hozzaferes_mindig": "A helyi hálózati hozzáférés mindig engedélyezett (nem halad át a Cloudflare-en).", "settings_security.a_helyi_halozati_hozzaferes_mindig": "A helyi hálózati hozzáférés mindig engedélyezett (nem halad át a Cloudflare-en).",
"settings_security.a_jelszavas_vedelem_nincs_beallitva": "A jelszavas védelem nincs beállítva. Kérd az üzemeltetőt a beállításhoz.", "settings_security.a_jelszavas_vedelem_nincs_beallitva": "A jelszavas védelem nincs beállítva. Kérd az üzemeltetőt a beállításhoz.",
"settings_security.a_visszaallitasi_jelszo_lekerese_nem": "A visszaállítási jelszó lekérése nem sikerült.", "settings_security.a_visszaallitasi_jelszo_lekerese_nem": "A visszaállítási jelszó lekérése nem sikerült.",
"settings_security.adja_meg_a_jelenlegi_jelszavat": "Adja meg a jelenlegi jelszavát", "settings_security.adja_meg_a_jelenlegi_jelszavat": "Add meg a jelenlegi jelszavad",
"settings_security.alkalmazas_kivalasztasa": "— Alkalmazás kiválasztása —", "settings_security.alkalmazas_kivalasztasa": "— Alkalmazás kiválasztása —",
"settings_security.alkalmazas_specifikus_felulirasok": "Alkalmazás-specifikus felülírások", "settings_security.alkalmazas_specifikus_felulirasok": "Alkalmazás-specifikus felülírások",
"settings_security.beallitasok_biztonsag_es_hozzaferes": "Beállítások — Biztonság és hozzáférés", "settings_security.beallitasok_biztonsag_es_hozzaferes": "Beállítások — Biztonság és hozzáférés",
@@ -1899,10 +1973,10 @@
"settings_security.elrejt": "Elrejt", "settings_security.elrejt": "Elrejt",
"settings_security.engedelyezett_orszagok": "Engedélyezett országok:", "settings_security.engedelyezett_orszagok": "Engedélyezett országok:",
"settings_security.engedelyezett_orszagok_globalis": "Engedélyezett országok (globális)", "settings_security.engedelyezett_orszagok_globalis": "Engedélyezett országok (globális)",
"settings_security.ezeket_az_adatokat_mentse_el": "Ezeket az adatokat mentse el biztos helyre. Újratelepítés esetén szükség lesz rájuk a rendszer visszaállításához.", "settings_security.ezeket_az_adatokat_mentse_el": "Ezeket az adatokat mentsd el biztos helyre. Újratelepítés esetén szükség lesz rájuk a rendszer visszaállításához.",
"settings_security.felhom_eu_kapcsolat": "<a href=\"mailto:{{.SupportEmail}}\" style=\"color: var(--blue);\">{{.SupportEmail}}</a>\n &nbsp;|&nbsp;\n <a href=\"{{.SupportURL}}\" target=\"_blank\" style=\"color: var(--blue);\">felhom.eu/kapcsolat</a>", "settings_security.felhom_eu_kapcsolat": "<a href=\"mailto:{{.SupportEmail}}\" style=\"color: var(--blue);\">{{.SupportEmail}}</a>\n &nbsp;|&nbsp;\n <a href=\"{{.SupportURL}}\" target=\"_blank\" style=\"color: var(--blue);\">felhom.eu/kapcsolat</a>",
"settings_security.figyelem": "Figyelem", "settings_security.figyelem": "Figyelem",
"settings_security.figyelem_magyarorszag_eltavolitasa_azt_j": "Figyelem: Magyarország eltávolítása azt jelenti, hogy magyar IP-ről sem lesz elérhető a rendszer távolról. Biztosan folytatja?", "settings_security.figyelem_magyarorszag_eltavolitasa_azt_j": "Figyelem: Magyarország eltávolítása azt jelenti, hogy magyar IP-ről sem lesz elérhető a rendszer távolról. Biztosan folytatod?",
"settings_security.foldrajzi_korlatozas": "Földrajzi korlátozás", "settings_security.foldrajzi_korlatozas": "Földrajzi korlátozás",
"settings_security.geo_korlatozas_aktiv": "Geo-korlátozás aktív", "settings_security.geo_korlatozas_aktiv": "Geo-korlátozás aktív",
"settings_security.halozati_hiba": "Hálózati hiba", "settings_security.halozati_hiba": "Hálózati hiba",
@@ -1914,7 +1988,7 @@
"settings_security.kesz": "Kész", "settings_security.kesz": "Kész",
"settings_security.kezi_szinkronizalas": "Kézi szinkronizálás", "settings_security.kezi_szinkronizalas": "Kézi szinkronizálás",
"settings_security.legalabb_8_karakter": "Legalább 8 karakter", "settings_security.legalabb_8_karakter": "Legalább 8 karakter",
"settings_security.magyarorszag_eltavolitasa_nem_ajanlott_f": "Magyarország eltávolítása nem ajánlott. Folytatja?", "settings_security.magyarorszag_eltavolitasa_nem_ajanlott_f": "Magyarország eltávolítása nem ajánlott. Folytatod?",
"settings_security.megerosites": "Megerősítés", "settings_security.megerosites": "Megerősítés",
"settings_security.megjelenit": "Megjelenít", "settings_security.megjelenit": "Megjelenít",
"settings_security.mentes": "Mentés...", "settings_security.mentes": "Mentés...",
@@ -1930,7 +2004,7 @@
"settings_security.uj_jelszo": "Új jelszó", "settings_security.uj_jelszo": "Új jelszó",
"settings_security.uj_jelszo_megerositese": "Új jelszó megerősítése", "settings_security.uj_jelszo_megerositese": "Új jelszó megerősítése",
"settings_security.utolso": "Utolsó:", "settings_security.utolso": "Utolsó:",
"settings_security.utolso_szinkronizalas": "Utolsó szinkronizálás: {{.GeoLastSync}}", "settings_security.utolso_szinkronizalas": "Utolsó szinkronizálás: {{fmtTimeStr .GeoLastSync}}",
"settings_security.veszhelyzeti_informaciok": "Vészhelyzeti információk", "settings_security.veszhelyzeti_informaciok": "Vészhelyzeti információk",
"settings_security.visszaallitasi_jelszo": "Visszaállítási jelszó", "settings_security.visszaallitasi_jelszo": "Visszaállítási jelszó",
"settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "A kiszolgáló ügynök jelenleg nem elérhető — próbáld meg később.", "settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "A kiszolgáló ügynök jelenleg nem elérhető — próbáld meg később.",
@@ -1945,18 +2019,18 @@
"settings_system.atmeretezes": "Átméretezés", "settings_system.atmeretezes": "Átméretezés",
"settings_system.atmeretezes_2": "Átméretezés...", "settings_system.atmeretezes_2": "Átméretezés...",
"settings_system.automatikus_frissites": "Automatikus frissítés", "settings_system.automatikus_frissites": "Automatikus frissítés",
"settings_system.az_egesz_kiszolgalo_szerver_ujrainditasa": "Az egész kiszolgáló (szerver) újraindítása. Minden alkalmazás rövid időre leáll, és a vezérlőpult kb. 30 másodpercig nem elérhető. Akkor használja, ha a teljes rendszer újraindítására van szükség — egyébként a fenti „Vezérlő újraindítása” elegendő.", "settings_system.az_egesz_kiszolgalo_szerver_ujrainditasa": "Az egész kiszolgáló (szerver) újraindítása. Minden alkalmazás rövid időre leáll, és a vezérlőpult kb. 30 másodpercig nem elérhető. Akkor használd, ha a teljes rendszer újraindítására van szükség — egyébként a fenti „Vezérlő újraindítása” elegendő.",
"settings_system.az_ujrainditas_a_vartnal_tovabb": "Az újraindítás a vártnál tovább tart. Töltse újra az oldalt kézzel.", "settings_system.az_ujrainditas_a_vartnal_tovabb": "Az újraindítás a vártnál tovább tart. Töltsd újra az oldalt kézzel.",
"settings_system.az_uzemelteto_altal_beallitott_ertekek": "Az üzemeltető által beállított értékek. Módosításhoz kérd az üzemeltetőt.", "settings_system.az_uzemelteto_altal_beallitott_ertekek": "Az üzemeltető által beállított értékek. Módosításhoz kérd az üzemeltetőt.",
"settings_system.beallitasok_rendszer": "Beállítások — Rendszer", "settings_system.beallitasok_rendszer": "Beállítások — Rendszer",
"settings_system.biztonsagi_mentes": "Biztonsági mentés", "settings_system.biztonsagi_mentes": "Biztonsági mentés",
"settings_system.biztosan_frissiti_a_controllert_n": "Biztosan frissíti a controllert?\\n\\nA folyamat alatt a vezérlőpult rövid időre elérhetetlenné válik.", "settings_system.biztosan_frissiti_a_controllert_n": "Biztosan frissíti a controllert?\\n\\nA folyamat alatt a vezérlőpult rövid időre elérhetetlenné válik.",
"settings_system.biztosan_ujrainditja_a_kiszolgalot_az": "Biztosan újraindítja a kiszolgálót? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak.", "settings_system.biztosan_ujrainditja_a_kiszolgalot_az": "Biztosan újraindítod a kiszolgálót? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak.",
"settings_system.biztosan_ujrainditja_a_vezerlot_a": "Biztosan újraindítja a vezérlőt? A művelet néhány másodpercig tart, és a felület rövid időre elérhetetlen lesz.", "settings_system.biztosan_ujrainditja_a_vezerlot_a": "Biztosan újraindítod a vezérlőt? A művelet néhány másodpercig tart, és a felület rövid időre elérhetetlen lesz.",
"settings_system.csokkentes": "Csökkentés", "settings_system.csokkentes": "Csökkentés",
"settings_system.ellenorzes": "Ellenőrzés...", "settings_system.ellenorzes": "Ellenőrzés...",
"settings_system.ervenytelen_memoria_ertek": "Érvénytelen memória érték", "settings_system.ervenytelen_memoria_ertek": "Érvénytelen memória érték",
"settings_system.ezek_az_ertekek_a_helyi": "Ezek az értékek a helyi hálózattól függenek és változhatnak. Távoli hibaelhárításnál ezt az oldalt kérjük felolvasni.", "settings_system.ezek_az_ertekek_a_helyi": "Ezek az értékek a helyi hálózattól függenek és változhatnak. Távoli hibaelhárításnál erről az oldalról olvasd fel őket.",
"settings_system.felhasznalt": "Felhasznált", "settings_system.felhasznalt": "Felhasznált",
"settings_system.folyamatban": "Folyamatban", "settings_system.folyamatban": "Folyamatban",
"settings_system.frissites": "Frissítés", "settings_system.frissites": "Frissítés",
@@ -1966,7 +2040,7 @@
"settings_system.frissites_folyamatban_a_vezerlo_hamarosa": "Frissítés folyamatban — a vezérlő hamarosan újraindul…", "settings_system.frissites_folyamatban_a_vezerlo_hamarosa": "Frissítés folyamatban — a vezérlő hamarosan újraindul…",
"settings_system.frissites_keresese": "Frissítés keresése", "settings_system.frissites_keresese": "Frissítés keresése",
"settings_system.frissites_telepitese": "Frissítés telepítése", "settings_system.frissites_telepitese": "Frissítés telepítése",
"settings_system.ha_a_vezerlo_hibasan_mukodik": "Ha a vezérlő hibásan működik, itt biztonságosan újraindíthatja — nem kell az egész szervert újraindítani.\n Az alkalmazásai futnak tovább; csak a vezérlő indul újra (néhány másodperc).", "settings_system.ha_a_vezerlo_hibasan_mukodik": "Ha a vezérlő hibásan működik, itt biztonságosan újraindíthatod — nem kell az egész szervert újraindítani.\n Az alkalmazásaid futnak tovább; csak a vezérlő indul újra (néhány másodperc).",
"settings_system.halozat": "Hálózat", "settings_system.halozat": "Hálózat",
"settings_system.halozati_nev": "Hálózati név", "settings_system.halozati_nev": "Hálózati név",
"settings_system.helyi_cim_lan": "Helyi cím (LAN)", "settings_system.helyi_cim_lan": "Helyi cím (LAN)",
@@ -2080,7 +2154,7 @@
"stacks.alkalmazas_visszaallitasa_exportalt_csom": "Alkalmazás visszaállítása exportált csomagból", "stacks.alkalmazas_visszaallitasa_exportalt_csom": "Alkalmazás visszaállítása exportált csomagból",
"stacks.alkalmazasok": "Alkalmazások", "stacks.alkalmazasok": "Alkalmazások",
"stacks.az_alkalmazas_adattaroloja_nem_elerheto": "Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat.", "stacks.az_alkalmazas_adattaroloja_nem_elerheto": "Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat.",
"stacks.az_alkalmazas_kornyezeteben_a_halozati": "Az alkalmazás környezetében a hálózati tárhely helyén üres helyi könyvtár van — az adatok nem a NAS-ra kerülnek. Jelezze az üzemeltetőnek.", "stacks.az_alkalmazas_kornyezeteben_a_halozati": "Az alkalmazás környezetében a hálózati tárhely helyén üres helyi könyvtár van — az adatok nem a NAS-ra kerülnek. Jelezd az üzemeltetőnek.",
"stacks.frissites": "Frissítés", "stacks.frissites": "Frissítés",
"stacks.futo": "Futó", "stacks.futo": "Futó",
"stacks.halozati_tarhely_hibas_az_alkalmazas": "Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja", "stacks.halozati_tarhely_hibas_az_alkalmazas": "Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja",
@@ -2106,7 +2180,7 @@
"storage.a_masolatok_redundansak_az_eredetik": "(A másolatok redundánsak — az eredetik a forrás-meghajtón maradnak.)", "storage.a_masolatok_redundansak_az_eredetik": "(A másolatok redundánsak — az eredetik a forrás-meghajtón maradnak.)",
"storage.a_meghajto_biztonsagosan_eltavolithato": "A meghajtó biztonságosan eltávolítható.", "storage.a_meghajto_biztonsagosan_eltavolithato": "A meghajtó biztonságosan eltávolítható.",
"storage.a_meghajto_kimarad_az_uj": "A meghajtó kimarad az új alkalmazás-telepítésekből. A rajta lévő alkalmazások változatlanul futnak.", "storage.a_meghajto_kimarad_az_uj": "A meghajtó kimarad az új alkalmazás-telepítésekből. A rajta lévő alkalmazások változatlanul futnak.",
"storage.a_meghajto_levalasztasra_kerul_az": "A meghajtó leválasztásra kerül. Az adatok megmaradnak, de az ott tárolt alkalmazások elvesztik a tárhelyüket, amíg újra nem csatolja.", "storage.a_meghajto_levalasztasra_kerul_az": "A meghajtó leválasztásra kerül. Az adatok megmaradnak, de az ott tárolt alkalmazások elvesztik a tárhelyüket, amíg újra nem csatolod.",
"storage.a_meghajto_ujra_valaszthato_az": "A meghajtó újra választható az új alkalmazás-telepítéseknél.", "storage.a_meghajto_ujra_valaszthato_az": "A meghajtó újra választható az új alkalmazás-telepítéseknél.",
"storage.a_meghajto_vegleges_kivonasa_a": "A meghajtó végleges kivonása a rendszerből. Ha céltárolót választ, előbb minden adat átkerül oda; áthelyezés nélkül az adatok a meghajtón maradnak.", "storage.a_meghajto_vegleges_kivonasa_a": "A meghajtó végleges kivonása a rendszerből. Ha céltárolót választ, előbb minden adat átkerül oda; áthelyezés nélkül az adatok a meghajtón maradnak.",
"storage.a_muvelet_utan_a_kovetkezo": "A művelet után a következő alkalmazások <strong>nem fognak működni</strong>:", "storage.a_muvelet_utan_a_kovetkezo": "A művelet után a következő alkalmazások <strong>nem fognak működni</strong>:",
@@ -2136,9 +2210,9 @@
"storage.belso_ssd_a_szerver_rendszere": "Belső SSD — a szerver rendszere, a Docker és a telepített alkalmazások adatbázisai itt találhatók.", "storage.belso_ssd_a_szerver_rendszere": "Belső SSD — a szerver rendszere, a Docker és a telepített alkalmazások adatbázisai itt találhatók.",
"storage.biztonsagi_mentes_vedett": "Biztonsági mentés — védett", "storage.biztonsagi_mentes_vedett": "Biztonsági mentés — védett",
"storage.biztonsagos_levalasztas": "Biztonságos leválasztás", "storage.biztonsagos_levalasztas": "Biztonságos leválasztás",
"storage.biztos_levalasztja_a_meghajtot": "Biztos leválasztja a meghajtót:", "storage.biztos_levalasztja_a_meghajtot": "Biztosan leválasztod a meghajtót:",
"storage.biztosan_eltavolitja_adattarolot": "Biztosan eltávolítja a(z) {{.Path}} adattárolót?", "storage.biztosan_eltavolitja_adattarolot": "Biztosan eltávolítod a(z) {{.Path}} adattárolót?",
"storage.biztosan_eltavolitja_meghajtot": "Biztosan eltávolítja a(z) {{.Label}} ({{.Path}}) meghajtót a rendszerből?\\n\\nA meghajtó adatai NEM törlődnek.", "storage.biztosan_eltavolitja_meghajtot": "Biztosan eltávolítod a(z) {{.Label}} ({{.Path}}) meghajtót a rendszerből?\\n\\nA meghajtó adatai NEM törlődnek.",
"storage.csak_a_nyilvantartasbol_torli_a": "Csak a nyilvántartásból törli a meghajtót — az adatok és a fájlrendszer érintetlenül maradnak.", "storage.csak_a_nyilvantartasbol_torli_a": "Csak a nyilvántartásból törli a meghajtót — az adatok és a fájlrendszer érintetlenül maradnak.",
"storage.csatlakoztatas": "Csatlakoztatás", "storage.csatlakoztatas": "Csatlakoztatás",
"storage.csatlakoztatas_2": "Csatlakoztatás...", "storage.csatlakoztatas_2": "Csatlakoztatás...",
@@ -2171,7 +2245,7 @@
"storage.hiba": "Hiba:", "storage.hiba": "Hiba:",
"storage.host_tarhely_rendszer_sablonok_iso": "Host tárhely — rendszer-sablonok, ISO-k, host szintű mentések. Nem tárol alkalmazásadatot.", "storage.host_tarhely_rendszer_sablonok_iso": "Host tárhely — rendszer-sablonok, ISO-k, host szintű mentések. Nem tárol alkalmazásadatot.",
"storage.hozzaadas": "Hozzáadás", "storage.hozzaadas": "Hozzáadás",
"storage.idopont": "Időpont: {{.DecommissionedAt}}", "storage.idopont": "Időpont: {{fmtTimeStr .DecommissionedAt}}",
"storage.inaktiv": "Inaktív", "storage.inaktiv": "Inaktív",
"storage.ismeretlen": "ismeretlen", "storage.ismeretlen": "ismeretlen",
"storage.kesz": "Kész", "storage.kesz": "Kész",
@@ -2189,16 +2263,16 @@
"storage.leszereles_athelyezessel_minden_adat_atm": "Leszerelés áthelyezéssel: minden adat átmásolása ide:", "storage.leszereles_athelyezessel_minden_adat_atm": "Leszerelés áthelyezéssel: minden adat átmásolása ide:",
"storage.leszereles_elinditva": "Leszerelés elindítva.", "storage.leszereles_elinditva": "Leszerelés elindítva.",
"storage.leszerelese_n_naz_adatok_nem": "leszerelése?\\n\\nAz adatok NEM törlődnek.", "storage.leszerelese_n_naz_adatok_nem": "leszerelése?\\n\\nAz adatok NEM törlődnek.",
"storage.leszerelesehez_athelyezes_nelkul_irja_be": "leszereléséhez (áthelyezés nélkül) írja be a meghajtó nevét megerősítésként.", "storage.leszerelesehez_athelyezes_nelkul_irja_be": "leszereléséhez (áthelyezés nélkül) írd be a meghajtó nevét megerősítésként.",
"storage.levalasztas": "Leválasztás", "storage.levalasztas": "Leválasztás",
"storage.levalasztas_folyamatban": "Leválasztás folyamatban…", "storage.levalasztas_folyamatban": "Leválasztás folyamatban…",
"storage.levalasztva": "Leválasztva", "storage.levalasztva": "Leválasztva",
"storage.levalasztva_2": "Leválasztva: {{.DisconnectedAt}}", "storage.levalasztva_2": "Leválasztva: {{fmtTimeStr .DisconnectedAt}}",
"storage.majd_a_z": "majd a(z)", "storage.majd_a_z": "majd a(z)",
"storage.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Már csatlakoztatott tárhely hozzáadása kézzel", "storage.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Már csatlakoztatott tárhely hozzáadása kézzel",
"storage.megerosites": "Megerősítés", "storage.megerosites": "Megerősítés",
"storage.megerositeshez_irja_be": "Megerősítéshez írja be:", "storage.megerositeshez_irja_be": "Megerősítéshez írd be:",
"storage.megerositeshez_irja_be_a_csatlakoztatasi": "Megerősítéshez írja be a csatlakoztatási nevet:", "storage.megerositeshez_irja_be_a_csatlakoztatasi": "Megerősítéshez írd be a csatlakoztatási nevet:",
"storage.meghajto_eltavolitasa": "Meghajtó eltávolítása", "storage.meghajto_eltavolitasa": "Meghajtó eltávolítása",
"storage.meghajto_leszerelese": "Meghajtó leszerelése", "storage.meghajto_leszerelese": "Meghajtó leszerelése",
"storage.meghajto_levalasztasa": "Meghajtó leválasztása", "storage.meghajto_levalasztasa": "Meghajtó leválasztása",
@@ -2222,7 +2296,7 @@
"storage.nem_regisztralt_meghajtok": "Nem regisztrált meghajtók", "storage.nem_regisztralt_meghajtok": "Nem regisztrált meghajtók",
"storage.nincs_alkalmazas_ezen_a_tarolon": "Nincs alkalmazás ezen a tárolón", "storage.nincs_alkalmazas_ezen_a_tarolon": "Nincs alkalmazás ezen a tárolón",
"storage.nincs_folyamatban_migracio": "Nincs folyamatban migráció.", "storage.nincs_folyamatban_migracio": "Nincs folyamatban migráció.",
"storage.nincs_regisztralt_adattarolo_adjon_hozza": "Nincs regisztrált adattároló. Adjon hozzá egyet az alábbi űrlappal.", "storage.nincs_regisztralt_adattarolo_adjon_hozza": "Nincs regisztrált adattároló. Adj hozzá egyet az alábbi űrlappal.",
"storage.osszes_adat_athelyezese": "Összes adat áthelyezése", "storage.osszes_adat_athelyezese": "Összes adat áthelyezése",
"storage.osszes_adatat_ide": "ÖSSZES adatát ide:", "storage.osszes_adatat_ide": "ÖSSZES adatát ide:",
"storage.pl_mnt_hdd_1_a": "Pl. /mnt/hdd_1 — a meghajtónak már csatolva kell lennie", "storage.pl_mnt_hdd_1_a": "Pl. /mnt/hdd_1 — a meghajtónak már csatolva kell lennie",
@@ -2245,7 +2319,7 @@
"storage.vedett_csak_operatori_alairassal_modosit": "védett — csak operátori aláírással módosíthatók", "storage.vedett_csak_operatori_alairassal_modosit": "védett — csak operátori aláírással módosíthatók",
"storage.vegleges_leszereles": "Végleges leszerelés", "storage.vegleges_leszereles": "Végleges leszerelés",
"storage.visszacsatlakoztatas": "Visszacsatlakoztatás", "storage.visszacsatlakoztatas": "Visszacsatlakoztatás",
"storage.visszacsatlakoztatja_a_z": "Visszacsatlakoztatja a(z)", "storage.visszacsatlakoztatja_a_z": "Visszacsatlakoztatod a(z)",
"storage_attach.1_meghajto_kivalasztasa": "1. Meghajtó kiválasztása", "storage_attach.1_meghajto_kivalasztasa": "1. Meghajtó kiválasztása",
"storage_attach.2_konfiguracio": "2. Konfiguráció", "storage_attach.2_konfiguracio": "2. Konfiguráció",
"storage_attach.a_meghajto_a_mnt_nev": "A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.", "storage_attach.a_meghajto_a_mnt_nev": "A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.",
@@ -2267,14 +2341,14 @@
"storage_attach.meglevo_meghajto_csatolasa": "Meglévő meghajtó csatolása", "storage_attach.meglevo_meghajto_csatolasa": "Meglévő meghajtó csatolása",
"storage_attach.megnevezes": "Megnevezés", "storage_attach.megnevezes": "Megnevezés",
"storage_attach.megsem": "Mégsem", "storage_attach.megsem": "Mégsem",
"storage_attach.nincs_csatolhato_meghajto_a_csatolashoz": "Nincs csatolható meghajtó — a csatoláshoz olyan (még nem használt) meghajtó kell, amelyen már van fájlrendszer. Új, üres meghajtóhoz használja az „Inicializálás” lehetőséget.", "storage_attach.nincs_csatolhato_meghajto_a_csatolashoz": "Nincs csatolható meghajtó — a csatoláshoz olyan (még nem használt) meghajtó kell, amelyen már van fájlrendszer. Új, üres meghajtóhoz használd az „Inicializálás” lehetőséget.",
"storage_attach.nyilvantartasba_veve": "nyilvántartásba véve", "storage_attach.nyilvantartasba_veve": "nyilvántartásba véve",
"storage_attach.valassza_ki_a_mar_fajlrendszerrel": "Válassza ki a már fájlrendszerrel rendelkező meghajtót.\n <strong>A meghajtón lévő adatok nem törlődnek</strong> — a csatolás csak elérhetővé teszi azokat.", "storage_attach.valassza_ki_a_mar_fajlrendszerrel": "Válaszd ki a már fájlrendszerrel rendelkező meghajtót.\n <strong>A meghajtón lévő adatok nem törlődnek</strong> — a csatolás csak elérhetővé teszi azokat.",
"storage_attach.vissza": "← Vissza", "storage_attach.vissza": "← Vissza",
"storage_attach.vissza_a_beallitasokhoz": ". <a href=\"/settings\">Vissza a Beállításokhoz →</a>", "storage_attach.vissza_a_beallitasokhoz": ". <a href=\"/settings\">Vissza a Beállításokhoz →</a>",
"storage_init.1_meghajto_kivalasztasa": "1. Meghajtó kiválasztása", "storage_init.1_meghajto_kivalasztasa": "1. Meghajtó kiválasztása",
"storage_init.2_konfiguracio": "2. Konfiguráció", "storage_init.2_konfiguracio": "2. Konfiguráció",
"storage_init.a_kivalasztott_meghajto_adatot_tartalmaz": "A kiválasztott meghajtó <strong>adatot tartalmaz</strong>. Az inicializálás (formázás) törli a rajta lévő\n összes adatot — a folytatáshoz meg kell erősítenie.", "storage_init.a_kivalasztott_meghajto_adatot_tartalmaz": "A kiválasztott meghajtó <strong>adatot tartalmaz</strong>. Az inicializálás (formázás) törli a rajta lévő\n összes adatot — a folytatáshoz meg kell erősítened.",
"storage_init.a_meghajto_a_mnt_nev": "A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.", "storage_init.a_meghajto_a_mnt_nev": "A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.",
"storage_init.a_meghajto_adatot_tartalmaz_a": "A meghajtó adatot tartalmaz. A formázás <strong>véglegesen törli</strong> a rajta lévő összes adatot.", "storage_init.a_meghajto_adatot_tartalmaz_a": "A meghajtó adatot tartalmaz. A formázás <strong>véglegesen törli</strong> a rajta lévő összes adatot.",
"storage_init.a_meghajto_sikeresen_inicializalva_es": "A meghajtó sikeresen inicializálva és regisztrálva:", "storage_init.a_meghajto_sikeresen_inicializalva_es": "A meghajtó sikeresen inicializálva és regisztrálva:",
@@ -2295,18 +2369,18 @@
"storage_init.kivalasztott_eszkoz": "Kiválasztott eszköz", "storage_init.kivalasztott_eszkoz": "Kiválasztott eszköz",
"storage_init.kulso_hdd_1tb": "Külső HDD 1TB", "storage_init.kulso_hdd_1tb": "Külső HDD 1TB",
"storage_init.mar_folyamatban_van_egy_inicializalas": "Már folyamatban van egy inicializálás.", "storage_init.mar_folyamatban_van_egy_inicializalas": "Már folyamatban van egy inicializálás.",
"storage_init.megerositeshez_irja_be_a_csatlakoztatasi": "Megerősítéshez írja be a csatlakoztatási nevet:", "storage_init.megerositeshez_irja_be_a_csatlakoztatasi": "Megerősítéshez írd be a csatlakoztatási nevet:",
"storage_init.meghajtok_betoltese_sikertelen": "Meghajtók betöltése sikertelen:", "storage_init.meghajtok_betoltese_sikertelen": "Meghajtók betöltése sikertelen:",
"storage_init.megnevezes": "Megnevezés", "storage_init.megnevezes": "Megnevezés",
"storage_init.megsem": "Mégsem", "storage_init.megsem": "Mégsem",
"storage_init.nincs_elerheto_meghajto_az_inicializalas": "Nincs elérhető meghajtó az inicializáláshoz. Csatlakoztasson egy új adathordozót — a rendszer-, a biztonsági mentés- és a már használatban lévő meghajtók itt nem jelennek meg.", "storage_init.nincs_elerheto_meghajto_az_inicializalas": "Nincs elérhető meghajtó az inicializáláshoz. Csatlakoztass egy új adathordozót — a rendszer-, a biztonsági mentés- és a már használatban lévő meghajtók itt nem jelennek meg.",
"storage_init.operatori_alairas_szukseges_ez_a": "<strong>Operátori aláírás szükséges.</strong> Ez a meghajtó védett (rendszer/biztonsági mentés).", "storage_init.operatori_alairas_szukseges_ez_a": "<strong>Operátori aláírás szükséges.</strong> Ez a meghajtó védett (rendszer/biztonsági mentés).",
"storage_init.regisztralas": "Regisztrálás", "storage_init.regisztralas": "Regisztrálás",
"storage_init.torles_es_inicializalas_inditasa": "Törlés és inicializálás indítása…", "storage_init.torles_es_inicializalas_inditasa": "Törlés és inicializálás indítása…",
"storage_init.torles_es_inicializalas_megerositese": "Törlés és inicializálás megerősítése", "storage_init.torles_es_inicializalas_megerositese": "Törlés és inicializálás megerősítése",
"storage_init.uj_meghajto_inicializalasa": "Új meghajtó inicializálása", "storage_init.uj_meghajto_inicializalasa": "Új meghajtó inicializálása",
"storage_init.ures_formazhato": "Üres — formázható", "storage_init.ures_formazhato": "Üres — formázható",
"storage_init.valassza_ki_a_formazando_felhasznaloi": "Válassza ki a formázandó <strong>felhasználói adatmeghajtót</strong>. Rendszer- és\n biztonsági-mentés meghajtók itt nem jelennek meg — azok védettek. Ha a meghajtó adatot tartalmaz, a törlést\n Önnek meg kell erősítenie.", "storage_init.valassza_ki_a_formazando_felhasznaloi": "Válaszd ki a formázandó <strong>felhasználói adatmeghajtót</strong>. Rendszer- és\n biztonsági-mentés meghajtók itt nem jelennek meg — azok védettek. Ha a meghajtó adatot tartalmaz, a törlést\n neked kell megerősítened.",
"storage_init.var": "vár", "storage_init.var": "vár",
"storage_init.vissza": "← Vissza", "storage_init.vissza": "← Vissza",
"storage_init.vissza_a_tarhelyhez": ". <a href=\"/storage\">Vissza a tárhelyhez →</a>", "storage_init.vissza_a_tarhelyhez": ". <a href=\"/storage\">Vissza a tárhelyhez →</a>",
@@ -2317,7 +2391,7 @@
"storage_network.a_legtobb_mediaalkalmazas_1000_es": "A legtöbb médiaalkalmazás 1000-es azonosítóval fut.", "storage_network.a_legtobb_mediaalkalmazas_1000_es": "A legtöbb médiaalkalmazás 1000-es azonosítóval fut.",
"storage_network.a_megosztas_a_gazdagepen_elerheto": "A megosztás a gazdagépen elérhető, de az alkalmazások környezetében üres helyi könyvtár látszik — az adatok NEM a NAS-ra kerülnének", "storage_network.a_megosztas_a_gazdagepen_elerheto": "A megosztás a gazdagépen elérhető, de az alkalmazások környezetében üres helyi könyvtár látszik — az adatok NEM a NAS-ra kerülnének",
"storage_network.a_megosztas_a_mnt_felhom": "A megosztás a /mnt/felhom-drives/&lt;név&gt; útvonalon lesz elérhető.", "storage_network.a_megosztas_a_mnt_felhom": "A megosztás a /mnt/felhom-drives/&lt;név&gt; útvonalon lesz elérhető.",
"storage_network.a_megosztas_be_van_allitva": "A megosztás be van állítva a gazdagépen, de nincs regisztrálva — egy megszakadt csatlakoztatás maradványa. Távolítsa el, vagy adja hozzá újra ugyanezzel a névvel.", "storage_network.a_megosztas_be_van_allitva": "A megosztás be van állítva a gazdagépen, de nincs regisztrálva — egy megszakadt csatlakoztatás maradványa. Távolítsd el, vagy add hozzá újra ugyanezzel a névvel.",
"storage_network.a_megosztas_elerheto_es_csatlakoztatva": "A megosztás elérhető és csatlakoztatva van", "storage_network.a_megosztas_elerheto_es_csatlakoztatva": "A megosztás elérhető és csatlakoztatva van",
"storage_network.a_nas_jelenleg_nem_erheto": "A NAS jelenleg nem érhető el — az érintett alkalmazások átmenetileg nem olvasnak róla", "storage_network.a_nas_jelenleg_nem_erheto": "A NAS jelenleg nem érhető el — az érintett alkalmazások átmenetileg nem olvasnak róla",
"storage_network.a_qnap_feluletere_szabott_lepesrol": "A QNAP felületére szabott lépésről lépésre útmutató készül.", "storage_network.a_qnap_feluletere_szabott_lepesrol": "A QNAP felületére szabott lépésről lépésre útmutató készül.",
@@ -2326,21 +2400,21 @@
"storage_network.arva": "Árva", "storage_network.arva": "Árva",
"storage_network.az_allapot_jelenleg_nem_lekerdezheto": "Az állapot jelenleg nem lekérdezhető", "storage_network.az_allapot_jelenleg_nem_lekerdezheto": "Az állapot jelenleg nem lekérdezhető",
"storage_network.beallitva_a_gazdagepen_de_nincs": "Beállítva a gazdagépen, de nincs regisztrálva", "storage_network.beallitva_a_gazdagepen_de_nincs": "Beállítva a gazdagépen, de nincs regisztrálva",
"storage_network.biztosan_eltavolitja_a_z": "Biztosan eltávolítja a(z)", "storage_network.biztosan_eltavolitja_a_z": "Biztosan eltávolítod a(z)",
"storage_network.csatlakoztatas": "Csatlakoztatás", "storage_network.csatlakoztatas": "Csatlakoztatás",
"storage_network.csatolasi_teszt": "Csatolási teszt…", "storage_network.csatolasi_teszt": "Csatolási teszt…",
"storage_network.egy_nas_megosztas_nfs_vagy": "Egy NAS-megosztás (NFS vagy SMB) csatlakoztatása nagy méretű médiatartalomhoz (film, fotó, zene).\n A megosztás kiválasztható médiaalkalmazás adatkönyvtáraként. A hálózati tárhely nem fizikai\n meghajtó — nincs leszerelés/áthelyezés, csak eltávolítás.", "storage_network.egy_nas_megosztas_nfs_vagy": "Egy NAS-megosztás (NFS vagy SMB) csatlakoztatása nagy méretű médiatartalomhoz (film, fotó, zene).\n A megosztás kiválasztható médiaalkalmazás adatkönyvtáraként. A hálózati tárhely nem fizikai\n meghajtó — nincs leszerelés/áthelyezés, csak eltávolítás.",
"storage_network.egyszeru_mas_nas_okhoz_kapcsolja": "Egyszerű (más NAS-okhoz): kapcsolja be az exporton a „minden felhasználó leképezése” (map all\n users / all squash) opciót írás-olvasás móddal — bármelyik helyi felhasználóra. A fájlok\n tulajdonosa a rendszerben „nobody”-ként látszik; az alkalmazások túlnyomó többségének ez megfelelő.", "storage_network.egyszeru_mas_nas_okhoz_kapcsolja": "Egyszerű (más NAS-okhoz): kapcsold be az exporton a „minden felhasználó leképezése” (map all\n users / all squash) opciót írás-olvasás móddal — bármelyik helyi felhasználóra. A fájlok\n tulajdonosa a rendszerben „nobody”-ként látszik; az alkalmazások túlnyomó többségének ez megfelelő.",
"storage_network.elerheto": "Elérhető", "storage_network.elerheto": "Elérhető",
"storage_network.elerheto_jelenleg_keszenletben_igeny_sze": "Elérhető, jelenleg készenlétben (igény szerint csatlakozik)", "storage_network.elerheto_jelenleg_keszenletben_igeny_sze": "Elérhető, jelenleg készenlétben (igény szerint csatlakozik)",
"storage_network.eltavolitas": "Eltávolítás", "storage_network.eltavolitas": "Eltávolítás",
"storage_network.engedelyezze_a_felhom_gep_ip": "Engedélyezze a Felhom gép IP-címét a megosztáson. Proxmox-fürt esetén minden csomópont IP-címét.", "storage_network.engedelyezze_a_felhom_gep_ip": "Engedélyezd a Felhom gép IP-címét a megosztáson. Proxmox-fürt esetén minden csomópont IP-címét.",
"storage_network.halozati_tarhely_eltavolitasa": "Hálózati tárhely eltávolítása", "storage_network.halozati_tarhely_eltavolitasa": "Hálózati tárhely eltávolítása",
"storage_network.halozati_tarhely_hozzaadasa": "Hálózati tárhely hozzáadása", "storage_network.halozati_tarhely_hozzaadasa": "Hálózati tárhely hozzáadása",
"storage_network.halozati_tarhelyet_n_na_megosztas": "hálózati tárhelyet?\\n\\nA megosztás leválasztásra kerül; a NAS-on lévő adatok érintetlenek maradnak.", "storage_network.halozati_tarhelyet_n_na_megosztas": "hálózati tárhelyet?\\n\\nA megosztás leválasztásra kerül; a NAS-on lévő adatok érintetlenek maradnak.",
"storage_network.hiba": "Hiba:", "storage_network.hiba": "Hiba:",
"storage_network.hibas_az_alkalmazasok_nem_a": "Hibás — az alkalmazások nem a NAS-t látják", "storage_network.hibas_az_alkalmazasok_nem_a": "Hibás — az alkalmazások nem a NAS-t látják",
"storage_network.hozzon_letre_vagy_jeloljon_ki": "Hozzon létre (vagy jelöljön ki) egy felhasználót a NAS-on, és adjon neki írás-olvasás jogot a\n megosztásra. Más beállítás nem szükséges — a fájlok a NAS-on ennek a felhasználónak a nevében\n jönnek létre.", "storage_network.hozzon_letre_vagy_jeloljon_ki": "Hozz létre (vagy jelölj ki) egy felhasználót a NAS-on, és adj neki írás-olvasás jogot a\n megosztásra. Más beállítás nem szükséges — a fájlok a NAS-on ennek a felhasználónak a nevében\n jönnek létre.",
"storage_network.igy_a_tulajdonos_informaciok_is": "— így a tulajdonos-információk is hibátlanok.", "storage_network.igy_a_tulajdonos_informaciok_is": "— így a tulajdonos-információk is hibátlanok.",
"storage_network.ip_cim_vagy_dns_nev": "IP-cím vagy DNS-név. A Windows-hálózati név (pl. FELHOM) itt általában nem oldható fel — a Megosztás oldal »közvetlen cím« sora adja meg a másik eszköz IP-címét.", "storage_network.ip_cim_vagy_dns_nev": "IP-cím vagy DNS-név. A Windows-hálózati név (pl. FELHOM) itt általában nem oldható fel — a Megosztás oldal »közvetlen cím« sora adja meg a másik eszköz IP-címét.",
"storage_network.irasteszt": "Írásteszt…", "storage_network.irasteszt": "Írásteszt…",
@@ -2366,7 +2440,7 @@
"storage_network.smb_felhasznalonev": "SMB felhasználónév", "storage_network.smb_felhasznalonev": "SMB felhasználónév",
"storage_network.smb_jelszo": "SMB jelszó", "storage_network.smb_jelszo": "SMB jelszó",
"storage_network.smb_synology_qnap_a_legtobb": "SMB (Synology, QNAP — a legtöbb NAS)", "storage_network.smb_synology_qnap_a_legtobb": "SMB (Synology, QNAP — a legtöbb NAS)",
"storage_network.synology_dsm_ellenorzott_lepesek_eloszor": "Synology (DSM, ellenőrzött lépések): először kapcsolja be az NFS-t — Vezérlőpult → File\n Services → NFS fül → „Enable NFS service”, és állítsa a „Maximum NFS protocol” értékét\n NFSv4.1-re (az alapértelmezett NFSv3 nem elegendő a csatlakoztatáshoz). Ezután a megosztott\n mappa szerkesztésében az „NFS Permissions” fülön hozzon létre szabályt a Felhom gép\n IP-címére, Read/Write jogosultsággal, a Squash mezőben a „Map all users to admin” opcióval.\n A megosztás elérési útja ugyanezen a fülön látható (pl. /volume1/&lt;mappa&gt;) — ez kerül a\n fenti „Megosztás” mezőbe.", "storage_network.synology_dsm_ellenorzott_lepesek_eloszor": "Synology (DSM, ellenőrzött lépések): először kapcsold be az NFS-t — Vezérlőpult → File\n Services → NFS fül → „Enable NFS service”, és állítsd a „Maximum NFS protocol” értékét\n NFSv4.1-re (az alapértelmezett NFSv3 nem elegendő a csatlakoztatáshoz). Ezután a megosztott\n mappa szerkesztésében az „NFS Permissions” fülön hozz létre szabályt a Felhom gép\n IP-címére, Read/Write jogosultsággal, a Squash mezőben a „Map all users to admin” opcióval.\n A megosztás elérési útja ugyanezen a fülön látható (pl. /volume1/&lt;mappa&gt;) — ez kerül a\n fenti „Megosztás” mezőbe.",
"storage_network.szerver_ip_vagy_hosztnev": "Szerver (IP vagy hosztnév)", "storage_network.szerver_ip_vagy_hosztnev": "Szerver (IP vagy hosztnév)",
"storage_network.tarhely_halozati_tarhely_nas": "Tárhely — Hálózati tárhely (NAS)", "storage_network.tarhely_halozati_tarhely_nas": "Tárhely — Hálózati tárhely (NAS)",
"storage_network.technikai_reszletek": "Technikai részletek", "storage_network.technikai_reszletek": "Technikai részletek",
@@ -2479,6 +2553,28 @@
"err.kept.not_listed": "Ez nem megőrzött adat, ezért nem nyúltunk hozzá.", "err.kept.not_listed": "Ez nem megőrzött adat, ezért nem nyúltunk hozzá.",
"err.kept.occupied": "Az alkalmazás mappájában már vannak adatok, ezért a megőrzött fájlokat nem tettük vissza. Nem változott semmi.", "err.kept.occupied": "Az alkalmazás mappájában már vannak adatok, ezért a megőrzött fájlokat nem tettük vissza. Nem változott semmi.",
"api.kept.busy": "Most egy mentés vagy visszaállítás fut. Ha befejeződött, próbáld újra.", "api.kept.busy": "Most egy mentés vagy visszaállítás fut. Ha befejeződött, próbáld újra.",
"api.escrow.dashboard_password_missing": "A vezérlőpult jelszava nincs beállítva — előbb állíts be jelszót.",
"api.escrow.too_many_attempts": "Túl sok sikertelen próbálkozás, próbáld újra 1 perc múlva.",
"api.escrow.stage_failed": "A távoli mentés jelszavának letéti előkészítése nem sikerült — a folyamat nem indult el. Próbáld újra.",
"api.escrow.already_running": "Egy kódkészítés már folyamatban van — várd meg, míg befejeződik.",
"api.escrow.code_not_shown": "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le. Indítsd újra a folyamatot: az új kód a régit érvényteleníti.",
"api.escrow.no_active_job": "Nincs aktív helyreállítási folyamat — előbb indítsd el a kódkészítést.",
"api.share.form_invalid": "Érvénytelen űrlap — töltsd újra az oldalt.",
"api.share.too_many_attempts": "Túl sok sikertelen próbálkozás, próbáld újra 1 perc múlva",
"api.export.upload_size_mismatch": "A feltöltött méret nem egyezik — próbáld újra.",
"api.netstorage.uid_range": "Az alkalmazás felhasználói azonosítója (uid) érvénytelen. Adj meg 1 és 65533 közötti értéket — a legtöbb médiaalkalmazás az 1000-est használja.",
"api.netstorage.remove_in_use": "A tároló nem távolítható el, amíg alkalmazás használja: %s. Előbb távolítsd el vagy költöztesd át az alkalmazást.",
"api.netstorage.add.unreachable": "A szerver nem érhető el (%s). Ellenőrizd az IP-címet, és hogy a NAS be van-e kapcsolva.",
"api.netstorage.add.flat_name_tip": " Tipp: a(z) »%s« Windows-hálózati névnek tűnik — használd az eszköz IP-címét.",
"api.netstorage.add.nfs_export": "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizd az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét.",
"api.netstorage.add.smb_auth": "Hibás SMB felhasználónév vagy jelszó.",
"api.netstorage.add.smb_share": "A megadott SMB-megosztás nem található a szerveren. Ellenőrizd a megosztás nevét.",
"api.netstorage.add.timeout": "Időtúllépés a csatolás közben — a szerver elérhető a hálózaton, de a megosztás nem csatolható. Ellenőrizd a NAS NFS/SMB szolgáltatását.",
"api.netstorage.add.not_writable": "A megosztás csatolható, de az alkalmazások nem tudnak rá írni. NFS esetén kapcsold be a NAS-on a „minden felhasználó leképezése” (map all users / all squash) beállítást a megosztáson — vagy állítsd a fájlok tulajdonosát a(z) %d azonosítóra. SMB esetén ellenőrizd, hogy a felhasználónak írási joga van a megosztáson.",
"api.netstorage.add.not_network_fs": "A hálózati tárhely csatolása a rendszeren belül nem jött létre megfelelően. Próbáld újra a csatlakoztatást; ha a hiba ismétlődik, jelezd az üzemeltetőnek.",
"api.netstorage.add.probe_io": "Írási hiba a megosztáson (az adat nem olvasható vissza hibátlanul). Ellenőrizd a megosztást és a hálózatot.",
"api.netstorage.add.busy": "Már folyamatban van egy csatlakoztatás. Várd meg, amíg befejeződik.",
"api.netstorage.add.failed": "A csatolás sikertelen. Részletek alább.",
"page.title.kept_data": "Megőrzött adatok", "page.title.kept_data": "Megőrzött adatok",
"note.restore.older_version": "A(z) %s visszaállt a(z) %s-i mentésből, a(z) %s verzióra. Elérhető frissítés: a doboz lépésenként hozza naprakészre.", "note.restore.older_version": "A(z) %s visszaállt a(z) %s-i mentésből, a(z) %s verzióra. Elérhető frissítés: a doboz lépésenként hozza naprakészre.",
"note.restore.older_version_no_step": "A(z) %s visszaállt a(z) %s-i mentésből, a(z) %s verzióra. Ettől a verziótól nem vezet kipróbált frissítési lépés, ezért a doboz magától nem frissíti.", "note.restore.older_version_no_step": "A(z) %s visszaállt a(z) %s-i mentésből, a(z) %s verzióra. Ettől a verziótól nem vezet kipróbált frissítési lépés, ezért a doboz magától nem frissíti.",
@@ -2512,7 +2608,9 @@
"app_info.close_signup_text": "Ennél az alkalmazásnál bárki regisztrálhat, aki megtalálja a címét. Zárd le a regisztrációt most — a családtagjaidat utána is fel tudod venni.", "app_info.close_signup_text": "Ennél az alkalmazásnál bárki regisztrálhat, aki megtalálja a címét. Zárd le a regisztrációt most — a családtagjaidat utána is fel tudod venni.",
"app_info.close_signup_btn": "Regisztráció lezárása most", "app_info.close_signup_btn": "Regisztráció lezárása most",
"app_info.signup_native_failed": "Az alkalmazás saját regisztrációs kapcsolóját nem sikerült lezárni. A cím zárolása így is véd.", "app_info.signup_native_failed": "Az alkalmazás saját regisztrációs kapcsolóját nem sikerült lezárni. A cím zárolása így is véd.",
"app_info.signup_native_open_failed": "Az alkalmazás saját regisztrációs kapcsolóját nem sikerült megnyitni a 15 percre, ezért zárva maradt. Új családtag így az alkalmazásban nem tud regisztrálni. Próbáld újra később.",
"app_info.signup_window_restart": "Ehhez az alkalmazás újraindul, és a 15 perc végén még egyszer.", "app_info.signup_window_restart": "Ehhez az alkalmazás újraindul, és a 15 perc végén még egyszer.",
"app_info.restarts_once": "Ehhez az alkalmazás egyszer újraindul, és közben rövid ideig nem érhető el.",
"err.setup_gate.close_signup_not_offered": "Ennél az alkalmazásnál nincs mit lezárni.", "err.setup_gate.close_signup_not_offered": "Ennél az alkalmazásnál nincs mit lezárni.",
"backups_offsite.offer_text": "Van alkalmazás, amelyről nem készül távoli mentés. Bekapcsolod mindegyikre?", "backups_offsite.offer_text": "Van alkalmazás, amelyről nem készül távoli mentés. Bekapcsolod mindegyikre?",
"backups_offsite.offer_all": "Igen, mindegyikre", "backups_offsite.offer_all": "Igen, mindegyikre",
@@ -2554,5 +2652,7 @@
"family_gate.pw_once": "A jelszó csak most látszik. Írd fel, vagy add át a családtagnak:", "family_gate.pw_once": "A jelszó csak most látszik. Írd fel, vagy add át a családtagnak:",
"family_gate.confirm_remove": "Eltávolítod? A belépései azonnal megszűnnek.", "family_gate.confirm_remove": "Eltávolítod? A belépései azonnal megszűnnek.",
"family_gate.confirm_reset": "Új jelszót adsz? A régi azonnal megszűnik.", "family_gate.confirm_reset": "Új jelszót adsz? A régi azonnal megszűnik.",
"family_gate.error": "Nem sikerült. Próbáld újra." "family_gate.error": "Nem sikerült. Próbáld újra.",
"waiting.body": "Ez a gép most indult el először, de még nem tudta letölteni a beállításait a Felhom központi rendszeréből. Percenként újrapróbálkozik; amint sikerül, ez az oldal magától a vezérlőpultra vált. Ha egy óra múlva is ezt látod, írj nekünk: support@felhom.eu",
"waiting.title": "A Felhom még nem érte el a központot"
} }
+212 -122
View File
@@ -26,8 +26,15 @@ type HealthReport struct {
// Issues and Warnings only, so the hub report is unchanged — pinned by // Issues and Warnings only, so the hub report is unchanged — pinned by
// TestR553_HubReportWarningsAreUnchangedOnTheWire. // TestR553_HubReportWarningsAreUnchangedOnTheWire.
WarningKinds []string WarningKinds []string
Info []string // informational items // WarningMsgs / IssueMsgs are parallel to Warnings / Issues (R-516 item 10): the bundle key and
Timestamp time.Time // arguments of the dashboard's OWN sentence for that entry, in the household's language. The
// Warnings/Issues text stays exactly what it was — it is ON THE WIRE (report health.*), and
// internal/report/builder.go copies Status, Issues and Warnings only (pinned by
// TestR553_HubReportWarningsAreUnchangedOnTheWire). An entry with no key shows its text verbatim.
WarningMsgs []MsgRef
IssueMsgs []MsgRef
Info []string // informational items
Timestamp time.Time
} }
// Warning kinds (R-553). A kind names WHAT the warning is about; the text stays the only thing shown. // Warning kinds (R-553). A kind names WHAT the warning is about; the text stays the only thing shown.
@@ -54,8 +61,43 @@ const (
// addWarning appends a warning together with its kind, so the two slices cannot drift apart. Every // addWarning appends a warning together with its kind, so the two slices cannot drift apart. Every
// warning goes through here; `WarningKindAt` reads them back. // warning goes through here; `WarningKindAt` reads them back.
func (r *HealthReport) addWarning(text, kind string) { func (r *HealthReport) addWarning(text, kind string) {
r.addWarningMsg(text, kind, MsgRef{})
}
// MsgRef names a dashboard sentence by bundle key; the zero value means "show the text verbatim".
type MsgRef struct {
Key string
Args []interface{}
}
// addWarningMsg is addWarning with the dashboard's own sentence beside the wire text (R-516 item 10).
func (r *HealthReport) addWarningMsg(text, kind string, msg MsgRef) {
r.Warnings = append(r.Warnings, text) r.Warnings = append(r.Warnings, text)
r.WarningKinds = append(r.WarningKinds, kind) r.WarningKinds = append(r.WarningKinds, kind)
r.WarningMsgs = append(r.WarningMsgs, msg)
}
// addIssue appends a critical issue with its dashboard sentence (zero MsgRef = verbatim), so the two
// slices cannot drift apart. Every issue goes through here; IssueMsgAt reads them back.
func (r *HealthReport) addIssue(text string, msg MsgRef) {
r.Issues = append(r.Issues, text)
r.IssueMsgs = append(r.IssueMsgs, msg)
}
// WarningMsgAt / IssueMsgAt return the dashboard sentence of entry i, or the zero MsgRef when there is
// none (a report built by hand in a test, or an entry with no key).
func (r *HealthReport) WarningMsgAt(i int) MsgRef {
if r == nil || i < 0 || i >= len(r.WarningMsgs) {
return MsgRef{}
}
return r.WarningMsgs[i]
}
func (r *HealthReport) IssueMsgAt(i int) MsgRef {
if r == nil || i < 0 || i >= len(r.IssueMsgs) {
return MsgRef{}
}
return r.IssueMsgs[i]
} }
// WarningKindAt returns the kind of Warnings[i], or "" when there is none (older callers, a report // WarningKindAt returns the kind of Warnings[i], or "" when there is none (older callers, a report
@@ -76,7 +118,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
debug := cfg.Logging.Level == "debug" && logger != nil debug := cfg.Logging.Level == "debug" && logger != nil
hddPath := cfg.Paths.HDDPath hddPath := "" // R-492: the global paths.hdd_path is gone; the registry is the only source
if len(storagePaths) > 0 { if len(storagePaths) > 0 {
hddPath = storagePaths[0].Path hddPath = storagePaths[0].Path
} }
@@ -89,109 +131,11 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
sysInfo.CPUPercent, sysInfo.TemperatureCelsius, sysInfo.TemperatureSource) sysInfo.CPUPercent, sysInfo.TemperatureCelsius, sysInfo.TemperatureSource)
} }
// 1. Disk usage (SSD). NOTE (storage-split): sysInfo.DiskPercent statfs's the controller checkResources(report, sysInfo, cfg, logger, debug)
// container's "/", whose overlay upperdir lives on the guest's /var/lib/docker volume — so this
// IS the Docker-data volume guard (post-split it's the dedicated data volume; pre-split it's the
// rootfs — either way it's wherever Docker's data-root lives). Warn at 80% / crit at 90% used
// trips ABOVE the prevention layer's 10%-free reserved buffer, so the customer is warned before
// the deploy gate even engages.
if sysInfo.DiskPercent > 0 {
if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) {
report.Issues = append(report.Issues, fmt.Sprintf("SSD disk usage critical: %.0f%%", sysInfo.DiskPercent))
if logger != nil {
logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
}
if debug {
logger.Printf("[DEBUG] [monitor] SSD disk: CRITICAL (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
}
} else if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) {
report.addWarning(fmt.Sprintf("SSD disk usage high: %.0f%%", sysInfo.DiskPercent), "")
if logger != nil {
logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
}
if debug {
logger.Printf("[DEBUG] [monitor] SSD disk: WARN (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
}
} else {
report.Info = append(report.Info, fmt.Sprintf("SSD: %.0f%% used", sysInfo.DiskPercent))
if debug {
logger.Printf("[DEBUG] [monitor] SSD disk: OK (%.0f%%)", sysInfo.DiskPercent)
}
}
}
// HDD disk usage
if sysInfo.HDDConfigured && sysInfo.HDDPercent > 0 {
if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) {
report.Issues = append(report.Issues, fmt.Sprintf("HDD disk usage critical: %.0f%%", sysInfo.HDDPercent))
if logger != nil {
logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
}
} else if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) {
report.addWarning(fmt.Sprintf("HDD disk usage high: %.0f%%", sysInfo.HDDPercent), "")
if logger != nil {
logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
}
}
}
// 2. Memory usage
if sysInfo.MemPercent > 0 {
if sysInfo.MemPercent >= float64(cfg.Monitoring.Thresholds.MemoryWarnPercent) {
report.addWarning(fmt.Sprintf("Memory usage high: %.0f%%", sysInfo.MemPercent), "")
if logger != nil {
logger.Printf("[WARN] [monitor] Memory threshold breached: %.0f%% (limit: %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent)
}
if debug {
logger.Printf("[DEBUG] [monitor] Memory: WARN (%.0f%% >= %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent)
}
} else {
report.Info = append(report.Info, fmt.Sprintf("Memory: %.0f%% used", sysInfo.MemPercent))
if debug {
logger.Printf("[DEBUG] [monitor] Memory: OK (%.0f%%)", sysInfo.MemPercent)
}
}
}
// 3. CPU usage
if sysInfo.CPUPercent > 0 {
if sysInfo.CPUPercent >= float64(cfg.Monitoring.Thresholds.CPUWarnPercent) {
report.addWarning(fmt.Sprintf("CPU usage high: %.0f%%", sysInfo.CPUPercent), "")
if logger != nil {
logger.Printf("[WARN] [monitor] CPU threshold breached: %.0f%% (limit: %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent)
}
if debug {
logger.Printf("[DEBUG] [monitor] CPU: WARN (%.0f%% >= %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent)
}
} else {
report.Info = append(report.Info, fmt.Sprintf("CPU: %.0f%%", sysInfo.CPUPercent))
if debug {
logger.Printf("[DEBUG] [monitor] CPU: OK (%.0f%%)", sysInfo.CPUPercent)
}
}
}
// 4. Temperature
if sysInfo.TemperatureCelsius > 0 {
if sysInfo.TemperatureCelsius >= float64(cfg.Monitoring.Thresholds.TemperatureWarnCelsius) {
report.addWarning(fmt.Sprintf("Temperature high: %.0f°C (%s)", sysInfo.TemperatureCelsius, sysInfo.TemperatureSource), "")
if logger != nil {
logger.Printf("[WARN] [monitor] Temperature threshold breached: %.0f°C (limit: %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius)
}
if debug {
logger.Printf("[DEBUG] [monitor] Temperature: WARN (%.0f°C >= %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius)
}
} else {
report.Info = append(report.Info, fmt.Sprintf("Temperature: %.0f°C", sysInfo.TemperatureCelsius))
if debug {
logger.Printf("[DEBUG] [monitor] Temperature: OK (%.0f°C)", sysInfo.TemperatureCelsius)
}
}
}
// 5. Docker health // 5. Docker health
if err := checkDocker(); err != nil { if err := checkDocker(); err != nil {
report.Issues = append(report.Issues, fmt.Sprintf("Docker: %v", err)) addDockerIssue(report, err)
if debug { if debug {
logger.Printf("[DEBUG] [monitor] Docker daemon: FAIL (%v)", err) logger.Printf("[DEBUG] [monitor] Docker daemon: FAIL (%v)", err)
} }
@@ -209,9 +153,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
logger.Printf("[DEBUG] [monitor] Checking %d protected containers: %v", len(protected), protected) logger.Printf("[DEBUG] [monitor] Checking %d protected containers: %v", len(protected), protected)
} }
missingProtected := checkProtectedContainers(protected) missingProtected := checkProtectedContainers(protected)
for _, name := range missingProtected { addProtectedIssues(report, missingProtected)
report.Issues = append(report.Issues, fmt.Sprintf("Protected container not running: %s", name))
}
if debug { if debug {
if len(missingProtected) > 0 { if len(missingProtected) > 0 {
logger.Printf("[DEBUG] [monitor] Protected containers missing: %v", missingProtected) logger.Printf("[DEBUG] [monitor] Protected containers missing: %v", missingProtected)
@@ -221,11 +163,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
} }
// 7. Storage paths // 7. Storage paths
storageIssues, storageWarnings, storageKinds := checkStoragePaths(storagePaths) addStorage(report, storagePaths)
report.Issues = append(report.Issues, storageIssues...)
for i, w := range storageWarnings {
report.addWarning(w, storageKinds[i])
}
// Determine status // Determine status
if len(report.Issues) > 0 { if len(report.Issues) > 0 {
@@ -279,6 +217,38 @@ func (r *HealthReport) FormatMessage() string {
return sb.String() return sb.String()
} }
// addDockerIssue / addProtectedIssues / addStorage are RunHealthCheck's Docker, protected-container
// and storage-path halves, split out so a test can drive them without a Docker daemon or a real drive
// (R-79 option A: TestR79_RemainingProducersCarryTheirDashboardSentence). Each entry keeps its wire
// text and carries the dashboard's own sentence beside it (R-516 item 10).
func addDockerIssue(report *HealthReport, err error) {
// The argument is Docker's own error text, which is English in every language (it comes from
// the docker client); the sentence around it follows the household.
report.addIssue(fmt.Sprintf("Docker: %v", err), MsgRef{"health.docker_unreachable", []interface{}{err.Error()}})
}
func addProtectedIssues(report *HealthReport, missing []string) {
for _, name := range missing {
report.addIssue(fmt.Sprintf("Protected container not running: %s", name), MsgRef{"health.protected_container_down", []interface{}{name}})
}
}
func addStorage(report *HealthReport, paths []settings.StoragePath) {
issues, issueMsgs, warnings, kinds, warnMsgs := checkStoragePaths(paths)
for i, is := range issues {
report.addIssue(is, issueMsgs[i])
}
for i, w := range warnings {
report.addWarningMsg(w, kinds[i], warnMsgs[i])
}
}
// isMountPointFn / diskUsageFn are test seams over the two drive probes checkStoragePaths makes.
var (
isMountPointFn = system.IsMountPoint
diskUsageFn = system.GetDiskUsage
)
func checkDocker() error { func checkDocker() error {
cmd := dockerexec.Command("docker", "info", "--format", "{{.ServerVersion}}") cmd := dockerexec.Command("docker", "info", "--format", "{{.ServerVersion}}")
out, err := cmd.Output() out, err := cmd.Output()
@@ -357,41 +327,161 @@ func checkProtectedContainers(protected []string) []string {
// checkStoragePaths returns the storage issues and, beside each warning, its KIND (R-553) — the // checkStoragePaths returns the storage issues and, beside each warning, its KIND (R-553) — the
// dashboard places the "not on a separate drive" warning inline under the storage bars, and it must // dashboard places the "not on a separate drive" warning inline under the storage bars, and it must
// find it by kind rather than by the words the sentence happens to contain today. // find it by kind rather than by the words the sentence happens to contain today. issueMsgs and
func checkStoragePaths(paths []settings.StoragePath) (issues, warnings, kinds []string) { // warnMsgs are parallel to issues and warnings: the dashboard sentence of each (R-79 option A; the
// Hungarian bundle value of each key equals the frozen wire format above, byte for byte).
func checkStoragePaths(paths []settings.StoragePath) (issues []string, issueMsgs []MsgRef, warnings, kinds []string, warnMsgs []MsgRef) {
warn := func(text, kind string, msg MsgRef) {
warnings, kinds, warnMsgs = append(warnings, text), append(kinds, kind), append(warnMsgs, msg)
}
for _, sp := range paths { for _, sp := range paths {
// Skip decommissioned paths — no longer in active use // Skip decommissioned paths — no longer in active use
if sp.Decommissioned { if sp.Decommissioned {
continue continue
} }
// Skip disconnected paths — handled by the storage watchdog // Skip disconnected paths — handled by the storage watchdog. No dashboard sentence: the
// dashboard drops this warning for its own alert.storage.disconnected banner (R-516 item 8).
if sp.Disconnected { if sp.Disconnected {
warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageDisconnected, sp.Label, sp.Path)), append(kinds, WarnKindStorageDisconnected) warn(fmt.Sprintf(warnFmtStorageDisconnected, sp.Label, sp.Path), WarnKindStorageDisconnected, MsgRef{})
continue continue
} }
// Path accessible? // Path accessible?
if _, err := os.Stat(sp.Path); err != nil { if _, err := os.Stat(sp.Path); err != nil {
warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageUnavailable, sp.Path)), append(kinds, WarnKindStorageUnavailable) warn(fmt.Sprintf(warnFmtStorageUnavailable, sp.Path), WarnKindStorageUnavailable,
MsgRef{"health.storage_unavailable", []interface{}{sp.Path}})
continue continue
} }
// Mount point check — warning, not issue (avoids false FAIL on demo/test environments) // Mount point check — warning, not issue (avoids false FAIL on demo/test environments)
if !system.IsMountPoint(sp.Path) { if !isMountPointFn(sp.Path) {
warnings = append(warnings, fmt.Sprintf( warn(fmt.Sprintf(warnFmtStorageNotSeparate, sp.Path), WarnKindStorageNotSeparate,
warnFmtStorageNotSeparate, sp.Path)) MsgRef{"health.storage_not_separate", []interface{}{sp.Path}})
kinds = append(kinds, WarnKindStorageNotSeparate)
} }
// Disk usage // Disk usage
if di := system.GetDiskUsage(sp.Path); di != nil { if di := diskUsageFn(sp.Path); di != nil {
if di.UsedPercent >= 95 { if di.UsedPercent >= 95 {
issues = append(issues, fmt.Sprintf(issueFmtStorageAlmostFull, sp.Path, di.UsedPercent)) issues = append(issues, fmt.Sprintf(issueFmtStorageAlmostFull, sp.Path, di.UsedPercent))
issueMsgs = append(issueMsgs, MsgRef{"health.storage_almost_full", []interface{}{sp.Path, di.UsedPercent}})
} else if di.UsedPercent >= 90 { } else if di.UsedPercent >= 90 {
warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageUsageHigh, sp.Path, di.UsedPercent)), append(kinds, WarnKindStorageUsageHigh) warn(fmt.Sprintf(warnFmtStorageUsageHigh, sp.Path, di.UsedPercent), WarnKindStorageUsageHigh,
MsgRef{"health.storage_usage_high", []interface{}{sp.Path, di.UsedPercent}})
} }
} }
} }
return return
} }
// checkResources is the threshold half of RunHealthCheck (disk, memory, CPU, temperature), split out
// so a test can drive it with a made-up SystemInfo instead of the box it runs on (R-516 item 10:
// TestR516_ResourceWarningsCarryTheirDashboardSentence).
func checkResources(report *HealthReport, sysInfo system.SystemInfo, cfg *config.Config, logger *log.Logger, debug bool) {
// 1. Disk usage (SSD). NOTE (storage-split): sysInfo.DiskPercent statfs's the controller
// container's "/", whose overlay upperdir lives on the guest's /var/lib/docker volume — so this
// IS the Docker-data volume guard (post-split it's the dedicated data volume; pre-split it's the
// rootfs — either way it's wherever Docker's data-root lives). Warn at 80% / crit at 90% used
// trips ABOVE the prevention layer's 10%-free reserved buffer, so the customer is warned before
// the deploy gate even engages.
if sysInfo.DiskPercent > 0 {
if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) {
report.addIssue(fmt.Sprintf("SSD disk usage critical: %.0f%%", sysInfo.DiskPercent),
MsgRef{"health.system_disk_critical", []interface{}{sysInfo.DiskPercent}})
if logger != nil {
logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
}
if debug {
logger.Printf("[DEBUG] [monitor] SSD disk: CRITICAL (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
}
} else if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) {
report.addWarningMsg(fmt.Sprintf("SSD disk usage high: %.0f%%", sysInfo.DiskPercent), "",
MsgRef{"health.system_disk_high", []interface{}{sysInfo.DiskPercent}})
if logger != nil {
logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
}
if debug {
logger.Printf("[DEBUG] [monitor] SSD disk: WARN (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
}
} else {
report.Info = append(report.Info, fmt.Sprintf("SSD: %.0f%% used", sysInfo.DiskPercent))
if debug {
logger.Printf("[DEBUG] [monitor] SSD disk: OK (%.0f%%)", sysInfo.DiskPercent)
}
}
}
// HDD disk usage
if sysInfo.HDDConfigured && sysInfo.HDDPercent > 0 {
if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) {
report.addIssue(fmt.Sprintf("HDD disk usage critical: %.0f%%", sysInfo.HDDPercent),
MsgRef{"health.data_disk_critical", []interface{}{sysInfo.HDDPercent}})
if logger != nil {
logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
}
} else if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) {
report.addWarningMsg(fmt.Sprintf("HDD disk usage high: %.0f%%", sysInfo.HDDPercent), "",
MsgRef{"health.data_disk_high", []interface{}{sysInfo.HDDPercent}})
if logger != nil {
logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
}
}
}
// 2. Memory usage
if sysInfo.MemPercent > 0 {
if sysInfo.MemPercent >= float64(cfg.Monitoring.Thresholds.MemoryWarnPercent) {
report.addWarningMsg(fmt.Sprintf("Memory usage high: %.0f%%", sysInfo.MemPercent), "",
MsgRef{"health.memory_high", []interface{}{sysInfo.MemPercent}})
if logger != nil {
logger.Printf("[WARN] [monitor] Memory threshold breached: %.0f%% (limit: %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent)
}
if debug {
logger.Printf("[DEBUG] [monitor] Memory: WARN (%.0f%% >= %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent)
}
} else {
report.Info = append(report.Info, fmt.Sprintf("Memory: %.0f%% used", sysInfo.MemPercent))
if debug {
logger.Printf("[DEBUG] [monitor] Memory: OK (%.0f%%)", sysInfo.MemPercent)
}
}
}
// 3. CPU usage
if sysInfo.CPUPercent > 0 {
if sysInfo.CPUPercent >= float64(cfg.Monitoring.Thresholds.CPUWarnPercent) {
report.addWarningMsg(fmt.Sprintf("CPU usage high: %.0f%%", sysInfo.CPUPercent), "",
MsgRef{"health.cpu_high", []interface{}{sysInfo.CPUPercent}})
if logger != nil {
logger.Printf("[WARN] [monitor] CPU threshold breached: %.0f%% (limit: %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent)
}
if debug {
logger.Printf("[DEBUG] [monitor] CPU: WARN (%.0f%% >= %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent)
}
} else {
report.Info = append(report.Info, fmt.Sprintf("CPU: %.0f%%", sysInfo.CPUPercent))
if debug {
logger.Printf("[DEBUG] [monitor] CPU: OK (%.0f%%)", sysInfo.CPUPercent)
}
}
}
// 4. Temperature
if sysInfo.TemperatureCelsius > 0 {
if sysInfo.TemperatureCelsius >= float64(cfg.Monitoring.Thresholds.TemperatureWarnCelsius) {
report.addWarningMsg(fmt.Sprintf("Temperature high: %.0f°C (%s)", sysInfo.TemperatureCelsius, sysInfo.TemperatureSource), "",
MsgRef{"health.temperature_high", []interface{}{sysInfo.TemperatureCelsius, sysInfo.TemperatureSource}})
if logger != nil {
logger.Printf("[WARN] [monitor] Temperature threshold breached: %.0f°C (limit: %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius)
}
if debug {
logger.Printf("[DEBUG] [monitor] Temperature: WARN (%.0f°C >= %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius)
}
} else {
report.Info = append(report.Info, fmt.Sprintf("Temperature: %.0f°C", sysInfo.TemperatureCelsius))
if debug {
logger.Printf("[DEBUG] [monitor] Temperature: OK (%.0f°C)", sysInfo.TemperatureCelsius)
}
}
}
}
@@ -0,0 +1,145 @@
package monitor
import (
"fmt"
"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/system"
)
// R-516 item 10 — the resource warnings („SSD disk usage high: 90%") reached the household's banner in
// English on every page. checkResources now attaches the dashboard's own sentence (a bundle key) beside
// each one, and the wire text is byte-for-byte what it was.
//
// COMPANION RED-PROOF: give the SSD warning a zero MsgRef (addWarning) — the "warn" case fails here
// and the banner test in internal/web shows the English wire text again.
func TestR516_ResourceWarningsCarryTheirDashboardSentence(t *testing.T) {
cfg := config.Default()
cases := []struct {
name string
info system.SystemInfo
wantIssue, wantKey []string
wantWire []string
}{
{"warn", system.SystemInfo{DiskPercent: 85, HDDConfigured: true, HDDPercent: 85, MemPercent: 90, CPUPercent: 95,
TemperatureCelsius: 80, TemperatureSource: "coretemp"}, nil,
[]string{"health.system_disk_high", "health.data_disk_high", "health.memory_high", "health.cpu_high", "health.temperature_high"},
[]string{"SSD disk usage high: 85%", "HDD disk usage high: 85%", "Memory usage high: 90%", "CPU usage high: 95%", "Temperature high: 80°C (coretemp)"}},
{"critical", system.SystemInfo{DiskPercent: 95, HDDConfigured: true, HDDPercent: 96},
[]string{"health.system_disk_critical", "health.data_disk_critical"}, nil,
[]string{"SSD disk usage critical: 95%", "HDD disk usage critical: 96%"}},
}
for _, c := range cases {
r := &HealthReport{}
checkResources(r, c.info, cfg, nil, false)
texts, keys := r.Warnings, []string{}
for i := range r.Warnings {
keys = append(keys, r.WarningMsgAt(i).Key)
}
if c.wantIssue != nil {
texts, keys = r.Issues, nil
for i := range r.Issues {
keys = append(keys, r.IssueMsgAt(i).Key)
}
}
want := c.wantKey
if c.wantIssue != nil {
want = c.wantIssue
}
if len(texts) != len(c.wantWire) {
t.Fatalf("%s: %d entries %q, want %d — the cases below would be compared against nothing", c.name, len(texts), texts, len(c.wantWire))
}
for i := range texts {
if texts[i] != c.wantWire[i] {
t.Errorf("%s: the wire text moved: %q, want %q", c.name, texts[i], c.wantWire[i])
}
if keys[i] != want[i] {
t.Errorf("%s: %q carries dashboard key %q, want %q", c.name, texts[i], keys[i], want[i])
}
}
if len(r.Warnings) != len(r.WarningKinds) || len(r.Warnings) != len(r.WarningMsgs) || len(r.Issues) != len(r.IssueMsgs) {
t.Errorf("%s: the parallel slices drifted apart", c.name)
}
}
}
// R-79 option A (operator ruling D3, 2026-10-08) — the six producers R-516 item 10 left verbatim:
// Docker unreachable, a protected container down, and the four storage-path entries. Each now
// carries its dashboard sentence; the wire text is byte for byte what it was (it is ON THE WIRE,
// report health.*; the frozen storage formats are also pinned by TestStorageWarningFormatsAreFrozen).
//
// COMPANION RED-PROOF: give any one of them a zero MsgRef (e.g. the unavailable-path warning back to
// addWarning) — its case fails here, and TestR79_StorageBannerFollowsTheHousehold in internal/web
// shows the Hungarian wire text to an English household again.
func TestR79_RemainingProducersCarryTheirDashboardSentence(t *testing.T) {
type entry struct{ wire, key string }
check := func(name string, texts []string, msgs func(int) MsgRef, want []entry) {
t.Helper()
if len(texts) != len(want) {
t.Fatalf("%s: %d entries %q, want %d — the cases below would be compared against nothing", name, len(texts), texts, len(want))
}
for i := range texts {
if texts[i] != want[i].wire {
t.Errorf("%s: the wire text moved: %q, want %q", name, texts[i], want[i].wire)
}
if k := msgs(i).Key; k != want[i].key {
t.Errorf("%s: %q carries dashboard key %q, want %q", name, texts[i], k, want[i].key)
}
}
}
// Docker and protected containers: driven through the same adders RunHealthCheck calls.
r := &HealthReport{}
addDockerIssue(r, fmt.Errorf("docker not reachable: exit status 1"))
addProtectedIssues(r, []string{"cloudflared", "traefik"})
check("docker+protected", r.Issues, r.IssueMsgAt, []entry{
{"Docker: docker not reachable: exit status 1", "health.docker_unreachable"},
{"Protected container not running: cloudflared", "health.protected_container_down"},
{"Protected container not running: traefik", "health.protected_container_down"},
})
if a := r.IssueMsgAt(1).Args; len(a) != 1 || a[0] != "cloudflared" {
t.Errorf("protected-container sentence args = %v, want [cloudflared]", a)
}
// Storage paths: a missing path, a path that is not a mount point at 92 %, one at 97 %.
oldMount, oldUsage := isMountPointFn, diskUsageFn
defer func() { isMountPointFn, diskUsageFn = oldMount, oldUsage }()
isMountPointFn = func(p string) bool { return !strings.HasSuffix(p, "/notsep") }
diskUsageFn = func(p string) *system.DiskUsageInfo {
switch {
case strings.HasSuffix(p, "/notsep"):
return &system.DiskUsageInfo{UsedPercent: 92}
case strings.HasSuffix(p, "/full"):
return &system.DiskUsageInfo{UsedPercent: 97}
}
return nil
}
dir := t.TempDir()
for _, sub := range []string{"notsep", "full"} {
if err := os.Mkdir(filepath.Join(dir, sub), 0o755); err != nil {
t.Fatal(err)
}
}
missing := filepath.Join(dir, "missing")
r = &HealthReport{}
addStorage(r, []settings.StoragePath{{Path: missing}, {Path: dir + "/notsep"}, {Path: dir + "/full"}})
check("storage warnings", r.Warnings, r.WarningMsgAt, []entry{
{"Adattároló nem elérhető: " + missing, "health.storage_unavailable"},
{"Az adattároló (" + dir + "/notsep) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak", "health.storage_not_separate"},
{"Adattároló használat magas: " + dir + "/notsep (92%)", "health.storage_usage_high"},
})
check("storage issues", r.Issues, r.IssueMsgAt, []entry{
{"Adattároló majdnem megtelt: " + dir + "/full (97%)", "health.storage_almost_full"},
})
if len(r.Warnings) != len(r.WarningKinds) || len(r.Warnings) != len(r.WarningMsgs) || len(r.Issues) != len(r.IssueMsgs) {
t.Errorf("storage: the parallel slices drifted apart")
}
if got := r.WarningKindAt(1); got != WarnKindStorageNotSeparate {
t.Errorf("not-separate warning lost its kind: %q", got)
}
}
@@ -27,7 +27,7 @@ func TestR553_StorageWarningsCarryKindsAndKeepTheirWords(t *testing.T) {
{Path: present}, {Path: present},
{Path: filepath.Join(dir, "kihagyott"), Decommissioned: true}, {Path: filepath.Join(dir, "kihagyott"), Decommissioned: true},
} }
_, warnings, kinds := checkStoragePaths(paths) _, _, warnings, kinds, _ := checkStoragePaths(paths)
if len(warnings) != len(kinds) { if len(warnings) != len(kinds) {
t.Fatalf("a warning without its kind (or the reverse): %d warnings, %d kinds", len(warnings), len(kinds)) t.Fatalf("a warning without its kind (or the reverse): %d warnings, %d kinds", len(warnings), len(kinds))
} }
@@ -53,7 +53,7 @@ func TestStorageWarningWireTextIsFrozen(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
_, warnings, kinds := checkStoragePaths([]settings.StoragePath{tc.in}) _, _, warnings, kinds, _ := checkStoragePaths([]settings.StoragePath{tc.in})
if len(warnings) != len(tc.want) { if len(warnings) != len(tc.want) {
t.Fatalf("got %d warnings %q, want %d %q", len(warnings), warnings, len(tc.want), tc.want) t.Fatalf("got %d warnings %q, want %d %q", len(warnings), warnings, len(tc.want), tc.want)
} }
@@ -62,6 +62,20 @@ func convertedProducers() []struct {
n.NotifyHealthChange("ok", nil, nil) n.NotifyHealthChange("ok", nil, nil)
}}, }},
{"db_dump_completed", func(n *Notifier) { n.NotifyDBDumpCompleted(DBDumpDetails{}) }}, {"db_dump_completed", func(n *Notifier) { n.NotifyDBDumpCompleted(DBDumpDetails{}) }},
// R-585: three of the six producers that sent Hungarian only.
{"db_dump_failed", func(n *Notifier) { n.NotifyDBDumpFailed("pg_dump: exit 1") }},
{"backup_failed_offbox", func(n *Notifier) { n.NotifyOffboxBackupFailed("a NAS nem elerheto") }},
{"offbox_enlarge_blocked", func(n *Notifier) { n.NotifyOffboxEnlargeBlocked("immich", "120 GB", 80, 100) }},
// R-585: the remaining customer-facing producers (backup_integrity_*, the interrupted operation).
{"backup_integrity_ok", func(n *Notifier) { n.NotifyIntegrityOK("42s", "") }},
{"backup_integrity_ok_subset", func(n *Notifier) { n.NotifyIntegrityOK("42s", "10%") }},
{"backup_integrity_failed", func(n *Notifier) { n.NotifyIntegrityFailed("restic check reported repository errors") }},
{"interrupted_restarted", func(n *Notifier) {
n.NotifyInterruptedOperation(InterruptedOperation{ReasonKey: "appstop.reason.volume_dump", Restarted: 2}, "op=x")
}},
{"interrupted_failed_held", func(n *Notifier) {
n.NotifyInterruptedOperation(InterruptedOperation{ReasonKey: "appstop.reason.app_export", Restarted: 1, Failed: 1, Refused: 2}, "op=x")
}},
{"controller_updated", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", true) }}, {"controller_updated", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", true) }},
{"controller_update_failed", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", false) }}, {"controller_update_failed", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", false) }},
{"controller_started", func(n *Notifier) { n.NotifyControllerStarted("0.256.0", nil) }}, {"controller_started", func(n *Notifier) { n.NotifyControllerStarted("0.256.0", nil) }},
+95 -13
View File
@@ -261,6 +261,13 @@ func (n *Notifier) PushEvent(eventType, severity, message string, details interf
n.pushEventBoth(eventType, severity, message, "", details) n.pushEventBoth(eventType, severity, message, "", details)
} }
// PushEventHousehold is PushEvent with the household's own rendering beside the Hungarian (R-558's
// `message_customer`), for a producer that renders both languages itself because the sentence carries
// figures it computed (fillwatch, R-521 F7). household "" = a Hungarian household: nothing extra.
func (n *Notifier) PushEventHousehold(eventType, severity, message, household string, details interface{}) {
n.pushEventBoth(eventType, severity, message, household, details)
}
func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer string, details interface{}) { func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer string, details interface{}) {
// The test seam sits HERE, not only in emit (v0.252.0, R-557). Most producers call PushEvent // The test seam sits HERE, not only in emit (v0.252.0, R-557). Most producers call PushEvent
// directly, so a test that set pushFn saw nothing from them and the difference was invisible: a // directly, so a test that set pushFn saw nothing from them and the difference was invisible: a
@@ -403,9 +410,55 @@ func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string)
} }
} }
// NotifyBackupFailed sends a backup failure event. // InterruptedOperation is the FACTS of an app-stopping operation a controller restart interrupted
func (n *Notifier) NotifyBackupFailed(message, errMsg string) { // (backup.AppStopRecovery.Facts): which kind it was, and how many apps were restarted, could not be
n.PushEvent("backup_failed", "error", message, BackupDetails{Error: errMsg}) // restarted, or are held by an absent drive. Counts only — app names travel in the detail.
type InterruptedOperation struct {
// ReasonKey is the bundle key naming the operation ("appstop.reason.*"); Reason is the raw reason,
// used only when ReasonKey is empty (a reason this build has no sentence for).
ReasonKey string
Reason string
Restarted int
Failed int
Refused int
}
// NotifyInterruptedOperation reports, as a `backup_failed` event, that an app-stopping operation was cut
// short by a controller restart (R-585). It used to send the OPERATOR's English sentence to every
// household — `backup_failed` is customer-enabled by default — so a Hungarian household's mail carried
// one English line. The sentence is now composed per language: the operation's name is rendered in the
// SAME language as the sentence around it, which is why this does not go through pushEventMsg (whose
// arguments are the same for both languages). detail is the machine tail (names only, never env values).
func (n *Notifier) NotifyInterruptedOperation(op InterruptedOperation, detail string) {
b, err := i18n.Shared()
if err != nil {
n.logger.Printf("[ERROR] NotifyInterruptedOperation: bundle unavailable: %v", err)
n.pushEventBoth("backup_failed", "error", "event.appstop.restarted", "", BackupDetails{Error: detail})
return
}
render := func(lang string) string {
reason := op.Reason
if op.ReasonKey != "" {
reason = b.Msg(lang, op.ReasonKey)
}
switch {
case op.Failed > 0 && op.Refused > 0:
return b.Msgf(lang, "event.appstop.failed_held", reason, op.Failed, op.Restarted+op.Failed, op.Refused)
case op.Failed > 0:
return b.Msgf(lang, "event.appstop.failed", reason, op.Failed, op.Restarted+op.Failed)
case op.Restarted == 0 && op.Refused > 0:
return b.Msgf(lang, "event.appstop.held", reason, op.Refused)
case op.Refused > 0:
return b.Msgf(lang, "event.appstop.restarted_held", reason, op.Restarted, op.Refused)
default:
return b.Msgf(lang, "event.appstop.restarted", reason, op.Restarted)
}
}
household := ""
if lang := n.boxLang(); lang != i18n.Default {
household = render(lang)
}
n.pushEventBoth("backup_failed", "error", render(i18n.Default), household, BackupDetails{Error: detail})
} }
// RecoveryUnitFailureDetails is the machine-readable tail of a Tier-1 capture failure. App NAMES and // RecoveryUnitFailureDetails is the machine-readable tail of a Tier-1 capture failure. App NAMES and
@@ -483,8 +536,17 @@ func (n *Notifier) NotifyBackupRunFailures(message string, d BackupRunFailuresDe
// body). NOTE: the event type "offbox_enlarge_blocked" must be added to the hub's allowedEventTypes + // body). NOTE: the event type "offbox_enlarge_blocked" must be added to the hub's allowedEventTypes +
// customerMessages for delivery (a hub-side task, flagged — until then the hub 400s/drops it and the // customerMessages for delivery (a hub-side task, flagged — until then the hub 400s/drops it and the
// in-dashboard LastWarning + /backups/remote note carry the message). // in-dashboard LastWarning + /backups/remote note carry the message).
func (n *Notifier) NotifyOffboxEnlargeBlocked(message string) { //
n.PushEvent("offbox_enlarge_blocked", "warning", message, nil) // R-585: it takes the FACTS, not a finished sentence, so the sentence follows the household's language —
// this type has no customerMessages entry on the hub, so its sentence IS the household's mail.
func (n *Notifier) NotifyOffboxEnlargeBlocked(stack, estimate string, usedGB, quotaGB int) {
n.pushEventMsg("offbox_enlarge_blocked", "warning", "event.offbox_enlarge_blocked", nil, stack, estimate, usedGB, quotaGB)
}
// NotifyOffboxBackupFailed is the off-box (NAS / off-site) run's failure — a `backup_failed` event whose
// headline follows the household's language (R-585). detail is the sanitised cause (OffsiteFailureMessage).
func (n *Notifier) NotifyOffboxBackupFailed(detail string) {
n.pushEventMsg("backup_failed", "error", "event.offbox_backup_failed", BackupDetails{Error: detail})
} }
// (NotifyBackupCompleted removed 2026-06-16 — the backup_completed event had no callers // (NotifyBackupCompleted removed 2026-06-16 — the backup_completed event had no callers
@@ -493,8 +555,9 @@ func (n *Notifier) NotifyOffboxEnlargeBlocked(message string) {
// below are still emitted and consumed.) // below are still emitted and consumed.)
// NotifyDBDumpFailed sends a DB dump failure event. // NotifyDBDumpFailed sends a DB dump failure event.
func (n *Notifier) NotifyDBDumpFailed(message, errMsg string) { // R-585: the headline is a bundle key, so it follows the household's language; errMsg is the cause.
n.PushEvent("db_dump_failed", "error", message, DBDumpDetails{Error: errMsg}) func (n *Notifier) NotifyDBDumpFailed(errMsg string) {
n.pushEventMsg("db_dump_failed", "error", "event.db_dump_failed", DBDumpDetails{Error: errMsg})
} }
// NotifyDBDumpCompleted sends a DB dump success event. // NotifyDBDumpCompleted sends a DB dump success event.
@@ -503,8 +566,13 @@ func (n *Notifier) NotifyDBDumpCompleted(details DBDumpDetails) {
} }
// NotifyIntegrityFailed sends a backup integrity check failure event. // NotifyIntegrityFailed sends a backup integrity check failure event.
func (n *Notifier) NotifyIntegrityFailed(message, errMsg string) { //
n.PushEvent("backup_integrity_failed", "error", message, &BackupDetails{Error: errMsg}) // R-585: the sentence is a bundle key, so it follows the household's language. It names what to do and
// what NOT to do („Ne törölj semmit" is load-bearing — a household that believes its backups are broken
// may "start fresh" and destroy the one copy that might still be partly recoverable); both languages are
// pinned by TestR359_OutcomeMessagesCarryNoMachineDetail. errMsg is the machine cause, never the sentence.
func (n *Notifier) NotifyIntegrityFailed(errMsg string) {
n.pushEventMsg("backup_integrity_failed", "error", "event.backup_integrity_failed", &BackupDetails{Error: errMsg})
} }
// NotifyOffsiteProofEmpty (R-87) reports that the nightly off-site proof found a backup that is // NotifyOffsiteProofEmpty (R-87) reports that the nightly off-site proof found a backup that is
@@ -525,8 +593,16 @@ func (n *Notifier) NotifyOffsiteProofEmpty(message, detail string) {
} }
// NotifyIntegrityOK sends a backup integrity check success event. // NotifyIntegrityOK sends a backup integrity check success event.
func (n *Notifier) NotifyIntegrityOK(message string) { //
n.PushEvent("backup_integrity_ok", "info", message, nil) // R-585: it takes the FACTS — how long the check ran, and how much of the stored data it re-read
// (empty for a structure-only pass) — so the sentence follows the household's language. The depth is a
// fact the sentence has to carry: "checked" means two different things depending on it.
func (n *Notifier) NotifyIntegrityOK(duration, readDataSubset string) {
if readDataSubset != "" {
n.pushEventMsg("backup_integrity_ok", "info", "event.backup_integrity_ok_subset", nil, duration, readDataSubset)
return
}
n.pushEventMsg("backup_integrity_ok", "info", "event.backup_integrity_ok", nil, duration)
} }
// NotifyControllerUpdated sends a controller update event. // NotifyControllerUpdated sends a controller update event.
@@ -960,11 +1036,16 @@ type preferencesRequest struct {
Email string `json:"email"` Email string `json:"email"`
EnabledEvents []string `json:"enabled_events"` EnabledEvents []string `json:"enabled_events"`
CooldownHours int `json:"cooldown_hours,omitempty"` CooldownHours int `json:"cooldown_hours,omitempty"`
// EmailCleared (R-922): the household deliberately cleared its address — the hub deletes the one it
// holds. OMITTED unless true: an empty address without it keeps the hub's no-clobber guard (an
// unconfigured box must never wipe a seeded address). Decoded by the hub's savePreferences.
EmailCleared bool `json:"email_cleared,omitempty"`
} }
// SyncPreferences pushes the current notification preferences to the hub. // SyncPreferences pushes the current notification preferences to the hub.
// Synchronous — returns error for the handler to display to the user. // Synchronous — returns error for the handler to display to the user.
func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldownHours int) error { // emailCleared (R-922) is settings.NotificationPrefs.EmailCleared — true only after a household's clear.
func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldownHours int, emailCleared bool) error {
if !n.enabled { if !n.enabled {
return util.MsgError("err.notify.hub_nem_konfiguralt") return util.MsgError("err.notify.hub_nem_konfiguralt")
} }
@@ -974,6 +1055,7 @@ func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldow
Email: email, Email: email,
EnabledEvents: enabledEvents, EnabledEvents: enabledEvents,
CooldownHours: cooldownHours, CooldownHours: cooldownHours,
EmailCleared: emailCleared,
} }
jsonData, err := json.Marshal(payload) jsonData, err := json.Marshal(payload)
@@ -1007,7 +1089,7 @@ func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldow
if n.debug { if n.debug {
n.logger.Printf("[DEBUG] SyncPreferences: response HTTP %d", resp.StatusCode) n.logger.Printf("[DEBUG] SyncPreferences: response HTTP %d", resp.StatusCode)
} }
n.logger.Printf("[INFO] Notification preferences synced to hub: email=%s, events=%v, cooldown=%dh", email, enabledEvents, cooldownHours) n.logger.Printf("[INFO] Notification preferences synced to hub: email=%s, events=%v, cooldown=%dh, email_cleared=%t", email, enabledEvents, cooldownHours, emailCleared)
return nil return nil
} }
@@ -57,6 +57,7 @@ var severityArg = map[string]int{
"pushEventMsg": 1, // (eventType, severity, key, details, args...) "pushEventMsg": 1, // (eventType, severity, key, details, args...)
"pushEventMsgSuffix": 1, // (eventType, severity, key, suffix, details, args...) "pushEventMsgSuffix": 1, // (eventType, severity, key, suffix, details, args...)
"pushEventBoth": 1, // (eventType, severity, message, messageCustomer, details) "pushEventBoth": 1, // (eventType, severity, message, messageCustomer, details)
"PushEventHousehold": 1, // (eventType, severity, message, household, details)
} }
// knownDynamicSeveritySites are the call sites that pass a VARIABLE rather than a literal, so this // knownDynamicSeveritySites are the call sites that pass a VARIABLE rather than a literal, so this
@@ -72,11 +73,12 @@ var knownDynamicSeveritySites = map[string]string{
"internal/notify/notifier.go:PushEvent": `pass-through of its own severity parameter to pushEventBoth — checked at PushEvent's callers`, "internal/notify/notifier.go:PushEvent": `pass-through of its own severity parameter to pushEventBoth — checked at PushEvent's callers`,
"internal/notify/notifier.go:pushEventMsg": `pass-through of its own severity parameter to pushEventBoth — checked at pushEventMsg's callers`, "internal/notify/notifier.go:pushEventMsg": `pass-through of its own severity parameter to pushEventBoth — checked at pushEventMsg's callers`,
"internal/notify/notifier.go:pushEventMsgSuffix": `pass-through of its own severity parameter to pushEventBoth — checked at pushEventMsgSuffix's callers`, "internal/notify/notifier.go:pushEventMsgSuffix": `pass-through of its own severity parameter to pushEventBoth — checked at pushEventMsgSuffix's callers`,
"internal/notify/notifier.go:PushEventHousehold": `pass-through of its own severity parameter to pushEventBoth — checked at PushEventHousehold's callers`,
"internal/notify/notifier.go:NotifyControllerUpdated": `local var: "info", or "error" when the update failed`, "internal/notify/notifier.go:NotifyControllerUpdated": `local var: "info", or "error" when the update failed`,
"internal/notify/notifier.go:NotifyDRCompleted": `local var: "info", or "warning" when failCount > 0`, "internal/notify/notifier.go:NotifyDRCompleted": `local var: "info", or "warning" when failCount > 0`,
"internal/notify/notifier.go:NotifyAgentChannelDown": `internal/channelhealth's classifier — every severity literal in checker.go is "warning" or "error"`, "internal/notify/notifier.go:NotifyAgentChannelDown": `internal/channelhealth's classifier — every severity literal in checker.go is "warning" or "error"`,
"internal/notify/notifier.go:NotifyDiskHealthDegraded": `DiskAlertKind.Severity() — pinned behaviourally by TestR329_DiskAlertSeveritiesStillSatisfyTheContract`, "internal/notify/notifier.go:NotifyDiskHealthDegraded": `DiskAlertKind.Severity() — pinned behaviourally by TestR329_DiskAlertSeveritiesStillSatisfyTheContract`,
"cmd/controller/main.go:main": `fillwatch.Band.Severity() in the SetNotify closure — pinned by TestR329_FillwatchNeverEmitsTheEmptySeverity`, "cmd/controller/main.go:fillEventPush": `fillwatch.Band.Severity() — pinned by TestR329_FillwatchNeverEmitsTheEmptySeverity`,
} }
func walkControllerFiles(t *testing.T, fn func(path string, fset *token.FileSet, f *ast.File)) { func walkControllerFiles(t *testing.T, fn func(path string, fset *token.FileSet, f *ast.File)) {
+110
View File
@@ -0,0 +1,110 @@
package notify
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-585 — the interrupted-operation alert follows the household's language.
//
// It used to push backup.AppStopRecovery.Message(), the OPERATOR's English sentence, through
// `backup_failed` — a type customer-enabled by default — so a Hungarian household's mail carried one
// English line. Every shape of the recovery is walked here:
// - the English rendering must equal Message() byte for byte (what an English household and the
// operator's log read today is unchanged, and the bundle cannot drift from the log line);
// - the Hungarian rendering must carry no English and name the operation in Hungarian;
// - a Hungarian household sends no second sentence, an English one sends exactly Message().
func TestR585_InterruptedOperationFollowsTheHousehold(t *testing.T) {
reasons := []backup.AppStopReason{backup.ReasonVolumeDump, backup.ReasonOffboxReconstitute, backup.ReasonAppExport}
shapes := []struct {
name string
restarted, failed, refused []string
}{
{"restarted", []string{"a", "b"}, nil, nil},
{"restarted_held", []string{"a"}, nil, []string{"c"}},
{"failed", []string{"a"}, []string{"b"}, nil},
{"failed_held", nil, []string{"b"}, []string{"c", "d"}},
{"held", nil, nil, []string{"c"}},
}
judged := 0
for _, reason := range reasons {
if reason.MessageKey() == "" {
t.Fatalf("%s has no bundle key — the household would read the raw reason %q", reason, reason)
}
for _, sh := range shapes {
r := &backup.AppStopRecovery{Reason: reason, Restarted: sh.restarted, Failed: sh.failed, Refused: sh.refused}
op := InterruptedOperation{
ReasonKey: reason.MessageKey(), Reason: string(reason),
Restarted: len(r.Restarted), Failed: len(r.Failed), Refused: len(r.Refused),
}
for _, lang := range []string{"en", "hu"} {
n := notifierInLang(t, lang)
var typ, hu, household string
n.pushFn = func(et, _, message, messageCustomer string, _ interface{}) {
typ, hu, household = et, message, messageCustomer
}
n.NotifyInterruptedOperation(op, r.Detail())
judged++
if typ != "backup_failed" {
t.Errorf("%s/%s: event type %q, want backup_failed (the hub allowlists it)", reason, sh.name, typ)
}
for _, english := range []string{"interrupted", "restart", "app(s)", "drive"} {
if strings.Contains(hu, english) {
t.Errorf("%s/%s: the Hungarian sentence carries English %q: %q", reason, sh.name, english, hu)
}
}
if !strings.Contains(hu, "Megszakadt") {
t.Errorf("%s/%s: the Hungarian sentence is not the bundle's: %q", reason, sh.name, hu)
}
switch lang {
case "en":
if household != r.Message() {
t.Errorf("%s/%s: the English household sentence drifted from the operator's log line\n got %q\n want %q",
reason, sh.name, household, r.Message())
}
case "hu":
if household != "" {
t.Errorf("%s/%s: a Hungarian household sent a second sentence %q", reason, sh.name, household)
}
}
}
}
}
if judged != len(reasons)*len(shapes)*2 {
t.Fatalf("judged %d renderings, want %d", judged, len(reasons)*len(shapes)*2)
}
}
// The integrity sentences moved from main.go constants into the bundle. Their Hungarian bytes are the
// ones every box has been sending (the parity rule): pinned verbatim here, the way
// TestEventMessageWireTextIsFrozen pins the others.
func TestR585_IntegrityHungarianIsUnchanged(t *testing.T) {
cases := []struct {
name string
call func(*Notifier)
want string
}{
{"ok", func(n *Notifier) { n.NotifyIntegrityOK("42s", "") },
"A távoli mentés ellenőrzése rendben lezajlott. (42s)"},
{"ok_subset", func(n *Notifier) { n.NotifyIntegrityOK("1m3s", "10%") },
"A távoli mentés ellenőrzése rendben lezajlott. (1m3s, a mentett adatok 10%-át újraolvasva)"},
{"failed", func(n *Notifier) { n.NotifyIntegrityFailed("restic check reported repository errors") },
"A távoli mentés ellenőrzése hibát talált a tárolóban. A mentések egy része sérült lehet. Ne törölj semmit, és vedd fel velünk a kapcsolatot."},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
n := notifierInLang(t, "hu")
got, seen := "", 0
n.pushFn = func(_, _, message, _ string, _ interface{}) { got, seen = message, seen+1 }
tc.call(n)
if seen != 1 {
t.Fatalf("pushed %d events, want 1", seen)
}
if got != tc.want {
t.Errorf("the Hungarian wire text changed\n got %q\n want %q", got, tc.want)
}
})
}
}
+191
View File
@@ -0,0 +1,191 @@
package quiesce
import (
"context"
"encoding/json"
"os"
"path/filepath"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
)
// R-899 (operator ruling 2026-10-08, option A): a daytime whole-guest backup never moves the night's backup.
// Every night takes its own.
//
// The agent answers /backup/due from the newest archive on the tier's storage, and a household's „Mentés most"
// press makes an archive like any other. So a press at 08:49 made the 24 h tier due again at 08:49 the next day —
// after the gate window [W+2h, W+6h) had closed — and that night had no whole-guest backup, no OS leg and no
// kernel step (measured on demo-hp, 2026-10-07 → 08, `audits/kernel-night-2026-10-07/readback/`).
//
// The rule, kept here and nowhere else: a press keeps its own record (it is a real copy, and the agent counts it
// for everything else), but it does not count for „has tonight's backup run". A tier is OWED tonight when
//
// a press succeeded on it after its last SCHEDULED success, and
// that last scheduled success is older than the opening of the current gate window (W+2h).
//
// An owed tier is due on the scheduled path even when the agent says it is not. The window gate still decides
// WHEN (its age is the press's, so the safety valve never fires for it): outside the window it waits, inside it
// runs. A scheduled success inside tonight's window ends the debt, so a press inside the window after tonight's
// backup forces nothing. A failed scheduled run does not end it (the breaker still spaces the retries).
//
// Without a window function (the pre-v0.168.0 shape) nothing is owed: there is no night to protect.
//
// The ledger is durable (beside the quiesce marker) so a controller restart between the press and the night
// does not forget the press. It is an attempt-free record of SUCCESSES only, and it is read only to decide
// due-ness — never as evidence that a backup exists (the agent's storage answers that).
//
// Pinned by TestR899_* (nightowed_test.go).
// wholeGuestLedger is the per-tier record of the last successful press and the last successful scheduled run.
type wholeGuestLedger struct {
Tiers map[string]ledgerTier `json:"tiers"`
}
type ledgerTier struct {
PressOK time.Time `json:"press_ok,omitempty"`
ScheduledOK time.Time `json:"scheduled_ok,omitempty"`
}
// manualCtxKey marks the context of a household press, so the agent adapter can tell the agent (R-899: the agent
// runs the night's OS leg only after a scheduled backup).
type manualCtxKey struct{}
// WithManualTrigger marks ctx as a household press.
func WithManualTrigger(ctx context.Context) context.Context {
return context.WithValue(ctx, manualCtxKey{}, true)
}
// IsManualTrigger reports whether ctx belongs to a household press.
func IsManualTrigger(ctx context.Context) bool {
v, _ := ctx.Value(manualCtxKey{}).(bool)
return v
}
func (l *Loop) ledgerPath() string {
if l.markerPath == "" {
return ""
}
return filepath.Join(filepath.Dir(l.markerPath), "whole-guest-ledger.json")
}
// loadLedger reads the ledger (in memory when there is no marker path). A missing or unreadable file is an
// empty ledger: nothing owed, the agent's own answer stands — the pre-R-899 behaviour, never a skipped backup.
func (l *Loop) loadLedger() wholeGuestLedger {
l.ledgerMu.Lock()
defer l.ledgerMu.Unlock()
return l.loadLedgerLocked()
}
func (l *Loop) loadLedgerLocked() wholeGuestLedger {
led := wholeGuestLedger{Tiers: map[string]ledgerTier{}}
p := l.ledgerPath()
if p == "" {
for k, v := range l.memLedger {
led.Tiers[k] = v
}
return led
}
data, err := os.ReadFile(p)
if err != nil {
if !os.IsNotExist(err) {
l.logger.Printf("[WARN] [quiesce] whole-guest ledger unreadable (%v) — no night is owed by a press this poll (R-899)", err)
}
return led
}
if err := json.Unmarshal(data, &led); err != nil {
l.logger.Printf("[WARN] [quiesce] whole-guest ledger corrupt (%v) — starting a new one (R-899)", err)
return wholeGuestLedger{Tiers: map[string]ledgerTier{}}
}
if led.Tiers == nil {
led.Tiers = map[string]ledgerTier{}
}
return led
}
// recordWholeGuestSuccess notes a successful backup on a tier, as a press or as a scheduled run.
func (l *Loop) recordWholeGuestSuccess(target string, manual bool) {
l.ledgerMu.Lock()
defer l.ledgerMu.Unlock()
led := l.loadLedgerLocked()
t := led.Tiers[target]
if manual {
t.PressOK = l.now()
} else {
t.ScheduledOK = l.now()
}
led.Tiers[target] = t
p := l.ledgerPath()
if p == "" {
if l.memLedger == nil {
l.memLedger = map[string]ledgerTier{}
}
l.memLedger[target] = t
return
}
data, err := json.MarshalIndent(led, "", " ")
if err == nil {
tmp := p + ".tmp"
if err = os.WriteFile(tmp, data, 0o600); err == nil {
err = os.Rename(tmp, p)
}
}
if err != nil {
l.logger.Printf("[WARN] [quiesce] could not save the whole-guest ledger (%v) — a press may still move the next night (R-899)", err)
}
}
// pressOwesNight reports whether a press made this tier's agent answer „not due" while tonight's scheduled
// backup has not run (the rule at the top of this file).
func (l *Loop) pressOwesNight(led wholeGuestLedger, target string) bool {
if l.windowStartFn == nil {
return false
}
t, ok := led.Tiers[target]
if !ok || t.PressOK.IsZero() || !t.PressOK.After(t.ScheduledOK) {
return false
}
open, ok := lastGateOpen(l.now(), l.windowStartFn())
if !ok {
return false
}
return t.ScheduledOK.Before(open)
}
// lastGateOpen returns the most recent opening of the gate window (W+2h, Budapest wall clock) at or before now.
func lastGateOpen(now time.Time, windowStart string) (time.Time, bool) {
startMin, err := backupwindow.ParseHHMM(windowStart)
if err != nil {
return time.Time{}, false
}
openMin := mod1440(startMin + gateOpenOffsetMin)
loc := budapestLocation()
n := now.In(loc)
open := time.Date(n.Year(), n.Month(), n.Day(), openMin/60, openMin%60, 0, 0, loc)
if open.After(n) {
open = open.AddDate(0, 0, -1)
}
return open, true
}
// withoutOwed drops the R-899 owed tiers: they never license the window gate's safety valve.
func withoutOwed(tiers []dueTier) []dueTier {
out := make([]dueTier, 0, len(tiers))
for _, t := range tiers {
if !t.owed {
out = append(out, t)
}
}
return out
}
// gateAge is the age the window gate judges: the oldest of the tiers that are due by the agent. When every due tier
// is owed by a press, it is zero — „just backed up" — so outside the window the gate waits and inside it runs.
func gateAge(tiers []dueTier) *int64 {
agentDue := withoutOwed(tiers)
if len(agentDue) == 0 {
zero := int64(0)
return &zero
}
return oldestAge(agentDue)
}
@@ -0,0 +1,192 @@
package quiesce
import (
"bytes"
"log"
"path/filepath"
"strings"
"testing"
"time"
)
// R-899 (operator ruling 2026-10-08, option A): a daytime whole-guest backup never moves the night's backup.
// These assert the CONSEQUENCE — is tonight's local backup started? — not the ledger's mechanism.
func budapest(t *testing.T, y int, m time.Month, d, hh, mm int) time.Time {
t.Helper()
return time.Date(y, m, d, hh, mm, 0, 0, budapestLocation())
}
// r899Loop is a tiered loop with the window gate on (W = 02:30 → gate [04:30, 08:30)) and a settable clock.
func r899Loop(t *testing.T, be *tierBackend, st *fakeStacks, markerPath string, now *time.Time, logs *bytes.Buffer) *Loop {
t.Helper()
l := New(Options{
Backend: be, Stacks: st, MarkerPath: markerPath,
StatusPoll: time.Millisecond, MaxQuiesce: 30 * time.Second,
Logger: log.New(logs, "", 0),
})
l.windowStartFn = func() string { return "02:30" }
l.now = func() time.Time { return *now }
return l
}
func press(t *testing.T, l *Loop) {
t.Helper()
if err := l.TriggerNow(); err != nil {
t.Fatalf("TriggerNow: %v", err)
}
l.mu.Lock()
l.mu.Unlock() //nolint:staticcheck // wait for the async cycle
}
// The 2026-10-07 case, replayed: last night's backup at 04:35, a press at 08:49, and the agent then answers
// „not due" at 04:35 the next night (its newest archive is the press, 19.7 h old). Tonight must still back up.
// Before R-899 the cycle started nothing and the night had no OS leg and no kernel step.
func TestR899_DaytimePressDoesNotCancelTheNight(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
var logs bytes.Buffer
now := budapest(t, 2026, 10, 7, 4, 35)
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
be.setDue("local", true)
if err := l.runOnce(t.Context()); err != nil {
t.Fatalf("night 1: %v", err)
}
be.setDue("local", false)
now = budapest(t, 2026, 10, 7, 8, 49)
press(t, l)
now = budapest(t, 2026, 10, 8, 4, 35) // agent: local NOT due (the press is 19.7 h old)
if err := l.runOnce(t.Context()); err != nil {
t.Fatalf("night 2: %v", err)
}
got := be.startedTargets()
if len(got) != 3 || got[2] != "local" {
t.Fatalf("night 2 must take its own local backup despite the morning press; started=%v\nlogs:\n%s", got, logs.String())
}
if !strings.Contains(logs.String(), "R-899") {
t.Fatalf("the forced night must say why in the log; logs:\n%s", logs.String())
}
// The press was marked as a press; the two scheduled runs were not.
if m := be.manualStarts; len(m) != 3 || m[0] || !m[1] || m[2] {
t.Fatalf("press mark per start = %v, want [false true false]", m)
}
// And once tonight's backup ran, the debt is paid: a later poll in the same window starts nothing.
now = budapest(t, 2026, 10, 8, 4, 45)
if err := l.runOnce(t.Context()); err != nil {
t.Fatalf("night 2, later poll: %v", err)
}
if got := be.startedTargets(); len(got) != 3 {
t.Fatalf("tonight's backup already ran — no second one; started=%v", got)
}
}
// Outside the window the owed night waits for the window (the press's own age never fires the valve).
func TestR899_OwedNightWaitsForTheWindow(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}}
var logs bytes.Buffer
now := budapest(t, 2026, 10, 7, 8, 49)
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
press(t, l)
now = budapest(t, 2026, 10, 7, 15, 0)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 1 {
t.Fatalf("outside the window nothing may start; started=%v", got)
}
now = budapest(t, 2026, 10, 8, 4, 31)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 2 || got[1] != "local" {
t.Fatalf("inside the window the owed night runs; started=%v", got)
}
}
// A press INSIDE the window after tonight's backup forces nothing more tonight.
func TestR899_PressAfterTonightsBackupForcesNothing(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}}
var logs bytes.Buffer
now := budapest(t, 2026, 10, 8, 4, 35)
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
be.setDue("local", true)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
be.setDue("local", false)
now = budapest(t, 2026, 10, 8, 5, 0)
press(t, l)
now = budapest(t, 2026, 10, 8, 5, 10)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 2 {
t.Fatalf("tonight's scheduled backup already ran before the press — no third backup; started=%v", got)
}
}
// Without a press, an agent „not due" stands (no extra backups for a box nobody pressed).
func TestR899_NoPressNoExtraBackup(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}}
var logs bytes.Buffer
now := budapest(t, 2026, 10, 8, 4, 35)
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 0 {
t.Fatalf("no press, agent not due → nothing; started=%v", got)
}
}
// The press is remembered across a controller restart (the ledger is on disk beside the marker).
func TestR899_PressSurvivesARestart(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}}
var logs bytes.Buffer
marker := filepath.Join(t.TempDir(), "quiesce-state.json")
now := budapest(t, 2026, 10, 7, 8, 49)
press(t, r899Loop(t, be, st, marker, &now, &logs))
now = budapest(t, 2026, 10, 8, 4, 35)
l2 := r899Loop(t, be, st, marker, &now, &logs) // a new process
if err := l2.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 2 || got[1] != "local" {
t.Fatalf("after a restart the press still does not count for tonight; started=%v", got)
}
}
// The gate-open instant, across midnight and a DST change.
func TestR899_LastGateOpen(t *testing.T) {
cases := []struct {
now time.Time
win string
want time.Time
}{
{budapest(t, 2026, 10, 8, 4, 35), "02:30", budapest(t, 2026, 10, 8, 4, 30)},
{budapest(t, 2026, 10, 8, 4, 29), "02:30", budapest(t, 2026, 10, 7, 4, 30)},
{budapest(t, 2026, 10, 8, 1, 0), "23:30", budapest(t, 2026, 10, 8, 1, 30).AddDate(0, 0, -1)},
{budapest(t, 2026, 10, 25, 5, 0), "02:30", budapest(t, 2026, 10, 25, 4, 30)}, // DST ends that night
}
for _, c := range cases {
got, ok := lastGateOpen(c.now, c.win)
if !ok || !got.Equal(c.want) {
t.Errorf("lastGateOpen(%s, %s) = %s, want %s", c.now, c.win, got, c.want)
}
}
}
+6 -2
View File
@@ -145,8 +145,12 @@ func TestNotify_BothFailingTiersAreReported(t *testing.T) {
rec := &recNotifier{} rec := &recNotifier{}
l.SetTierNotifier(rec) l.SetTierNotifier(rec)
if err := l.runOnce(context.Background()); err != nil { // `09` §3 decision 156: one tier per window, so the second tier is reached by the NEXT cycle —
t.Fatal(err) // where the failed first tier is now in backoff and must not stand in its way.
for cycle := 1; cycle <= 2; cycle++ {
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("cycle %d: %v", cycle, err)
}
} }
gotLocal, gotPBS := rec.count("failed", "local"), rec.count("failed", "felhom-pbs") gotLocal, gotPBS := rec.count("failed", "local"), rec.count("failed", "felhom-pbs")
if gotLocal != 1 || gotPBS != 1 { if gotLocal != 1 || gotPBS != 1 {
+58
View File
@@ -0,0 +1,58 @@
package quiesce
import "context"
// R-921 — CHECK FIRST, STOP SECOND.
//
// MEASURED 2026-10-08 night on demo-hp (felhom.eu documentation/audits/dooplex-survival-2026-10-09/partE/
// R-518.txt): the controller stopped every app for the off-site tier, the agent refused the backup
// („a heavy operation is already in flight", busy=backup:local — the night OS step that follows the local
// copy), and the apps were down about a minute for no copy.
//
// What the agent lets the controller see BEFORE a stop (felhom-agent v0.154.0, read 2026-10-09): the agent
// refuses POST /backup for two reasons. (1) A job of ANOTHER tier of this guest is in flight
// (localapi otherTierInFlight) — that IS readable, per tier, from GET /backup/status?target=…. (2) Its
// host-wide heavy-operation gate is held (backup.InFlight: the OS step after the night's local copy, a
// restore-test, fstrim) — that is served by NO endpoint (InFlight.Busy() exists and nothing exposes it).
// So the pre-check below covers reason (1) only. Reason (2) — the measured instance — is met by the
// BUSY path in quiesceAndPollTiers, which resumes the apps at once (pinned by
// TestR921_BusyRefusalResumesAtOnce); closing it before the stop needs the agent to serve its gate.
//
// The race (free at the check, busy at the start) needs nothing new: it is the BUSY path again.
// InFlightProber is the OPTIONAL pre-check surface (R-921). An adapter that does not implement it keeps
// the pre-R-921 behaviour exactly: stop, ask, and on refusal resume at once.
type InFlightProber interface {
// BackupJobsInFlight returns the tiers whose backup job the agent holds in flight right now
// (phase running or snapshotted). An agent without per-tier jobs (pre-R-82) answers nil, nil.
BackupJobsInFlight(ctx context.Context) ([]string, error)
}
// anotherTierInFlight reports whether the agent would refuse the window's first tier because a job of a
// DIFFERENT tier is in flight — so the window must not stop any app. The window's OWN tier in flight is
// not a refusal (the agent answers its start with the running job, 202), and that path is left as it was.
//
// Fail toward backing up: an unanswerable pre-check, an untargeted window (pre-R-82 agent) or an adapter
// without the surface all return false — the window runs as before.
func (l *Loop) anotherTierInFlight(ctx context.Context, first string) bool {
if first == "" {
return false
}
p, ok := l.backend.(InFlightProber)
if !ok {
return false
}
busy, err := p.BackupJobsInFlight(ctx)
if err != nil {
l.logger.Printf("[WARN] [quiesce] pre-check: could not ask the agent which backup jobs are in flight (%v) — stopping the apps and asking as before (R-921)", err)
return false
}
for _, t := range busy {
if t != first {
l.logger.Printf("[INFO] [quiesce] tier %s is due, but the agent still holds a backup job on tier %s — no app is stopped; the tier stays due and is asked again at the next poll (R-921)",
tierLabel(first), tierLabel(t))
return true
}
}
return false
}
+119 -41
View File
@@ -131,6 +131,10 @@ type Loop struct {
// restartFailed (F-CRIT-1) is the set of stacks this loop stopped and could NOT restart. Guarded // restartFailed (F-CRIT-1) is the set of stacks this loop stopped and could NOT restart. Guarded
// by suppressMu — same concern, same lock. See suppress.go. // by suppressMu — same concern, same lock. See suppress.go.
restartFailed map[string]struct{} restartFailed map[string]struct{}
// R-899: the whole-guest ledger (nightowed.go) — the last successful press and scheduled run per tier.
// ledgerMu guards the file and memLedger (the in-memory copy used when there is no marker path).
ledgerMu sync.Mutex
memLedger map[string]ledgerTier
} }
// SetTierNotifier wires the hub-event seam. INIT-ONLY — call once at startup, before Run. // SetTierNotifier wires the hub-event seam. INIT-ONLY — call once at startup, before Run.
@@ -219,8 +223,9 @@ func (l *Loop) runOnce(ctx context.Context) error {
return nil return nil
} }
// R-82: resolve EVERY due tier up front. This is the dedup rule (tiers.go): both tiers due on // R-82: resolve EVERY due tier up front (the breaker, the catch-up wait and the window gate below
// the weekly night yields ONE window with two backups, never two stop/start cycles. // judge them all). Since `09` §3 decision 156 the window then backs up only the first one; the
// others stay due and the next poll takes them, each in its own short window (tiers.go table).
dueTiers, _, err := l.resolveDueTiers(ctx) dueTiers, _, err := l.resolveDueTiers(ctx)
if err != nil { if err != nil {
return fmt.Errorf("check due: %w", err) return fmt.Errorf("check due: %w", err)
@@ -255,7 +260,7 @@ func (l *Loop) runOnce(ctx context.Context) error {
// cadence+24h" — cannot be suppressed by a fresher sibling tier. // cadence+24h" — cannot be suppressed by a fresher sibling tier.
if l.windowStartFn != nil { if l.windowStartFn != nil {
window := l.windowStartFn() window := l.windowStartFn()
if !scheduledRunAllowed(l.now().In(budapestLocation()), window, oldestAge(dueTiers), valveLicensed(dueTiers), l.cadence) { if !scheduledRunAllowed(l.now().In(budapestLocation()), window, gateAge(dueTiers), valveLicensed(withoutOwed(dueTiers)), l.cadence) {
from, to := gateBounds(window) from, to := gateBounds(window)
l.logger.Printf("[DEBUG] [quiesce] scheduled backup due but outside the backup window [%s–%s) — deferring to the next poll inside it", from, to) l.logger.Printf("[DEBUG] [quiesce] scheduled backup due but outside the backup window [%s–%s) — deferring to the next poll inside it", from, to)
return nil return nil
@@ -425,10 +430,16 @@ func (l *Loop) TriggerNow() error {
ctx, cancel := context.WithTimeout(context.Background(), l.maxQuiesce+5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), l.maxQuiesce+5*time.Minute)
defer cancel() defer cancel()
l.logger.Printf("[INFO] [quiesce] manual backup requested — quiescing now") l.logger.Printf("[INFO] [quiesce] manual backup requested — quiescing now")
// Manual runs bypass due-ness (that is the point) but must still cover EVERY tier, in one // Manual runs bypass due-ness (that is the point). Since `09` §3 decision 156 (R-518 option A)
// window. A manual "Mentés most" that silently skipped the DR tier would be the same // the press makes the LOCAL (primary) copy only, with one short stop; the off-site tier is not
// applied-and-empty fault in a different costume. // requested here — it stays due and the next scheduled night run takes it in its own window.
if err := l.quiesceAndPollTiers(ctx, l.allTiersForManualRun(ctx)); err != nil { // The page says so in both languages (templates/backups.html, keys
// backups.a_mentes_alatt_az_alkalmazasok + backups.elinditod_a_teljes_rendszermentest_most,
// pinned by TestR518_BackupButtonStatesTheShortLocalOnlyStop).
// R-899: the press is marked, so its success is recorded as a press (it does not count for
// tonight's backup) and the agent is told not to start the night's OS leg after it.
ctx = WithManualTrigger(ctx)
if err := l.quiesceAndPollTiers(ctx, l.manualRunTiers(ctx)); err != nil {
l.logger.Printf("[ERROR] [quiesce] manual backup cycle error: %v", err) l.logger.Printf("[ERROR] [quiesce] manual backup cycle error: %v", err)
} }
}() }()
@@ -443,8 +454,59 @@ func (l *Loop) quiesceAndPoll(ctx context.Context) error {
return l.quiesceAndPollTiers(ctx, []dueTier{{target: ""}}) return l.quiesceAndPollTiers(ctx, []dueTier{{target: ""}})
} }
// allTiersForManualRun lists every tier a manual run should cover: all advertised tiers on an R-82 // manualRunTiers lists the tier a manual press backs up: on an R-82 agent ONLY the primary (local)
// agent, or the single untargeted tier otherwise. Due-ness is deliberately NOT consulted. // tier, or the single untargeted tier otherwise. Due-ness is deliberately NOT consulted.
//
// `09` §3 decision 156 (R-518 option A): the press makes the local copy with one short stop; the
// off-site tier follows at the next scheduled night run. Pinned by TestManualPress_RunsOnlyLocalTier.
//
// The primary is the tier the agent FLAGS Primary — not the first in the list. If the agent flags a primary
// but its storage is reported absent, the press backs up NOTHING and says so at ERROR: the ruling is „the
// local copy only", so the off-site tier is not a stand-in (it still runs at night), and no app is stopped
// (an empty window returns before the stop). Pinned by TestManualRunTiers_NoPrimaryAvailable_BacksUpNothing.
// An agent that flags NO tier primary at all (none advertised it) keeps the old order: the first tier.
func (l *Loop) manualRunTiers(ctx context.Context) []dueTier {
all := l.allTiersForManualRun(ctx)
if len(all) == 0 {
return all
}
if len(all) == 1 && all[0].target == "" {
return all // a pre-R-82 agent: the single untargeted tier
}
for _, t := range all {
if t.primary {
return []dueTier{t}
}
}
if l.primaryAdvertised(ctx) {
l.logger.Printf("[ERROR] [quiesce] manual run: the local copy's storage is not available — nothing backed up now; the off-site copy follows at the night run (`09` §3 decision 156)")
return nil
}
l.logger.Printf("[WARN] [quiesce] manual run: the agent flags no tier as the local one — backing up tier %s (the first available)", tierLabel(all[0].target))
return all[:1]
}
// primaryAdvertised reports whether the agent flags ANY tier primary, its storage absent or not.
func (l *Loop) primaryAdvertised(ctx context.Context) bool {
tb, ok := l.backend.(TieredBackend)
if !ok {
return false
}
tiers, err := tb.Tiers(ctx)
if err != nil {
return false
}
for _, t := range tiers {
if t.Primary {
return true
}
}
return false
}
// allTiersForManualRun lists every tier the agent can back up now: all advertised tiers whose storage
// is not absent on an R-82 agent, or the single untargeted tier otherwise. manualRunTiers picks the
// primary from it.
func (l *Loop) allTiersForManualRun(ctx context.Context) []dueTier { func (l *Loop) allTiersForManualRun(ctx context.Context) []dueTier {
tb, ok := l.backend.(TieredBackend) tb, ok := l.backend.(TieredBackend)
if !ok { if !ok {
@@ -462,20 +524,26 @@ func (l *Loop) allTiersForManualRun(ctx context.Context) []dueTier {
kept := l.skipAbsentTiers(tiers) kept := l.skipAbsentTiers(tiers)
out := make([]dueTier, 0, len(kept)) out := make([]dueTier, 0, len(kept))
for _, t := range kept { for _, t := range kept {
out = append(out, dueTier{target: t.Target}) out = append(out, dueTier{target: t.Target, primary: t.Primary})
} }
return out return out
} }
// quiesceAndPollTiers is the R-82 multi-tier cycle: ONE marker, ONE stop, N backups run // quiesceAndPollTiers is ONE quiesce window: ONE marker, ONE stop, ONE backup, ONE resume at that
// SEQUENTIALLY inside the window, ONE resume, then the tail polled to completion. // backup's `snapshotted`, then the upload polled to completion with the apps running.
// //
// Why sequential: vzdump takes a guest lock, so a second backup cannot start until the first // ONE STOP PER BACKUP TIER (`09` §3 decision 156, R-518 option A, 2026-10-06). This REVERSES R-82's
// finishes. Why the app stays down until the LAST tier snapshots: the whole point of quiescing is // „ONE quiesce window for both due tiers (never two app outages for one night)": that window kept every
// app-consistency, and resuming after tier 1's snapshot would leave tier 2 capturing a RUNNING app. // app down for the first tier's whole upload (5 min 47 s measured on demo-hp, 9 apps). Now the window
// Consequence, stated plainly because it is user-visible: on the both-due night downtime is // backs up only the FIRST tier in `tiers` that starts; the apps resume at its snapshot; any other due
// (first tier's full backup) + (last tier's snapshot), not one snapshot. Tier ORDER therefore // tier stays due and a LATER cycle (the next poll, after this one has finished) takes it in its own
// matters — see resolveDueTiers. // short window — never straight after this one. Every copy is still taken with the apps stopped, so
// each tier stays app-consistent. Pinned by TestBothTiersDue_FirstCycleRunsOnlyFirstTier,
// TestFirstTierSnapshot_ResumesAppWhileUploadContinues and TestLeftoverTier_RunsInNextCycleInItsOwnWindow.
//
// A tier that does not START (agent BUSY, or a start error) took no copy, so the next tier in `tiers`
// is tried inside the same window — exactly as before; the apps are already down and no copy has been
// made yet, so this still costs one stop for at most one backup.
// //
// Crash-safety is unchanged and non-negotiable: the marker is written BEFORE anything stops, // Crash-safety is unchanged and non-negotiable: the marker is written BEFORE anything stops,
// unquiesce fires exactly once no matter which tier fails, and the GUARANTEE is the crash MARKER plus // unquiesce fires exactly once no matter which tier fails, and the GUARANTEE is the crash MARKER plus
@@ -488,6 +556,11 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
if len(tiers) == 0 { if len(tiers) == 0 {
return nil return nil
} }
// R-921: check first, stop second — a tier the agent will refuse (another tier's job in flight) stops
// no app. Pinned by TestR921_NoStopWhileAnotherTiersJobIsInFlight (precheck.go says what it cannot see).
if l.anotherTierInFlight(ctx, tiers[0].target) {
return nil
}
running := l.stacks.RunningAppStacks() running := l.stacks.RunningAppStacks()
marker := Marker{Active: true, StartedAt: l.now(), StoppedStacks: running} marker := Marker{Active: true, StartedAt: l.now(), StoppedStacks: running}
if err := l.writeMarker(marker); err != nil { if err := l.writeMarker(marker); err != nil {
@@ -528,7 +601,7 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
deadline := l.now().Add(l.maxQuiesce) deadline := l.now().Add(l.maxQuiesce)
var firstErr error var firstErr error
// ONE window, N tiers, sequential. The app resumes only after the LAST tier snapshots. // ONE window, ONE backup (decision 156): the first tier that starts is the window's only backup.
for i, t := range tiers { for i, t := range tiers {
label := tierLabel(t.target) label := tierLabel(t.target)
last := i == len(tiers)-1 last := i == len(tiers)-1
@@ -565,7 +638,9 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
_ = l.writeMarker(marker) // best-effort: record the CURRENT tier's job id for diagnosis _ = l.writeMarker(marker) // best-effort: record the CURRENT tier's job id for diagnosis
l.logger.Printf("[INFO] [quiesce] tier %s: backup job %s started — polling", label, jobID) l.logger.Printf("[INFO] [quiesce] tier %s: backup job %s started — polling", label, jobID)
phase, stillRunning, perr := l.pollTier(ctx, t.target, jobID, label, deadline, last, &unquiesced, unquiesce) // Decision 156: this is the window's only backup, so the apps resume at ITS snapshot
// (pollTier's early resume) and its upload finishes with them running.
phase, stillRunning, perr := l.pollTier(ctx, t.target, jobID, label, deadline, &unquiesced, unquiesce)
if perr != nil && firstErr == nil { if perr != nil && firstErr == nil {
firstErr = perr firstErr = perr
} }
@@ -578,6 +653,7 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
// must NOT count as a failure, or a slow-but-healthy tier would back itself off. // must NOT count as a failure, or a slow-but-healthy tier would back itself off.
default: default:
l.noteTierSuccess(t.target, label) l.noteTierSuccess(t.target, label)
l.recordWholeGuestSuccess(t.target, IsManualTrigger(ctx)) // R-899 (nightowed.go)
} }
if stillRunning { if stillRunning {
// The max-quiesce guard fired while THIS tier's backup is still going (a first full // The max-quiesce guard fired while THIS tier's backup is still going (a first full
@@ -596,6 +672,14 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
label, len(tiers)-i-1) label, len(tiers)-i-1)
break break
} }
// Decision 156: one backup per window. The remaining due tiers stay due; a later cycle (the
// next poll — this cycle holds the single-flight lock until the upload above has finished)
// takes each in its own short window. Never straight after this one.
if rest := len(tiers) - i - 1; rest > 0 {
l.logger.Printf("[INFO] [quiesce] tier %s finished its window — %d other due tier(s) wait for a later cycle, each with its own short stop (`09` §3 decision 156)",
label, rest)
}
break
} }
// Belt: if every tier failed to start, nothing above unquiesced. The deferred call covers it, // Belt: if every tier failed to start, nothing above unquiesced. The deferred call covers it,
@@ -604,16 +688,17 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
return firstErr return firstErr
} }
// pollTier polls ONE tier's job. It returns the terminal (or snapshotted-at-deadline) phase. // pollTier polls ONE tier's job — the window's only backup — to a TERMINAL phase (done/failed).
// //
// The app is resumed ONLY when this is the LAST tier — that is what keeps every tier // The app resumes at `snapshotted` (8B.2 early resume) and the poll continues to the terminal phase
// app-consistent while still costing exactly one stop/start pair. For a non-last tier the loop // with the app running, so the outcome is still recorded (breaker, hub notice) and the cycle holds the
// waits for a TERMINAL phase (done/failed), because vzdump holds the guest lock and the next tier // single-flight lock until the agent's job — and its guest lock — has finished. Since `09` §3
// cannot start until this one truly finishes. // decision 156 every window has exactly one backup, so there is no "non-last tier" that must keep the
// app down any more.
// Returns (phase, stillRunning, err). stillRunning=true means the quiesce bound elapsed while the // Returns (phase, stillRunning, err). stillRunning=true means the quiesce bound elapsed while the
// backup is STILL going — the caller must not start another tier (one backup at a time per guest). // backup is STILL going — the caller must not start another tier (one backup at a time per guest).
func (l *Loop) pollTier(ctx context.Context, target, jobID, label string, deadline time.Time, func (l *Loop) pollTier(ctx context.Context, target, jobID, label string, deadline time.Time,
last bool, unquiesced *bool, unquiesce func(string)) (string, bool, error) { unquiesced *bool, unquiesce func(string)) (string, bool, error) {
for { for {
if !l.now().Before(deadline) { if !l.now().Before(deadline) {
l.logger.Printf("[WARN] [quiesce] max-quiesce-duration (%s) exceeded on tier %s (job %s) — unquiescing while the backup continues on the agent", l.logger.Printf("[WARN] [quiesce] max-quiesce-duration (%s) exceeded on tier %s (job %s) — unquiescing while the backup continues on the agent",
@@ -628,25 +713,18 @@ func (l *Loop) pollTier(ctx context.Context, target, jobID, label string, deadli
} }
switch phase { switch phase {
case phaseSnapshotted: case phaseSnapshotted:
// 8B.2 early resume — but ONLY on the last tier. Resuming here on a non-last tier would // 8B.2 early resume. Safe for every tier since decision 156: no other tier is started in
// leave the following tier capturing a running app, losing app-consistency for exactly // this window, so no later copy can capture a running app.
// the DR tier we most want it on. if !*unquiesced {
if last && !*unquiesced { l.logger.Printf("[INFO] [quiesce] tier %s: job %s snapshotted — resuming app early (8B.2); the upload continues with the apps running", label, jobID)
l.logger.Printf("[INFO] [quiesce] tier %s: job %s snapshotted — resuming app early (8B.2)", label, jobID) unquiesce("snapshotted (early resume)")
unquiesce("snapshotted (early resume, last tier)")
} }
case phaseDone: case phaseDone:
if last { l.logger.Printf("[INFO] [quiesce] tier %s: backup job %s done", label, jobID)
l.logger.Printf("[INFO] [quiesce] tier %s: backup job %s done", label, jobID) unquiesce("backup done")
unquiesce("backup done")
} else {
l.logger.Printf("[INFO] [quiesce] tier %s: backup job %s done — next tier may start (app still quiesced)", label, jobID)
}
return phaseDone, false, nil return phaseDone, false, nil
case phaseFailed: case phaseFailed:
if last { unquiesce("backup failed")
unquiesce("backup failed")
}
return phaseFailed, false, nil return phaseFailed, false, nil
} }
select { select {
@@ -0,0 +1,286 @@
package quiesce
import (
"context"
"errors"
"io"
"log"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// R-921 (measured 2026-10-08 night on demo-hp): the controller stopped every app for the off-site tier,
// the agent refused the backup (`a heavy operation is already in flight`), and the apps were down about a
// minute for nothing. The rule this file pins: CHECK FIRST, STOP SECOND — and when the check could not
// see the refusal coming, RESUME AT ONCE.
//
// The agent's local API serves no read of its host-wide heavy-operation gate (felhom-agent
// internal/backup.InFlight.Busy() is served by no endpoint). What it DOES serve is each tier's job phase
// (GET /backup/status?target=…), and a job in flight on ANOTHER tier is the agent's first refusal reason
// (localapi handleBackup → otherTierInFlight → HTTP 409). So the pre-check covers that reason; the other
// (the gate held by the night OS step, a restore-test, fstrim) can only be met by the immediate resume.
// r921Backend is a tiered fake agent that records every call into a shared, ordered event log with the
// fake stacks, so a test can say "nothing happened between the refusal and the first restart".
type r921Backend struct {
mu sync.Mutex
events *[]string
tiers []BackupTier
due map[string]bool
inFlight []string // targets whose job the agent holds in flight (running|snapshotted)
probeErr error
busyOn map[string]bool // StartBackupFor answers ErrTierBusy for these targets
starts []string
}
func (b *r921Backend) log(e string) {
b.mu.Lock()
defer b.mu.Unlock()
*b.events = append(*b.events, e)
}
func (b *r921Backend) Tiers(context.Context) ([]BackupTier, error) { return b.tiers, nil }
func (b *r921Backend) DueFor(_ context.Context, target string) (bool, *int64, string, error) {
return b.due[target], nil, "", nil
}
func (b *r921Backend) StartBackupFor(_ context.Context, target string) (string, error) {
b.mu.Lock()
b.starts = append(b.starts, target)
b.mu.Unlock()
if b.busyOn[target] {
b.log("start:" + target + "=BUSY")
return "", busyErr()
}
b.log("start:" + target)
return "job-" + target, nil
}
func (b *r921Backend) BackupStatusFor(_ context.Context, target string) (string, error) {
b.log("status:" + target)
return phaseDone, nil
}
func (b *r921Backend) BackupJobsInFlight(context.Context) ([]string, error) {
b.log("probe")
if b.probeErr != nil {
return nil, b.probeErr
}
return append([]string(nil), b.inFlight...), nil
}
func (b *r921Backend) Due(context.Context) (bool, *int64, error) { return false, nil, nil }
func (b *r921Backend) StartBackup(context.Context) (string, error) { return "", errors.New("unused") }
func (b *r921Backend) BackupStatus(context.Context) (string, error) {
return phaseDone, nil
}
// r921Stacks logs stops and starts into the same event log.
type r921Stacks struct {
mu sync.Mutex
events *[]string
running []string
}
func (s *r921Stacks) RunningAppStacks() []string { return append([]string(nil), s.running...) }
func (s *r921Stacks) StopStack(n string) error {
s.mu.Lock()
defer s.mu.Unlock()
*s.events = append(*s.events, "stop:"+n)
return nil
}
func (s *r921Stacks) StartStack(n string) error {
s.mu.Lock()
defer s.mu.Unlock()
*s.events = append(*s.events, "startstack:"+n)
return nil
}
func r921Setup(t *testing.T) (*r921Backend, *r921Stacks, *Loop, *[]string, *strings.Builder) {
t.Helper()
var events []string
be := &r921Backend{
events: &events,
tiers: []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}},
due: map[string]bool{},
busyOn: map[string]bool{},
}
st := &r921Stacks{events: &events, running: []string{"immich", "paperless-ngx", "vaultwarden"}}
var logs strings.Builder
l := New(Options{
Backend: be,
Stacks: st,
MarkerPath: filepath.Join(t.TempDir(), "quiesce-state.json"),
StatusPoll: time.Millisecond,
MaxQuiesce: 30 * time.Second,
Logger: log.New(&logs, "", 0),
})
return be, st, l, &events, &logs
}
func countPrefix(events []string, prefix string) int {
n := 0
for _, e := range events {
if strings.HasPrefix(e, prefix) {
n++
}
}
return n
}
// RED TEST. Another tier's job is in flight on the agent (a first off-site upload that outlived the
// quiesce bound, or a controller restarted mid-upload) and the local tier is due. The agent WILL refuse
// the local backup (otherTierInFlight → 409). Today's code stops every app, asks, is refused, restarts
// them: a stop for nothing. Asserted on the CONSEQUENCE — no app was stopped and no backup was requested
// — not on the mechanism. It must also leave the tier due (no breaker, no contention verdict): the next
// poll asks again, which costs the household nothing.
func TestR921_NoStopWhileAnotherTiersJobIsInFlight(t *testing.T) {
be, _, l, events, logs := r921Setup(t)
be.due["local"] = true
be.inFlight = []string{"felhom-pbs"}
be.busyOn["local"] = true // what the real agent answers while another tier's job is in flight
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce: %v", err)
}
if n := countPrefix(*events, "stop:"); n != 0 {
t.Fatalf("R-921: %d app(s) stopped for a tier the agent refuses (another tier's job is in flight); events=%v", n, *events)
}
if len(be.starts) != 0 {
t.Fatalf("R-921: a backup was requested although another tier's job is in flight: %v", be.starts)
}
if got := l.breaker.failuresFor("local"); got != 0 {
t.Errorf("the pre-check armed the failure breaker (%d) — nothing failed", got)
}
if _, blocked := l.contention.blocked("local", l.now()); blocked {
t.Errorf("the pre-check set a contention verdict — the tier must simply stay due and be asked again next poll")
}
if !strings.Contains(logs.String(), "felhom-pbs") || !strings.Contains(logs.String(), "R-921") {
t.Errorf("the skip is not explained in the log (want the in-flight tier and R-921): %q", logs.String())
}
// The job ends → the next poll backs the local tier up as normal (the skip did not swallow the tier).
be.inFlight = nil
be.busyOn["local"] = false
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce 2: %v", err)
}
if len(be.starts) != 1 || be.starts[0] != "local" {
t.Fatalf("after the job ended the local tier was not backed up: starts=%v", be.starts)
}
}
// The measured instance and the race, both on the BUSY path: the pre-check cannot see the refusal
// coming (the night OS step holds the gate — not readable from the agent — or the agent became busy
// between the check and the start). Then the resume must follow the refusal AT ONCE: the very next
// event after the refused start is the first app's restart — no other agent call, no further tier, no
// wait in between. Measured on the ordered event log, not on a clock.
func TestR921_BusyRefusalResumesAtOnce(t *testing.T) {
for _, tc := range []struct {
name string
due []string
busy []string
probe bool // the pre-check answered "free" (the race) vs. a backend without the pre-check
}{
{"measured: only the off-site tier due, refused (no pre-check)", []string{"felhom-pbs"}, []string{"felhom-pbs"}, false},
{"race: pre-check said free, the start was refused", []string{"felhom-pbs"}, []string{"felhom-pbs"}, true},
} {
t.Run(tc.name, func(t *testing.T) {
be, st, l, events, _ := r921Setup(t)
for _, d := range tc.due {
be.due[d] = true
}
for _, b := range tc.busy {
be.busyOn[b] = true
}
var loop *Loop = l
if !tc.probe {
// A backend WITHOUT the pre-check surface (an adapter that does not implement it).
nb := &noProbeBackend{be}
loop = New(Options{Backend: nb, Stacks: st, MarkerPath: filepath.Join(t.TempDir(), "m.json"),
StatusPoll: time.Millisecond, MaxQuiesce: 30 * time.Second, Logger: log.New(io.Discard, "", 0)})
}
if err := loop.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce: %v", err)
}
ev := *events
idx := -1
for i, e := range ev {
if strings.HasSuffix(e, "=BUSY") {
idx = i
}
}
if idx < 0 {
t.Fatalf("no refused start in the events: %v", ev)
}
if idx+1 >= len(ev) || !strings.HasPrefix(ev[idx+1], "startstack:") {
t.Fatalf("the resume did not follow the refusal at once; events after the refusal: %v", ev[idx+1:])
}
if got, want := countPrefix(ev[idx+1:], "startstack:"), len(st.running); got != want {
t.Fatalf("restarted %d app(s) after the refusal, want all %d; events=%v", got, want, ev)
}
for _, e := range ev[idx+1:] {
if !strings.HasPrefix(e, "startstack:") {
t.Fatalf("an agent call (%q) came after the refusal — the BUSY path must only resume; events=%v", e, ev)
}
}
if _, blocked := loop.contention.blocked("felhom-pbs", loop.now()); !blocked {
t.Errorf("the refused tier was not deferred — the next poll would stop the apps again")
}
})
}
}
// A pre-check that cannot be answered must not suppress a backup: fail toward backing up, exactly as
// before R-921 (stop, ask, and on refusal resume at once).
func TestR921_PrecheckErrorFailsTowardBackingUp(t *testing.T) {
be, _, l, events, logs := r921Setup(t)
be.due["local"] = true
be.probeErr = errors.New("agentapi: GET /backup/tiers: connection refused")
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce: %v", err)
}
if len(be.starts) != 1 || be.starts[0] != "local" {
t.Fatalf("an unanswerable pre-check suppressed the backup: starts=%v events=%v", be.starts, *events)
}
if !strings.Contains(logs.String(), "[WARN]") {
t.Errorf("the failed pre-check was not logged at WARN: %q", logs.String())
}
}
// The window's OWN tier already in flight is NOT skipped: the agent answers that start with the running
// job (202, idempotent), the loop attaches to it and records its outcome — unchanged behaviour, chosen
// deliberately (only a tier the agent REFUSES is skipped before the stop).
func TestR921_SameTierInFlightIsNotSkipped(t *testing.T) {
be, _, l, _, _ := r921Setup(t)
be.due["felhom-pbs"] = true
be.inFlight = []string{"felhom-pbs"}
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce: %v", err)
}
if len(be.starts) != 1 || be.starts[0] != "felhom-pbs" {
t.Fatalf("the window's own in-flight tier was skipped; starts=%v", be.starts)
}
}
// noProbeBackend hides BackupJobsInFlight (an adapter without the pre-check surface).
type noProbeBackend struct{ b *r921Backend }
func (n *noProbeBackend) Tiers(ctx context.Context) ([]BackupTier, error) { return n.b.Tiers(ctx) }
func (n *noProbeBackend) DueFor(ctx context.Context, t string) (bool, *int64, string, error) {
return n.b.DueFor(ctx, t)
}
func (n *noProbeBackend) StartBackupFor(ctx context.Context, t string) (string, error) {
return n.b.StartBackupFor(ctx, t)
}
func (n *noProbeBackend) BackupStatusFor(ctx context.Context, t string) (string, error) {
return n.b.BackupStatusFor(ctx, t)
}
func (n *noProbeBackend) Due(ctx context.Context) (bool, *int64, error) { return n.b.Due(ctx) }
func (n *noProbeBackend) StartBackup(ctx context.Context) (string, error) {
return n.b.StartBackup(ctx)
}
func (n *noProbeBackend) BackupStatus(ctx context.Context) (string, error) {
return n.b.BackupStatus(ctx)
}
@@ -79,3 +79,27 @@ func TestManualRun_UnknownStorageNotSkipped(t *testing.T) {
t.Fatalf("a tier without an absent verdict was dropped: %v", got) t.Fatalf("a tier without an absent verdict was dropped: %v", got)
} }
} }
// `09` §3 decision 156: a press backs up ONE tier. When no available tier is flagged primary (the
// primary's storage is absent), the first available tier is backed up — never nothing.
// `09` §3 decision 156: the press makes the LOCAL copy only. With the local storage absent it makes no copy at
// all — the off-site tier is never its stand-in (it runs at night) — and stops no app.
func TestManualRunTiers_NoPrimaryAvailable_BacksUpNothing(t *testing.T) {
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true, StorageAbsent: true}, {Target: "felhom-pbs"}, {Target: "other"}}
st := &fakeStacks{}
l := newTierLoop(t, be, st, nil)
got := l.manualRunTiers(context.Background())
if len(got) != 0 {
t.Fatalf("want no tier (the local copy's storage is absent), got %+v", got)
}
if err := l.quiesceAndPollTiers(context.Background(), got); err != nil {
t.Fatalf("empty window: %v", err)
}
if len(be.started) != 0 {
t.Fatalf("an off-site backup was started for a manual press: %v", be.started)
}
if n := len(st.stoppedNames()); n != 0 {
t.Fatalf("%d app(s) were stopped for a press that backs up nothing", n)
}
}
+29 -11
View File
@@ -5,22 +5,25 @@ import (
"errors" "errors"
) )
// R-82 Slice B — one quiesce window, two tiers. // R-82 Slice B — two tiers; since `09` §3 decision 156 (R-518 option A), one stop per tier.
// //
// The agent gained per-target backup tiers in v0.97.0 ("local daily + PBS weekly"). The controller // The agent gained per-target backup tiers in v0.97.0 ("local daily + PBS weekly"). The controller
// owns quiescing, so the multi-tier schedule has to be reconciled HERE: on the weekly night both // owns quiescing, so the multi-tier schedule has to be reconciled HERE. R-82 put both tiers into ONE
// tiers come due at once, and running two quiesce cycles would mean **two app outages for one // quiesce window ("never two app outages for one night"); that kept every app down for the whole
// night's work** — which would undo the entire argument for weekly-over-daily. // local upload (5 min 47 s measured on demo-hp, 9 apps, 2026-10-05). Decision 156 reverses it: each
// window backs up one tier and resumes the apps at its snapshot.
// //
// THE DEDUP RULE (specified, not emergent): // THE RULE (specified, not emergent):
// //
// local due | PBS due | result // local due | PBS due | result
// ----------+---------+--------------------------------------------------------------- // ----------+---------+---------------------------------------------------------------
// yes | no | one quiesce, local backup // yes | no | one quiesce, local backup
// no | yes | one quiesce, PBS backup // no | yes | one quiesce, PBS backup
// yes | yes | ONE quiesce window, BOTH backups inside it — never two cycles // yes | yes | one quiesce, local backup; PBS stays due → a LATER cycle, its own quiesce
// no | no | no quiesce // no | no | no quiesce
// //
// A manual press backs up the primary (local) tier only (manualRunTiers).
//
// ErrTiersUnsupported is returned by TieredBackend.Tiers when the agent does not serve // ErrTiersUnsupported is returned by TieredBackend.Tiers when the agent does not serve
// GET /backup/tiers — i.e. it predates R-82 (the endpoint 404s). It is the DESIGNED capability // GET /backup/tiers — i.e. it predates R-82 (the endpoint 404s). It is the DESIGNED capability
// probe, not an error condition: the loop degrades to the single untargeted tier and logs it once. // probe, not an error condition: the loop degrades to the single untargeted tier and logs it once.
@@ -131,15 +134,19 @@ type dueTier struct {
ageSecs *int64 ageSecs *int64
// state (R-88 Part 2) disambiguates a nil ageSecs. Empty = legacy agent. // state (R-88 Part 2) disambiguates a nil ageSecs. Empty = legacy agent.
state AgeState state AgeState
// primary is the agent's Primary flag (the local tier). Set on the manual path, which backs up
// only the primary (`09` §3 decision 156).
primary bool
// owed (R-899) marks a tier the agent called „not due" only because of a household press. It runs inside the
// window and never fires the safety valve (nightowed.go).
owed bool
} }
// resolveDueTiers answers "what must this cycle back up?" — the dedup rule above, in one place. // resolveDueTiers answers "what must this cycle back up?" — the dedup rule above, in one place.
// //
// Returns the due tiers IN AGENT ORDER (primary first). That order is deliberate and it is a // Returns the due tiers IN AGENT ORDER (primary first). Since `09` §3 decision 156 a window backs up
// downtime decision, not cosmetics: tiers run SEQUENTIALLY because vzdump holds a guest lock, and // only the FIRST of them, so the order decides which tier goes first when both are due: the local
// the app stays stopped until the LAST tier has snapshotted. Running the fast local tier first and // copy (the one a restore most often reads) is taken first, the off-site copy at the next cycle.
// the slow WAN/PBS tier last makes downtime ≈ (local backup) + (PBS snapshot); the reverse order
// would make it ≈ (PBS backup) + (local snapshot), which is far worse.
// //
// degraded is true when the agent is pre-R-82 and the caller must use the untargeted path. // degraded is true when the agent is pre-R-82 and the caller must use the untargeted path.
func (l *Loop) resolveDueTiers(ctx context.Context) (due []dueTier, degraded bool, err error) { func (l *Loop) resolveDueTiers(ctx context.Context) (due []dueTier, degraded bool, err error) {
@@ -171,6 +178,7 @@ func (l *Loop) resolveDueTiers(ctx context.Context) (due []dueTier, degraded boo
l.logger.Printf("[WARN] [quiesce] agent advertised ZERO backup tiers — falling back to the untargeted path") l.logger.Printf("[WARN] [quiesce] agent advertised ZERO backup tiers — falling back to the untargeted path")
return l.resolveUntargeted(ctx) return l.resolveUntargeted(ctx)
} }
led := l.loadLedger() // R-899: read once per cycle
for _, t := range tiers { for _, t := range tiers {
isDue, age, wireState, derr := tb.DueFor(ctx, t.Target) isDue, age, wireState, derr := tb.DueFor(ctx, t.Target)
if derr != nil { if derr != nil {
@@ -186,6 +194,12 @@ func (l *Loop) resolveDueTiers(ctx context.Context) (due []dueTier, degraded boo
l.logAgeStateDegradeOnce() l.logAgeStateDegradeOnce()
} }
due = append(due, dueTier{target: t.Target, ageSecs: age, state: st}) due = append(due, dueTier{target: t.Target, ageSecs: age, state: st})
continue
}
if l.pressOwesNight(led, t.Target) {
// R-899: the agent says „not due" only because of a household press — tonight still takes its own.
l.logger.Printf("[INFO] [quiesce] tier %s: not due by the agent, but its newest copy is a manual press and tonight's scheduled backup has not run — due tonight (R-899)", tierLabel(t.Target))
due = append(due, dueTier{target: t.Target, ageSecs: age, state: ageStateFromWire(wireState), owed: true})
} }
} }
return due, false, nil return due, false, nil
@@ -198,6 +212,10 @@ func (l *Loop) resolveUntargeted(ctx context.Context) ([]dueTier, bool, error) {
return nil, true, err return nil, true, err
} }
if !isDue { if !isDue {
if l.pressOwesNight(l.loadLedger(), "") {
l.logger.Printf("[INFO] [quiesce] not due by the agent, but its newest copy is a manual press and tonight's scheduled backup has not run — due tonight (R-899)")
return []dueTier{{target: "", ageSecs: age, owed: true}}, true, nil
}
return nil, true, nil return nil, true, nil
} }
return []dueTier{{target: "", ageSecs: age}}, true, nil return []dueTier{{target: "", ageSecs: age}}, true, nil
+161 -50
View File
@@ -12,11 +12,11 @@ import (
"time" "time"
) )
// R-82 Slice B — one quiesce window, two tiers. // R-82 Slice B — two tiers; since `09` §3 decision 156 (R-518 option A), one stop per tier.
// //
// Two properties are load-bearing and neither is provable by "no error was returned": // Two properties are load-bearing and neither is provable by "no error was returned":
// 1. On the both-due night there is EXACTLY ONE stop/start pair. Two would mean two app outages // 1. Each window backs up ONE tier and resumes the apps at its snapshot; another due tier gets its
// for one night's work, undoing the whole argument for weekly-over-daily. // own window in a later cycle (decision 156 reverses R-82's one window for both tiers).
// 2. A new controller against an OLD agent still TAKES A BACKUP. The hollow version of that test // 2. A new controller against an OLD agent still TAKES A BACKUP. The hollow version of that test
// asserts "no error" while silently skipping the backup — the exact failure it exists to catch. // asserts "no error" while silently skipping the backup — the exact failure it exists to catch.
@@ -41,11 +41,20 @@ type tierBackend struct {
// way to assert "the app had not resumed when this tier started". // way to assert "the app had not resumed when this tier started".
stacks *fakeStacks stacks *fakeStacks
startsAtStart []int startsAtStart []int
// stopsAtStart samples the stop count when each tier starts — a new window shows as a higher
// stop count than the previous tier saw (R-518, decision 156).
stopsAtStart []int
// statusRestarts[target] samples the restart count at EACH status poll of that tier, so a test can
// say "the apps were already started when the Nth poll answered" (R-518, decision 156).
statusRestarts map[string][]int
// manualStarts records, per start, whether the context carried the R-899 press mark.
manualStarts []bool
} }
func newTierBackend() *tierBackend { func newTierBackend() *tierBackend {
return &tierBackend{ return &tierBackend{
dueSet: map[string]bool{}, phases: map[string][]string{}, phaseIdx: map[string]int{}, dueSet: map[string]bool{}, phases: map[string][]string{}, phaseIdx: map[string]int{},
statusRestarts: map[string][]int{},
} }
} }
@@ -63,21 +72,26 @@ func (b *tierBackend) DueFor(_ context.Context, target string) (bool, *int64, st
defer b.mu.Unlock() defer b.mu.Unlock()
return b.dueSet[target], nil, "", nil return b.dueSet[target], nil, "", nil
} }
func (b *tierBackend) StartBackupFor(_ context.Context, target string) (string, error) { func (b *tierBackend) StartBackupFor(ctx context.Context, target string) (string, error) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
if b.startErrOn == target { if b.startErrOn == target {
return "", fmt.Errorf("simulated start failure on %s", target) return "", fmt.Errorf("simulated start failure on %s", target)
} }
b.started = append(b.started, target) b.started = append(b.started, target)
b.manualStarts = append(b.manualStarts, IsManualTrigger(ctx))
if b.stacks != nil { if b.stacks != nil {
b.startsAtStart = append(b.startsAtStart, len(b.stacks.startedNames())) b.startsAtStart = append(b.startsAtStart, len(b.stacks.startedNames()))
b.stopsAtStart = append(b.stopsAtStart, len(b.stacks.stoppedNames()))
} }
return "job-" + target, nil return "job-" + target, nil
} }
func (b *tierBackend) BackupStatusFor(_ context.Context, target string) (string, error) { func (b *tierBackend) BackupStatusFor(_ context.Context, target string) (string, error) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
if b.stacks != nil {
b.statusRestarts[target] = append(b.statusRestarts[target], len(b.stacks.startedNames()))
}
seq := b.phases[target] seq := b.phases[target]
i := b.phaseIdx[target] i := b.phaseIdx[target]
if i < len(seq) { if i < len(seq) {
@@ -108,6 +122,24 @@ func (b *tierBackend) restartsWhenEachTierStarted() []int {
return append([]int(nil), b.startsAtStart...) return append([]int(nil), b.startsAtStart...)
} }
func (b *tierBackend) stopsWhenEachTierStarted() []int {
b.mu.Lock()
defer b.mu.Unlock()
return append([]int(nil), b.stopsAtStart...)
}
func (b *tierBackend) restartsAtEachPoll(target string) []int {
b.mu.Lock()
defer b.mu.Unlock()
return append([]int(nil), b.statusRestarts[target]...)
}
func (b *tierBackend) setDue(target string, due bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.dueSet[target] = due
}
func (b *tierBackend) startedTargets() []string { func (b *tierBackend) startedTargets() []string {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
@@ -129,20 +161,25 @@ func newTierLoop(t *testing.T, be Backend, st *fakeStacks, logTo io.Writer) *Loo
}) })
} }
// ---- RED-PROOF 3 — the both-due night ---------------------------------------------------- // ---- R-518 option A — one stop per backup tier (`09` §3 decision 156) --------------------
// EXACTLY ONE stop/start pair, with BOTH backups inside it. Asserting only "both backups ran"
// would pass against an implementation that quiesces twice — the count is the assertion.
// //
// COMPANION RED-PROOF (observed): make runOnce call quiesceAndPollTiers once per due tier // Decision 156 REVERSES R-82's „ONE quiesce window for both due tiers (never two app outages for one
// (a per-tier cycle instead of one window) → stops/starts become 2/2 and this fails with // night)". The one long window kept every app down for the whole local upload (5 min 47 s measured on
// "want EXACTLY 1 stop and 1 start ... got stops=2 starts=2". Restored. // demo-hp, 9 apps). Now each window runs ONLY the first due tier and resumes the apps at its
func TestBothTiersDue_ExactlyOneQuiesceWindow(t *testing.T) { // `snapshotted`; another due tier waits for a LATER cycle and gets its own short window. Every copy is
// still taken with the apps stopped, so each stays app-consistent.
// Both tiers due → the first cycle stops once, backs up ONLY the first (primary) tier, starts once.
// The other tier is not started in this cycle — never straight after tier 1 (the next poll picks it
// up, pinned by TestLeftoverTier_RunsInNextCycleInItsOwnWindow).
//
// Was TestBothTiersDue_ExactlyOneQuiesceWindow (R-82), which pinned both tiers inside one window.
func TestBothTiersDue_FirstCycleRunsOnlyFirstTier(t *testing.T) {
be := newTierBackend() be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}} be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
be.dueSet["local"] = true be.dueSet["local"] = true
be.dueSet["felhom-pbs"] = true be.dueSet["felhom-pbs"] = true
be.phases["local"] = []string{phaseDone} be.phases["local"] = []string{phaseSnapshotted, phaseDone}
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone} be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
st := &fakeStacks{running: []string{"immich"}} st := &fakeStacks{running: []string{"immich"}}
@@ -151,14 +188,120 @@ func TestBothTiersDue_ExactlyOneQuiesceWindow(t *testing.T) {
if err := l.runOnce(context.Background()); err != nil { if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce: %v", err) t.Fatalf("runOnce: %v", err)
} }
stops, starts := len(st.stoppedNames()), len(st.startedNames()) if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
if stops != 1 || starts != 1 { t.Fatalf("one window: want EXACTLY 1 stop and 1 start, got stops=%d starts=%d (stopped=%v started=%v)",
t.Fatalf("both-due night must be ONE quiesce window: want EXACTLY 1 stop and 1 start, got stops=%d starts=%d (stopped=%v started=%v)",
stops, starts, st.stoppedNames(), st.startedNames()) stops, starts, st.stoppedNames(), st.startedNames())
} }
if got := be.startedTargets(); len(got) != 1 || got[0] != "local" {
t.Fatalf("decision 156: a window backs up ONLY the first due tier (primary); got %v", got)
}
}
// THE red test of R-518: the apps must run while the local copy still uploads. Local reports
// `snapshotted` three times, then `done`; the stacks must already have been STARTED when the SECOND
// `snapshotted` poll answered. Under R-82 the app stayed down until the LAST tier snapshotted, so the
// whole local upload ran with every app stopped.
//
// Was TestNonLastTierSnapshot_DoesNotResumeApp (R-82), which pinned the opposite (decision 156
// reverses R-82's one window for both tiers).
func TestFirstTierSnapshot_ResumesAppWhileUploadContinues(t *testing.T) {
st := &fakeStacks{running: []string{"immich"}}
be := newTierBackend()
be.stacks = st
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
be.dueSet["local"] = true
be.dueSet["felhom-pbs"] = true
be.phases["local"] = []string{phaseSnapshotted, phaseSnapshotted, phaseSnapshotted, phaseDone}
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
l := newTierLoop(t, be, st, nil)
if err := l.runOnce(context.Background()); err != nil {
t.Fatal(err)
}
polls := be.restartsAtEachPoll("local")
if len(polls) < 2 {
t.Fatalf("the local tier must be polled past its first snapshot; restarts per poll = %v", polls)
}
if polls[1] < 1 {
t.Fatalf("the apps were still STOPPED when the second `snapshotted` poll answered (restarts per poll = %v) — they must resume at the first tier's snapshot (decision 156)", polls)
}
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
t.Fatalf("want exactly one stop/start pair; got %d/%d", stops, starts)
}
}
// The tier left over by a cycle IS run by the NEXT cycle, in its OWN window (a second stop that comes
// after the first window's start). Without this, decision 156 would silently drop the off-site copy.
func TestLeftoverTier_RunsInNextCycleInItsOwnWindow(t *testing.T) {
st := &fakeStacks{running: []string{"immich"}}
be := newTierBackend()
be.stacks = st
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
be.dueSet["local"] = true
be.dueSet["felhom-pbs"] = true
be.phases["local"] = []string{phaseSnapshotted, phaseDone}
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
l := newTierLoop(t, be, st, nil)
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("cycle 1: %v", err)
}
if got := be.startedTargets(); len(got) != 1 || got[0] != "local" {
t.Fatalf("cycle 1 must run only the local tier; got %v", got)
}
// The agent now reports local as backed up; the off-site tier is still due.
be.setDue("local", false)
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("cycle 2: %v", err)
}
got := be.startedTargets() got := be.startedTargets()
if len(got) != 2 || got[0] != "local" || got[1] != "felhom-pbs" { if len(got) != 2 || got[1] != "felhom-pbs" {
t.Fatalf("both tiers must back up, primary first: got %v", got) t.Fatalf("cycle 2 must run the left-over off-site tier; got %v", got)
}
// Its own window: when it started, the first window had already resumed (1 start) and a NEW stop
// had happened (2 stops).
stopsAt, startsAt := be.stopsWhenEachTierStarted(), be.restartsWhenEachTierStarted()
if stopsAt[1] != 2 || startsAt[1] != 1 {
t.Fatalf("the off-site tier must start in its OWN window; at its start stops=%d starts=%d (want 2/1)", stopsAt[1], startsAt[1])
}
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 2 || starts != 2 {
t.Fatalf("two cycles, two windows: want 2 stops / 2 starts, got %d/%d", stops, starts)
}
// A third cycle with nothing due touches nothing.
be.setDue("felhom-pbs", false)
if err := l.runOnce(context.Background()); err != nil {
t.Fatal(err)
}
if stops := len(st.stoppedNames()); stops != 2 {
t.Fatalf("nothing due must not stop the apps again; stops=%d", stops)
}
}
// A manual press („Mentés most") makes the LOCAL copy only, in one short stop; the off-site tier is
// NOT requested — it follows at the next scheduled night run (decision 156).
func TestManualPress_RunsOnlyLocalTier(t *testing.T) {
st := &fakeStacks{running: []string{"immich"}}
be := newTierBackend()
be.stacks = st
// Off-site listed FIRST on purpose: the press must pick the tier the agent flags Primary, not
// whichever comes first.
be.tiers = []BackupTier{{Target: "felhom-pbs"}, {Target: "local", Primary: true}}
be.phases["local"] = []string{phaseSnapshotted, phaseDone}
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
l := newTierLoop(t, be, st, nil)
if err := l.TriggerNow(); err != nil {
t.Fatalf("TriggerNow: %v", err)
}
// TriggerNow holds l.mu for the whole async cycle; taking it waits for the cycle to finish.
l.mu.Lock()
l.mu.Unlock() //nolint:staticcheck // deliberate: wait for the async cycle
if got := be.startedTargets(); len(got) != 1 || got[0] != "local" {
t.Fatalf("a manual press must back up ONLY the local (primary) tier; started=%v", got)
}
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
t.Fatalf("a manual press is one short stop; got %d/%d", stops, starts)
} }
} }
@@ -200,38 +343,6 @@ func TestNoTierDue_NoQuiesce(t *testing.T) {
} }
} }
// The app must stay DOWN until the LAST tier snapshots. Resuming after tier 1 would leave the DR
// tier capturing a running app — losing app-consistency on exactly the tier we most want it on.
func TestNonLastTierSnapshot_DoesNotResumeApp(t *testing.T) {
st := &fakeStacks{running: []string{"immich"}}
be := newTierBackend()
be.stacks = st
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
be.dueSet["local"] = true
be.dueSet["felhom-pbs"] = true
// The local tier snapshots first, then finishes. The app must NOT come back at its snapshot.
be.phases["local"] = []string{phaseSnapshotted, phaseSnapshotted, phaseDone}
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
l := newTierLoop(t, be, st, nil)
if err := l.runOnce(context.Background()); err != nil {
t.Fatal(err)
}
// THE assertion: when the SECOND (last) tier started, zero restarts had happened — i.e. the app
// was still quiesced. Resuming at tier 1's snapshot would leave the DR tier capturing a RUNNING
// app, losing app-consistency on exactly the tier we most want it on.
at := be.restartsWhenEachTierStarted()
if len(at) != 2 {
t.Fatalf("both tiers must start; sampled %v", at)
}
if at[1] != 0 {
t.Fatalf("the app had ALREADY resumed (%d restarts) when the last tier started — non-last snapshot must not resume", at[1])
}
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
t.Fatalf("want exactly one stop/start pair; got %d/%d", stops, starts)
}
}
// ---- RED-PROOF 2 — new controller ↔ OLD agent -------------------------------------------- // ---- RED-PROOF 2 — new controller ↔ OLD agent --------------------------------------------
// The agent 404s /backup/tiers. The controller MUST degrade to the untargeted tier, LOG it, and // The agent 404s /backup/tiers. The controller MUST degrade to the untargeted tier, LOG it, and
+40 -31
View File
@@ -4,55 +4,64 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
) )
// Info holds the data needed for the recovery info file and settings UI. // Info holds the data needed for the recovery info file.
type Info struct { type Info struct {
CustomerID string CustomerID string
RetrievalPassword string RetrievalPassword string
HubURL string HubURL string
SupportEmail string SupportEmail string
SupportURL string SupportURL string
// DashboardURL is the household's dashboard, e.g. https://felhom.<domain> (R-554, v0.308.0): the file
// points at its Restore page and its „Adatok visszaszerzése" page, not at the deleted setup wizard.
DashboardURL string
// Lang is the box's language; the file is written in it (Hungarian when empty or unknown).
Lang string
} }
// GenerateRecoveryFile writes a plain text recovery-info.txt to the given directory. // recoveryInfoKey is the file's text in the bundle (hu.json / en.json), with ten %s, in order:
func GenerateRecoveryFile(info Info, version, outputDir string) error { // customer id, hub URL, retrieval password, support e-mail, support web, dashboard, restore page,
if err := os.MkdirAll(outputDir, 0755); err != nil { // recovery page, generated-at, controller version.
return fmt.Errorf("creating recovery info directory: %w", err) const recoveryInfoKey = "recovery_info.body"
// Render returns the text of recovery-info.txt (R-554, `09` §3 decision 222): what to do when something must
// be restored — the dashboard's Backup → Restore page, and after a reinstall the recovery code on the
// „Adatok visszaszerzése" page. Pinned by TestRecoveryInfo_PointsAtDashboardNotWizard.
func Render(info Info, version string, now time.Time) (string, error) {
b, err := i18n.Shared()
if err != nil {
return "", fmt.Errorf("loading message bundle: %w", err)
} }
dash := strings.TrimRight(info.DashboardURL, "/")
content := fmt.Sprintf(`Felhom Controller — Vészhelyzeti információk return b.Msgf(i18n.Normalize(info.Lang), recoveryInfoKey,
=============================================
Ügyfél azonosító: %s
Hub URL: %s
Visszaállítási jelszó: %s
Támogatás:
Email: %s
Web: %s
Visszaállítási útmutató:
1. Telepítse az operációs rendszert (Debian 13)
2. Futtassa a docker-setup.sh szkriptet:
sudo ./docker-setup.sh --domain <domain>
3. Nyissa meg a böngészőben: http://<ip>:8081
4. Válassza a "Visszaállítás mentésből" opciót
5. Adja meg az ügyfél azonosítót és a visszaállítási jelszót
6. Kövesse a varázsló utasításait
Generálva: %s
Controller verzió: %s
`,
info.CustomerID, info.CustomerID,
info.HubURL, info.HubURL,
info.RetrievalPassword, info.RetrievalPassword,
info.SupportEmail, info.SupportEmail,
info.SupportURL, info.SupportURL,
time.Now().Format("2006-01-02 15:04:05"), dash,
dash+"/backups/restore",
dash+"/recovery",
now.Format("2006-01-02 15:04:05"),
version, version,
) ), nil
}
// GenerateRecoveryFile writes a plain text recovery-info.txt to the given directory (0600: it holds the
// retrieval password).
func GenerateRecoveryFile(info Info, version, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("creating recovery info directory: %w", err)
}
content, err := Render(info, version, time.Now())
if err != nil {
return err
}
path := filepath.Join(outputDir, "recovery-info.txt") path := filepath.Join(outputDir, "recovery-info.txt")
tmp := path + ".tmp" tmp := path + ".tmp"
+68
View File
@@ -0,0 +1,68 @@
package recovery
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// R-554 (`09` §3 decision 222): recovery-info.txt points at the dashboard's Restore page and the recovery
// code — never at the deleted first-boot wizard (docker-setup.sh, :8081, „Visszaállítás mentésből").
//
// RED-PROOF (REPORT): the old text (docker-setup.sh … http://<ip>:8081 … varázsló) fails every
// "must carry" line and every "must not carry" line.
func TestRecoveryInfo_PointsAtDashboardNotWizard(t *testing.T) {
info := Info{
CustomerID: "c-123", RetrievalPassword: "TESTONLY-retrieval-pw", HubURL: "https://hub.example",
SupportEmail: "support@felhom.eu", SupportURL: "https://felhom.eu/kapcsolat",
DashboardURL: "https://felhom.example.hu/",
}
at := time.Date(2026, 10, 12, 15, 4, 5, 0, time.UTC)
for _, tc := range []struct {
lang string
must []string
}{
{"hu", []string{"Biztonsági mentés → Visszaállítás", "helyreállítási kód", "Adatok visszaszerzése"}},
{"en", []string{"Backup → Restore", "recovery code", "Retrieve data"}},
} {
info.Lang = tc.lang
got, err := Render(info, "0.308.0", at)
if err != nil {
t.Fatal(err)
}
for _, want := range append(tc.must,
"https://felhom.example.hu/backups/restore", "https://felhom.example.hu/recovery",
"c-123", "https://hub.example", "TESTONLY-retrieval-pw", "2026-10-12 15:04:05", "0.308.0") {
if !strings.Contains(got, want) {
t.Errorf("%s: the file does not carry %q", tc.lang, want)
}
}
for _, gone := range []string{"docker-setup.sh", ":8081", "Visszaállítás mentésből", "varázsló", "wizard", "%!"} {
if strings.Contains(got, gone) {
t.Errorf("%s: the file still carries %q", tc.lang, gone)
}
}
}
}
// The file is written 0600 (it holds the retrieval password) and in the box's language.
func TestGenerateRecoveryFile_WritesPrivateFile(t *testing.T) {
dir := t.TempDir()
if err := GenerateRecoveryFile(Info{CustomerID: "c-1", DashboardURL: "https://felhom.x.hu", Lang: "en"}, "0.308.0", dir); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "recovery-info.txt")
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Errorf("mode = %v, want 0600", fi.Mode().Perm())
}
b, _ := os.ReadFile(p)
if !strings.Contains(string(b), "Emergency information") {
t.Errorf("an English box's file is not in English")
}
}
+16 -2
View File
@@ -66,7 +66,7 @@ func BuildReport(
// System info // System info
staticInfo := metrics.GetStaticInfo() staticInfo := metrics.GetStaticInfo()
hddPath := cfg.Paths.HDDPath hddPath := "" // R-492: the global paths.hdd_path is gone; the registry is the only source
if len(storagePaths) > 0 { if len(storagePaths) > 0 {
hddPath = storagePaths[0].Path hddPath = storagePaths[0].Path
} }
@@ -91,7 +91,7 @@ func BuildReport(
// Storage — root filesystem + all registered storage paths // Storage — root filesystem + all registered storage paths
r.Storage = []StorageReport{ r.Storage = []StorageReport{
{Mount: "/", Label: "SSD", TotalGB: sysInfo.DiskTotalGB, UsedGB: sysInfo.DiskUsedGB, Percent: sysInfo.DiskPercent}, rootStorageEntry(sysInfo),
} }
for _, sp := range storagePaths { for _, sp := range storagePaths {
if sp.Decommissioned { if sp.Decommissioned {
@@ -173,6 +173,9 @@ func BuildReport(
// consume-once ACK-flag pattern (selftail.go). nil in the steady state. // consume-once ACK-flag pattern (selftail.go). nil in the steady state.
r.ControllerLogTail = buildControllerLogTail(logger) r.ControllerLogTail = buildControllerLogTail(logger)
// Operator actions (decision 185): results the hub is still waiting for (opactions.go).
r.OperatorActionResults = pendingOperatorActionResults()
// Geo-restriction status — ALWAYS present (even when never configured) so the hub // Geo-restriction status — ALWAYS present (even when never configured) so the hub
// always renders the section. A nil pointer (omitempty) made the hub hide the whole // always renders the section. A nil pointer (omitempty) made the hub hide the whole
// section for a never-configured controller; a present-but-disabled report renders // section for a never-configured controller; a present-but-disabled report renders
@@ -325,3 +328,14 @@ func buildStacksReport(stackMgr *stacks.Manager) StacksReport {
return sr return sr
} }
// rootStorageEntry is the report's "/" entry. When the root statfs failed (DiskKnown false) the
// zeros it carries are not a measurement, so the entry says so (R-266): the hub skips banding it
// instead of reading a failed read as an empty disk.
func rootStorageEntry(sysInfo system.SystemInfo) StorageReport {
return StorageReport{
Mount: "/", Label: "SSD",
TotalGB: sysInfo.DiskTotalGB, UsedGB: sysInfo.DiskUsedGB, Percent: sysInfo.DiskPercent,
ReadFailed: !sysInfo.DiskKnown,
}
}
+348
View File
@@ -0,0 +1,348 @@
package report
import (
"context"
"errors"
"fmt"
"log"
"sort"
"strconv"
"sync"
"time"
)
// Operator actions (R-314 / R-279 / R-177, `09` §3 decision 185 — D1, design option A of
// felhom.eu/documentation/audits/day-2026-10-08/design-R-314-279-177.md).
//
// THE DOOR IS THE REPORT REPLY, the shape selftail.go proved for log pulls: the operator presses a
// button on the hub, the hub stores a row and wakes this box's wait channel, the out-of-cycle report's
// reply carries `operator_actions: [{id, action, arg}]`, and the RUNNING controller acts on it — inside
// the process that owns the state, so there is no second process and no lost update (the CLI levers'
// problem, main.go). The result rides the next report as `operator_action_results: [{id, outcome,
// message}]`; the hub lists an action until its result has arrived, then stops.
//
// THE LIST IS CLOSED. Four actions, and run_job names one of four jobs. Nothing here deletes data,
// starts a countdown or shortens one — `03` §4 asks for a signing key only to destroy or overwrite the
// only copy, and none of these does. An unknown action, an unknown job or an argument out of range is
// answered `refused` and NOTHING is called. Pinned by TestOpActions_ClosedList (which fails when an
// entry is added, so a new action is a decision, not an edit) and TestOpActions_UnknownIsRefused.
//
// ONCE PER ID. An id is acted on the first time a reply carries it; every later reply that still lists
// it (the result has not reached the hub yet) only re-sends the result. The record is in memory: after a
// controller restart an action the hub still lists runs again. That is safe by construction — an
// off-site run or a check repeated adds nothing harmful, a second stop finds nothing to stop, and a
// second extension counts from the new „now" and can never shorten (ExtendAbandon refuses that).
const (
OpOffsiteBackupNow = "offsite_backup_now"
OpAbandonStop = "abandon_stop"
OpAbandonExtend = "abandon_extend"
OpRunJob = "run_job"
OutcomeDone = "done"
OutcomeRefused = "refused"
OutcomeFailed = "failed"
// AbandonExtendMinDays / AbandonExtendMaxDays bound abandon_extend's argument (decision 185).
AbandonExtendMinDays = 1
AbandonExtendMaxDays = 30
// opActionsPerReply caps how many NEW actions one reply may start — a defence against a hub bug
// flooding the box, never reached by a person pressing buttons.
opActionsPerReply = 10
// opActionForgetAfter drops a finished, no-longer-listed id from memory.
opActionForgetAfter = 24 * time.Hour
// offsiteRunTimeout matches the household's own „back up now" (offbox_handlers.go).
offsiteRunTimeout = 3 * time.Hour
)
// operatorActionNames is THE closed list. Adding an entry fails TestOpActions_ClosedList by design.
var operatorActionNames = []string{OpOffsiteBackupNow, OpAbandonStop, OpAbandonExtend, OpRunJob}
// operatorJobNames is the fixed set run_job may name — the scheduler's own job names (main.go).
var operatorJobNames = []string{"fill-watch", "offsite-integrity", "offsite-proof", "disk-health-check"}
// OperatorActionNames returns the closed action list (sorted copy).
func OperatorActionNames() []string { return sortedCopy(operatorActionNames) }
// OperatorJobNames returns the fixed run_job set (sorted copy).
func OperatorJobNames() []string { return sortedCopy(operatorJobNames) }
func sortedCopy(in []string) []string {
out := append([]string(nil), in...)
sort.Strings(out)
return out
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// ErrActionRefused, wrapped by a handler, turns its error into a `refused` outcome (the action could
// not START — e.g. an off-site run already in flight) instead of `failed` (it started and went wrong).
var ErrActionRefused = errors.New("refused")
// OperatorAction is one entry of the reply's operator_actions list.
type OperatorAction struct {
ID int64 `json:"id"`
Action string `json:"action"`
Arg string `json:"arg,omitempty"`
}
// OperatorActionResult is one entry of the report's operator_action_results list.
type OperatorActionResult struct {
ID int64 `json:"id"`
Outcome string `json:"outcome"` // done | refused | failed
Message string `json:"message"`
}
// OperatorActionHandlers are the four doors, wired by main.go. A nil handler answers `refused`
// („not available on this box").
type OperatorActionHandlers struct {
// OffsiteBackupNow returns a refusal sentence when the run may not start (the household's
// „back up now" checks), else the run itself, which the executor starts in its own goroutine.
OffsiteBackupNow func() (refusal string, run func(ctx context.Context) error)
AbandonStop func() error
AbandonExtend func(days int) (time.Time, error)
// RunJob is Scheduler.RunNow: an error = not started (unknown/running); the channel = the job's end.
RunJob func(name string) (<-chan error, error)
// ResultReady sends an out-of-cycle report so a result reaches the hub in seconds (optional).
ResultReady func()
}
type opEntry struct {
finished bool
result OperatorActionResult
finishedAt time.Time
}
// OperatorActions is the executor. One per process.
type OperatorActions struct {
ctx context.Context
h OperatorActionHandlers
logger *log.Logger
now func() time.Time
mu sync.Mutex
seen map[int64]*opEntry
listed map[int64]bool // ids the LAST reply carried — results are sent only for these
running sync.WaitGroup // tests wait on async actions
}
// NewOperatorActions builds the executor. ctx bounds the asynchronous actions.
func NewOperatorActions(ctx context.Context, h OperatorActionHandlers, logger *log.Logger) *OperatorActions {
return &OperatorActions{ctx: ctx, h: h, logger: logger, now: time.Now,
seen: map[int64]*opEntry{}, listed: map[int64]bool{}}
}
func (o *OperatorActions) logf(format string, args ...interface{}) {
if o.logger != nil {
o.logger.Printf(format, args...)
}
}
// Reconcile takes one reply's operator_actions list: new ids are acted on (once), every listed id is
// remembered so its result keeps riding the reports until the hub stops listing it.
func (o *OperatorActions) Reconcile(list []OperatorAction) {
o.mu.Lock()
o.listed = make(map[int64]bool, len(list))
var fresh []OperatorAction
for _, a := range list {
o.listed[a.ID] = true
if _, ok := o.seen[a.ID]; ok {
continue // once per id: already acted on (or acting) — only its result is re-sent
}
if len(fresh) >= opActionsPerReply {
continue // not marked seen: the next reply offers it again
}
o.seen[a.ID] = &opEntry{}
fresh = append(fresh, a)
}
// Forget finished ids the hub no longer lists, after a day (memory bound; the hub never re-lists
// an id whose result it holds).
cutoff := o.now().Add(-opActionForgetAfter)
for id, e := range o.seen {
if e.finished && !o.listed[id] && e.finishedAt.Before(cutoff) {
delete(o.seen, id)
}
}
o.mu.Unlock()
if len(list) > 0 {
o.logf("[DEBUG] [opaction] reply listed %d action(s), %d new", len(list), len(fresh))
}
for _, a := range fresh {
o.dispatch(a)
}
}
// Results returns the finished results the hub is still waiting for (ids the last reply listed).
func (o *OperatorActions) Results() []OperatorActionResult {
o.mu.Lock()
defer o.mu.Unlock()
var out []OperatorActionResult
for id, e := range o.seen {
if e.finished && o.listed[id] {
out = append(out, e.result)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (o *OperatorActions) finish(a OperatorAction, outcome, msg string) {
o.mu.Lock()
if e, ok := o.seen[a.ID]; ok {
e.finished = true
e.finishedAt = o.now()
e.result = OperatorActionResult{ID: a.ID, Outcome: outcome, Message: msg}
}
o.mu.Unlock()
o.logf("[INFO] [opaction] operator action #%d %s: %s — %s", a.ID, a.Action, outcome, msg)
if o.h.ResultReady != nil {
o.h.ResultReady()
}
}
func outcomeOf(err error) string {
switch {
case err == nil:
return OutcomeDone
case errors.Is(err, ErrActionRefused):
return OutcomeRefused
default:
return OutcomeFailed
}
}
// dispatch validates against the closed list and acts. Validation happens BEFORE any handler is
// touched: a refusal calls nothing.
func (o *OperatorActions) dispatch(a OperatorAction) {
// Customer-visible transparency (the box's own log, like an operator log pull): never silent.
o.logf("[INFO] [opaction] operator action #%d received: %s", a.ID, a.Action)
switch a.Action {
case OpOffsiteBackupNow:
if a.Arg != "" {
o.finish(a, OutcomeRefused, "this action takes no argument")
return
}
if o.h.OffsiteBackupNow == nil {
o.finish(a, OutcomeRefused, "off-site backup is not available on this box")
return
}
refusal, run := o.h.OffsiteBackupNow()
if refusal != "" || run == nil {
if refusal == "" {
refusal = "the off-site backup could not be started"
}
o.finish(a, OutcomeRefused, refusal)
return
}
o.running.Add(1)
go func() {
defer o.running.Done()
ctx, cancel := context.WithTimeout(o.ctx, offsiteRunTimeout)
defer cancel()
err := run(ctx)
msg := "the off-site backup finished"
if err != nil {
msg = "the off-site backup: " + err.Error()
}
o.finish(a, outcomeOf(err), msg)
}()
case OpAbandonStop:
if a.Arg != "" {
o.finish(a, OutcomeRefused, "this action takes no argument")
return
}
if o.h.AbandonStop == nil {
o.finish(a, OutcomeRefused, "not available on this box")
return
}
if err := o.h.AbandonStop(); err != nil {
o.finish(a, outcomeOf(err), err.Error())
return
}
o.finish(a, OutcomeDone, "the deletion countdown is stopped; the set-aside history is kept")
case OpAbandonExtend:
days, err := strconv.Atoi(a.Arg)
if err != nil || days < AbandonExtendMinDays || days > AbandonExtendMaxDays {
o.finish(a, OutcomeRefused, fmt.Sprintf("the number of days must be %d-%d", AbandonExtendMinDays, AbandonExtendMaxDays))
return
}
if o.h.AbandonExtend == nil {
o.finish(a, OutcomeRefused, "not available on this box")
return
}
due, err := o.h.AbandonExtend(days)
if err != nil {
o.finish(a, outcomeOf(err), err.Error())
return
}
o.finish(a, OutcomeDone, "the set-aside history is now deleted on "+due.Format("2006-01-02"))
case OpRunJob:
if !contains(operatorJobNames, a.Arg) {
o.finish(a, OutcomeRefused, "unknown job name")
return
}
if o.h.RunJob == nil {
o.finish(a, OutcomeRefused, "not available on this box")
return
}
done, err := o.h.RunJob(a.Arg)
if err != nil {
o.finish(a, OutcomeRefused, "the job "+a.Arg+" was not started: "+err.Error())
return
}
o.running.Add(1)
go func() {
defer o.running.Done()
var jerr error
select {
case jerr = <-done:
case <-o.ctx.Done():
jerr = o.ctx.Err()
}
// „done" says the job RAN TO ITS END, never what it found (security review 2026-10-08,
// „presence is not success"): offsite-integrity and offsite-proof return nil whatever their
// verdict — the verdict travels in their own log line, event and alarm.
msg := "the job " + a.Arg + " ran to its end — this does not say what it found; its finding is in its own log line and alarms"
if jerr != nil {
msg = "the job " + a.Arg + ": " + jerr.Error()
}
o.finish(a, outcomeOf(jerr), msg)
}()
default:
o.finish(a, OutcomeRefused, "unknown action")
}
}
// ── package wiring (the selftail.go shape: BuildReport reads it, main.go sets it) ──
var (
activeOpActionsMu sync.Mutex
activeOpActions *OperatorActions
)
// SetOperatorActions wires the executor whose results BuildReport attaches. nil = none.
func SetOperatorActions(o *OperatorActions) {
activeOpActionsMu.Lock()
defer activeOpActionsMu.Unlock()
activeOpActions = o
}
// pendingOperatorActionResults is what BuildReport attaches (nil in the steady state).
func pendingOperatorActionResults() []OperatorActionResult {
activeOpActionsMu.Lock()
o := activeOpActions
activeOpActionsMu.Unlock()
if o == nil {
return nil
}
return o.Results()
}
@@ -0,0 +1,199 @@
package report
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// `09` §3 decision 185 (D1): the operator's actions. See opactions.go's header.
// The list is CLOSED. This test fails when an action or a job is added, by design: a new entry is an
// operator decision (no action may delete data, start a countdown or shorten one), not an edit. The
// hub pins the same four in its own test (hub internal/store opactions_test.go).
func TestOpActions_ClosedList(t *testing.T) {
if got, want := OperatorActionNames(), []string{"abandon_extend", "abandon_stop", "offsite_backup_now", "run_job"}; !reflect.DeepEqual(got, want) {
t.Fatalf("operator actions = %v, want exactly %v — a new action needs the operator's word (decision 185)", got, want)
}
if got, want := OperatorJobNames(), []string{"disk-health-check", "fill-watch", "offsite-integrity", "offsite-proof"}; !reflect.DeepEqual(got, want) {
t.Fatalf("run_job names = %v, want exactly %v", got, want)
}
}
// calls records every handler call.
type calls struct {
mu sync.Mutex
list []string
}
func (c *calls) add(s string) { c.mu.Lock(); c.list = append(c.list, s); c.mu.Unlock() }
func (c *calls) get() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.list...)
}
func recordingHandlers(c *calls) OperatorActionHandlers {
return OperatorActionHandlers{
OffsiteBackupNow: func() (string, func(context.Context) error) {
c.add("offsite-check")
return "", func(context.Context) error { c.add("offsite-run"); return nil }
},
AbandonStop: func() error { c.add("stop"); return nil },
AbandonExtend: func(d int) (time.Time, error) {
c.add("extend")
return time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), nil
},
RunJob: func(name string) (<-chan error, error) {
c.add("job:" + name)
ch := make(chan error, 1)
ch <- nil
close(ch)
return ch, nil
},
}
}
func resultFor(t *testing.T, o *OperatorActions, id int64) OperatorActionResult {
t.Helper()
for _, r := range o.Results() {
if r.ID == id {
return r
}
}
t.Fatalf("no result for #%d in %+v", id, o.Results())
return OperatorActionResult{}
}
// Red test (3) of the design: an unknown action, an unknown job, an argument out of range → refused,
// and NOTHING is called.
func TestOpActions_UnknownIsRefusedAndCallsNothing(t *testing.T) {
c := &calls{}
o := NewOperatorActions(context.Background(), recordingHandlers(c), nil)
list := []OperatorAction{
{ID: 1, Action: "delete_everything"},
{ID: 2, Action: OpRunJob, Arg: "offsite-abandon-sweep"}, // a real job, NOT on the list: it deletes
{ID: 3, Action: OpRunJob, Arg: ""},
{ID: 4, Action: OpAbandonExtend, Arg: "0"},
{ID: 5, Action: OpAbandonExtend, Arg: "31"},
{ID: 6, Action: OpAbandonExtend, Arg: "-3"},
{ID: 7, Action: OpAbandonExtend, Arg: "7 "},
{ID: 8, Action: OpAbandonStop, Arg: "x"},
{ID: 9, Action: OpOffsiteBackupNow, Arg: "x"},
}
o.Reconcile(list)
o.running.Wait()
if got := c.get(); len(got) != 0 {
t.Fatalf("a refused action called %v", got)
}
for _, a := range list {
if r := resultFor(t, o, a.ID); r.Outcome != OutcomeRefused || r.Message == "" {
t.Errorf("#%d %s(%q): %+v, want refused with a reason", a.ID, a.Action, a.Arg, r)
}
}
}
// Red test (2): the same id delivered twice → the action runs ONCE; its result is re-sent while listed.
func TestOpActions_OncePerID(t *testing.T) {
c := &calls{}
o := NewOperatorActions(context.Background(), recordingHandlers(c), nil)
a := OperatorAction{ID: 42, Action: OpAbandonStop}
o.Reconcile([]OperatorAction{a})
o.Reconcile([]OperatorAction{a})
o.Reconcile([]OperatorAction{a})
if got := c.get(); len(got) != 1 || got[0] != "stop" {
t.Fatalf("calls = %v, want exactly one stop", got)
}
if r := resultFor(t, o, 42); r.Outcome != OutcomeDone {
t.Fatalf("result = %+v", r)
}
// The hub has the result and stops listing the id → it is no longer sent, and is not run again.
o.Reconcile(nil)
if rs := o.Results(); len(rs) != 0 {
t.Fatalf("a result the hub no longer waits for is still sent: %+v", rs)
}
o.Reconcile([]OperatorAction{a})
if got := c.get(); len(got) != 1 {
t.Fatalf("re-listed id ran again: %v", got)
}
}
func TestOpActions_EachDoorAndItsOutcome(t *testing.T) {
c := &calls{}
h := recordingHandlers(c)
fired := 0
var fmu sync.Mutex
h.ResultReady = func() { fmu.Lock(); fired++; fmu.Unlock() }
o := NewOperatorActions(context.Background(), h, nil)
o.Reconcile([]OperatorAction{
{ID: 1, Action: OpOffsiteBackupNow},
{ID: 2, Action: OpAbandonExtend, Arg: "30"},
{ID: 3, Action: OpRunJob, Arg: "fill-watch"},
})
o.running.Wait()
for id := int64(1); id <= 3; id++ {
if r := resultFor(t, o, id); r.Outcome != OutcomeDone {
t.Errorf("#%d: %+v", id, r)
}
}
// „done" for a job means it ran, never what it found (security review 2026-10-08): the two off-site checks
// return nil whatever their verdict, so the message must not read as a pass.
if r := resultFor(t, o, 3); !strings.Contains(r.Message, "does not say what it found") {
t.Errorf("run_job done message = %q — it must say it does not report the finding", r.Message)
}
if r := resultFor(t, o, 2); r.Message != "the set-aside history is now deleted on 2026-11-01" {
t.Errorf("extend message = %q", r.Message)
}
fmu.Lock()
defer fmu.Unlock()
if fired != 3 {
t.Errorf("ResultReady fired %d times, want 3 (one per finished action)", fired)
}
}
func TestOpActions_RefusalsAndFailuresAreDistinct(t *testing.T) {
h := OperatorActionHandlers{
OffsiteBackupNow: func() (string, func(context.Context) error) { return "a backup run is already in flight", nil },
AbandonStop: func() error { return errors.New("no abandonment countdown is running on this box") },
RunJob: func(string) (<-chan error, error) { return nil, errors.New("the job is already running") },
}
o := NewOperatorActions(context.Background(), h, nil)
o.Reconcile([]OperatorAction{{ID: 1, Action: OpOffsiteBackupNow}, {ID: 2, Action: OpAbandonStop}, {ID: 3, Action: OpRunJob, Arg: "offsite-proof"}, {ID: 4, Action: OpAbandonExtend, Arg: "5"}})
o.running.Wait()
want := map[int64]string{1: OutcomeRefused, 2: OutcomeFailed, 3: OutcomeRefused, 4: OutcomeRefused /* nil handler */}
for id, w := range want {
if r := resultFor(t, o, id); r.Outcome != w {
t.Errorf("#%d: outcome %q, want %q (%s)", id, r.Outcome, w, r.Message)
}
}
}
// The wire: the reply's operator_actions decodes into PushResponse, and the report's
// operator_action_results is what BuildReport attaches.
func TestOpActions_WireShapes(t *testing.T) {
var pr PushResponse
if err := json.Unmarshal([]byte(`{"status":"ok","operator_actions":[{"id":7,"action":"run_job","arg":"fill-watch"}]}`), &pr); err != nil {
t.Fatal(err)
}
if len(pr.OperatorActions) != 1 || pr.OperatorActions[0] != (OperatorAction{ID: 7, Action: "run_job", Arg: "fill-watch"}) {
t.Fatalf("decoded %+v", pr.OperatorActions)
}
c := &calls{}
o := NewOperatorActions(context.Background(), recordingHandlers(c), nil)
SetOperatorActions(o)
defer SetOperatorActions(nil)
o.Reconcile(pr.OperatorActions)
o.running.Wait()
b, _ := json.Marshal(Report{OperatorActionResults: pendingOperatorActionResults()})
var back struct {
Results []map[string]interface{} `json:"operator_action_results"`
}
if err := json.Unmarshal(b, &back); err != nil || len(back.Results) != 1 || back.Results[0]["outcome"] != "done" || back.Results[0]["id"].(float64) != 7 {
t.Fatalf("report carried %s", b)
}
}
+10 -2
View File
@@ -51,8 +51,14 @@ type PushResponse struct {
// Claim (v0.122.0, F-4) — the hub's active claim-code state (bcrypt hash + generation) for // Claim (v0.122.0, F-4) — the hub's active claim-code state (bcrypt hash + generation) for
// the customer-claim gate. nil on an old hub / no claim row → the cache stays as-is. // the customer-claim gate. nil on an old hub / no claim row → the cache stays as-is.
Claim *ClaimStatus `json:"claim"` Claim *ClaimStatus `json:"claim"`
// OperatorActions (R-314/R-279/R-177, `09` §3 decision 185) — the operator's pending actions for
// this box, listed until their result arrives (opactions.go). Absent on an old hub = none.
OperatorActions []OperatorAction `json:"operator_actions"`
} }
// maxPushResponseBytes bounds the report ACK read (see Push).
const maxPushResponseBytes = 64 << 10
// Pusher sends reports to the central hub. // Pusher sends reports to the central hub.
type Pusher struct { type Pusher struct {
hubURL string hubURL string
@@ -126,8 +132,10 @@ func (p *Pusher) Push(report *Report) error {
continue continue
} }
// Read response body to parse customer_blocked field // Read response body to parse customer_blocked field. The cap was 4 KiB; an ACK over it fails to
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) // parse and EVERY field in it is dropped (floor, config version, escrow, operator actions), so it
// is 64 KiB now — far above any real ACK, still a bound.
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxPushResponseBytes))
resp.Body.Close() resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
@@ -0,0 +1,37 @@
package report
import (
"encoding/json"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// R-266: a failed root statfs reaches the hub as read_failed, not as a 0-of-0 disk the hub would band
// as "0 % used". A measured disk carries no read_failed at all (omitempty), so an old hub sees no change.
func TestRootStorageEntry_ReadFailed(t *testing.T) {
failed := rootStorageEntry(system.SystemInfo{DiskKnown: false})
if !failed.ReadFailed {
t.Fatal("root statfs failed (DiskKnown false) but the storage entry does not say read_failed")
}
if failed.TotalGB != 0 || failed.UsedGB != 0 || failed.Percent != 0 || failed.Mount != "/" {
t.Errorf("failed entry changed its numbers or mount: %+v", failed)
}
b, _ := json.Marshal(failed)
if !strings.Contains(string(b), `"read_failed":true`) {
t.Errorf("wire form lacks read_failed: %s", b)
}
ok := rootStorageEntry(system.SystemInfo{DiskKnown: true, DiskTotalGB: 100, DiskUsedGB: 40, DiskPercent: 40})
if ok.ReadFailed {
t.Error("a measured root disk is marked read_failed")
}
b, _ = json.Marshal(ok)
if strings.Contains(string(b), "read_failed") {
t.Errorf("a measured disk must not carry read_failed on the wire: %s", b)
}
if ok.TotalGB != 100 || ok.Percent != 40 {
t.Errorf("measured numbers lost: %+v", ok)
}
}
+10
View File
@@ -45,6 +45,11 @@ type Report struct {
// app-tail flow above is untouched). // app-tail flow above is untouched).
ControllerLogTail *ControllerLogTail `json:"controller_log_tail,omitempty"` ControllerLogTail *ControllerLogTail `json:"controller_log_tail,omitempty"`
// OperatorActionResults (R-314/R-279/R-177, `09` §3 decision 185) — the outcome of each operator
// action the reply listed (opactions.go), re-sent until the hub stops listing the id. Additive;
// absent in the steady state.
OperatorActionResults []OperatorActionResult `json:"operator_action_results,omitempty"`
// Claimed (v0.122.0, F-4) — whether the customer has completed the dashboard claim (set // Claimed (v0.122.0, F-4) — whether the customer has completed the dashboard claim (set
// their own password). The hub ingests it SET-ONLY: a later false (wiped settings.json // their own password). The hub ingests it SET-ONLY: a later false (wiped settings.json
// after DR) never un-claims the customer hub-side. // after DR) never un-claims the customer hub-side.
@@ -89,6 +94,11 @@ type StorageReport struct {
Disconnected bool `json:"disconnected,omitempty"` Disconnected bool `json:"disconnected,omitempty"`
Decommissioned bool `json:"decommissioned,omitempty"` Decommissioned bool `json:"decommissioned,omitempty"`
MigratedTo string `json:"migrated_to,omitempty"` MigratedTo string `json:"migrated_to,omitempty"`
// ReadFailed (R-266, v0.308.0) — the measurement behind this entry FAILED (today: the root "/"
// statfs, SystemInfo.DiskKnown false). The numbers stay the zeros the failed read left; the hub
// must not band them as "0 % used". Absent (false) means the numbers were measured. Pinned by
// TestRootStorageEntry_ReadFailed.
ReadFailed bool `json:"read_failed,omitempty"`
} }
// ContainerReport holds aggregate and per-container status. // ContainerReport holds aggregate and per-container status.
@@ -0,0 +1,76 @@
package scheduler
import (
"context"
"errors"
"testing"
"time"
)
// R-314/R-177 (`09` §3 decision 185): RunNow runs a registered job once, now, and refuses an unknown
// name and a job already running — the operator's run_job action rests on these three answers.
func TestRunNow_RunsOnceAndReportsCompletion(t *testing.T) {
s := discardScheduler()
calls := 0
var sawOnDemand bool
s.Daily("fill-watch", farFuture(5), func(ctx context.Context) error {
calls++
sawOnDemand = OnDemand(ctx)
return nil
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.Start(ctx)
defer s.Stop()
done, err := s.RunNow("fill-watch")
if err != nil {
t.Fatalf("RunNow(fill-watch): %v", err)
}
select {
case jerr := <-done:
if jerr != nil {
t.Fatalf("job error = %v", jerr)
}
case <-time.After(5 * time.Second):
t.Fatal("RunNow never completed")
}
if calls != 1 || !sawOnDemand {
t.Fatalf("calls=%d onDemand=%v, want 1 and true", calls, sawOnDemand)
}
if j := s.GetJobs()[0]; j.Running || j.LastRun.IsZero() {
t.Fatalf("after RunNow: Running=%v LastRun=%v — want false and set", j.Running, j.LastRun)
}
}
func TestRunNow_RefusesUnknownRunningAndUnstarted(t *testing.T) {
s := discardScheduler()
release := make(chan struct{})
s.Daily("offsite-integrity", farFuture(5), func(ctx context.Context) error { <-release; return nil })
if _, err := s.RunNow("offsite-integrity"); !errors.Is(err, ErrNotStarted) {
t.Fatalf("before Start: err=%v, want ErrNotStarted", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.Start(ctx)
defer s.Stop()
if _, err := s.RunNow("no-such-job"); !errors.Is(err, ErrUnknownJob) {
t.Fatalf("unknown: err=%v, want ErrUnknownJob", err)
}
done, err := s.RunNow("offsite-integrity")
if err != nil {
t.Fatalf("first RunNow: %v", err)
}
if _, err := s.RunNow("offsite-integrity"); !errors.Is(err, ErrJobRunning) {
t.Fatalf("second RunNow while running: err=%v, want ErrJobRunning", err)
}
close(release)
<-done
// Released: it may run again.
done2, err := s.RunNow("offsite-integrity")
if err != nil {
t.Fatalf("RunNow after completion: %v", err)
}
<-done2
}
+72 -2
View File
@@ -2,6 +2,7 @@ package scheduler
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"sync" "sync"
@@ -359,7 +360,74 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
} }
job.Running = true job.Running = true
s.mu.Unlock() s.mu.Unlock()
s.runClaimed(s.ctx, job, quiet)
}
// ErrUnknownJob / ErrJobRunning / ErrNotStarted are RunNow's refusals — values, so a caller can
// branch on them (R-224's lesson: a string is not something a caller can branch on).
var (
ErrUnknownJob = errors.New("no job with this name is registered on this box")
ErrJobRunning = errors.New("the job is already running")
ErrNotStarted = errors.New("the scheduler is not running")
)
// onDemandKey marks the context of a RunNow execution (OnDemand reads it).
type onDemandKey struct{}
// OnDemand reports whether the job is running because someone asked for it now (RunNow), not on
// its schedule. A job whose scheduled run checks due-ness first (the off-site integrity check) uses
// it to run anyway — the operator asked for a check, not for "is a check due".
func OnDemand(ctx context.Context) bool {
v, _ := ctx.Value(onDemandKey{}).(bool)
return v
}
// RunNow runs a registered job once, now, outside its schedule (R-314/R-177, `09` §3 decision 185:
// the operator's run_job action). It REFUSES an unknown name and a job that is already running —
// never queues, never runs it twice at once (the same Running flag the schedule uses, claimed under
// the mutex, so a scheduled tick and RunNow cannot both start it). On success the job runs in its own
// goroutine and its error (nil = completed) arrives on the returned channel, which is then closed.
func (s *Scheduler) RunNow(name string) (<-chan error, error) {
s.mu.Lock()
if !s.started || s.ctx == nil {
s.mu.Unlock()
return nil, ErrNotStarted
}
var job *Job
for _, j := range s.jobs {
if j.Name == name {
job = j
break
}
}
if job == nil {
s.mu.Unlock()
s.dbg("RunNow %q refused: unknown job", name)
return nil, ErrUnknownJob
}
if job.Running {
s.mu.Unlock()
s.dbg("RunNow %q refused: already running", name)
return nil, ErrJobRunning
}
job.Running = true
ctx := context.WithValue(s.ctx, onDemandKey{}, true)
s.wg.Add(1)
s.mu.Unlock()
s.logger.Printf("[INFO] [scheduler] Job %s started on demand", name)
done := make(chan error, 1)
go func() {
defer s.wg.Done()
defer close(done)
done <- s.runClaimed(ctx, job, false)
}()
return done, nil
}
// runClaimed executes a job whose Running flag the caller has already set. It clears the flag,
// records LastRun/LastErr, and returns the job's error.
func (s *Scheduler) runClaimed(ctx context.Context, job *Job, quiet bool) (err error) {
defer func() { defer func() {
s.mu.Lock() s.mu.Lock()
job.Running = false job.Running = false
@@ -369,8 +437,9 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
// Panic recovery // Panic recovery
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
s.mu.Lock() s.mu.Lock()
job.LastErr = fmt.Errorf("panic: %v", r) job.LastErr = err
job.LastRun = time.Now() job.LastRun = time.Now()
s.mu.Unlock() s.mu.Unlock()
s.logger.Printf("[ERROR] [scheduler] Job %s panicked: %v", job.Name, r) s.logger.Printf("[ERROR] [scheduler] Job %s panicked: %v", job.Name, r)
@@ -383,7 +452,7 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
s.dbg("job %s: execution starting", job.Name) s.dbg("job %s: execution starting", job.Name)
start := time.Now() start := time.Now()
err := job.Fn(s.ctx) err = job.Fn(ctx)
elapsed := time.Since(start) elapsed := time.Since(start)
s.mu.Lock() s.mu.Lock()
@@ -400,6 +469,7 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
// Routine per-cycle timing line → TRACE (dropped from the ring; the completed/failed lines above // Routine per-cycle timing line → TRACE (dropped from the ring; the completed/failed lines above
// carry the outcome). This was the biggest ring filler under load (fix-6). // carry the outcome). This was the biggest ring filler under load (fix-6).
s.trace("job %s: finished in %s (err=%v)", job.Name, elapsed.Round(time.Millisecond), err) s.trace("job %s: finished in %s (err=%v)", job.Name, elapsed.Round(time.Millisecond), err)
return err
} }
// parseDailyTime parses "HH:MM" and returns hour and minute. // parseDailyTime parses "HH:MM" and returns hour and minute.
@@ -0,0 +1,88 @@
package settings
// R-388 slice 1 (`08-alarm-ladder.md` §8.1, `09` §3 decision 200; operator answers 2026-10-12):
// the notification page shows THREE group switches instead of one switch per detector. A group is a
// view over the SAME `enabled_events` list — saving a group writes all its detectors into it, so
// nothing on the wire and nothing on the hub changes.
//
// Every detector the page can save belongs to EXACTLY ONE group. Pinned by
// TestR388_EveryPageSwitchIsInExactlyOneGroup (web), which reads the rendered page's switches and
// fails when a new detector gets a checkbox but no group.
// Notification group keys (also the form-field stems `group_<key>` / `group_<key>_shown`).
const (
NotifGroupMachine = "machine" // „A gépem és a meghajtóim" — the household acts
NotifGroupApps = "apps" // „Az alkalmazásaim"
NotifGroupHandled = "handled" // „Amit mi intézünk" — we act; the operator always gets these
)
// Group states as the page renders them.
const (
NotifGroupOn = "on"
NotifGroupOff = "off"
NotifGroupCustom = "custom" // some of its detectors on, some off — „egyedi"
)
// NotificationGroup is one switch on the notification page and the detectors behind it.
type NotificationGroup struct {
Key string
Events []string
}
// NotificationGroups is the §8.1 membership table, in page order.
var NotificationGroups = []NotificationGroup{
{Key: NotifGroupMachine, Events: []string{
"storage_disconnected", "storage_reconnected", "disk_warning", "disk_critical", "offbox_enlarge_blocked",
}},
{Key: NotifGroupApps, Events: []string{
"app_stopped_unhealthy", "app_start_failed", "app_update_held", "app_update_undone",
}},
{Key: NotifGroupHandled, Events: []string{
"backup_failed", "db_dump_failed", "backup_integrity_failed", "crossdrive_failed",
"expected_backup_missed", "expected_dbdump_missed", "node_down", "health_critical", "health_recovered",
}},
}
// NotificationGroupByKey returns the group with this key, or false.
func NotificationGroupByKey(key string) (NotificationGroup, bool) {
for _, g := range NotificationGroups {
if g.Key == key {
return g, true
}
}
return NotificationGroup{}, false
}
// NotificationGroupState reports whether all, none or some of the group's detectors are in enabled.
// An unknown key reads as off (renders unchecked, and the save leaves the details authoritative).
func NotificationGroupState(enabled []string, key string) string {
g, ok := NotificationGroupByKey(key)
if !ok {
return NotifGroupOff
}
set := make(map[string]bool, len(enabled))
for _, e := range enabled {
set[e] = true
}
on := 0
for _, e := range g.Events {
if set[e] {
on++
}
}
switch {
case on == len(g.Events):
return NotifGroupOn
case on == 0:
return NotifGroupOff
default:
return NotifGroupCustom
}
}
// GroupState is the template's view of NotificationGroupState over this household's list.
// VALUE receiver on purpose: a pointer receiver compiles and passes vet, then 500s at render
// (`.claude/rules/ui-hungarian.md`).
func (p NotificationPrefs) GroupState(key string) string {
return NotificationGroupState(p.EnabledEvents, key)
}
@@ -0,0 +1,355 @@
package settings
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
// R-578 — the settings-callback deadlock, as a gate over EVERY package of the controller.
//
// `UpdateOffboxStatus` (and every other *Settings method that takes a func and the settings lock)
// runs its callback while holding the settings WRITE lock. sync.RWMutex is not reentrant: anything
// inside that callback that takes the settings lock again — a getter, a setter, or a package helper
// that ends in one (`m.note` → `m.boxLang` → `GetLanguage`) — DEADLOCKS while holding the lock, and
// wedges everything else on the box that touches settings.json. Release C of localisation slice 2
// shipped exactly that; the only symptom was a 25-minute test timeout. The backup-only predecessor,
// TestNoteHelpersAreNotCalledUnderTheSettingsLock, knew three helper names in one package.
//
// This test derives everything from source instead of a list:
// - lockers: *Settings methods that take s.mu (directly or through another *Settings method);
// - takers: lockers that have a func-typed parameter (the callback runners);
// - tainted: per package, every func/method whose body calls an exported locker or another
// tainted name of the same package (fixpoint) — so a new helper is covered the day it is written;
// - violation: a call to a locker or a tainted name inside a func literal passed to a taker, OR a
// taker given something that is not a literal (the gate could not see inside it).
//
// The matching is by NAME (no type checker — this must stay fast inside `go test ./...`). A false
// positive is possible if an unrelated type has a method named like a settings locker and is called
// inside a callback; rename it or hoist the call — both are cheap, a hang is not.
//
// RED-PROOF (R-578): put `lang := m.note("x")` inside the final UpdateOffboxStatus callback in
// internal/backup/offbox.go → this test names internal/backup/offbox.go:<line> and the call chain.
func TestR578NothingTakesTheSettingsLockInsideASettingsCallback(t *testing.T) {
root := filepath.Join("..", "..") // controller/
pkgs := parseControllerPackages(t, root)
own, ok := pkgs[filepath.Join(root, "internal", "settings")]
if !ok {
t.Fatal("internal/settings was not parsed — the walk no longer finds this package")
}
lockers, takers := settingsLockers(own)
if len(lockers) < 20 || len(takers) == 0 {
t.Fatalf("found %d settings lockers and %d callback runners — the pattern no longer matches the code", len(lockers), len(takers))
}
exported := map[string]bool{}
for name := range lockers {
if ast.IsExported(name) {
exported[name] = true
}
}
var bad []string
callbacks := 0
backupTaint := map[string]bool{}
for dir, files := range pkgs {
tainted := taintedFuncs(files, exported)
if dir == filepath.Join(root, "internal", "backup") {
backupTaint = tainted
}
for _, pf := range files {
ast.Inspect(pf.file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
idx, isTaker := takers[sel.Sel.Name]
if !isTaker {
return true
}
for _, i := range idx {
if i >= len(call.Args) {
continue
}
arg := call.Args[i]
if lit, ok := arg.(*ast.FuncLit); ok {
callbacks++
for _, hit := range lockingCalls(lit.Body, exported, tainted) {
bad = append(bad, pf.pos(hit.pos)+" "+hit.name+"() inside "+sel.Sel.Name+"(func…)")
}
continue
}
bad = append(bad, pf.pos(arg.Pos())+" "+sel.Sel.Name+" is passed a non-literal callback — the gate cannot see inside it; pass a func literal")
}
return true
})
}
}
// Positive controls: the instrument is looking at something, and the transitive taint reaches
// the helpers that actually caused the 2026-09-18 deadlock.
if callbacks < 10 {
t.Fatalf("found only %d settings callbacks across the controller — the walk no longer matches the code", callbacks)
}
for _, h := range []string{"boxLang", "note", "noteErr"} {
if !backupTaint[h] {
t.Errorf("internal/backup %s() is not seen as taking the settings lock — the taint analysis lost the chain that deadlocked release C", h)
}
}
if len(bad) > 0 {
sort.Strings(bad)
t.Errorf("a settings callback (which holds the settings WRITE lock) calls something that takes "+
"the settings lock again — sync.RWMutex is not reentrant, so this DEADLOCKS and wedges "+
"settings.json for the whole box. Hoist the call before the callback (%d):\n %s",
len(bad), strings.Join(bad, "\n "))
}
t.Logf("%d packages, %d settings lockers, %d callback runners, %d callbacks examined", len(pkgs), len(lockers), len(takers), callbacks)
}
// TestR578GateConvictsAHelperChain is the decoy: a synthetic package whose callback reaches the
// settings lock only through two local helpers. If the taint fixpoint stops following calls, this
// fails before the real gate quietly goes blind.
func TestR578GateConvictsAHelperChain(t *testing.T) {
src := `package decoy
type M struct{ settings *S }
func (m *M) lang() string { return m.settings.GetLanguage() }
func (m *M) msg() string { return "x" + m.lang() }
func (m *M) clean() string { return "y" }
func (m *M) run() {
_ = m.settings.UpdateOffboxStatus(func(o *T) { o.A = m.msg(); o.B = m.clean() })
}`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "decoy.go", src, 0)
if err != nil {
t.Fatal(err)
}
files := []parsedFile{{fset: fset, file: f, path: "decoy.go"}}
exported := map[string]bool{"GetLanguage": true}
tainted := taintedFuncs(files, exported)
if !tainted["msg"] || !tainted["lang"] || tainted["clean"] {
t.Fatalf("taint = %v; want lang+msg tainted, clean not", tainted)
}
var lit *ast.FuncLit
ast.Inspect(f, func(n ast.Node) bool {
if l, ok := n.(*ast.FuncLit); ok {
lit = l
}
return true
})
hits := lockingCalls(lit.Body, exported, tainted)
if len(hits) != 1 || hits[0].name != "msg" {
t.Fatalf("hits = %+v; want exactly msg()", hits)
}
}
type parsedFile struct {
fset *token.FileSet
file *ast.File
path string
}
func (p parsedFile) pos(pos token.Pos) string {
ps := p.fset.Position(pos)
return filepath.ToSlash(p.path) + ":" + strconv.Itoa(ps.Line)
}
// parseControllerPackages parses every non-test .go file under controller/, grouped by directory.
func parseControllerPackages(t *testing.T, root string) map[string][]parsedFile {
t.Helper()
fset := token.NewFileSet()
out := map[string][]parsedFile{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
base := info.Name()
if path != root && (strings.HasPrefix(base, ".") || base == "testdata" || base == "vendor") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return perr
}
dir := filepath.Dir(path)
rel, _ := filepath.Rel(root, path)
out[dir] = append(out[dir], parsedFile{fset: fset, file: f, path: rel})
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
// settingsLockers returns the *Settings methods that take s.mu (directly, or by calling another
// *Settings method that does), and the subset that also take a func-typed parameter (with the
// argument positions of those parameters).
func settingsLockers(files []parsedFile) (lockers map[string]bool, takers map[string][]int) {
type method struct {
recv string
decl *ast.FuncDecl
}
var methods []method
for _, pf := range files {
for _, d := range pf.file.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok || fd.Recv == nil || len(fd.Recv.List) != 1 || fd.Body == nil {
continue
}
star, ok := fd.Recv.List[0].Type.(*ast.StarExpr)
if !ok {
continue
}
if id, ok := star.X.(*ast.Ident); !ok || id.Name != "Settings" {
continue
}
recv := "_"
if len(fd.Recv.List[0].Names) == 1 {
recv = fd.Recv.List[0].Names[0].Name
}
methods = append(methods, method{recv, fd})
}
}
lockers = map[string]bool{}
for changed := true; changed; {
changed = false
for _, m := range methods {
if lockers[m.decl.Name.Name] {
continue
}
found := false
ast.Inspect(m.decl.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || found {
return !found
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
// s.mu.Lock() / s.mu.RLock()
if (sel.Sel.Name == "Lock" || sel.Sel.Name == "RLock") && isRecvField(sel.X, m.recv, "mu") {
found = true
}
// s.OtherLockingMethod()
if id, ok := sel.X.(*ast.Ident); ok && id.Name == m.recv && lockers[sel.Sel.Name] {
found = true
}
return !found
})
if found {
lockers[m.decl.Name.Name] = true
changed = true
}
}
}
// takers: locker -> the argument positions that are func-typed.
takers = map[string][]int{}
for _, m := range methods {
if !lockers[m.decl.Name.Name] {
continue
}
pos := 0
for _, p := range m.decl.Type.Params.List {
n := len(p.Names)
if n == 0 {
n = 1
}
if _, ok := p.Type.(*ast.FuncType); ok {
for k := 0; k < n; k++ {
takers[m.decl.Name.Name] = append(takers[m.decl.Name.Name], pos+k)
}
}
pos += n
}
}
return lockers, takers
}
func isRecvField(x ast.Expr, recv, field string) bool {
sel, ok := x.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != field {
return false
}
id, ok := sel.X.(*ast.Ident)
return ok && id.Name == recv
}
// calledName is the name a call expression invokes: `f(…)` → f, `x.y.f(…)` → f.
func calledName(call *ast.CallExpr) string {
switch fn := call.Fun.(type) {
case *ast.Ident:
return fn.Name
case *ast.SelectorExpr:
return fn.Sel.Name
}
return ""
}
// taintedFuncs returns, for one package, the names of funcs/methods whose body (transitively, within
// the package) calls an exported settings locker.
func taintedFuncs(files []parsedFile, exported map[string]bool) map[string]bool {
bodies := map[string][]*ast.BlockStmt{}
for _, pf := range files {
for _, d := range pf.file.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Body != nil {
bodies[fd.Name.Name] = append(bodies[fd.Name.Name], fd.Body)
}
}
}
tainted := map[string]bool{}
for changed := true; changed; {
changed = false
for name, bs := range bodies {
if tainted[name] {
continue
}
for _, b := range bs {
if len(lockingCalls(b, exported, tainted)) > 0 {
tainted[name] = true
changed = true
break
}
}
}
}
return tainted
}
type lockHit struct {
name string
pos token.Pos
}
func lockingCalls(body ast.Node, exported, tainted map[string]bool) []lockHit {
var hits []lockHit
ast.Inspect(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
name := calledName(call)
_, isSel := call.Fun.(*ast.SelectorExpr)
if (isSel && exported[name]) || tainted[name] {
hits = append(hits, lockHit{name, call.Pos()})
}
return true
})
return hits
}
@@ -0,0 +1,57 @@
package settings
import (
"io"
"log"
"path/filepath"
"testing"
)
// R-922: the clear marker's rule as a table — set only by a deliberate clear, kept while the address stays
// empty, dropped by a new address — and it survives a reload from disk (a clear the hub missed is pushed
// again by the next start: SyncOnStartup).
func TestR922_ClearedByRule(t *testing.T) {
for _, tc := range []struct {
name string
stored *NotificationPrefs
newEmail string
want bool
}{
{"never configured, empty save", &NotificationPrefs{}, "", false},
{"nil stored prefs", nil, "", false},
{"had an address, empty save = clear", &NotificationPrefs{Email: "a@example.hu"}, "", true},
{"already cleared, empty save keeps it", &NotificationPrefs{EmailCleared: true}, "", true},
{"cleared, new address drops it", &NotificationPrefs{EmailCleared: true}, "b@example.hu", false},
{"address changed", &NotificationPrefs{Email: "a@example.hu"}, "b@example.hu", false},
} {
if got := tc.stored.ClearedBy(tc.newEmail); got != tc.want {
t.Errorf("%s: ClearedBy(%q) = %v, want %v", tc.name, tc.newEmail, got, tc.want)
}
}
}
func TestR922_MarkerPersistsAndDrivesStartupSync(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
lg := log.New(io.Discard, "", 0)
s, err := Load(path, lg)
if err != nil {
t.Fatal(err)
}
if s.GetNotificationPrefs().SyncOnStartup() {
t.Fatal("a never-configured box would push on startup")
}
if err := s.SetNotificationPrefs(&NotificationPrefs{EmailCleared: true, CooldownHours: 6}); err != nil {
t.Fatal(err)
}
s2, err := Load(path, lg)
if err != nil {
t.Fatal(err)
}
p := s2.GetNotificationPrefs()
if !p.EmailCleared {
t.Fatal("the clear marker did not survive a reload")
}
if !p.SyncOnStartup() {
t.Fatal("a cleared box does not push its clear on startup — a clear the hub missed would never reach it")
}
}
@@ -33,8 +33,9 @@ func TestRefuseAppNamespace_DoesNotNameAnImpossibleAction(t *testing.T) {
} }
} }
// The NAS refusal is a different situation and keeps its own wording — it points at a list that // The NAS refusal is a different situation and keeps its own wording — it points at a list that
// genuinely does have entries, so it is not the same defect. // genuinely does have entries, so it is not the same defect. (R-516, 2026-10-06 night: the same
if !strings.Contains(refuseAppNamespaceNetwork, "Válasszon csatlakoztatott meghajtót") { // sentence in the product's te-form — „Válassz", not „Válasszon".)
if !strings.Contains(refuseAppNamespaceNetwork, "Válassz csatlakoztatott meghajtót") {
t.Fatal("the NAS refusal was changed; it is a different situation and was not part of R-220") t.Fatal("the NAS refusal was changed; it is a different situation and was not part of R-220")
} }
} }

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