Compare commits
84 Commits
034a2b7363
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| acbbf4b305 | |||
| e24430cce5 | |||
| 201f936e9a | |||
| 1b2f74c52f | |||
| 7626a28de3 | |||
| 402fa41e45 | |||
| 462196e29b | |||
| 68294ecb6f | |||
| faef8f8a91 | |||
| fd28d70bff | |||
| dcd89364a4 | |||
| fc6466a50a | |||
| 9e5b88146e | |||
| c255195e37 | |||
| 1cb43a21a2 | |||
| b3a879b50e | |||
| ec9b997151 | |||
| 13eeb484ab | |||
| d7bfdccf57 | |||
| 3f84f82c3d | |||
| d3e17e9b2e | |||
| e06680b797 | |||
| 63441f0a69 | |||
| 740339567a | |||
| 1040cfe225 | |||
| fcd9f09927 | |||
| 423b6d3404 | |||
| e37e9b50c6 | |||
| d5f2e47ea1 | |||
| 05e129267a | |||
| a40729a698 | |||
| a0370b4ed8 | |||
| 75b3b39254 | |||
| 6d07ca2be4 | |||
| 4d3a0246df | |||
| 29bebbb937 | |||
| 431da7ebbc | |||
| 191836946b | |||
| e2392935a3 | |||
| a3e85e5a40 | |||
| 9acc3adb96 | |||
| 0629c16a4b | |||
| 1de22a166a | |||
| 3d6ba2852f | |||
| f65ace0ca6 | |||
| 5e7522e023 | |||
| 0b1b8d3445 | |||
| 66aee8939b | |||
| ce2e31f4b2 | |||
| 5b8656974b | |||
| 9c945688c0 | |||
| 9d2d62831a | |||
| d720495cd5 | |||
| d8c2634472 | |||
| 31242786af | |||
| 36088fd82e | |||
| 871d05b84d | |||
| c393d8529a | |||
| 2d63714eca | |||
| 13bda270c3 | |||
| ff4a99a0ab | |||
| bee2c2d815 | |||
| c67b26be28 | |||
| 4c3c2037fd | |||
| 2fc314d491 | |||
| 0a960ba58d | |||
| 7044b11804 | |||
| 2e5ef7e410 | |||
| e4774e6a06 | |||
| 0f2eab7a3e | |||
| ff1758a21c | |||
| 84d6ef706d | |||
| ef199c5705 | |||
| c4f4019721 | |||
| d7efa7b54e | |||
| ca89e70d53 | |||
| b05be0ac11 | |||
| b36a85ac04 | |||
| 1991c742a3 | |||
| 4347983a72 | |||
| 05a447ef02 | |||
| 28a5203d01 | |||
| 6fac245d24 | |||
| 33c397380e |
+10
-10
@@ -6,17 +6,17 @@ paths: ["controller/**/*.go", "controller/**/*.html", "controller/**/*.css", "co
|
||||
|
||||
## The ONE entry point
|
||||
|
||||
**Run `python3 controller/scripts/controller_gates.py` (from `controller/`) after ANY change in this
|
||||
repo.** It runs the local gates — `template_id_gate`, `emoji_gate`, `native_confirm_gate`,
|
||||
`offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`, `docker_run_volume_path_gate`,
|
||||
`secret_in_markup_gate`, `retrieval_promise_gate`, `debug_route_gate` — plus `reuse_refs_check`,
|
||||
`instructions_gate` and `observations_gate` on the repo root, streaming each gate's own output and
|
||||
exiting non-zero if any fails. **The runner's `GATES` table is the list; this sentence is a pointer to
|
||||
it, not a second copy** — it has already drifted once (it said "seven" while nine were registered).
|
||||
**Run `python3 scripts/controller_gates.py` from `controller/` (or `python3
|
||||
controller/scripts/controller_gates.py` from the repo root) after ANY change in this repo.** It runs
|
||||
every gate in the runner's `GATES` table, the shared `reuse_refs_check`, `instructions_gate` and
|
||||
`observations_gate` among them, streaming each gate's own output and exiting non-zero if any fails;
|
||||
one, `golden-notice`, is ADVISORY and never refuses. **The `GATES` table is the list; this sentence is a
|
||||
pointer to it, not a second copy** — it drifted twice (it said "seven" while nine were registered, then
|
||||
named ten local gates while fourteen were).
|
||||
|
||||
- `--fast` selects the gates that touch no network and no container runtime; today that is all of them.
|
||||
- **A missing gate script is a FAILURE, never a skip.**
|
||||
- **The shared `reuse_refs_check.py` and `instructions_gate.py` live in `felhom.eu/scripts/` and are
|
||||
- **The shared `reuse_refs_check.py`, `instructions_gate.py` and `observations_gate.py` live in `felhom.eu/scripts/` and are
|
||||
never copied here** — a copy would recreate the drift they detect; an absent sibling clone FAILS.
|
||||
- **The pre-push hook** (`.githooks/pre-push`) runs it with `--fast` and refuses a failing push. It is
|
||||
per-clone — switch it on once with `git config core.hooksPath .githooks`, and a manual run WARNS
|
||||
@@ -59,7 +59,7 @@ fast, and wrong.
|
||||
|
||||
**A decoy is the LABEL without the FACT** — a directory with the right name and no bake log, a
|
||||
handler case that exists only in a comment, a note whose prose mentions the marker it lacks. Write
|
||||
one for every new gate, run it, and watch it convict. `scripts/decoy_coverage_gate.py` refuses a gate
|
||||
one for every new gate, run it, and watch it convict. `felhom.eu/scripts/decoy_coverage_gate.py` (run by felhom.eu's `repo_gates.py`, for all four repos) refuses a gate
|
||||
registered without one, or without a named exemption carrying its row.
|
||||
|
||||
**Earned by five instances, every one found by accident:** R-410, R-400, R-378, R-419, R-94. The
|
||||
@@ -75,4 +75,4 @@ green and correct today, blind the moment anyone adds a subdirectory. Prefer `os
|
||||
glob over a hand-maintained list of files.
|
||||
|
||||
**A decoy nobody would write proves nothing** — say the gate is sound and move on. Five of mine were
|
||||
withdrawn as illegitimate and are named in `documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
|
||||
withdrawn as illegitimate and are named in `felhom.eu/documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
|
||||
|
||||
@@ -6,7 +6,8 @@ unconditional: true
|
||||
> Goal sessions, nightly sessions, "work the register" sessions. **A session that starts from
|
||||
> `/goal` or a standing brief inherits these rules exactly as it inherits the gates.** They are the
|
||||
> part of `PROMPT-TEMPLATE.md` that a task file used to carry and a goal does not. Same wording lives
|
||||
> in all three repos' `.claude/rules/`; change it in all three or in none.
|
||||
> in `felhom.eu`, `felhom-controller`, `felhom-agent` and `app-catalog-felhom.eu` `.claude/rules/`, and in the workspace
|
||||
> root's unversioned `.claude/rules/`; change all five or none.
|
||||
|
||||
## 1. What you may pick up on your own
|
||||
|
||||
@@ -22,7 +23,8 @@ unconditional: true
|
||||
customer data; anything that changes a promise the product makes to a customer; anything that
|
||||
reverses a documented design decision (`documentation/architecture/` — a design decision is not a
|
||||
defect, R-370); anything on DooPlex 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)
|
||||
|
||||
@@ -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.
|
||||
9. **Never leave a half-state.** If time runs out, revert to clean and say what was reverted.
|
||||
10. **Teardown, three layers, stated** — machine, host, hub — or "provisioned nothing".
|
||||
11. **Every helper prompt carries the brief's fences in full** (operator ruling 2026-10-07). A helper session (a
|
||||
subagent, a fork, a workflow agent) gets the brief's fence list word for word — every protected machine, every
|
||||
„no", every delivery and Docker limit — not a summary and not „the usual fences". Earned on 2026-10-06 night: two
|
||||
helpers whose prompts carried only part of the fences ran `docker volume prune` on the bench and a Docker-using
|
||||
gate on DooPlex.
|
||||
|
||||
## 4. The morning note
|
||||
|
||||
@@ -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 needs the operator, each with what happens if they do nothing. No file paths, no function
|
||||
names, no row numbers as the subject of a sentence.
|
||||
|
||||
## 5. Instruction files
|
||||
|
||||
**Instruction files (`CLAUDE.md`, `.claude/rules/*`) are kept true by the session that finds them wrong**
|
||||
(operator ruling 2026-10-06, `09` §3 decision 150). A session MAY, without asking: correct a stale fact (a command, a
|
||||
count, a version, a path, a description of what a gate does), add a fact it proved, and remove a reference to something
|
||||
that no longer exists. Each edit is named in the report (file, line, before, after, why). A session MAY NOT, without the
|
||||
operator's word: loosen a safety rule, a fence, a „never", a protected machine, a secret rule, or a review step; or
|
||||
remove a rule. When in doubt, it is a rule change, and it goes to the operator. If Claude Code's own permission check
|
||||
asks before such an edit, wait for the operator's click; if it refuses, record that and file the exact line.
|
||||
|
||||
## 6. The system poster stays true
|
||||
|
||||
**A session that changes a fact listed in `architecture/felhom-system-poster.facts.md` (a machine, a
|
||||
role, a traffic path, a backup tier, a time, a retention, a key, a known gap) updates that file in
|
||||
the same commit.** If the change is text only, it also edits the matching text in
|
||||
`felhom-system-poster.html`. If it needs a new drawing, it adds the line
|
||||
**"System poster needs a refresh: <what changed>"** to `STATUS.md`'s "waiting on the operator" list.
|
||||
**The session report names which of the three it did.**
|
||||
|
||||
Why the three-way split: the poster is a drawing made in Claude Design, and **nothing in this
|
||||
repository renders it** — unlike `where-felhom-stands.html`, which has `render_stands.py`. So the
|
||||
facts file is the source of truth and the drawing trails it. `scripts/poster_facts_gate.py` warns
|
||||
when the facts file has a newer commit than the poster; it **never fails a push**, because a refresh
|
||||
needs the operator and another tool, and a gate nobody can clear is a gate people learn to route
|
||||
around.
|
||||
|
||||
+393
-1
@@ -1,4 +1,384 @@
|
||||
## unreleased
|
||||
## v0.310.0 — „A gép kikapcsolása": the household can turn the whole machine off (R-998); honest restart text (R-999) (2026-10-13)
|
||||
|
||||
**MinAgent: 0.155.0** — the new route calls the agent's `POST /host/poweroff`. An older agent: the button is hidden and
|
||||
one line says an update is needed (the `host_poweroff` feature row; a 404 gets the same answer). **Hub:** v0.149.0 holds
|
||||
the silence alarms for 14 days; without it the box still powers off, and the operator gets the usual outage alarms.
|
||||
Design: felhom.eu `documentation/architecture/14-host-power-off.md`.
|
||||
|
||||
- **R-998 — the button.** Beállítások → Rendszer, after the restart cards: „A gép kikapcsolása" / "Turn the machine
|
||||
off", „A gép teljesen kikapcsol. Az alkalmazásaid és a mentések leállnak. **Újra bekapcsolni csak otthon lehet: nyomd
|
||||
meg a gép bekapcsológombját.**" A dialog where the household types KIKAPCSOLÁS / TURN OFF (the button stays off until
|
||||
then; the server checks the word again, unaccented spelling accepted). `POST /api/server/poweroff`, dashboard login
|
||||
only, POST only. The controller refuses itself during a backup, restore or app update; the agent's refusal reasons
|
||||
are shown as household sentences (`poweroff.refused.*`, both languages).
|
||||
- **R-999 — „Kiszolgáló újraindítása" said „the whole server".** It restarts the guest. Now „A Felhom újraindítása" /
|
||||
"Restart Felhom": „Újraindítja a Felhom részt a gépen: a vezérlőpultot és az összes alkalmazást. A gép maga bekapcsolva
|
||||
marad. Kb. 30 másodpercig semmi nem érhető el, utána minden magától visszajön. …" — the confirmation too.
|
||||
- **R-983 (one of seven):** `/api/server/reboot` acted on a GET (CsrfProtect lets GET through) — now 405.
|
||||
- Tests (`internal/web/poweroff_handlers_test.go`): the word is required (5 wrong bodies, 5 right ones), POST only, the
|
||||
agent's and the controller's refusals shown and nothing asked of the agent, the card hidden for an old agent (and a 404
|
||||
answered as outdated), every refusal reason has a sentence in both languages, the restart route refuses a GET.
|
||||
Red-proofed: each guard removed in turn convicted its test. The four `settings_system` i18n parity fixtures were
|
||||
regenerated on purpose; the diff is exactly the new card, the dialog's type-to-confirm and the restart texts.
|
||||
|
||||
## 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-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.
|
||||
@@ -15,6 +395,18 @@
|
||||
- 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.
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -27,7 +27,7 @@ integrations, git-sync, notifications. Disk/host/Proxmox concerns are delegated
|
||||
| which box may I break | `felhom.eu/documentation/runbooks/target-selection.md` |
|
||||
| host addresses, break-glass, node facts | `felhom.eu/documentation/operations/nodes.md` |
|
||||
| what version is live anywhere | ask the hub (`/hosts`, `/configs`) or the box — **never a doc** |
|
||||
| the authoritative design | `felhom.eu/documentation/architecture/01/02/03-*.md` |
|
||||
| the authoritative design | `felhom.eu/documentation/architecture/NN-*.md` (01–03 for this repo; 07 backup, 09 updates, 10 language) |
|
||||
|
||||
## Session-critical invariants
|
||||
|
||||
@@ -37,8 +37,8 @@ The rest live in `REUSE.md`. These cost incidents to learn:
|
||||
- Docker's `.State` says "running" even for unhealthy containers — the `.Status` parse is the truth.
|
||||
- In-memory `Deployed` is set BEFORE `compose up -d` (slow-pull race); reverted on failure.
|
||||
- `compose up -d` exits 0 on crash-loops — the post-start status check is the detection.
|
||||
- Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller)
|
||||
cannot be stopped from the UI.
|
||||
- Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller,
|
||||
and always samba) cannot be stopped from the UI.
|
||||
- Verify a container image HAS the healthcheck tool before using it (BusyBox wget / python3 / curl —
|
||||
the catalog `REUSE.md` maps the families).
|
||||
- `IsRunning()` is CONCURRENCY, false during a verification restore — display MUST use
|
||||
@@ -61,7 +61,7 @@ credentials) — they load when you touch a template or stylesheet.
|
||||
|
||||
| Surface | Command |
|
||||
|---|---|
|
||||
| Gates (after ANY change) | `python3 controller/scripts/controller_gates.py` — from `controller/` |
|
||||
| Gates (after ANY change) | `python3 scripts/controller_gates.py` — from `controller/` |
|
||||
| Green gate | `go build ./... && go vet ./... && go test ./...` |
|
||||
| Build + deploy | the **`felhom-build-deploy`** skill — do not hand-roll it |
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
|
||||
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):**
|
||||
> `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).
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
# REPORT — controller v0.297.0 (2026-10-05, burn-down round 2)
|
||||
# REPORT — felhom-controller v0.310.0: „A gép kikapcsolása" (2026-10-13)
|
||||
|
||||
Full session report: `felhom.eu/REPORT-burndown2-2026-10-05.md`. Baseline `114ff27` (v0.296.0). **MinAgent 0.131.0**.
|
||||
Baseline `1b2f74c` (v0.309.0). Commit `201f936`. **MinAgent 0.155.0.** Needs hub v0.149.0 for the 14-day alarm hold.
|
||||
Design: felhom.eu `documentation/architecture/14-host-power-off.md`.
|
||||
|
||||
24 rows fixed (see the CHANGELOG entry), plus two small twins found on the way (the banner countdown at 0 days; the rest
|
||||
of `deepCopyStack`'s shared references). R-126 and R-325 NOT done: R-126 needs an operator choice between two options;
|
||||
R-325 needs a same-step change in felhom.eu's `hub_copy_gate.py`. Tests + red-proofs:
|
||||
`felhom.eu/documentation/audits/burndown2-2026-10-05/controller-red-proofs.txt`.
|
||||
## What the household sees
|
||||
|
||||
`go build/vet/test ./...` green; `controller_gates.py --fast` green (incl. the new gofmt gate). Image, golden and
|
||||
delivery: see the session report.
|
||||
Beállítások → Rendszer, after the restart cards: „A gép kikapcsolása" — „A gép teljesen kikapcsol. Az alkalmazásaid és a
|
||||
mentések leállnak. **Újra bekapcsolni csak otthon lehet: nyomd meg a gép bekapcsológombját.**" The button opens a dialog;
|
||||
the household types KIKAPCSOLÁS (EN: TURN OFF); then „A gép kikapcsol. Néhány perc múlva teljesen leáll…". A refusal
|
||||
shows one sentence (backup running, just started, other system on the machine…). With an older agent: the button is
|
||||
hidden and one line says an update is needed.
|
||||
|
||||
The restart card now says what it does (R-999): „A Felhom újraindítása" — „Újraindítja a Felhom részt a gépen: a
|
||||
vezérlőpultot és az összes alkalmazást. A gép maga bekapcsolva marad. Kb. 30 másodpercig semmi nem érhető el, utána
|
||||
minden magától visszajön. Ha csak a vezérlőpult akad, a fenti „Vezérlő újraindítása" elég." `/api/server/reboot` is
|
||||
POST only (one of R-983's seven).
|
||||
|
||||
## Proof
|
||||
|
||||
- `internal/web/poweroff_handlers_test.go` (6 tests): the word, POST only, refusals shown and nothing asked of the agent,
|
||||
hidden for an old agent, a sentence for every reason in both languages, the restart route refuses a GET. Each guard
|
||||
red-proofed. The four `settings_system` i18n parity fixtures regenerated on purpose (diff = the new card, the dialog's
|
||||
type-to-confirm, the restart texts). `go test ./...` and `controller_gates.py` green.
|
||||
- Live on the N100 (endpoint level — the calls the page makes; no browser on DooPlex): the card and the new texts on
|
||||
`/settings`, the old heading gone; `POST /api/server/poweroff` with no word and with „igen" → 400 naming the word;
|
||||
`GET /api/server/reboot` → 405; with KIKAPCSOLÁS → 202, and the machine went off. The power button brought it back:
|
||||
dashboard 200 within 50 s, the same 5 containers healthy, controller 0.310.0.
|
||||
|
||||
## Delivered
|
||||
|
||||
Image `gitea.dooplex.hu/admin/felhom-controller:0.310.0` pushed. Hand-deployed to the N100 guest 9201 only (needed for
|
||||
the live proof). demo-hp and Tester 1 get it with the operator's floor raise (floor 0.310.0, `min_agent` 0.155.0).
|
||||
|
||||
@@ -62,7 +62,9 @@
|
||||
| `stacks.ErrStackNotFound` / `ErrProtectedStack` / `ErrNotDeployed` / `ErrStillRunning` / `ErrNotOrphaned` | controller/internal/stacks/stack_errors.go | sentinels | the API's stop/start/restart/update, remove and delete status code (`api.stackOpStatusFor`, R-569) | 404 / 403 / 409 / 409 / 409. Only the producers in manager.go and delete.go carry them; a new refusal on those paths must carry one or it answers 500 |
|
||||
| `backup.ErrOffsiteQuota` | controller/internal/backup/offbox.go | sentinel | `ClassifyOffsiteFailure` telling a quota over-run apart | The other arms of that switch stay TEXT matches on purpose — they are restic's and ssh's own English output, which we neither write nor translate |
|
||||
| `monitor.WarnKind*` + `HealthReport.addWarning` / `WarningKindAt` | controller/internal/monitor/healthcheck.go | `(text, kind string)` | a health warning whose PLACEMENT the dashboard decides | Internal only: `internal/report/builder.go` copies Status/Issues/Warnings, so kinds never reach the hub (pinned) |
|
||||
| `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
|
||||
|
||||
@@ -92,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 |
|
||||
| `RecordedUnitForStack` + `RecordedAddress` | controller/internal/backup/offbox_placement.go | `(stack) (RecordedPlacement, RecordedAddress, bool)` | Reading back the address + data folder a backup recorded, for a reinstall prefill | Local file reads over every readable namespace root — **no network, no restic, no restore**; it exists for the NOT-INSTALLED case where `GetStackHDDPath` is `""`. **`RecordedAddress.Known()` requires BOTH halves:** an absent `SUBDOMAIN` makes the live deploy path fall back to the CATALOG default (`stacks/deploy.go:88-90`), and offering that back as "what your backup says" is a fabricated fact |
|
||||
| **`Metadata.For(lang)` + `LocalizeStacks` / `LocalizeStackPtr` / `Stack.MetaFor` (R-560, v0.257.0)** | controller/internal/stacks/metadata_i18n.go | `(lang string) Metadata`; `([]Stack, lang) []Stack`; `(*Stack, lang) *Stack` | THE only way a page may reach catalog COPY — the app description, tagline, use cases, first steps, prerequisites, deploy-field labels and descriptions, optional-config text, integration labels, data-path labels and the initial-credentials note | Reading `stack.Meta.<copy>` off the manager renders HUNGARIAN to an English household, with no error and nothing wrong-looking on the page — `TestNoDirectMetaCopyReadOnPages` keeps the named, reasoned allow-list of every direct read in `internal/web`. **`For("hu")` is the parsed struct with `I18n` cleared** and nothing else (pinned over all 53 real catalog files), so a translation cannot change the Hungarian product. **Fallback is FIELD BY FIELD** — absent or blank English shows Hungarian, which is what makes a half-translated app shippable. **Lists replace WHOLE; every other list is matched by its own key** (`env_var`, option `value`, `match_group`, `target`, `path`) — position matching mistranslates silently the first time a field is inserted. **It never writes through:** the metadata is shared by concurrent requests, so an in-place merge leaks one household's language into another's page |
|
||||
| `AppInfo.WithAddress` (R-498) | controller/internal/stacks/appinfo_address.go | `(subdomain, domain string) AppInfo` | Filling the catalog copy's `<label>.DOMAIN` / bare `DOMAIN` placeholder with the household's real address (effective subdomain + customer domain) | Returns a COPY (the metadata is shared); `\bDOMAIN\b` leaves `SUBDOMAIN`/`MAIL_DOMAIN` alone; no domain → unchanged. Apply AFTER `LocalizeStackPtr`. The deploy page still fills its post-deploy steps in JS (`deploy.html`), `knownLoginLine` its own line |
|
||||
| `Metadata.HasDeployField` | controller/internal/stacks/metadata.go | `(envVar string) bool` | "Does this app have somewhere to PUT a recorded value?" | **13 of 53 templates declare `HDD_PATH`; 40 do not** (measured 2026-08-21). For the 40 a recorded placement is a FACT TO STATE, never a value to write into a field that does not exist |
|
||||
| `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention |
|
||||
| `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
|
||||
@@ -110,7 +113,6 @@
|
||||
| `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback |
|
||||
| `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) |
|
||||
| `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) |
|
||||
| `atomicWriteFile` | controller/internal/setup/handlers.go | `(path, data, perm) error` | setup-wizard writes | Same bind-mount fallback caveat |
|
||||
| `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand |
|
||||
| `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops |
|
||||
| `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
|
||||
@@ -122,7 +124,9 @@
|
||||
| `infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount` | controller/internal/infra/samba.go | consts | single source of truth for the samba container identity | the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (`felhom-samba`) is NOT the stack name (`samba`) — `EffectiveProtected` needs the container one |
|
||||
| `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 |
|
||||
| `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot |
|
||||
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE R-82 multi-tier backup schedule — several whole-guest tiers (local daily + PBS weekly) reconciled into ONE quiesce window | **Both tiers due ⇒ ONE stop/start pair**, never two (two = two app outages for one night). Tiers run SEQUENTIALLY (vzdump holds a guest lock) and the app stays down until the LAST tier snapshots — resuming earlier loses app-consistency on the DR tier. Order is fast-first (agent advertises primary first) or downtime blows up. `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
|
||||
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE multi-tier whole-guest backup schedule (local daily + PBS weekly) | **One stop per tier** (`09` §3 decision 156, v0.301.0 — REVERSES R-82's one window for both tiers): a window backs up only the FIRST due tier and resumes the apps at its `snapshotted`; another due tier waits for a later cycle. A tier that refuses to start (BUSY or an error) lets the next tier try in the same window. Order is agent order (primary first). `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
|
||||
| `quiesce.InFlightProber` + `Loop.anotherTierInFlight` · `agentapi.Client.BackupJobsInFlight` (R-921) | controller/internal/quiesce/precheck.go · controller/internal/agentapi/backup_tiers.go | `BackupJobsInFlight(ctx) ([]string, error)` | Check first, stop second: no app is stopped for a tier the agent will refuse because ANOTHER tier's job is in flight | OPTIONAL interface (fakes without it keep the old path). Fails toward backing up on any read error. **It cannot see the agent's host-wide heavy-op gate** (OS step, restore-test, fstrim — no endpoint serves it): that refusal is met by the BUSY path's immediate resume (`TestR921_BusyRefusalResumesAtOnce`). The window's OWN tier in flight is not skipped (the agent answers with the running job) |
|
||||
| `quiesce` whole-guest ledger — `Loop.pressOwesNight` / `recordWholeGuestSuccess`, `WithManualTrigger` / `IsManualTrigger` (R-899) | controller/internal/quiesce/nightowed.go | `pressOwesNight(led, target) bool`; `WithManualTrigger(ctx) ctx` | **A household press never cancels the night's whole-guest backup** (operator ruling 2026-10-08) | The ledger (`whole-guest-ledger.json` beside the marker) records SUCCESSES only, per tier, press vs scheduled — it decides due-ness and is never evidence that a copy exists (the agent's storage answers that). An „owed" tier never fires the window gate's safety valve (`withoutOwed`/`gateAge`). The press mark rides the context to the agent adapter (`agentapi.StartBackupForTrigger` → `trigger=manual`), so the agent runs no OS leg after a press. |
|
||||
| `quiesce.failureBreaker` + `Loop.dropBackedOffTiers` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `blocked/recordFailure/recordSuccess(target, now)`; `backoffFor(n) time.Duration` | **R-88** — a tier whose backups keep failing stops re-quiescing. Backoff 15m→30m→1h→2h→4h (cap), reset on success | **It gates the QUIESCE, not the backup** — the harm was never the failing backup, it was the app outage taken to attempt it, so backed-off tiers are dropped from the due set BEFORE any stack is stopped. **Per TARGET** — a broken offsite tier must never suppress a healthy local one (`TestBreaker_OneFailingTierDoesNotSuppressAHealthyOne`). **Never permanent** — the cap bounds the retry INTERVAL, it never stops retrying; a latched breaker is a silent backup outage, worse than the loop it replaces. **`TriggerNow` is never gated** (it already bypasses due-ness and the window gate), though a manual run still RECORDS its outcome. **`stillRunning` is NOT a failure** — a first full offsite snapshot legitimately runs for hours. State is **in-memory on purpose**: a restart forgets the backoff and re-attempts, which is the cheap direction to fail. Log the deferral ONCE when armed, never per tick. |
|
||||
| `quiesce.TierNotifier` + `Loop.SetTierNotifier` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `BackupFailed(tier,msg,err)` / `BackupRecovered(tier,msg)`; `SetTierNotifier(n)` INIT-ONLY | **R-97a** — the whole-guest backup tier reports its outcome to the hub | A **seam, not an import** — quiesce keeps no dependency on `internal/notify` (same reason `windowStartFn` is injected). Wired by a setter because main.go builds the notifier AFTER the loop; `nil` = unprovisioned guest, not an error. **Edge-triggered:** failure fires only when the breaker ARMS (`n == 1`), never per retry — the cadence is 15m/30m/1h/2h/4h and an event per attempt is an inbox nobody reads. Recovery rides `recordSuccess`'s existing bool. **Event types are OPERATOR-ONLY** (`whole_guest_backup_failed`/`_recovered`, hub >= v0.78.0) — NOT `backup_failed`, which has a customerMessages entry AND sits in live `enabled_events`, so it would email the CUSTOMER about a backup they cannot act on. `WholeGuestBackupDetails.Tier` is load-bearing: the hub keys its per-tier cooldown on it. |
|
||||
| `quiesce.Loop.SuppressedStacks` + `markQuiesced` / `markUnquiesced` | controller/internal/quiesce/suppress.go | `() map[string]bool` (nil-safe on a nil *Loop) | **R-97b** — an app THIS controller stopped for a backup is not a fault | Consumed at the SINGLE derivation point `classifyRunStates` (which computes both the banner dead-list and the notifier Down-set — keep it one place). **Cycle-keyed, not state-based:** v0.164.0's `!= StateStopped` filter cannot see an app caught MID-RESTART (`starting`/`unhealthy`), which is how BookStack alarmed on 2026-07-27. The window (`quiesceAlarmGrace` = 180 s, derived from the deploy flow's 120 s health timeout and Mealie's 60 s start_period) **EXPIRES** — permanent suppression turns a loud false alarm into a silent real one. Open-ended while the cycle runs (a first offsite snapshot legitimately takes hours). **This set alone is NOT the whole answer** — see `AppStopGuard.SuppressedStacks` (R-330) for the per-app operations; `classifyRunStates` consumes the union of both. |
|
||||
@@ -159,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.Tier2MirrorDirsForApp` / `RemoveTier2Mirrors` + `settings.DeleteAppBackupPrefs` (v0.240.0, R-474 / R-486) | controller/internal/backup/r474_remove_mirrors.go | `(stack) []string`; `(stack, dirs) []string` | deleting an app's backups on removal — the Tier-2 mirror lives on ANOTHER drive, outside RemoveStack's per-app base; the same dirs size the backup card (R-485) | Read the mirror dirs BEFORE the prefs are forgotten. Deletes only `<root>/backups/secondary/<stack>` for a known root; never `_shares`, never a path that merely cleans to it. **The Tier-2 RECORD goes only with `remove_backups`** (R-486) — a removal that keeps the backups must keep the record, or the mirror is unrestorable. Pinned by `TestR474_RemoveHandlerDeletesUnitMirrorAndPrefs` + `TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo` |
|
||||
| `appbackup.dbTypeForImage` (R-484, v0.240.0) | controller/internal/appbackup/dbservices.go | `(image) (DBType, bool)` | the ONE place an image is judged a database — nightly dumps, pre-update safety dump, DB-only replay | Derived Postgres images (`postgis`, `pgvector`, `timescaledb`) are Postgres. A new engine image goes HERE and in `dbservices_test.go`'s table, never in a second matcher |
|
||||
| `backup.Manager.HoldAfterFailedUpdate` / `RunAppBackupNow` / `WriteUpdateSafetyDump` / `UpdateBusy` (v0.237.0) | controller/internal/backup/update_guard.go | see file | the update's hold, per-app backup-now, safety dump, busy check | The hold is `settings.RestoreHold` with `Reason: update_failed` — SAME store and gate as R-379, never a second map. `RunAppBackupNow` composes the nightly legs for ONE app (admission, DB dump, volume dump, capture, Tier-2) — do not write a second backup orchestration. A successful unit restore lifts an UPDATE hold only. **`isHeld` is ALSO true while a guarded update is moving the app (`SetUpdatingCheck`, v0.238.1)** — found live: the periodic capture overwrote a primary unit during a health wait |
|
||||
| `backup.Manager.HoldAfterFailedUpdate` / `RunAppBackupNow` / `WriteUpdateSafetyDump` / `UpdateBusy` (v0.237.0) | controller/internal/backup/update_guard.go | see file | the update's hold, per-app backup-now, safety dump, busy check | The hold is `settings.RestoreHold` with `Reason: update_failed` — SAME store and gate as R-379, never a second map. `RunAppBackupNow` composes the nightly legs for ONE app (admission, DB dump, volume dump, capture, Tier-2) — do not write a second backup orchestration. A successful unit restore lifts an UPDATE hold only. **`isHeld` is ALSO true while a guarded update is moving the app (`SetUpdatingCheck`, v0.238.1)** — found live: the periodic capture overwrote a primary unit during a health wait. **R-645: every night leg (DB dump, volume dump, capture, Tier-2) also skips an app whose pin is not what it runs (`versionSkip`, wired by `SetVersionCheck` → `stacks.Manager.PinNotRunning`); unknown never skips. The page line is `VersionSkipFor`** |
|
||||
| `stacks.Manager.memoryVerdict` (v0.237.0) | controller/internal/stacks/deploy.go | `(newReq, newLimit, releasedReq, releasedLimit int) (refusal, warning string)` | the deploy's memory check, shared with the update | An update RELEASES the app's current request first. Deploy passes `0, 0` and is byte-identical in wording and log line |
|
||||
| `Syncer.SetRenderPlanFn` + `renderSource` (v0.235.0) | controller/internal/sync/sync.go | `func(appName string) stacks.RenderPlan` | the catalog render table | **NIL-SAFE: no seam = copy verbatim = the old product.** Catalog images == pin → verbatim (fixes flow + self-healing, both deliberately kept); differ → the WHOLE stored definition, **never a ref substitution into a newer template** (`wger 2.6`). `.felhom.yml` always verbatim (R-458). The syncer must NEVER read app.yaml. Re-reads the applied file before writing it — a test caught it writing an empty compose over a live app |
|
||||
| `Stack.CatalogImages` vs `Stack.TemplateImages` (v0.235.0) | controller/internal/stacks/manager.go | both `map[string]string` | badge input vs "what the next `up -d` gives this app" | **THE TRAP: same type, same shape, opposite meaning after the freeze.** `TemplateImages` reads the LIVE (possibly frozen) compose file; `CatalogImages` reads the syncer's clone. `web.compareInstalledToTemplate` MUST use `CatalogImages` or it answers „Naprakész" on exactly the apps that are behind, with every test green. Red-proved |
|
||||
@@ -243,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) |
|
||||
| `notify.DiskAlert` / `DiskAlertKind` / `DiskAlertKind.Severity()` | controller/internal/notify/notifier.go | `NotifyDiskHealthDegraded(DiskAlert)` | The disk-health alert payload + its five Hungarian message shapes | The notifier owns customer copy — pass a `DiskAlert`, never a pre-formatted string, or Hungarian scatters across packages. `Severity()` is the ONE mapping kind→hub severity and is exported so any package can assert the contract instead of duplicating the literal |
|
||||
| `Notifier.PushEventHousehold` (v0.309.0, R-521 F7) | controller/internal/notify/notifier.go | `(eventType, severity, message, household, details)` | A producer that renders BOTH languages itself because its sentence carries figures it computed (fillwatch) | Prefer `pushEventMsg` (one key, both renderings) inside notify; this is for a sentence built outside. `household` "" for a Hungarian household. Registered in the R-329 severity contract (`severityArg`) |
|
||||
| `Notifier.Notify*` convenience methods | controller/internal/notify/notifier.go | typed wrappers (backup/DB/storage/channel/DR…) | Standard events | Add a typed wrapper rather than raw PushEvent calls |
|
||||
| `report.BuildReport` / `Pusher.Push` | controller/internal/report/builder.go + pusher.go | periodic hub report | Box→hub reporting | ACK carries `config_version` → `ConfigRefresher.Reconcile` |
|
||||
| `report.Trigger` (`NewTrigger`/`Fire`/`Run`) | controller/internal/report/trigger.go | `Fire()` after a hub-relevant user action | THE out-of-cycle report push (v0.139.0) — fire via `api.Router.reportPushNow` / `web.Server.reportTriggerNow`, both nil-safe | Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the `internal/sync` REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit |
|
||||
@@ -254,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 |
|
||||
| `bootstrap.MaybeIngest` / `RefreshConfig` | controller/internal/bootstrap/bootstrap.go | bootstrap.json → controller.yaml | Day-0 + refresh | Overwrites controller.yaml, NEVER settings.json |
|
||||
| `api.GracefulSelfRestart` | controller/internal/api/selfrestart.go | `(logger)` | Controller self-restart | Detached exit; bootstrap unit re-runs the image |
|
||||
| `settings.NotificationGroups` + `NotificationGroupState` / `NotificationPrefs.GroupState` (R-388 slice 1, v0.309.0) | controller/internal/settings/notif_groups.go | `(enabled []string, key) "on"\|"off"\|"custom"` | THE `08` §8.1 group membership of every customer-switchable detector — the notification page's three switches | A group is a VIEW over `enabled_events`, never a stored field. A new detector with a page switch MUST join exactly one group (`TestR388_EveryPageSwitchIsInExactlyOneGroup` fails otherwise). The save rule lives in `Server.applyNotificationGroupSwitches`: only a MOVED group switch overrides the per-detector switches — an untouched „egyedi" group is saved back unchanged |
|
||||
| `Settings.AddPendingEvent/DrainPendingEvents` | controller/internal/settings/settings.go | offline event queue | Events while hub unreachable | — |
|
||||
| `Manager.SetUnitNotify` + `UnitSpace` (R-158/R-167, v0.191.0) | controller/internal/backup/recovery_unit.go | `(func(stack string, err error, *UnitSpace))` | THE per-app Tier-1 recovery-unit capture failure alert — fires PER APP from `captureAllRecoveryUnits`, loop continues | **OPERATOR-TIER** (`recovery_unit_capture_failed`, in the hub's `operatorOnlyEvents`). **NEVER route it to `backup_failed`** — that type is in `DefaultEnabledEvents` and carries Hungarian copy, so it emails the CUSTOMER about a failure they cannot act on (D-c; R-158's own proposal said `backup_failed` and D-c overrides it). `UnitSpace` is **nil when the target filesystem is unreadable** and renders as *"unavailable"*, never as zeros — "0 GB free" and "we could not look" are opposite diagnoses. No controller-side cooldown: the hub owns it |
|
||||
| `Manager.beginRunSummary` / `noteFailure` / `noteAttempted` / `emitRunSummary` / `SetRunSummaryNotify` (R-182, v0.194.0) | controller/internal/backup/runsummary.go | `(kind, runID) func()` / `(app, leg, reason)` / `(RunSummary)` | **THE per-run operator digest.** One `backup_run_failures` event at the end of a run listing every failed app, its leg and its reason — emitted ONLY when something failed | **The RECORD and the NOTIFICATION are different things and must stay so.** The per-app `recovery_unit_capture_failed` event is the record (hub routes it *record-only*, stored + logged every time); this digest is the notification. Before R-182 one event was both, and did neither: nine arrived, two were mailed, seven vanished before `LogNotification`. **Lifetime is `admissionSet`'s exactly** — absent collector means "no run in flight", never a stale answer. **A refusal is noted ONCE, inside `admitApp` where the verdict is taken**, not at the three legs that consult it: R-181's one-verdict-covers-all-three contract makes per-leg noting produce "2 of 1 apps failed". **Deliberate skips (disconnected / decommissioned) must NEVER be noted** — they have their own alert and a nightly digest about an unplugged drive is an ignored digest. **A clean run emits NOTHING**; silence is safe only because the hub's deadline check (`monitor/deadline.go:396,417`) raises a missed backup from report freshness independently — if that is ever weakened this design loses its footing. **`run_id` is unique per real run** (so the hub's 1-h cooldown cannot collapse a manual run into the nightly one) and **deliberately EMPTY on the periodic refresh sweep**, which must stay under that cooldown or a polled status page becomes a mail flood |
|
||||
| `Manager.admitApp` / `beginAdmissionRun` / `decideAdmission` / `estimatedWriteBytes` (R-181, v0.193.0) | controller/internal/backup/admission.go | `(stackName) bool` / `() func()` | **THE reserve gate. Call it before ANY per-app backup write** — one verdict per app per run, covering the DB dump, the volume dump and the unit capture (all three write under one per-app root) | **Decided LAZILY at the app's first write, never once at run start** — app A's dump can put app B under the reserve, so a run-start verdict reads a disk that no longer exists. **Never re-decided between an app's own legs**: that is exactly the split R-181 closed (bulk written, capture refused). **Reset per run** via the closer `beginAdmissionRun` returns. **Must sit ahead of `DumpAppVolumesSafe`**, which stops the stack as its first act — a refusal decided inside it has already bounced the app. Fires **exactly one** `unitNotify` per refused app per run. Nil admission set (periodic status refresh) → decides fresh, which is still once per app per sweep. Wiring pinned by an **AST walk** in `TestAdmission_IsWiredIntoEveryProductionWriteLeg`, not `strings.Contains` |
|
||||
| `Manager.floorVerdict` + `FloorUsedPercent`/`FloorFreeGiB` / `ErrCaptureFloor` / `floorReason` (R-165 B2 v0.192.0, size term R-181 v0.193.0) | controller/internal/backup/recovery_unit.go | `(*UnitSpace, estGiB float64) (*UnitSpace, floorReason)` | The pure two-question predicate behind `admitApp`: is the filesystem already below the reserve (`floorHeadroom`), and would THIS app's write take it below (`floorSize`)? | **Headroom is about the FILESYSTEM, never a per-unit cap** — a size cap is R-163 rebuilt inside one volume; the size term bounds the *delta*, not the unit. **REFUSES, never deletes:** nothing here is generational (a unit is one fixed path per app, a DB dump one fixed name), so pruning could only destroy a DIFFERENT app's only local copy — **never repurpose `pruneStalePrimaryDirs`**, which removes ORPHANED dirs from an app that moved drives and has no notion of age. Two terms (97% / 1 GiB) in `fillwatch`'s shape, deliberately BEYOND its critical band (95% / 2 GiB) so the customer is always warned first — pinned by `TestFloorSitsBelowTheCriticalWarningBand`. **`estGiB == 0` degrades to headroom-only on purpose** — refusing an app with no history makes the FIRST backup the one that can never happen. A nil reading neither refuses nor warns (§8.4). Inject `unitSpaceFn` in tests rather than manufacturing occupancy on a real disk |
|
||||
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space |
|
||||
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space. **Both languages (v0.309.0, R-521 F7):** the sentence is the bundle's `event.fill.*`, the household's rendering rides as `message_customer` via `Notifier.PushEventHousehold` (`SetLang`; `SetExtra` takes the language); a built-in area carries a `LabelKey`, a household's own drive label is used as written. Hungarian pinned byte-for-byte by `TestMessageHungarianWireText` (v0.309.0 dropped the formal „Kérjük," — R-516) |
|
||||
|
||||
### Scheduler / time / UI
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `Scheduler.RunNow` / `scheduler.OnDemand` | controller/internal/scheduler/scheduler.go | `RunNow(name) error`; `OnDemand(ctx) bool` | Run a registered job once now (operator action `run_job`, decision 185) | Refuses unknown and already-running jobs; a job that checks due-ness reads `OnDemand(ctx)` to skip that check on demand |
|
||||
| `report.OperatorActions` | controller/internal/report/opactions.go | `Reconcile(reply)` → results in the next report | Hub-requested actions in the report reply — the `selftail.go` pattern plus results | The action table is CLOSED and pinned by `TestOpActions_ClosedList`; once per id in memory; every entry must be safe to repeat |
|
||||
| `web.sessionFingerprint` / `loadSessions` / `saveSessionsLocked` / `saveSessionsRevokingLocked` | controller/internal/web/session_store.go | dashboard sessions on disk (R-35) | Any session lookup | The map is keyed by sha256(cookie), NEVER the cookie — hash with `sessionFingerprint` first; a path that ENDS sessions uses the revoking save |
|
||||
| `Scheduler.Every` / `Daily` | controller/internal/scheduler/scheduler.go | `(name, interval/"HH:MM", fn)` | ALL background jobs | Daily is Europe/Budapest, DST-safe (`nextDailyRun` avoids Add(24h)); register in main.go block (§5) |
|
||||
| `Scheduler.UpdateDaily` | controller/internal/scheduler/scheduler.go | `(name, "HH:MM") bool` | Retime a daily job at runtime (no restart) | Per-job buffered `resched` chan + select case in `runDailyJob`; false (WARN) on invalid time / unknown-or-non-daily name; read `Schedule` under the mutex in the loop |
|
||||
| `backupwindow.*` (LegTimes / GateWindow / EffectiveWindow / ParseHHMM / FmtHHMM / Valid) | controller/internal/backupwindow/backupwindow.go | pure `string`↔`int` | Backup-window arithmetic (v0.168.0) | Offsets (W+60m/W+105m, gate W+2h..W+6h) are CONSTANTS — derived, never stored; wrap-safe modulo 1440; `EffectiveWindow(settings, yaml)` = settings>yaml>"02:30" |
|
||||
@@ -273,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 |
|
||||
| `huTemplateSource` (test) | controller/internal/web/i18n_wiring_test.go | `(name) ([]byte, error)` | A test that asserts a template's Hungarian WORDING | Reads the expansion, not the raw file — since slice 1 the wording lives in hu.json. Never use `templateFS.ReadFile` for a wording assertion |
|
||||
| `Server.langFor` / `templatesFor` / `addLanguageData` | controller/internal/web/server.go + i18n_web.go | request → lang → template set | Rendering a page in the household's language | Only `executeTemplate` is language-aware; `s.tmpl` is the Hungarian set; Go-side titles via `data["TitleKey"]` |
|
||||
| `bootstrap.Unconfigured` / `WaitForConfig` / `WaitingHandler` (v0.308.0, R-554) | controller/internal/bootstrap/waiting.go | `WaitForConfig(ctx, ln, configPath, cfg, logger, pull, every, version) *config.Config` | The ONLY unconfigured-box path: the bilingual waiting page + a retry of `MaybeIngest` every minute; returns the configured config (nil on stop) | Replaces the deleted setup wizard. `/api/health` stays 200 while waiting; a nil listener still retries |
|
||||
| `i18n.FormatDateTime` / `FormatDate` / `FormatMonthDayTime` / `FormatDecimal` / `LocalizeDecimals` | controller/internal/i18n/format.go | `(lang, …) string` | EVERY date or decimal number shown on the dashboard (R-562, v0.308.0): hu „2026. 10. 10. 15:04" + decimal comma, en „2026-10-10 15:04" + point | Pass the time already in Budapest; `LocalizeDecimals` is only for a size another package already formatted („1.5 GB") — new code calls `FormatDecimal`. Templates use the bound names in `formatFuncs(lang)` (funcmap.go: `fmtTime`, `fmtTimeStr`, `fmtGB`, `fmtBytes`, `numText`, …); Go page data uses `fmtLocalTime(lang, t)` / `fmtRFC3339Local(lang, s)` / `fmtSizeBytes(lang, n)`; page scripts use `fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` (layout.html). Never a `"2006-01-02 15:04"` literal for display. CSS widths keep `printf "%.1f"` |
|
||||
| `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) |
|
||||
| `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` |
|
||||
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input; setup wizard has its OWN csrf (§6) |
|
||||
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input |
|
||||
| `LogBuffer` + `Lines(maxBytes)` | controller/internal/web/logbuffer.go | ring buffer io.Writer | In-memory log capture for the debug UI + the report `controller_log_tail` source | v0.116.0: ALWAYS constructed (any logging.level) — the logger is `MultiWriter(LevelFilterWriter(stdout, level), ring)`; `Lines` drops OLDEST to honor the byte budget |
|
||||
| `logx.Debugf/Infof/Warnf/Errorf` | controller/internal/logx/logx.go | `(l *log.Logger, format, args…)` | ALL NEW leveled log lines (the v0.116.0 sweep standard) | routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3) |
|
||||
| `web.LevelFilterWriter` | controller/internal/web/levelfilter.go | `NewLevelFilterWriter(w, minLevel)` | stdout leveling under the always-on ring | untagged lines parse INFO; always reports full length written |
|
||||
@@ -310,7 +321,7 @@
|
||||
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` |
|
||||
| `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) |
|
||||
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
|
||||
| `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else |
|
||||
| `config.LoadPermissive` | Skips validation — first boot only (customer.id/domain may be unset until `bootstrap.WaitForConfig` gets the configuration, R-554) | `config.Load` everywhere else |
|
||||
| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
|
||||
| any settings getter/setter or helper that ends in one (`m.note`, `boxLang`) INSIDE a `settings.UpdateOffboxStatus`/`UpdateCrossDriveStatus` callback (R-578) | The callback runs under the settings WRITE lock and `sync.RWMutex` is not reentrant — it DEADLOCKS holding the lock and wedges settings.json for the box | Resolve the value BEFORE the callback (`lang := m.boxLang()`); `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` (controller/internal/settings/r578_callback_lock_gate_test.go) convicts it in every package |
|
||||
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
|
||||
@@ -351,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 |
|
||||
| `bootrecon.StackProvider` (R-52, v0.156.0) | controller/internal/bootrecon/bootrecon.go | `*stacks.Manager` (GetStacks/StartStack/RefreshStatus) | `fakeStacks` counts StartStack per app; the load-bearing assertion is the NEGATIVE — a zero-container stack (a UI Stop = `compose down` = containers removed) must record **0** starts, while a boot orphan (containers present, Exited) records exactly 1. `Reconciler.sleep` is injected so the 30 s gap costs nothing |
|
||||
| `bootReconcileFn` + `runBootReconcile` (package-main seam, v0.156.0) | controller/cmd/controller/main.go | `bootrecon.New(mgr, logger).Run` | controller/cmd/controller/bootrecon_wiring_test.go. **The wiring itself is asserted by an AST walk** over `func main()`, not a `strings.Contains` — the substring version passed its own red-proof because a commented-out call still contains the string. Comments are not callers |
|
||||
| `crashboot.Probe` / `crashboot.Grace` (R-856) | controller/internal/crashboot/crashboot.go | `crashboot.AgentProbe(agentClient.CrashGuard)` (GET /host/crash-guard) | `fixed(Fact, err)` in crashboot_test.go. The dead-app check gates on `bootGrace.Within(now)` — 90 s normally, `CrashGrace` (15 min) when the HOST's last boot was unclean and within `BootWindow` of the controller start. **Unknown (no agent, 404, no state) is a normal boot** — never a longer silence on a guess. Asserted by AST walk in cmd/controller/r856_wiring_test.go |
|
||||
| `classifyRunStates` (pure fix-3 derivation, v0.164.0) | controller/cmd/controller/main.go | `([]stacks.Stack, quiesced, failedRestart map[string]bool, now time.Time)` → `(dead []web.DeadApp, states []notify.AppRunState)` | classify_runstates_test.go. **THE single fix-3 rule: down = `(IsDownState(st.State) || st.CrashLooping(now)) && !userStopped && !quiesced`.** **`quiesced` is a UNION of TWO suppression sets** (R-330, v0.224.0): `quiesce.Loop.SuppressedStacks()` (whole-guest vzdump/PBS) and `backup.AppStopGuard.SuppressedStacks()` (per-app volume dump / offbox reconstitute / `.fab` export), merged by `unionSuppressed` in `scanDeployedAppRunStates`. **Adding a third way to stop an app means adding its set here** — R-330 was 61 false customer e-mails caused by exactly that omission, with a working suppressor sitting three lines away. C9-F2 (v0.183.0) added the crash-loop term: `restarting` is NOT in `IsDownState` and must not be — adding it alarms on every deploy and update fleet-wide — so a SUSTAINED restarting run (`stacks.crashLoopAfter` = 5 m, above the 120 s deploy timeout, Mealie's 60 s start_period AND R-97b's 180 s grace) becomes down instead. `now` is injected so the threshold is a testable contract. A deliberate UI stop (`compose down` → zero containers → StateStopped, I1) must not alarm — banner OR email — while faults (Exited/Degraded) alarm byte-identically; I2 (P2 census: all catalog services `unless-stopped`) is why a crash never rests at stopped. **Do NOT touch `IsDownState`** (other callers rely on stopped=down) and do NOT filter in `buildDeadAppAlerts`/`NotifyAppStartFailures` — one derivation point. If I1 or I2 changes, revisit the suppression |
|
||||
| `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go |
|
||||
| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
|
||||
@@ -401,13 +413,12 @@ Cross-repo edges:
|
||||
|
||||
| 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` |
|
||||
| humanizeBytes ×3 | controller/internal/appbackup/appdata.go `HumanizeBytes` (canonical) + private twin; controller/internal/appexport/estimate.go `humanizeBytes`; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge) |
|
||||
| copyFile ×2 | controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go |
|
||||
| dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` |
|
||||
| timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) |
|
||||
| CSRF ×2 | controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled |
|
||||
| Budapest timezone loader ×4 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` vs controller/internal/api/kept_install.go `budapest` (v0.277.0, the kept choice's dates) (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
|
||||
| JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` |
|
||||
| Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) |
|
||||
|
||||
+132
-114
@@ -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 +
|
||||
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,
|
||||
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)"
|
||||
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
|
||||
@@ -298,10 +298,15 @@ self-reported failure gets:
|
||||
|
||||
- **Rendben** — clean.
|
||||
- **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
|
||||
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.
|
||||
|
||||
`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
|
||||
the Postgres import is a belt only; MariaDB DDL is not transactional, which is why the rollback
|
||||
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
|
||||
`PlaceOffsiteRestore` resolve the destination with `Manager.GetAppDrivePath` — **the same
|
||||
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
|
||||
> 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
|
||||
> 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
|
||||
> 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)* +
|
||||
@@ -1878,6 +1893,10 @@ appear in the restore dropdown with per-app snapshot filtering.
|
||||
|
||||
**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
|
||||
- **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
|
||||
- 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
|
||||
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 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
|
||||
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
|
||||
@@ -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
|
||||
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`.
|
||||
**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 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
|
||||
@@ -2602,6 +2630,10 @@ Continuously monitors registered storage paths for disconnection/reconnection (p
|
||||
| Protected containers | — | not running |
|
||||
| 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:
|
||||
- Path doesn't exist → 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) |
|
||||
| `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`) |
|
||||
| `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 |
|
||||
| `controller_started` | info | Controller process starts |
|
||||
| `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
|
||||
|
||||
Events the customer receives notifications for (configurable in settings):
|
||||
`backup_failed`, `db_dump_failed`, `disk_warning`, `disk_critical`, `storage_disconnected`, `node_down`, `health_critical`, `expected_backup_missed`, `expected_dbdump_missed`
|
||||
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`, `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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -3217,16 +3270,52 @@ Five sections:
|
||||
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
|
||||
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_*`).
|
||||
|
||||
### 8a. Turning the machine off — „A gép kikapcsolása" (v0.310.0, R-998)
|
||||
|
||||
Beállítások → Rendszer, after the restart cards. Dashboard login only (`RequireAuth` + CSRF; family members' app logins
|
||||
never reach the dashboard). The household types **KIKAPCSOLÁS** / **TURN OFF** (the server also takes the unaccented
|
||||
spelling); `POST /api/server/poweroff {"confirm": "…"}` → the agent's `POST /host/poweroff` (agent ≥ 0.155.0, the
|
||||
`host_poweroff` feature row). The agent shuts this guest down, then powers the host off; switching it on again needs the
|
||||
machine's power button — the card says so. Refusals come back as one household sentence: a backup/restore/update the
|
||||
controller sees itself, or the agent's reason (`too_soon_after_boot`, `already_requested_this_boot`, `backup_running`,
|
||||
`other_guests_running`, …). An agent known to be older hides the button and shows one line. The hub holds its silence
|
||||
alarms for 14 days (hub v0.149.0). Design: felhom.eu `documentation/architecture/14-host-power-off.md`. Tests:
|
||||
`internal/web/poweroff_handlers_test.go`.
|
||||
|
||||
**„A Felhom újraindítása"** (was „Kiszolgáló újraindítása", R-999) restarts the guest — the dashboard and the apps —
|
||||
not the machine; its text now says so. `/api/server/reboot` is POST only (R-983, one of its seven).
|
||||
|
||||
### 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/`)
|
||||
|
||||
Periodic JSON push (default every 15 min) to the central felhom-hub service:
|
||||
- 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
|
||||
- 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
|
||||
@@ -3311,77 +3400,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)
|
||||
- 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`.
|
||||
|
||||
#### Setup Mode Detection (`internal/setup/setup.go`)
|
||||
|
||||
`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:
|
||||
- `:8080` — behind Traefik (accessible via domain, e.g. `https://felhom.example.com`)
|
||||
- `:8081` — direct HTTP (accessible via LAN IP, e.g. `http://192.168.0.100:8081`)
|
||||
|
||||
#### Wizard Flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ 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 |
|
||||
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,
|
||||
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)`,
|
||||
i.e. `customer.id` empty — now serves ONE page on its listener, in both languages („A Felhom még nem érte el a
|
||||
központot" / "Felhom has not reached the central service yet"), answers `/api/health` with
|
||||
`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ő
|
||||
varázsló" trigger are gone; `web.setup_listen` is still read so an old `controller.yaml` loads, and is unused.
|
||||
Pinned by `TestWaitForConfig_ServesWaitingPageThenStarts`.
|
||||
|
||||
#### Local Infra Backup (`internal/backup/local_infra.go`)
|
||||
|
||||
@@ -3392,11 +3422,15 @@ The controller writes infrastructure snapshots to **every connected drive** afte
|
||||
- `{timestamp}-backup.json` + `{timestamp}-metadata.json` pairs (timestamp format: `20060102T150405Z`)
|
||||
- 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`)
|
||||
|
||||
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):
|
||||
|
||||
@@ -3412,29 +3446,11 @@ They are deliberately **per-secret**, not one generic "reveal any named secret"
|
||||
|
||||
### 11. Disaster Recovery
|
||||
|
||||
When a system drive fails and is replaced, the recovery flow uses the setup wizard:
|
||||
|
||||
```
|
||||
1. docker-setup.sh deploys fresh controller with minimal config
|
||||
- With --hub-customer: credentials pre-seeded via env vars
|
||||
- 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
|
||||
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
|
||||
**Adatok visszaszerzése** page (`/recovery`) with the household's recovery code. A box that cannot reach the hub at its
|
||||
first boot waits on the page described in §10.
|
||||
|
||||
**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 +3985,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
|
||||
**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
|
||||
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
|
||||
the apps' own UIs (not ours).
|
||||
|
||||
@@ -4059,8 +4075,17 @@ the apps' own UIs (not ours).
|
||||
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**.
|
||||
`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
|
||||
not-found page** — R-577.
|
||||
carries the cookie into the household setting (§16). **The guest share pages carry the globe too (v0.308.0,
|
||||
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.
|
||||
- **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
|
||||
@@ -4197,13 +4222,6 @@ controller/
|
||||
│ │ ├── builder.go # Hub report builder (all subsystems → JSON)
|
||||
│ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth, parses customer_blocked)
|
||||
│ │ └── 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)
|
||||
│ └── web/
|
||||
│ ├── server.go # HTTP server, routing, static files, catch-all middleware, executeTemplate wrapper
|
||||
@@ -4301,7 +4319,7 @@ monitoring:
|
||||
|
||||
web:
|
||||
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:
|
||||
enabled: true
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
body := mainBody(t)
|
||||
names := callsInMain(t, body)
|
||||
|
||||
if indexOfCall(names, "NotifyBackupFailed") < 0 {
|
||||
if indexOfCall(names, "NotifyInterruptedOperation") < 0 {
|
||||
t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " +
|
||||
"controller died mid-backup and nobody is told (§2.4)")
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
//
|
||||
// R-174 STRENGTHENED THIS. `!= nil` alone is no longer sufficient, because Recover now returns a
|
||||
// non-nil result for a recovery that merely REFUSED starts (an absent data drive) — the drive
|
||||
// gate working as designed. `NotifyBackupFailed` sends `backup_failed`, which is customer-enabled
|
||||
// gate working as designed. `NotifyInterruptedOperation` (R-585; was `NotifyBackupFailed`) sends `backup_failed`, which is customer-enabled
|
||||
// by default (settings.DefaultEnabledEvents), so a nil-only guard would email the customer
|
||||
// "A biztonsági mentés sikertelen!" about an app nothing is wrong with. The guard must consult
|
||||
// Alarming().
|
||||
@@ -156,7 +156,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
}
|
||||
carries := false
|
||||
for _, name := range callsInMain(t, ifst.Body) {
|
||||
if name == "NotifyBackupFailed" {
|
||||
if name == "NotifyInterruptedOperation" {
|
||||
carries = true
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
if !guardedByAlarming {
|
||||
t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " +
|
||||
"recovery that only REFUSED starts (drive absent) would be reported through " +
|
||||
"NotifyBackupFailed, a customer-enabled event type, telling the customer their backup " +
|
||||
"NotifyInterruptedOperation (backup_failed), a customer-enabled event type, telling the customer their backup " +
|
||||
"failed when the drive gate was simply doing its job (R-174)")
|
||||
}
|
||||
}
|
||||
|
||||
+139
-134
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/sockheal"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -35,9 +35,9 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/channelhealth"
|
||||
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||
"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/fillwatch"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
||||
"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/selfupdate"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/setup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
@@ -324,11 +323,25 @@ func main() {
|
||||
system.DebugLogger = logger
|
||||
}
|
||||
|
||||
// --- Setup mode: if no customer ID configured, run setup wizard ---
|
||||
if setup.NeedsSetup(cfg) {
|
||||
logger.Printf("[INFO] felhom-controller %s — setup mode", Version)
|
||||
runSetupMode(cfg, logger)
|
||||
return
|
||||
// --- Not configured yet: wait for the hub (R-554, v0.308.0) ---
|
||||
// The first-boot setup wizard is deleted. A box whose first ingestion failed shows ONE waiting page
|
||||
// (both languages) and retries every minute; once configured it falls through to the normal start
|
||||
// below, on the same listener. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.
|
||||
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) ---
|
||||
@@ -387,7 +400,7 @@ func main() {
|
||||
stackMgr.MigrateEncryption()
|
||||
|
||||
// --- 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
|
||||
// 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).
|
||||
@@ -497,6 +510,11 @@ func main() {
|
||||
// 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,
|
||||
// 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 {
|
||||
logger.Printf("[WARN] [update] %d interrupted update(s) will resume after the backup side is wired: %v", len(resumed), resumed)
|
||||
}
|
||||
@@ -595,6 +613,8 @@ func main() {
|
||||
// updated, not only once it is held — found live in Scenario F, see backup.Manager.isHeld.
|
||||
if backupMgr != nil {
|
||||
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.
|
||||
backupMgr.SetUndoCopyRemover(stackMgr.RemoveUndoCopies)
|
||||
}
|
||||
@@ -690,7 +710,15 @@ func main() {
|
||||
// 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.
|
||||
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 {
|
||||
logger.Printf("[WARN] [appstop] %s — not alarming: %s", appStopRecovery.Message(), appStopRecovery.Detail())
|
||||
}
|
||||
@@ -921,9 +949,23 @@ func main() {
|
||||
// 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
|
||||
// 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 {
|
||||
if time.Since(startTime) < deadAppBootGrace {
|
||||
return nil // still inside the startup settle window
|
||||
if bootGrace.Within(time.Now()) {
|
||||
return nil // still inside the startup settle window (90 s, or ~15 min after a crash boot)
|
||||
}
|
||||
dead, states := scanDeployedAppRunStates(stackMgr, quiesceLoop, appStopGuard, backupMgr)
|
||||
alertMgr.SetDeadAppAlerts(dead)
|
||||
@@ -1047,6 +1089,15 @@ func main() {
|
||||
RecordEscrowKeyHash: sett.SetHubEscrowKeySHA256,
|
||||
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
|
||||
hubPusher.OnPushResponse = func(resp *report.PushResponse) {
|
||||
if resp.CustomerBlocked {
|
||||
@@ -1090,6 +1141,8 @@ func main() {
|
||||
// generation). The web gate reads it on the next request — no restart needed.
|
||||
claimSync := &report.ClaimSync{Settings: sett, Logger: logger}
|
||||
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
|
||||
alertMgr.SetHubPushStatus(func() web.HubPushStatusData {
|
||||
@@ -1139,7 +1192,7 @@ func main() {
|
||||
dbDumpLeg := withLeg(nightchain.LegDBDump, func(ctx context.Context) error {
|
||||
err := backupMgr.RunDBDumps(ctx)
|
||||
if err != nil {
|
||||
notifier.NotifyDBDumpFailed("Adatbázis mentés sikertelen", err.Error())
|
||||
notifier.NotifyDBDumpFailed(err.Error())
|
||||
} else {
|
||||
notifier.NotifyDBDumpCompleted(notify.DBDumpDetails{})
|
||||
}
|
||||
@@ -1243,8 +1296,7 @@ func main() {
|
||||
if err != nil {
|
||||
// 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.
|
||||
notifier.NotifyBackupFailed("Off-box (NAS) mentés sikertelen",
|
||||
backupMgr.OffsiteFailureMessage(err, dur))
|
||||
notifier.NotifyOffboxBackupFailed(backupMgr.OffsiteFailureMessage(err, dur))
|
||||
}
|
||||
})
|
||||
// R-203 (operator half): a completed off-site run could NOT capture a directory an app declares
|
||||
@@ -1342,6 +1394,13 @@ func main() {
|
||||
// path over.
|
||||
backupMgr.SetRestoreHoldNotify(func(stack string, replayErr, rollbackErr error) {
|
||||
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"
|
||||
if rollbackErr != nil {
|
||||
reason = rollbackErr.Error()
|
||||
@@ -1355,9 +1414,7 @@ func main() {
|
||||
// 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).
|
||||
backupMgr.SetOffboxEnlargeBlockedNotifier(func(stack string, estBytes int64, usedGB, quotaGB int) {
|
||||
notifier.NotifyOffboxEnlargeBlocked(fmt.Sprintf(
|
||||
"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))
|
||||
notifier.NotifyOffboxEnlargeBlocked(stack, appbackup.HumanizeBytes(estBytes), usedGB, quotaGB) // R-585
|
||||
})
|
||||
// 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.
|
||||
@@ -1462,7 +1519,9 @@ func main() {
|
||||
// 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.
|
||||
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
|
||||
})
|
||||
|
||||
@@ -1533,21 +1592,12 @@ func main() {
|
||||
}
|
||||
})
|
||||
if notifier != nil {
|
||||
fillWatcher.SetNotify(func(e fillwatch.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.
|
||||
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(),
|
||||
})
|
||||
})
|
||||
fillWatcher.SetNotify(fillEventPush(notifier))
|
||||
}
|
||||
// `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).
|
||||
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.Every("fill-watch-interval", fillWatchInterval, func(ctx context.Context) error { return fillWatcher.Check() })
|
||||
|
||||
@@ -1666,6 +1716,8 @@ func main() {
|
||||
HubURL: cfg.Hub.URL,
|
||||
SupportEmail: "support@felhom.eu",
|
||||
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 {
|
||||
logger.Printf("[WARN] Failed to generate recovery-info.txt: %v", err)
|
||||
@@ -1746,8 +1798,9 @@ func main() {
|
||||
if notifier.IsEnabled() {
|
||||
go func() {
|
||||
prefs := sett.GetNotificationPrefs()
|
||||
if prefs.Email != "" {
|
||||
if err := notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours); err != nil {
|
||||
// R-922: a household's clear is pushed too, so a clear the hub missed reaches it at the next start.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1777,6 +1830,7 @@ func main() {
|
||||
}
|
||||
reportTrigger = report.NewTrigger(fireReport, logger)
|
||||
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
|
||||
// and fire the trigger the instant operator intent moves — so an operator save round-trips in
|
||||
@@ -2010,12 +2064,13 @@ func main() {
|
||||
// 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
|
||||
// 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
|
||||
// running controller, so a periodic re-check would add noise without adding signal.
|
||||
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 {
|
||||
notifier.NotifyEndpointDrift(d.EnglishMessage(), d.FingerprintAgrees)
|
||||
}
|
||||
@@ -2101,6 +2156,9 @@ func main() {
|
||||
// Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance,
|
||||
// a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive.
|
||||
mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot))))
|
||||
// R-998: „A gép kikapcsolása" — the household turns the whole machine off (agent POST /host/poweroff, doc 14).
|
||||
// Dashboard login only (RequireAuth): family members' app logins never reach the dashboard.
|
||||
mux.Handle("/api/server/poweroff", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerPowerOff))))
|
||||
// Whole-guest (appliance) backup visibility + manual trigger. Distinct prefix from apiRouter's
|
||||
// app-data /api/backup/{run,status} (DB dumps) to avoid shadowing the /api/ catch-all subtree.
|
||||
mux.Handle("/api/guest-backup/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeBackupAPI))))
|
||||
@@ -2467,28 +2525,52 @@ const fillWatchInterval = 10 * time.Minute
|
||||
// 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
|
||||
// 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 {
|
||||
seen := make(map[string]bool)
|
||||
var out []fillwatch.Target
|
||||
add := func(path, label string) {
|
||||
add := func(path, label, labelKey string) {
|
||||
if path == "" || seen[path] {
|
||||
return
|
||||
}
|
||||
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.
|
||||
add(system.DockerVolumePath, "Alkalmazások területe")
|
||||
add(system.DockerVolumePath, "Alkalmazások területe", "event.fill.label_apps")
|
||||
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 {
|
||||
for _, sp := range sett.GetStoragePaths() {
|
||||
if sp.Decommissioned {
|
||||
continue
|
||||
}
|
||||
add(sp.Path, sp.Label)
|
||||
add(sp.Path, sp.Label, "")
|
||||
}
|
||||
}
|
||||
return fillwatch.SortTargets(out)
|
||||
@@ -3443,8 +3525,9 @@ func (b quiesceBackend) Due(ctx context.Context) (bool, *int64, error) {
|
||||
return r.Due, r.AgeSecs, err
|
||||
}
|
||||
func (b quiesceBackend) StartBackup(ctx context.Context) (string, error) {
|
||||
r, err := b.c.StartBackup(ctx)
|
||||
return r.JobID, mapBusy(err) // F-A1: the untargeted (pre-R-82) path can 409 identically
|
||||
// R-899: a press says so. 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) {
|
||||
r, err := b.c.BackupStatus(ctx)
|
||||
@@ -3480,7 +3563,7 @@ func (b quiesceBackend) DueFor(ctx context.Context, target string) (bool, *int64
|
||||
return r.Due, r.AgeSecs, r.AgeState, err
|
||||
}
|
||||
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
|
||||
// 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
|
||||
@@ -3506,6 +3589,15 @@ func (b quiesceBackend) BackupStatusFor(ctx context.Context, target string) (str
|
||||
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).
|
||||
//
|
||||
// The whole-guest tier had NO route to the hub at all — `internal/quiesce` did not import
|
||||
@@ -3636,71 +3728,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.
|
||||
func discoverHDDPaths(stacksDir string, logger *log.Logger) []string {
|
||||
entries, err := os.ReadDir(stacksDir)
|
||||
@@ -3770,7 +3797,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
|
||||
// still pushed, because the hub stores it and the event stream is where "was it checked?" is
|
||||
// answered.
|
||||
n.NotifyIntegrityOK(integrityOKMsg(res))
|
||||
n.NotifyIntegrityOK(res.Duration.Round(time.Second).String(), res.ReadDataSubset) // R-585: facts; the bundle composes
|
||||
return res
|
||||
default:
|
||||
mgr.RecordIntegrityVerdict(res)
|
||||
@@ -3780,22 +3807,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
|
||||
// can diagnose without a rebuild. R-379 is the reason that split exists: 615 bytes of raw
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ─────────────────────────────────────────────
|
||||
//
|
||||
// ONE function, like runOffsiteIntegrityCheck above, so a future debug button cannot drift from the
|
||||
@@ -3856,17 +3872,6 @@ func proofEmptyDetail(res backup.ProofResult) string {
|
||||
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
|
||||
// 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.
|
||||
@@ -4083,7 +4088,7 @@ func budapestLoc() *time.Location {
|
||||
|
||||
// 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.
|
||||
func keptSpaceSentence(sm *stacks.Manager, drive string) string {
|
||||
func keptSpaceSentence(sm *stacks.Manager, drive, lang string) string {
|
||||
items := sm.ListKept([]string{drive})
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
@@ -4092,5 +4097,5 @@ func keptSpaceSentence(sm *stacks.Manager, drive string) string {
|
||||
for _, it := range items {
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// R-359 — the scheduled job must be PROVEN WIRED.
|
||||
@@ -87,16 +89,33 @@ func TestR359_DebugCallbackIsWired(t *testing.T) {
|
||||
|
||||
func TestR359_OutcomeMessagesCarryNoMachineDetail(t *testing.T) {
|
||||
// The customer gets a sentence; restic's words go to the log. R-379: 615 bytes of raw database
|
||||
// text reached a customer once.
|
||||
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
|
||||
if contains(integrityFailedMsg, bad) {
|
||||
t.Errorf("the failure sentence carries machine detail %q: %q", bad, integrityFailedMsg)
|
||||
}
|
||||
// text reached a customer once. R-585: the sentence is a bundle key now, so BOTH languages are
|
||||
// held to it — an English household must be told what not to do just as plainly.
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// It must still tell them what to do — and what NOT to do.
|
||||
for _, want := range []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"} {
|
||||
if !contains(integrityFailedMsg, want) {
|
||||
t.Errorf("the failure sentence is a dead end; missing %q", want)
|
||||
for _, tc := range []struct {
|
||||
lang string
|
||||
wants []string
|
||||
}{
|
||||
{"hu", []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"}},
|
||||
{"en", []string{"Do not delete anything", "contact us"}},
|
||||
} {
|
||||
msg, fellBack, ok := b.Text(tc.lang, "event.backup_integrity_failed")
|
||||
if !ok || fellBack {
|
||||
t.Fatalf("%s: event.backup_integrity_failed is missing (ok=%v fellBack=%v)", tc.lang, ok, fellBack)
|
||||
}
|
||||
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
|
||||
if contains(msg, bad) {
|
||||
t.Errorf("%s: the failure sentence carries machine detail %q: %q", tc.lang, bad, msg)
|
||||
}
|
||||
}
|
||||
// It must still tell them what to do — and what NOT to do.
|
||||
for _, want := range tc.wants {
|
||||
if !contains(msg, want) {
|
||||
t.Errorf("%s: the failure sentence is a dead end; missing %q", tc.lang, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,21 @@ func targetQuery(target string) string {
|
||||
return "?target=" + url.QueryEscape(target)
|
||||
}
|
||||
|
||||
// backupStartQuery is targetQuery plus `trigger=manual` for a household press (R-899).
|
||||
func backupStartQuery(target string, manual bool) string {
|
||||
q := url.Values{}
|
||||
if target != "" {
|
||||
q.Set("target", target)
|
||||
}
|
||||
if manual {
|
||||
q.Set("trigger", "manual")
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "?" + q.Encode()
|
||||
}
|
||||
|
||||
// BackupDueFor reports whether THIS TIER is due. A fresh backup on another tier must not satisfy it
|
||||
// — that filtering happens agent-side (latestSuccessfulBackupForTarget); this just asks per tier.
|
||||
func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse, error) {
|
||||
@@ -80,8 +95,15 @@ func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse,
|
||||
|
||||
// StartBackupFor enqueues a backup of this guest ON THE GIVEN TIER.
|
||||
func (c *Client) StartBackupFor(ctx context.Context, target string) (BackupResponse, error) {
|
||||
return c.StartBackupForTrigger(ctx, target, false)
|
||||
}
|
||||
|
||||
// StartBackupForTrigger is StartBackupFor that also says whether this is a household press (R-899): a press
|
||||
// carries `trigger=manual`, and an agent that knows it does not start the night's OS leg after it. An older
|
||||
// agent ignores the parameter (it reads only `target`), so the request is safe against any agent.
|
||||
func (c *Client) StartBackupForTrigger(ctx context.Context, target string, manual bool) (BackupResponse, error) {
|
||||
var out BackupResponse
|
||||
body, err := c.post(ctx, "/backup"+targetQuery(target), struct{}{})
|
||||
body, err := c.post(ctx, "/backup"+backupStartQuery(target, manual), struct{}{})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -105,6 +127,33 @@ func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResp
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BackupJobsInFlight (R-921) lists the tiers whose backup job the agent holds in flight right now — phase
|
||||
// running or snapshotted, the agent's own definition (localapi backupInFlight). A job in flight on another
|
||||
// tier is the agent's first reason to refuse POST /backup (HTTP 409), so the controller asks this BEFORE it
|
||||
// stops any app. It reads only GET /backup/tiers and GET /backup/status?target= (both agent >= v0.97.0); a
|
||||
// pre-R-82 agent (no /backup/tiers) answers nil, nil. Any other error is returned — the caller fails toward
|
||||
// backing up. It does NOT see the agent's host-wide heavy-operation gate: no endpoint serves it.
|
||||
func (c *Client) BackupJobsInFlight(ctx context.Context) ([]string, error) {
|
||||
tiers, err := c.BackupTiers(ctx)
|
||||
if errors.Is(err, ErrTiersUnsupported) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, t := range tiers.Tiers {
|
||||
st, err := c.BackupStatusFor(ctx, t.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st.Phase == PhaseRunning || st.Phase == PhaseSnapshotted {
|
||||
out = append(out, t.Target)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
|
||||
type SetBackupTargetResponse struct {
|
||||
Target string `json:"target"`
|
||||
|
||||
@@ -255,6 +255,9 @@ const (
|
||||
PhaseRunning = "running"
|
||||
PhaseDone = "done"
|
||||
PhaseFailed = "failed"
|
||||
// PhaseSnapshotted (8B.2): the storage snapshot is taken, the upload still runs and still holds the
|
||||
// guest — the agent counts it as IN FLIGHT (felhom-agent localapi backupInFlight).
|
||||
PhaseSnapshotted = "snapshotted"
|
||||
)
|
||||
|
||||
// BackupDue reports whether a policy-scheduled backup is due for this guest (the quiesce trigger).
|
||||
@@ -272,15 +275,7 @@ func (c *Client) BackupDue(ctx context.Context) (DueResponse, error) {
|
||||
|
||||
// StartBackup enqueues a backup of this guest (the agent vzdump) and returns the job to poll.
|
||||
func (c *Client) StartBackup(ctx context.Context) (BackupResponse, error) {
|
||||
var out BackupResponse
|
||||
body, err := c.post(ctx, "/backup", struct{}{})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode POST /backup: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
return c.StartBackupForTrigger(ctx, "", false)
|
||||
}
|
||||
|
||||
// BackupStatus reports the current/last backup job phase for this guest.
|
||||
@@ -1065,6 +1060,13 @@ type SmartSummary struct {
|
||||
ReallocatedSectors *int `json:"reallocated_sectors"`
|
||||
PendingSectors *int `json:"pending_sectors"`
|
||||
OfflineUncorrectable *int `json:"offline_uncorrectable"`
|
||||
// R-330 (agent v0.150.0+): three more SATA raw counters — 187 Reported_Uncorrect, 188
|
||||
// Command_Timeout (vendor-packed on some drives: carried as reported), 199 UDMA_CRC_Error_Count.
|
||||
// nil = unknown (an older agent, an NVMe/USB device, or not reported), never zero. CARRIED ONLY:
|
||||
// no verdict, banner or mail reads them yet — that would change what a household is told.
|
||||
ReportedUncorrect *int64 `json:"reported_uncorrect,omitempty"`
|
||||
CommandTimeout *int64 `json:"command_timeout,omitempty"`
|
||||
UDMACRCErrors *int64 `json:"udma_crc_errors,omitempty"`
|
||||
// NVMe attributes.
|
||||
CriticalWarning *int `json:"critical_warning"`
|
||||
MediaErrors *int `json:"media_errors"`
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CrashGuardState mirrors the agent's GET /host/crash-guard (R-856, `09` §3 decision 143): what the
|
||||
// host's crash guard (`felhom-crash-guard`, `11` §5.9) recorded about the most recent HOST boot. The
|
||||
// agent reads /var/lib/felhom-crash-guard/state.json (0644) and passes these fields through.
|
||||
//
|
||||
// Present=false: the host has no crash guard or no state yet. An agent that predates the route answers
|
||||
// 404 (a *StatusError) — both mean UNKNOWN, and the controller then keeps its normal boot grace.
|
||||
type CrashGuardState struct {
|
||||
Present bool `json:"present"`
|
||||
LastBootAt string `json:"last_boot_at,omitempty"` // RFC3339 UTC ("2006-01-02T15:04:05Z")
|
||||
LastBootUnclean bool `json:"last_boot_unclean"`
|
||||
Tripped bool `json:"tripped"`
|
||||
}
|
||||
|
||||
// CrashGuard calls GET /host/crash-guard.
|
||||
func (c *Client) CrashGuard(ctx context.Context) (CrashGuardState, error) {
|
||||
var out CrashGuardState
|
||||
body, err := c.get(ctx, "/host/crash-guard")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /host/crash-guard: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-856: GET /host/crash-guard decodes the crash guard's fields, and an agent that predates the route
|
||||
// surfaces as a typed 404 (the caller reads it as unknown → the normal boot grace).
|
||||
func TestR856_CrashGuardDecodesAndAnOlderAgentIs404(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /host/crash-guard", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":true,"last_boot_at":"2026-10-04T12:00:00Z","last_boot_unclean":true,"tripped":false}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
st, err := c.CrashGuard(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !st.Present || !st.LastBootUnclean || st.LastBootAt != "2026-10-04T12:00:00Z" || st.Tripped {
|
||||
t.Fatalf("state = %+v", st)
|
||||
}
|
||||
|
||||
old := httptest.NewTLSServer(http.NewServeMux())
|
||||
defer old.Close()
|
||||
_, err = clientFor(t, old, strings.TrimPrefix(old.URL, "https://")).CrashGuard(context.Background())
|
||||
var se *StatusError
|
||||
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
|
||||
t.Fatalf("an older agent must answer a typed 404, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ type DiskPrior struct {
|
||||
//
|
||||
// 1. nil / "" / UNKNOWN -> Nincs adat
|
||||
// 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
|
||||
// 5. percentage_used >= 100 -> Hiba
|
||||
// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED)
|
||||
@@ -69,7 +69,7 @@ type DiskPrior struct {
|
||||
// 10. reallocated > 0 -> Figyelmeztetés
|
||||
// 11. media_errors > 0 -> 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
|
||||
//
|
||||
// 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
|
||||
// a box that was powered off or restarted across the sustain window and so has no prior.
|
||||
//
|
||||
// WHY rows 3 and 13 skip an NVMe drive (R-333, `09` §3 decision 206, v0.308.0): the 55/60 °C bands are
|
||||
// SPINNING-DISK bands. A healthy NVMe idles at 53 °C (measured on demo-hp) and routinely passes 60 °C
|
||||
// under load with no fault, so the bands would tell a household "Hiba" about a working drive. An NVMe
|
||||
// drive judges its own heat: critical_warning bit 1 is the device's temperature alarm, and row 4 still
|
||||
// turns any critical_warning bit into Hiba. Pinned by TestDiskVerdict_NVMeHasNoHeatBand.
|
||||
//
|
||||
// Pure: no clock, no I/O, no logging. Everything it needs arrives as an argument.
|
||||
func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
|
||||
// 1 — no data. Never alarms.
|
||||
@@ -94,8 +100,9 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
|
||||
}
|
||||
// Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters,
|
||||
// because the overall verdict is structurally unable to report this class of fault.
|
||||
nvme := IsNVMe(s)
|
||||
switch {
|
||||
case atLeast(s.TemperatureC, TemperatureFailC): // 3
|
||||
case !nvme && atLeast(s.TemperatureC, TemperatureFailC): // 3
|
||||
return DiskVerdictFail
|
||||
case positive(s.CriticalWarning): // 4
|
||||
return DiskVerdictFail
|
||||
@@ -118,7 +125,7 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
|
||||
return DiskVerdictWarn
|
||||
case atLeast(s.PercentageUsed, percentageUsedWarn): // 12
|
||||
return DiskVerdictWarn
|
||||
case atLeast(s.TemperatureC, temperatureWarnC): // 13
|
||||
case !nvme && atLeast(s.TemperatureC, temperatureWarnC): // 13
|
||||
return DiskVerdictWarn
|
||||
}
|
||||
return DiskVerdictOK // 14
|
||||
@@ -190,12 +197,21 @@ func DegradedAttributes(s *SmartSummary) []string {
|
||||
if atLeast(s.PercentageUsed, percentageUsedWarn) {
|
||||
out = append(out, "elhasználódás")
|
||||
}
|
||||
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable.
|
||||
if atLeast(s.TemperatureC, temperatureWarnC) {
|
||||
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. Not for an
|
||||
// NVMe drive (R-333): its heat never triggers a verdict, so naming it would give a wrong reason.
|
||||
if !IsNVMe(s) && atLeast(s.TemperatureC, temperatureWarnC) {
|
||||
out = append(out, "hőmérséklet")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsNVMe reports whether the summary came from an NVMe drive. The agent fills critical_warning,
|
||||
// percentage_used and media_errors ONLY from smartctl's nvme_smart_health_information_log
|
||||
// (felhom-agent internal/storage/smart.go), so any of them present means NVMe; a SATA or USB drive
|
||||
// carries none of them.
|
||||
func IsNVMe(s *SmartSummary) bool {
|
||||
return s != nil && (s.CriticalWarning != nil || s.PercentageUsed != nil || s.MediaErrors != nil)
|
||||
}
|
||||
|
||||
func positive(p *int) bool { return p != nil && *p > 0 }
|
||||
func atLeast(p *int, n int) bool { return p != nil && *p >= n }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package agentapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// R-333 (`09` §3 decision 206): no heat band for an NVMe drive. Both directions, because "fixed" means
|
||||
// one kind of drive stopped alarming on heat and the other did not, and the drive's own alarm stays.
|
||||
func TestDiskVerdict_NVMeHasNoHeatBand(t *testing.T) {
|
||||
noPrior := DiskPrior{}
|
||||
cases := []struct {
|
||||
name string
|
||||
in *SmartSummary
|
||||
want DiskVerdict
|
||||
}{
|
||||
// A healthy NVMe under load: 72 °C, the device raises no flag. No heat alarm.
|
||||
{"hot NVMe, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(0), PercentageUsed: ip(3), MediaErrors: ip(0)}, DiskVerdictOK},
|
||||
{"warm NVMe 56 °C, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), CriticalWarning: ip(0)}, DiskVerdictOK},
|
||||
// The same drive with its own temperature bit set (critical_warning bit 1 = 0x02): Hiba.
|
||||
{"hot NVMe with critical_warning temperature bit → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}, DiskVerdictFail},
|
||||
{"cool NVMe with critical_warning → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(40), CriticalWarning: ip(1)}, DiskVerdictFail},
|
||||
// Spinning/SATA drives keep their 55/60 °C bands, unchanged.
|
||||
{"SATA 56 °C → warn", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), ReallocatedSectors: ip(0)}, DiskVerdictWarn},
|
||||
{"SATA 60 °C → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(60), ReallocatedSectors: ip(0)}, DiskVerdictFail},
|
||||
{"SATA 54 °C → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(54)}, DiskVerdictOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DiskVerdictFor(c.in, noPrior); got != c.want {
|
||||
t.Errorf("%s: DiskVerdictFor = %s, want %s", c.name, got.Label(), c.want.Label())
|
||||
}
|
||||
}
|
||||
// The alert body must not name heat as the reason for an NVMe drive.
|
||||
for _, a := range DegradedAttributes(&SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}) {
|
||||
if a == "hőmérséklet" {
|
||||
t.Error("an NVMe alert names temperature as an attribute; the verdict fired on the drive's own warning")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,10 @@ const (
|
||||
// out loud — a true sentence about our own incuriosity that a customer reads as a statement about
|
||||
// their code.
|
||||
RecoveryCodeOpensRetained
|
||||
// RecoveryOlderUnchecked — the code did not open the current package, no earlier package opened it, and
|
||||
// NOT every earlier package the hub holds was tried (R-304, agent 424). So whether the code is wrong is
|
||||
// NOT known. The customer must not be told to check their typing as if it were.
|
||||
RecoveryOlderUnchecked
|
||||
)
|
||||
|
||||
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
|
||||
@@ -251,6 +255,12 @@ func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained bool) Recove
|
||||
return RecoveryCodeOpensRetained
|
||||
}
|
||||
return RecoveryUnknown
|
||||
case http.StatusFailedDependency:
|
||||
// R-304. NOT version-gated, deliberately — unlike 400 and 422 above. Those two make a claim about the
|
||||
// customer's code (wrong / correct), so a status we did not design must not be read as one. This class
|
||||
// claims only „not everything was checked; contact support", which is the same direction as
|
||||
// RecoveryUnknown: misreading a stray 424 here can never accuse anyone.
|
||||
return RecoveryOlderUnchecked
|
||||
case http.StatusBadRequest:
|
||||
if trustRefusal {
|
||||
return RecoveryAskedAndRefused
|
||||
@@ -276,6 +286,8 @@ func (f RecoveryFailure) String() string {
|
||||
return "bundle-too-old"
|
||||
case RecoveryCodeOpensRetained:
|
||||
return "code-opens-retained"
|
||||
case RecoveryOlderUnchecked:
|
||||
return "older-unchecked"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
|
||||
// for two months, and it heals itself when the agent updates.
|
||||
const FeatureRetainedRecoveryClass Feature = "retained_recovery_class"
|
||||
|
||||
// FeatureHostPowerOff is the household power-off (R-998, agent v0.155.0): POST /host/poweroff. A POST that switches
|
||||
// the machine off cannot be probed, so the VERSION decides. The settings card hides only on a definite SupportNo; on
|
||||
// Unknown it stays, and an old agent's 404 is answered with the update sentence.
|
||||
const FeatureHostPowerOff Feature = "host_poweroff"
|
||||
|
||||
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
|
||||
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
||||
type SupportState int
|
||||
@@ -159,6 +164,10 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
|
||||
FeatureRetainedRecoveryClass: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe
|
||||
},
|
||||
// R-998: probing would switch the machine off. The row must exist (table-gap rule above).
|
||||
FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe
|
||||
},
|
||||
}
|
||||
|
||||
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
|
||||
@@ -192,6 +201,9 @@ var featureMinAgent = map[Feature]string{
|
||||
// screen must keep saying it cannot tell the two apart — which was true, and is what this gate
|
||||
// preserves for boxes that have not updated yet.
|
||||
FeatureRetainedRecoveryClass: "0.129.0",
|
||||
|
||||
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
|
||||
FeatureHostPowerOff: "0.155.0",
|
||||
}
|
||||
|
||||
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
|
||||
)
|
||||
|
||||
// ── POST /host/poweroff (R-998, agent >= 0.155.0, felhom.eu documentation/architecture/14-host-power-off.md) ──
|
||||
//
|
||||
// The household's „A gép kikapcsolása". The agent answers 202 (it shuts this guest down, then the host) or 409
|
||||
// with a machine-readable reason. Unlike post(), the 409 BODY matters here: the dashboard tells the household
|
||||
// WHY (a backup runs, too soon after start, already asked…), so this call reads it.
|
||||
|
||||
// Agent refusal reasons (stable wire values; mirror felhom-agent internal/localapi/hostpoweroff.go).
|
||||
const (
|
||||
PowerOffTooSoon = "too_soon_after_boot"
|
||||
PowerOffAlreadyAsked = "already_requested_this_boot"
|
||||
PowerOffBackup = "backup_running"
|
||||
PowerOffRestoreTest = "restore_test_running"
|
||||
PowerOffOSUpdate = "os_update_running"
|
||||
PowerOffCtrlUpdate = "controller_update_running"
|
||||
PowerOffDrive = "drive_operation_running"
|
||||
PowerOffEscrow = "escrow_ceremony_running"
|
||||
PowerOffBusy = "host_busy"
|
||||
PowerOffOtherGuests = "other_guests_running"
|
||||
PowerOffUnknown = "state_unknown"
|
||||
)
|
||||
|
||||
// PowerOffRefused is the agent's 409: Reason is one of the constants above (or a newer one), Detail is for the log.
|
||||
type PowerOffRefused struct {
|
||||
Reason string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (e *PowerOffRefused) Error() string {
|
||||
return "agent refused the power-off: " + e.Reason + " (" + e.Detail + ")"
|
||||
}
|
||||
|
||||
// HostPowerOff asks the agent to shut this guest down and power the host off. nil = accepted (the box goes down
|
||||
// within minutes). A *PowerOffRefused = the agent said no, with its reason. A *StatusError 404 = an agent that
|
||||
// predates the route.
|
||||
func (c *Client) HostPowerOff(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/host/poweroff", bytes.NewReader([]byte("{}")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
start := time.Now()
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentapi: POST /host/poweroff: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.noteAgentVersion(resp)
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
logx.Debugf(c.logger, "[agentapi] POST /host/poweroff -> %d (%dms)", resp.StatusCode, time.Since(start).Milliseconds())
|
||||
switch resp.StatusCode {
|
||||
case http.StatusAccepted, http.StatusOK:
|
||||
return nil
|
||||
case http.StatusConflict:
|
||||
var env struct {
|
||||
Data struct {
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(raw, &env) != nil || env.Data.Reason == "" {
|
||||
return &PowerOffRefused{Reason: PowerOffUnknown, Detail: "unreadable 409 body"}
|
||||
}
|
||||
return &PowerOffRefused{Reason: env.Data.Reason, Detail: env.Data.Detail}
|
||||
default:
|
||||
return &StatusError{Method: http.MethodPost, Path: "/host/poweroff", Code: resp.StatusCode}
|
||||
}
|
||||
}
|
||||
|
||||
// IsPowerOffRefused returns the refusal, if err is one.
|
||||
func IsPowerOffRefused(err error) (*PowerOffRefused, bool) {
|
||||
var r *PowerOffRefused
|
||||
ok := errors.As(err, &r)
|
||||
return r, ok
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-330 (disk health Phase 2, the wire only). The agent (v0.150.0+) sends three more SATA raw
|
||||
// counters; the controller must DECODE them (a field the struct lacks is silently dropped by
|
||||
// encoding/json) and must not yet ACT on them.
|
||||
|
||||
// The 2026-08-14 failing drive as the agent now reports it (values from
|
||||
// felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json).
|
||||
const r330AgentJSON = `{"health":"PASSED","temperature_c":40,"power_on_hours":null,
|
||||
"reallocated_sectors":0,"pending_sectors":352,"offline_uncorrectable":352,
|
||||
"reported_uncorrect":1001,"command_timeout":0,"udma_crc_errors":0,
|
||||
"critical_warning":null,"media_errors":null,"percentage_used":null}`
|
||||
|
||||
// COMPANION RED-PROOF (observed): rename the controller tag `reported_uncorrect` → `reported_uncorrectable`
|
||||
// → this fails with "187 must decode from the agent's JSON (raw 1001); got <nil>". Restored.
|
||||
func TestR330_DecodesTheThreeCounters(t *testing.T) {
|
||||
var s SmartSummary
|
||||
if err := json.Unmarshal([]byte(r330AgentJSON), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.ReportedUncorrect == nil || *s.ReportedUncorrect != 1001 {
|
||||
t.Fatalf("187 must decode from the agent's JSON (raw 1001); got %v", s.ReportedUncorrect)
|
||||
}
|
||||
// A measured ZERO stays a zero (not nil): 188 and 199 were reported as 0 by this drive.
|
||||
if s.CommandTimeout == nil || *s.CommandTimeout != 0 || s.UDMACRCErrors == nil || *s.UDMACRCErrors != 0 {
|
||||
t.Fatalf("188/199 reported as 0 must decode as a measured 0; got %v %v", s.CommandTimeout, s.UDMACRCErrors)
|
||||
}
|
||||
|
||||
// An older agent omits the keys → nil (unknown), never 0.
|
||||
var old SmartSummary
|
||||
if err := json.Unmarshal([]byte(`{"health":"PASSED","pending_sectors":0}`), &old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if old.ReportedUncorrect != nil || old.CommandTimeout != nil || old.UDMACRCErrors != nil {
|
||||
t.Fatalf("an older agent's report must leave the counters nil (unknown); got %v %v %v",
|
||||
old.ReportedUncorrect, old.CommandTimeout, old.UDMACRCErrors)
|
||||
}
|
||||
}
|
||||
|
||||
// CARRIED ONLY: tonight the counters change no verdict, label or degraded-attribute list — reading
|
||||
// them is a change to what a household is told and is not taken here (R-330's next slice).
|
||||
func TestR330_CountersChangeNoVerdictYet(t *testing.T) {
|
||||
with := realDrive()
|
||||
big := int64(5000)
|
||||
with.ReportedUncorrect, with.CommandTimeout, with.UDMACRCErrors = &big, &big, &big
|
||||
without := realDrive()
|
||||
for _, prior := range []DiskPrior{{}, {SawUncorrectable: true}} {
|
||||
if a, b := DiskVerdictFor(with, prior), DiskVerdictFor(without, prior); a != b {
|
||||
t.Fatalf("prior %+v: the R-330 counters changed the verdict (%v vs %v) — that is a household-facing change", prior, a, b)
|
||||
}
|
||||
}
|
||||
if a, b := DegradedAttributes(with), DegradedAttributes(without); !reflect.DeepEqual(a, b) {
|
||||
t.Fatalf("the R-330 counters changed the degraded-attribute list: %v vs %v", a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-899: a household press tells the agent so (`trigger=manual`), and nothing else does — the agent runs the
|
||||
// night's OS leg only after a backup that is not a press. The request the agent RECEIVES is asserted.
|
||||
func TestR899_PressCarriesTriggerManual(t *testing.T) {
|
||||
var got []string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /backup", func(w http.ResponseWriter, r *http.Request) {
|
||||
got = append(got, r.URL.RawQuery)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"job_id":"backup-9201-2","phase":"running"}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.StartBackup(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.StartBackupFor(ctx, "local"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.StartBackupForTrigger(ctx, "local", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.StartBackupForTrigger(ctx, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"", "target=local", "target=local&trigger=manual", "trigger=manual"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("queries = %q, want %q", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("query %d = %q, want %q (all: %q)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-921: the pre-check reads the agent's real wire shapes — GET /backup/tiers, then GET /backup/status per
|
||||
// target — and reports exactly the tiers whose job is running or snapshotted (the agent's in-flight set).
|
||||
func TestBackupJobsInFlight_ReportsRunningAndSnapshottedTiers(t *testing.T) {
|
||||
phases := map[string]string{"local": "done", "felhom-pbs": "snapshotted", "extra": "running"}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[` +
|
||||
`{"target":"local","cadence_seconds":86400,"primary":true,"storage":"present"},` +
|
||||
`{"target":"felhom-pbs","cadence_seconds":604800,"primary":false,"storage":"present"},` +
|
||||
`{"target":"extra","cadence_seconds":604800,"primary":false,"storage":"present"}]}}`))
|
||||
})
|
||||
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
tg := r.URL.Query().Get("target")
|
||||
ph, ok := phases[tg]
|
||||
if !ok {
|
||||
http.Error(w, "unexpected target "+tg, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"phase":"` + ph + `","target":"` + tg + `"}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
got, err := c.BackupJobsInFlight(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BackupJobsInFlight: %v", err)
|
||||
}
|
||||
if want := []string{"felhom-pbs", "extra"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("in-flight tiers = %v, want %v (done must not count; running and snapshotted must)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-R-82 agent (no /backup/tiers → 404) has no per-tier jobs: nil, nil — never an error that would be
|
||||
// read as "cannot tell", and never a busy verdict.
|
||||
func TestBackupJobsInFlight_PreR82AgentIsNotBusy(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
s := httptest.NewTLSServer(mux) // every route 404s
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
got, err := c.BackupJobsInFlight(context.Background())
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("pre-R-82 agent: got %v, %v — want nil, nil", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A status read that fails is returned, so the loop can fail toward backing up and say so.
|
||||
func TestBackupJobsInFlight_StatusErrorIsReturned(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[{"target":"local","primary":true}]}}`))
|
||||
})
|
||||
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
if _, err := c.BackupJobsInFlight(context.Background()); err == nil {
|
||||
t.Fatal("a failed status read was swallowed — the caller could not tell it from 'nothing in flight'")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
|
||||
resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")}
|
||||
if warning != "" {
|
||||
resp.Data = map[string]string{"warning": warning}
|
||||
if d := r.deployWarningData(req, warning); d != nil {
|
||||
resp.Data = d
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, resp)
|
||||
|
||||
@@ -1651,3 +1651,13 @@ func startAnswer(name, action, state string) apiResponse {
|
||||
Message: "Stack " + name + " " + action + " requested — state now: " + state,
|
||||
Data: map[string]interface{}{"state": state}}
|
||||
}
|
||||
|
||||
// deployWarningData renders the deploy's soft warning for the answer. R-575: DeployStack hands back the
|
||||
// warning's bundle KEY, never a sentence, so it is rendered here in the reader's language. nil = none.
|
||||
// Pinned by TestR575_OvercommitWarningFollowsTheLanguage.
|
||||
func (r *Router) deployWarningData(req *http.Request, warningKey string) map[string]string {
|
||||
if warningKey == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"warning": r.msg(req, warningKey)}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,23 @@ func (r AppStopReason) humanReason() string {
|
||||
}
|
||||
}
|
||||
|
||||
// MessageKey is the bundle key that names this reason in the household's language (R-585), or "" for a
|
||||
// reason this build has no sentence for — the caller then shows the raw reason, as humanReason does.
|
||||
// Pinned against humanReason by TestR585_InterruptedOperationFollowsTheHousehold (internal/notify): the
|
||||
// English bundle text must equal what the operator's log line says.
|
||||
func (r AppStopReason) MessageKey() string {
|
||||
switch r {
|
||||
case ReasonVolumeDump:
|
||||
return "appstop.reason.volume_dump"
|
||||
case ReasonOffboxReconstitute:
|
||||
return "appstop.reason.offbox_reconstitute"
|
||||
case ReasonAppExport:
|
||||
return "appstop.reason.app_export"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// AppStopMarker is the persisted "these apps were stopped by an operation that has not reported
|
||||
// finishing — they are owed a restart" note.
|
||||
type AppStopMarker struct {
|
||||
|
||||
@@ -25,6 +25,14 @@ import (
|
||||
// moved out of the controller into the host agent (slice 8C). This Manager now
|
||||
// only owns the app-data domain.
|
||||
type Manager struct {
|
||||
// R-897: when this controller started, and the seams of the post-boot drive wait (driveready.go). Zero
|
||||
// startedAt = no wait (a Manager built outside NewManager, e.g. a test that does not ask for it).
|
||||
startedAt time.Time
|
||||
driveLive func(root string) bool
|
||||
driveWaitPoll func(time.Duration)
|
||||
nowFn func() time.Time
|
||||
driveWarned map[string]bool
|
||||
driveMu sync.Mutex // its own lock: the capture sweep may run while m.mu is held elsewhere
|
||||
cfg *config.Config
|
||||
logger *log.Logger
|
||||
settings *settings.Settings
|
||||
@@ -202,6 +210,8 @@ type Manager struct {
|
||||
|
||||
// updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck.
|
||||
updatingCheck func(stackName string) bool
|
||||
// versionCheck (R-645) — nil-safe; see versionSkip / SetVersionCheck.
|
||||
versionCheck func(stackName string) (detail string, mismatch bool)
|
||||
// undoCopyRemover (R-671, v0.272.0) deletes an app's leftover undo copies — stacks.Manager.RemoveUndoCopies,
|
||||
// wired in main.go (SetUndoCopyRemover). nil-safe: without it the copies stay, as before.
|
||||
undoCopyRemover func(stackName string) int
|
||||
@@ -368,6 +378,7 @@ func NewManager(cfg *config.Config, sett *settings.Settings, logger *log.Logger)
|
||||
logger: logger,
|
||||
settings: sett,
|
||||
systemDataPath: cfg.Paths.SystemDataPath,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
// R-166: its OWN file next to quiesce-state.json, never inside it — one file, one writer.
|
||||
m.appStop = NewAppStopGuard(filepath.Join(cfg.Paths.DataDir, "appstop-state.json"), logger)
|
||||
@@ -620,6 +631,14 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// R-645 (09 §3 decision 142): an app not running its pinned version is left alone — its dump
|
||||
// would land in the unit beside the good definition and become the restore point. A SKIP: the
|
||||
// unit keeps the last good copy, and the backups page says why.
|
||||
if why, skip := m.versionSkip(db.StackName); skip {
|
||||
m.logger.Printf("[WARN] [backup] Skipping DB dump for %s — it is not running its pinned version (%s); the last good backup is kept (R-645)", db.StackName, why)
|
||||
summary = append(summary, fmt.Sprintf("SKIP %s (not running its pinned version)", db.ContainerName))
|
||||
continue
|
||||
}
|
||||
// R-181: the reserve, BEFORE the first byte of this app's backup is written. This is usually
|
||||
// where an app's verdict is taken, because the DB leg runs first; the volume leg and the
|
||||
// capture then read the same memo. SKIP, not FAIL — a deliberate hold is not a broken dump,
|
||||
@@ -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))
|
||||
continue
|
||||
}
|
||||
// R-645: never stop, dump or restart an app that is not running its pinned version.
|
||||
if why, skip := m.versionSkip(stack.Name); skip {
|
||||
m.logger.Printf("[WARN] [backup] Skipping volume dump for %s — it is not running its pinned version (%s); the last good backup is kept (R-645)", stack.Name, why)
|
||||
summary = append(summary, fmt.Sprintf("SKIP %s volumes (not running its pinned version)", stack.Name))
|
||||
continue
|
||||
}
|
||||
// Volume check FIRST — a volume-less stack must not be stopped at all (see gate-order note).
|
||||
if len(m.stackProvider.GetDockerVolumes(stack.Name)) == 0 {
|
||||
if m.isDebug() {
|
||||
@@ -1286,6 +1311,12 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
|
||||
var results []DumpResult
|
||||
var latestTime time.Time
|
||||
for _, f := range status.DumpFiles {
|
||||
// 2026-10-10: a `pre-restore-` undo copy (R-379) is not part of any run — it stays in the file
|
||||
// list, never in the run's table (demo-hp listed adventurelog four times after a restart).
|
||||
// Pinned by TestSynthesisedRunListsNoUndoCopy.
|
||||
if f.IsUndo {
|
||||
continue
|
||||
}
|
||||
results = append(results, DumpResult{
|
||||
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
|
||||
FilePath: f.FileName,
|
||||
@@ -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
|
||||
// the stop cut off left a fresh .sql beside last night's tars — "Success" must not be read off that.
|
||||
status.LastDBDump = &DBDumpStatus{
|
||||
LastRun: latestTime,
|
||||
Results: results,
|
||||
Success: m.runInterrupted.IsZero(),
|
||||
// Only undo copies on disk → no run to show (nil, as with no files at all).
|
||||
if len(results) > 0 {
|
||||
status.LastDBDump = &DBDumpStatus{
|
||||
LastRun: latestTime,
|
||||
Results: results,
|
||||
Success: m.runInterrupted.IsZero(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1186,9 +1186,9 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
// must not be told "nothing is selected".
|
||||
// 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
|
||||
// not a failure). Still a Hungarian LITERAL and still carrying the lowercase marker
|
||||
// „nincs mentésre jelölt alkalmazás" (R-570: boxes with the older persisted text are
|
||||
// recognised by those words). Pinned by TestR240_ZeroSelectionRunDoesNotSaySuccess.
|
||||
// not a failure). Still a Hungarian LITERAL; since R-570 closed (2026-10-10) no reader depends
|
||||
// on its words — the page decides on warnKind — so localisation may translate it.
|
||||
// Pinned by TestR240_ZeroSelectionRunDoesNotSaySuccess.
|
||||
if len(apps) == 0 && !runResult.sharesBackedUp {
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
// 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)
|
||||
// `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) {
|
||||
o.AbandonAt = due.Format(time.RFC3339)
|
||||
}); 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
func (m *Manager) SetRestoreHoldNotify(fn func(stack string, replayErr, rollbackErr error)) {
|
||||
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) ------------------------------------------------
|
||||
// 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
|
||||
@@ -830,8 +941,13 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
|
||||
}
|
||||
n, cErr := copier(pl.src, pl.dst)
|
||||
if cErr != nil {
|
||||
// Best-effort bring-up: leaving the app stopped after a partial copy would turn a failed
|
||||
// restore into an outage.
|
||||
// R-893 option C (security review 2026-10-08): once the snapshot's (possibly older) definition
|
||||
// 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 {
|
||||
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"))
|
||||
res.VolumesReplayed = nVols
|
||||
if vErr != nil {
|
||||
// A partial replay must never read as a completion. Bring the app back up rather than leaving
|
||||
// an outage, then surface it — the same shape the file leg above uses.
|
||||
// A partial replay must never read as a completion. R-893 (security review 2026-10-08): a volume
|
||||
// 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 {
|
||||
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.
|
||||
if hasDB {
|
||||
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.
|
||||
if sErr := restartStack(); sErr != nil {
|
||||
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))
|
||||
}
|
||||
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 {
|
||||
m.logger.Printf("[WARN] [offbox] %s: start after a successful rollback failed: %v", stack, sErr)
|
||||
}
|
||||
|
||||
@@ -156,37 +156,6 @@ func backupNoteHU(t *testing.T, key string) string {
|
||||
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,
|
||||
// not a precaution.
|
||||
//
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// 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
|
||||
// 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) {
|
||||
m, sett := newOffboxManager(t)
|
||||
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") {
|
||||
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 {
|
||||
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
|
||||
// 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) {
|
||||
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
|
||||
seedScratchVolumes(t, m, "immich", "immich_a.tar", "immich_b.tar")
|
||||
m.volumeReplayFrom = func(_, _ string) (int, error) {
|
||||
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)
|
||||
if err == nil {
|
||||
t.Fatal("a partial volume replay must be reported as a failure, not a completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "immich_b") {
|
||||
t.Errorf("the failure must name the volume that did not come back; got %q", err.Error())
|
||||
if noticed == nil || !strings.Contains(noticed.Error(), "immich_b") {
|
||||
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 {
|
||||
t.Error("the app was left stopped after a failed volume replay")
|
||||
if prov.fullStarted {
|
||||
t.Error("the app was STARTED on a partly replaced set of volumes — it must be held (R-893)")
|
||||
}
|
||||
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".
|
||||
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, ¬ified
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -416,7 +416,7 @@ func (m *Manager) floorVerdict(u *UnitSpace, estGiB float64) (*UnitSpace, floorR
|
||||
availAfter := u.AvailGB - estGiB
|
||||
usedAfter := u.UsedPercent
|
||||
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 {
|
||||
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)) {
|
||||
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.
|
||||
// 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
|
||||
@@ -458,6 +463,17 @@ func (m *Manager) captureAllRecoveryUnits(dataRun bool) {
|
||||
if m.isHeld(stack.Name) {
|
||||
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)
|
||||
// The reserve, checked BEFORE anything is written. Per app, and the loop continues.
|
||||
if !m.admitApp(stack.Name) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
// (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.
|
||||
//
|
||||
// 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
|
||||
// is only used for logging (the source of truth is now the on-disk volume tars).
|
||||
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)
|
||||
|
||||
// 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
|
||||
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 {
|
||||
m.logger.Printf("[WARN] RESTORE could not stop %s: %v (proceeding anyway)", stackName, err)
|
||||
@@ -62,30 +93,56 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error {
|
||||
|
||||
// Populate Docker volumes from restored tars
|
||||
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 {
|
||||
m.logger.Printf("[ERROR] RESTORE volume restore failed for %s: %v", stackName, err)
|
||||
dataErr = err
|
||||
_, volErr := m.restoreDockerVolumes(stackName, drivePath)
|
||||
if volErr != nil {
|
||||
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() {
|
||||
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 {
|
||||
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
|
||||
if err := m.waitForHealthy(stackName, healthTimeout); err != nil {
|
||||
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
|
||||
// has already consumed.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
// 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).
|
||||
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 {
|
||||
m.logger.Printf("[ERROR] [backup] DB-only start for %s: %v", stackName, err)
|
||||
if dataErr == nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
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
|
||||
if runOne == nil {
|
||||
runOne = m.RunTier2
|
||||
|
||||
@@ -600,6 +600,39 @@ func (m *Manager) isHeld(stackName string) bool {
|
||||
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).
|
||||
// INIT-ONLY, in main.go — pinned by TestSlice4_UpdatingCheckIsWiredAtStartup. The backup package cannot
|
||||
// import stacks, which is why it is a seam.
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
// 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
|
||||
// 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
|
||||
// controller's hub reports 401 (the hub's /report needs the customer-scoped key, not the host key).
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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
|
||||
// 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
|
||||
// (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"
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
// - 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 {
|
||||
@@ -110,37 +111,37 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
|
||||
data, err := os.ReadFile(bpath)
|
||||
if err != nil {
|
||||
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
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
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)
|
||||
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). ---
|
||||
merged, err := mergeLocalAPI(pulled, b.LocalAPI)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
reloaded, err := config.LoadPermissive(configPath)
|
||||
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
|
||||
}
|
||||
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 —
|
||||
// so it is surfaced, as a boolean, never as a value.
|
||||
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.
|
||||
@@ -362,7 +368,12 @@ type EndpointDrift struct {
|
||||
// unprovisioned guest is not a drifted one;
|
||||
// - the bootstrap local_api block is incomplete (any of endpoint/fingerprint/token empty) — the
|
||||
// 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)
|
||||
// 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 == "" {
|
||||
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
|
||||
}
|
||||
d := &EndpointDrift{
|
||||
@@ -390,6 +403,18 @@ func DetectEndpointDrift(configPath string, cfg *config.Config, logger *log.Logg
|
||||
ConfigEndpoint: cfg.LocalAPI.Endpoint,
|
||||
BootstrapEndpoint: b.LocalAPI.Endpoint,
|
||||
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 {
|
||||
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).
|
||||
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) "+
|
||||
"— the controller is dialling controller.yaml's value; the agent may be listening on the other.",
|
||||
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,
|
||||
// no addresses — the operator gets those in the event and the log).
|
||||
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."
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ type PathsConfig struct {
|
||||
|
||||
type WebConfig struct {
|
||||
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"`
|
||||
SessionSecret string `yaml:"session_secret"`
|
||||
|
||||
|
||||
@@ -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
|
||||
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
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
|
||||
}
|
||||
if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
|
||||
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// ── Thresholds ───────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -120,6 +122,11 @@ type Usage struct {
|
||||
type Target struct {
|
||||
Path 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.
|
||||
@@ -127,7 +134,10 @@ type Event struct {
|
||||
Target Target
|
||||
Band Band
|
||||
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.
|
||||
@@ -142,9 +152,12 @@ type Watcher struct {
|
||||
usage func(path string) *Usage
|
||||
// notify pushes the customer event. Nil-safe.
|
||||
notify func(Event)
|
||||
// extra appends a sentence to the warning for one target — the kept folders on it the household
|
||||
// can delete (`09` §3 decision 36). Nil-safe; "" appends nothing.
|
||||
extra func(Target) string
|
||||
// extra appends a sentence to the warning for one target, in a language — the kept folders on it
|
||||
// the household can delete (`09` §3 decision 36). Nil-safe; "" appends nothing.
|
||||
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
|
||||
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 }
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
msg := Message(t, *u, next)
|
||||
if w.extra != nil {
|
||||
if x := w.extra(t); x != "" {
|
||||
msg += " " + x
|
||||
msg := w.messageWithExtra(i18n.Default, t, *u, next)
|
||||
household := ""
|
||||
if w.lang != nil {
|
||||
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",
|
||||
t.Path, t.Label, prev, next, u.UsedPercent, u.AvailGB, u.TotalGB)
|
||||
emitted++
|
||||
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()
|
||||
}
|
||||
|
||||
// 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
|
||||
// holds w.mu.
|
||||
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
|
||||
// would discard the label and the figures — exactly why `offbox_enlarge_blocked` and
|
||||
// `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
|
||||
if t.LabelKey != "" && b.Has(i18n.Normalize(lang), t.LabelKey) {
|
||||
name = b.Msg(lang, t.LabelKey)
|
||||
}
|
||||
if name == "" {
|
||||
name = t.Path
|
||||
}
|
||||
if band == BandCritical {
|
||||
return fmt.Sprintf(
|
||||
"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 b.Msgf(lang, "event.fill.critical", name, gbIn(lang, u.AvailGB), percent(u.UsedPercent))
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"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))
|
||||
return b.Msgf(lang, "event.fill.warning", name, percent(u.UsedPercent), gbIn(lang, u.AvailGB))
|
||||
}
|
||||
|
||||
// hunGB formats a GB figure the Hungarian way — decimal COMMA, one decimal place. A "4.2 GB" in a
|
||||
// Hungarian sentence reads as a typo to the customer.
|
||||
func hunGB(gb float64) string {
|
||||
// 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, and "4,2 GB" in an English one.
|
||||
func gbIn(lang string, gb float64) string {
|
||||
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
|
||||
// in a stable sequence (tests, and a readable log).
|
||||
|
||||
@@ -273,7 +273,7 @@ func TestCustomerMessageNamesTheDriveTheSpaceAndTheAction(t *testing.T) {
|
||||
if !strings.Contains(msg, "87%") {
|
||||
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)
|
||||
}
|
||||
// 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(string) *Usage { return &Usage{UsedPercent: 97, AvailGB: 3, UsedGB: 97, TotalGB: 100} })
|
||||
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" {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"alert.deadapp.single": "An installed app is not running: %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.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.unreachable": "The hub cannot be reached — last error: %s",
|
||||
"alert.link.monitoring": "System monitor",
|
||||
@@ -74,6 +75,7 @@
|
||||
"app_export.titkositasi_jelszo": "Encryption password",
|
||||
"app_export.valassz_tarolot": "Choose a storage...",
|
||||
"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.adatbazis": "Database:",
|
||||
"app_import.adatok": "Data:",
|
||||
@@ -155,6 +157,7 @@
|
||||
"app_info.mentesek": "Backups",
|
||||
"app_info.mire_hasznalhato": "What is it for?",
|
||||
"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.regi_adatok_torlese": "Deleting old data",
|
||||
"app_info.telepites": "Install",
|
||||
@@ -170,6 +173,7 @@
|
||||
"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.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.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.",
|
||||
@@ -190,7 +194,7 @@
|
||||
"backups.a_kijeloles_nem_sikerult": "The drive could not be 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_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_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.",
|
||||
@@ -204,7 +208,7 @@
|
||||
"backups.beagyazott_db_k_a_kotetmentesben": "embedded databases, inside the volume backup",
|
||||
"backups.biztonsagi_mentes": "Backup",
|
||||
"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.fajl": "{{$n := len .Backup.DumpFiles}}{{$n}} {{if eq $n 1}}file{{else}}files{{end}}",
|
||||
"backups.helyi_masolat": "Local copy: <strong>{{.BackupLegTier2}}</strong>",
|
||||
@@ -627,7 +631,7 @@
|
||||
"common.magyar_felulet": "Hungarian interface",
|
||||
"common.megsem": "Cancel",
|
||||
"common.pi_kompatibilis": "Runs on Pi",
|
||||
"common.rendszer_gyoker": "System (/)",
|
||||
"common.rendszer_gyoker": "System",
|
||||
"common.torles": "Delete",
|
||||
"common.ugyfel_azonosito": "Customer ID",
|
||||
"common.ujrainditas": "Restart",
|
||||
@@ -902,6 +906,8 @@
|
||||
"dashboard.telepites": "Install",
|
||||
"dashboard.telepitett_alkalmazasok": "Installed apps",
|
||||
"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.vedett": "Protected",
|
||||
"dashboard.vezerlopult": "Dashboard",
|
||||
@@ -922,7 +928,6 @@
|
||||
"debug.bejegyzes": "entries",
|
||||
"debug.betoltes": "Loading...",
|
||||
"debug.cimke": "Label",
|
||||
"debug.controller_ujraindulasa": "Controller restarting...",
|
||||
"debug.cpu_atlag": "CPU (avg)",
|
||||
"debug.csak_cross_drive": "Cross-drive only",
|
||||
"debug.csak_db_dump": "DB dump only",
|
||||
@@ -932,7 +937,6 @@
|
||||
"debug.db": "items",
|
||||
"debug.debug_mod": "Debug mode",
|
||||
"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.elavult_temp_fajlok": "Stale temp files",
|
||||
"debug.eleres": "Path",
|
||||
@@ -941,8 +945,6 @@
|
||||
"debug.ertesites_teszt": "Notification test",
|
||||
"debug.esemeny_tipus": "Event type:",
|
||||
"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.folyamatban": "In progress...",
|
||||
"debug.frissites": "Refresh",
|
||||
@@ -966,8 +968,6 @@
|
||||
"debug.igen": "Yes",
|
||||
"debug.igen_2": "yes",
|
||||
"debug.inaktiv": "Inactive",
|
||||
"debug.irja_be_reset": "Type: RESET",
|
||||
"debug.irja_be_reset_a_megerositeshez": "Type \"RESET\" to confirm:",
|
||||
"debug.jelenlegi": "Current:",
|
||||
"debug.katalogus_limit": "Catalog limit",
|
||||
"debug.kattintson_a_gombra_a_telemetria": "Click the button to run telemetry.",
|
||||
@@ -1022,7 +1022,6 @@
|
||||
"debug.titkositott": "encrypted",
|
||||
"debug.torles": "Clear",
|
||||
"debug.ugynok": "Agent",
|
||||
"debug.ujrainditas_setup_modban": "Restart in setup mode",
|
||||
"debug.utemezo": "Scheduler",
|
||||
"debug.utolso_db_dump": "Last DB dump:",
|
||||
"debug.utolso_esemenyek": "Recent events",
|
||||
@@ -1033,7 +1032,6 @@
|
||||
"debug.utvonal": "Path",
|
||||
"debug.varakozik": "Waiting",
|
||||
"debug.verzio": "Version",
|
||||
"debug.veszhelyzet_szimulacio": "Emergency simulation",
|
||||
"debug.vezerlo": "Controller",
|
||||
"debug.api.invalid_request": "Invalid request",
|
||||
"debug.api.notifier_unconfigured": "Notifier is not configured",
|
||||
@@ -1057,9 +1055,6 @@
|
||||
"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.reset_confirm_invalid": "Invalid confirmation — type: RESET",
|
||||
"debug.api.marker_write_failed": "Marker file write error: %v",
|
||||
"debug.api.restarting_to_setup": "Restarting the controller into setup mode...",
|
||||
"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",
|
||||
@@ -1258,6 +1253,8 @@
|
||||
"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_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.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",
|
||||
@@ -1426,6 +1423,20 @@
|
||||
"event.crossdrive_completed": "Second backup copy finished: %s",
|
||||
"event.crossdrive_failed": "Second backup copy failed: %s",
|
||||
"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_started": "Disaster recovery started (%d app(s))",
|
||||
"event.health_critical": "System health is critical (was: %s)",
|
||||
@@ -1433,6 +1444,10 @@
|
||||
"event.health_recovered": "System health is back to normal: %s (was: %s)",
|
||||
"event.storage_disconnected": "Drive disconnected unexpectedly: %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.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.",
|
||||
@@ -1486,6 +1501,7 @@
|
||||
"flash.share.disabled": "Sharing is off.",
|
||||
"flash.share.enable_failed": "Sharing could not be turned on.",
|
||||
"flash.share.enabled": "Sharing is on.",
|
||||
"flash.share.wrong_password": "Wrong password",
|
||||
"flash.share.not_on": "Sharing is not on.",
|
||||
"flash.share.pw_clear_failed": "The password could not be removed.",
|
||||
"flash.share.pw_cleared": "The share password is removed.",
|
||||
@@ -1544,6 +1560,19 @@
|
||||
"func.time.tomorrow_at": "tomorrow %s",
|
||||
"func.time.yesterday": "yesterday",
|
||||
"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.files": "holds the settings, the database and the files",
|
||||
"hold.copy_holds.volumes": "holds the settings, the database and the data volumes",
|
||||
@@ -1652,6 +1681,7 @@
|
||||
"layout.nyelv": "Language",
|
||||
"layout.rendszer": "System",
|
||||
"layout.rendszermonitor": "System monitor",
|
||||
"layout.hibakereses": "Debug",
|
||||
"layout.sablonok_frissitve": "Templates refreshed",
|
||||
"layout.sikeresen_eltavolitva": "Removed!",
|
||||
"layout.sikeresen_torolve": "Deleted!",
|
||||
@@ -1746,6 +1776,7 @@
|
||||
"note.offsite.whole_unit_gap": "(the whole app — it has no local backup unit)",
|
||||
"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_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.all_files_present": "Every file that was checked is in place.",
|
||||
"note.restore.and_database": " and the database",
|
||||
@@ -1848,7 +1879,7 @@
|
||||
"recovery.adatok": "Retrieve",
|
||||
"recovery.adatok_visszaszerzese_felhom": "Retrieve data — Felhom",
|
||||
"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.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.",
|
||||
@@ -1867,12 +1898,14 @@
|
||||
"recovery.meret": "Size",
|
||||
"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.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.tiz_szo_szokozokkel_elvalasztva": "ten words, separated by spaces",
|
||||
"recovery.tovabb_a_visszaallitashoz": "Continue to restore",
|
||||
"recovery.ugyhogy_tartsd_keznel": "so keep it at hand.",
|
||||
"recovery.vissza_a_kezdolapra": "Back to the home page",
|
||||
"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.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)”.",
|
||||
@@ -1905,6 +1938,8 @@
|
||||
"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.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.elvart_adatbazismentes_elmaradt": "Expected database backup missed",
|
||||
"settings_notifications.elvart_fajlmentes_elmaradt": "Expected file backup missed",
|
||||
@@ -1912,7 +1947,6 @@
|
||||
"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.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_megtelt_vagy_meghibasodott": "Disk full or failed",
|
||||
"settings_notifications.masodlagos_mentes_sikertelen": "Secondary backup failed",
|
||||
@@ -1926,9 +1960,19 @@
|
||||
"settings_notifications.rendszer_allapot_helyreallt": "System health recovered",
|
||||
"settings_notifications.rendszer_allapot_kritikus": "System health critical",
|
||||
"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.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_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.",
|
||||
@@ -1972,7 +2016,7 @@
|
||||
"settings_security.uj_jelszo": "New password",
|
||||
"settings_security.uj_jelszo_megerositese": "Confirm new password",
|
||||
"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.visszaallitasi_jelszo": "Retrieval password",
|
||||
"settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "The host agent is not reachable right now — try again later.",
|
||||
@@ -1987,13 +2031,13 @@
|
||||
"settings_system.atmeretezes": "Resize",
|
||||
"settings_system.atmeretezes_2": "Resizing...",
|
||||
"settings_system.automatikus_frissites": "Automatic update",
|
||||
"settings_system.az_egesz_kiszolgalo_szerver_ujrainditasa": "Restarts the whole server. Every app stops for a short time, and the dashboard is unreachable for about 30 seconds. Use it when the whole system needs a restart — otherwise “Restart controller” above is enough.",
|
||||
"settings_system.az_egesz_kiszolgalo_szerver_ujrainditasa": "Restarts the Felhom part of the machine: the dashboard and all your apps. The machine itself stays on. Nothing can be reached for about 30 seconds; then everything comes back by itself. If only the dashboard is stuck, “Restart controller” above is enough.",
|
||||
"settings_system.az_ujrainditas_a_vartnal_tovabb": "The restart is taking longer than expected. Reload the page by hand.",
|
||||
"settings_system.az_uzemelteto_altal_beallitott_ertekek": "Values set by the operator. Ask the operator to change them.",
|
||||
"settings_system.beallitasok_rendszer": "Settings — System",
|
||||
"settings_system.biztonsagi_mentes": "Backup",
|
||||
"settings_system.biztosan_frissiti_a_controllert_n": "Update the controller?\\n\\nThe dashboard is unreachable for a short time during the update.",
|
||||
"settings_system.biztosan_ujrainditja_a_kiszolgalot_az": "Restart the server? The apps and the dashboard become unreachable for about 30 seconds.",
|
||||
"settings_system.biztosan_ujrainditja_a_kiszolgalot_az": "Restart Felhom? The apps and the dashboard become unreachable for about 30 seconds. The machine stays on.",
|
||||
"settings_system.biztosan_ujrainditja_a_vezerlot_a": "Restart the controller? It takes a few seconds, and the page is unreachable for a short time.",
|
||||
"settings_system.csokkentes": "Reduce",
|
||||
"settings_system.ellenorzes": "Checking...",
|
||||
@@ -2019,7 +2063,7 @@
|
||||
"settings_system.jelenlegi_verzio": "Current version",
|
||||
"settings_system.kapcsolodasi_hiba": "Connection error",
|
||||
"settings_system.kesz": "Done.",
|
||||
"settings_system.kiszolgalo_ujrainditasa": "Restart server",
|
||||
"settings_system.kiszolgalo_ujrainditasa": "Restart Felhom",
|
||||
"settings_system.legujabb_verzio": "Latest version",
|
||||
"settings_system.megengedett_tartomany": "Allowed range",
|
||||
"settings_system.megerosites": "Confirm",
|
||||
@@ -2213,7 +2257,7 @@
|
||||
"storage.hiba": "Error:",
|
||||
"storage.host_tarhely_rendszer_sablonok_iso": "Host storage — system templates, ISOs, host-level backups. It holds no app data.",
|
||||
"storage.hozzaadas": "Add",
|
||||
"storage.idopont": "Time: {{.DecommissionedAt}}",
|
||||
"storage.idopont": "Time: {{fmtTimeStr .DecommissionedAt}}",
|
||||
"storage.inaktiv": "Inactive",
|
||||
"storage.ismeretlen": "unknown",
|
||||
"storage.kesz": "Done",
|
||||
@@ -2235,7 +2279,7 @@
|
||||
"storage.levalasztas": "Disconnect",
|
||||
"storage.levalasztas_folyamatban": "Disconnecting…",
|
||||
"storage.levalasztva": "Disconnected",
|
||||
"storage.levalasztva_2": "Disconnected: {{.DisconnectedAt}}",
|
||||
"storage.levalasztva_2": "Disconnected: {{fmtTimeStr .DisconnectedAt}}",
|
||||
"storage.majd_a_z": "then",
|
||||
"storage.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Add already mounted storage by hand",
|
||||
"storage.megerosites": "Confirm",
|
||||
@@ -2521,6 +2565,28 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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.",
|
||||
@@ -2554,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_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_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.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.",
|
||||
"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",
|
||||
@@ -2596,5 +2664,26 @@
|
||||
"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_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",
|
||||
"settings_system.megerositeshez_ird_be": "To confirm, type:",
|
||||
"settings_system.a_gep_kikapcsolasa": "Turn the machine off",
|
||||
"settings_system.a_gep_teljesen_kikapcsol": "The machine switches off completely. Your apps and the backups stop.",
|
||||
"settings_system.ujra_bekapcsolni_csak_otthon": "It can only be switched on again at home: press the power button of the machine.",
|
||||
"settings_system.kikapcsolas_frissites_utan": "Turning the machine off needs an update of the software of the machine. It comes by itself; until then this button is hidden.",
|
||||
"settings_system.kikapcsolas_szo": "TURN OFF",
|
||||
"settings_system.kikapcsolas": "Turn off",
|
||||
"settings_system.biztosan_kikapcsolod_a_gepet": "Turn the machine off? Felhom stops and the machine switches off. Nobody can switch it on again from afar: only at home, with the power button of the machine.",
|
||||
"settings_system.a_gep_kikapcsol_folyamatban": "The machine is switching off. In a few minutes it stops completely, and this page will not answer any more. To switch it on again: press the power button of the machine.",
|
||||
"poweroff.confirm_required": "Type the confirmation word: TURN OFF.",
|
||||
"poweroff.agent_down": "The machine cannot be turned off now: the dashboard cannot reach the manager of the machine. Try again in a few minutes.",
|
||||
"poweroff.agent_outdated": "Turning the machine off needs an update of the software of the machine. It comes by itself.",
|
||||
"poweroff.refused.too_soon": "The machine has just started. You can turn it off in 10 minutes.",
|
||||
"poweroff.refused.already": "The machine is already switching off.",
|
||||
"poweroff.refused.backup": "A backup or a restore is running. Wait until it ends, then try again.",
|
||||
"poweroff.refused.update": "An update is running. Wait until it ends, then try again.",
|
||||
"poweroff.refused.drive": "The machine is working on a drive. Wait until it ends, then try again.",
|
||||
"poweroff.refused.other_guests": "Another system runs on this machine, so it cannot be turned off from here.",
|
||||
"poweroff.refused.generic": "The machine cannot turn itself off safely right now. Try again in a few minutes."
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
"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.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_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.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.unreachable": "Hub nem elérhető — utolsó hiba: %s",
|
||||
"alert.link.monitoring": "Rendszermonitor",
|
||||
@@ -25,9 +26,9 @@
|
||||
"api.backup.tier2_started": "2. mentés elindítva",
|
||||
"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.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.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.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",
|
||||
@@ -70,7 +71,8 @@
|
||||
"app_export.titkositasi_jelszo": "Titkosítási jelszó",
|
||||
"app_export.valassz_tarolot": "Válassz tárolót...",
|
||||
"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.adatok": "Adatok:",
|
||||
"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.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.fab_csomag_feltoltese_huzza_ide": ".fab csomag feltöltése — húzza ide, vagy válassza ki a fájlt",
|
||||
"app_import.fab_csomag_feltoltese_huzza_ide": ".fab csomag feltöltése — húzd ide, vagy válaszd ki a fájlt",
|
||||
"app_import.fajl_kivalasztasa": "Fájl kiválasztása",
|
||||
"app_import.feltoltes": "Feltöltés:",
|
||||
"app_import.feltoltes_0": "Feltöltés: 0%",
|
||||
@@ -151,6 +153,7 @@
|
||||
"app_info.mentesek": "Mentések",
|
||||
"app_info.mire_hasznalhato": "Mire használható?",
|
||||
"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.regi_adatok_torlese": "Régi adatok törlése",
|
||||
"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.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.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.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.",
|
||||
@@ -186,7 +190,7 @@
|
||||
"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_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_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.",
|
||||
@@ -200,7 +204,7 @@
|
||||
"backups.beagyazott_db_k_a_kotetmentesben": "beágyazott DB-k a kötetmentésben",
|
||||
"backups.biztonsagi_mentes": "Biztonsági mentés",
|
||||
"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.fajl": "{{len .Backup.DumpFiles}} fájl",
|
||||
"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_sikeres": "Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}",
|
||||
"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_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_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í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.2_fontos_tudnivalok": "2. Fontos tudnivalók",
|
||||
"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_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_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_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.",
|
||||
@@ -372,13 +376,13 @@
|
||||
"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_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_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_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ő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_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_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_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.",
|
||||
@@ -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.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.valasszon": "— Válasszon —",
|
||||
"backups_restore.valasszon_alkalmazast": "— Válasszon alkalmazást —",
|
||||
"backups_restore.valasszon": "— Válassz —",
|
||||
"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.vasarnap": "vasárnap",
|
||||
"backups_restore.visszaallitas": "Visszaállítás",
|
||||
@@ -560,7 +564,7 @@
|
||||
"backups_restore_wizard.vegrehajtas": "Végrehajtás",
|
||||
"backups_restore_wizard.vissza": "← Vissza",
|
||||
"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.fajl_visszaallitas": "Fájl-visszaállítás",
|
||||
"backups_shared.folyamatban": "folyamatban",
|
||||
@@ -622,7 +626,7 @@
|
||||
"common.magyar_felulet": "Magyar felület",
|
||||
"common.megsem": "Mégsem",
|
||||
"common.pi_kompatibilis": "Pi kompatibilis",
|
||||
"common.rendszer_gyoker": "Rendszer (/)",
|
||||
"common.rendszer_gyoker": "Rendszer",
|
||||
"common.torles": "Törlés",
|
||||
"common.ugyfel_azonosito": "Ügyfél azonosító",
|
||||
"common.ujrainditas": "Újraindítás",
|
||||
@@ -870,7 +874,7 @@
|
||||
"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_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.fogyoban_a_hely": "Fogyóban a hely",
|
||||
"dashboard.futo_alkalmazas": "Futó alkalmazás",
|
||||
@@ -897,6 +901,8 @@
|
||||
"dashboard.telepites": "Telepítés",
|
||||
"dashboard.telepitett_alkalmazasok": "Telepített alkalmazások",
|
||||
"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.vedett": "Védett",
|
||||
"dashboard.vezerlopult": "Vezérlőpult",
|
||||
@@ -917,7 +923,6 @@
|
||||
"debug.bejegyzes": "bejegyzés",
|
||||
"debug.betoltes": "Betöltés...",
|
||||
"debug.cimke": "Cimke",
|
||||
"debug.controller_ujraindulasa": "Controller újraindulása...",
|
||||
"debug.cpu_atlag": "CPU (átlag)",
|
||||
"debug.csak_cross_drive": "Csak cross-drive",
|
||||
"debug.csak_db_dump": "Csak DB dump",
|
||||
@@ -927,7 +932,6 @@
|
||||
"debug.db": "db",
|
||||
"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.dr_telepito_varazslo": "DR / Telepítő varázsló",
|
||||
"debug.dry_run_frissites": "Dry-run frissítés",
|
||||
"debug.elavult_temp_fajlok": "Elavult temp fájlok",
|
||||
"debug.eleres": "Elérés",
|
||||
@@ -936,8 +940,6 @@
|
||||
"debug.ertesites_teszt": "Értesítés teszt",
|
||||
"debug.esemeny_tipus": "Esemény típus:",
|
||||
"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.folyamatban": "Folyamatban...",
|
||||
"debug.frissites": "Frissítés",
|
||||
@@ -961,11 +963,9 @@
|
||||
"debug.igen": "Igen",
|
||||
"debug.igen_2": "igen",
|
||||
"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.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.kesz": "Kész",
|
||||
"debug.kimenet": "Kimenet",
|
||||
@@ -1017,7 +1017,6 @@
|
||||
"debug.titkositott": "titkosított",
|
||||
"debug.torles": "Törlés",
|
||||
"debug.ugynok": "Ügynök",
|
||||
"debug.ujrainditas_setup_modban": "Újraindítás setup módban",
|
||||
"debug.utemezo": "Ütemező",
|
||||
"debug.utolso_db_dump": "Utolsó DB dump:",
|
||||
"debug.utolso_esemenyek": "Utolsó események",
|
||||
@@ -1028,7 +1027,6 @@
|
||||
"debug.utvonal": "Útvonal",
|
||||
"debug.varakozik": "Várakozik",
|
||||
"debug.verzio": "Verzió",
|
||||
"debug.veszhelyzet_szimulacio": "Vészhelyzet szimuláció",
|
||||
"debug.vezerlo": "Vezérlő",
|
||||
"debug.api.invalid_request": "Érvénytelen kérés",
|
||||
"debug.api.notifier_unconfigured": "Notifier nincs konfigurálva",
|
||||
@@ -1052,9 +1050,6 @@
|
||||
"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.reset_confirm_invalid": "Érvénytelen megerősítés — írja be: RESET",
|
||||
"debug.api.marker_write_failed": "Marker fájl írási hiba: %v",
|
||||
"debug.api.restarting_to_setup": "Controller újraindítása setup módba...",
|
||||
"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ó",
|
||||
@@ -1066,7 +1061,7 @@
|
||||
"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_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_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.",
|
||||
@@ -1112,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_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.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.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",
|
||||
@@ -1157,12 +1152,12 @@
|
||||
"deploy.n_nnehany_hiba_tortent_n": "\\n\\nNéhány hiba történt:\\n",
|
||||
"deploy.naplok_megtekintese": "Naplók megtekintése",
|
||||
"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_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_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_regisztralt_adattarolo_adja_meg": "Nincs regisztrált adattároló — adja meg kézzel az útvonalat",
|
||||
"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ó — add meg kézzel az útvonalat",
|
||||
"deploy.nincs_talalat": "Nincs találat",
|
||||
"deploy.nincs_telepitve": "Nincs telepítve",
|
||||
"deploy.opcionalis_beallitasok": "Opcionális beállítások",
|
||||
@@ -1253,6 +1248,8 @@
|
||||
"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_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.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",
|
||||
@@ -1302,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.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.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.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",
|
||||
@@ -1340,7 +1337,7 @@
|
||||
"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.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_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",
|
||||
@@ -1374,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.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.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_2": "formázás sikertelen: %s",
|
||||
"err.web.leszereles_visszavonasa_sikertelen": "leszerelés visszavonása sikertelen: %s",
|
||||
@@ -1417,6 +1414,20 @@
|
||||
"event.crossdrive_completed": "Másodlagos mentés elkészült: %s",
|
||||
"event.crossdrive_failed": "Másodlagos mentés sikertelen: %s",
|
||||
"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_started": "Katasztrófa helyreállítás elindítva (%d alkalmazás)",
|
||||
"event.health_critical": "Rendszer állapot kritikus (volt: %s)",
|
||||
@@ -1424,11 +1435,15 @@
|
||||
"event.health_recovered": "Rendszer állapot helyreállt: %s (volt: %s)",
|
||||
"event.storage_disconnected": "Meghajtó váratlanul leválasztva: %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.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_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_setting_updated": "A távoli mentés beállítása frissítve.",
|
||||
"flash.offbox.config_invalid": "Érvénytelen beállítás: %s",
|
||||
@@ -1477,6 +1492,7 @@
|
||||
"flash.share.disabled": "A megosztás kikapcsolva.",
|
||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||
"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.pw_clear_failed": "A jelszó törlése nem sikerült.",
|
||||
"flash.share.pw_cleared": "A megosztási jelszó törölve.",
|
||||
@@ -1532,6 +1548,19 @@
|
||||
"func.time.tomorrow_at": "holnap %s",
|
||||
"func.time.yesterday": "tegnap",
|
||||
"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.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",
|
||||
@@ -1552,7 +1581,7 @@
|
||||
"launcher.a_megosztas_jelszoval_vedett": "A megosztás jelszóval védett.",
|
||||
"launcher.alkalmazasok_telepitese": "Alkalmazások telepítése",
|
||||
"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.inditopult": "Indítópult",
|
||||
"launcher.inditopult_2": "Indítópult — {{.Domain}}",
|
||||
@@ -1568,7 +1597,7 @@
|
||||
"launcher.megosztas_bekapcsolasa": "Megosztás bekapcsolása",
|
||||
"launcher.megosztas_kikapcsolasa": "Megosztás kikapcsolása",
|
||||
"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.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.",
|
||||
@@ -1609,7 +1638,7 @@
|
||||
"layout.felhasznaloi_adatok_torlese_a_merevlemez": "Felhasználói adatok törlése a merevlemezről",
|
||||
"layout.folyamatban": "Folyamatban...",
|
||||
"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_megosztas": "Hálózati megosztás",
|
||||
"layout.halozati_tarhely": "Hálózati tárhely",
|
||||
@@ -1640,6 +1669,7 @@
|
||||
"layout.nyelv": "Nyelv",
|
||||
"layout.rendszer": "Rendszer",
|
||||
"layout.rendszermonitor": "Rendszermonitor",
|
||||
"layout.hibakereses": "Hibakeresés",
|
||||
"layout.sablonok_frissitve": "Sablonok frissítve",
|
||||
"layout.sikeresen_eltavolitva": "Sikeresen eltávolítva!",
|
||||
"layout.sikeresen_torolve": "Sikeresen törölve!",
|
||||
@@ -1734,6 +1764,7 @@
|
||||
"note.offsite.whole_unit_gap": "(a teljes alkalmazás — nincs helyi mentési egysége)",
|
||||
"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_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.all_files_present": "Minden vizsgált fájl megvan a helyén.",
|
||||
"note.restore.and_database": " és az adatbázis",
|
||||
@@ -1836,7 +1867,7 @@
|
||||
"recovery.adatok": "Adatok",
|
||||
"recovery.adatok_visszaszerzese_felhom": "Adatok visszaszerzése — Felhom",
|
||||
"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.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.",
|
||||
@@ -1855,12 +1886,14 @@
|
||||
"recovery.meret": "Méret",
|
||||
"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.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.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.ugyhogy_tartsd_keznel": "úgyhogy tartsd kéznél.",
|
||||
"recovery.vissza_a_kezdolapra": "Vissza a kezdőlapra",
|
||||
"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.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)”.",
|
||||
@@ -1893,6 +1926,8 @@
|
||||
"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.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.elvart_adatbazismentes_elmaradt": "Elvárt adatbázismentés elmaradt",
|
||||
"settings_notifications.elvart_fajlmentes_elmaradt": "Elvárt fájlmentés elmaradt",
|
||||
@@ -1900,7 +1935,6 @@
|
||||
"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.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_megtelt_vagy_meghibasodott": "Lemez megtelt vagy meghibásodott",
|
||||
"settings_notifications.masodlagos_mentes_sikertelen": "Másodlagos mentés sikertelen",
|
||||
@@ -1914,14 +1948,24 @@
|
||||
"settings_notifications.rendszer_allapot_helyreallt": "Rendszer állapot helyreállt",
|
||||
"settings_notifications.rendszer_allapot_kritikus": "Rendszer állapot kritikus",
|
||||
"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.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_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_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_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",
|
||||
@@ -1929,10 +1973,10 @@
|
||||
"settings_security.elrejt": "Elrejt",
|
||||
"settings_security.engedelyezett_orszagok": "Engedélyezett országok:",
|
||||
"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 | \n <a href=\"{{.SupportURL}}\" target=\"_blank\" style=\"color: var(--blue);\">felhom.eu/kapcsolat</a>",
|
||||
"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.geo_korlatozas_aktiv": "Geo-korlátozás aktív",
|
||||
"settings_security.halozati_hiba": "Hálózati hiba",
|
||||
@@ -1944,7 +1988,7 @@
|
||||
"settings_security.kesz": "Kész",
|
||||
"settings_security.kezi_szinkronizalas": "Kézi szinkronizálás",
|
||||
"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.megjelenit": "Megjelenít",
|
||||
"settings_security.mentes": "Mentés...",
|
||||
@@ -1960,7 +2004,7 @@
|
||||
"settings_security.uj_jelszo": "Új jelszó",
|
||||
"settings_security.uj_jelszo_megerositese": "Új jelszó megerősítése",
|
||||
"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.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.",
|
||||
@@ -1975,18 +2019,18 @@
|
||||
"settings_system.atmeretezes": "Átméretezés",
|
||||
"settings_system.atmeretezes_2": "Átméretezé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_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_egesz_kiszolgalo_szerver_ujrainditasa": "Újraindítja a Felhom részt a gépen: a vezérlőpultot és az összes alkalmazást. A gép maga bekapcsolva marad. Kb. 30 másodpercig semmi nem érhető el, utána minden magától visszajön. Ha csak a vezérlőpult akad, a fenti „Vezérlő újraindítása” elég.",
|
||||
"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.beallitasok_rendszer": "Beállítások — Rendszer",
|
||||
"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_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_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_kiszolgalot_az": "Biztosan újraindítod a Felhomot? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak. A gép bekapcsolva marad.",
|
||||
"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.ellenorzes": "Ellenőrzés...",
|
||||
"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.folyamatban": "Folyamatban",
|
||||
"settings_system.frissites": "Frissítés",
|
||||
@@ -1996,7 +2040,7 @@
|
||||
"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_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.halozati_nev": "Hálózati név",
|
||||
"settings_system.helyi_cim_lan": "Helyi cím (LAN)",
|
||||
@@ -2007,7 +2051,7 @@
|
||||
"settings_system.jelenlegi_verzio": "Jelenlegi verzió",
|
||||
"settings_system.kapcsolodasi_hiba": "Kapcsolódási hiba",
|
||||
"settings_system.kesz": "Kész.",
|
||||
"settings_system.kiszolgalo_ujrainditasa": "Kiszolgáló újraindítása",
|
||||
"settings_system.kiszolgalo_ujrainditasa": "A Felhom újraindítása",
|
||||
"settings_system.legujabb_verzio": "Legújabb verzió",
|
||||
"settings_system.megengedett_tartomany": "Megengedett tartomány",
|
||||
"settings_system.megerosites": "Megerősítés",
|
||||
@@ -2110,7 +2154,7 @@
|
||||
"stacks.alkalmazas_visszaallitasa_exportalt_csom": "Alkalmazás visszaállítása exportált csomagból",
|
||||
"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_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.futo": "Futó",
|
||||
"stacks.halozati_tarhely_hibas_az_alkalmazas": "Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja",
|
||||
@@ -2136,7 +2180,7 @@
|
||||
"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_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_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>:",
|
||||
@@ -2166,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.biztonsagi_mentes_vedett": "Biztonsági mentés — védett",
|
||||
"storage.biztonsagos_levalasztas": "Biztonságos leválasztás",
|
||||
"storage.biztos_levalasztja_a_meghajtot": "Biztos leválasztja a meghajtót:",
|
||||
"storage.biztosan_eltavolitja_adattarolot": "Biztosan eltávolítja 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.biztos_levalasztja_a_meghajtot": "Biztosan leválasztod a meghajtót:",
|
||||
"storage.biztosan_eltavolitja_adattarolot": "Biztosan eltávolítod a(z) {{.Path}} adattárolót?",
|
||||
"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.csatlakoztatas": "Csatlakoztatás",
|
||||
"storage.csatlakoztatas_2": "Csatlakoztatás...",
|
||||
@@ -2201,7 +2245,7 @@
|
||||
"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.hozzaadas": "Hozzáadás",
|
||||
"storage.idopont": "Időpont: {{.DecommissionedAt}}",
|
||||
"storage.idopont": "Időpont: {{fmtTimeStr .DecommissionedAt}}",
|
||||
"storage.inaktiv": "Inaktív",
|
||||
"storage.ismeretlen": "ismeretlen",
|
||||
"storage.kesz": "Kész",
|
||||
@@ -2219,16 +2263,16 @@
|
||||
"storage.leszereles_athelyezessel_minden_adat_atm": "Leszerelés áthelyezéssel: minden adat átmásolása ide:",
|
||||
"storage.leszereles_elinditva": "Leszerelés elindítva.",
|
||||
"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_folyamatban": "Leválasztás folyamatban…",
|
||||
"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.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Már csatlakoztatott tárhely hozzáadása kézzel",
|
||||
"storage.megerosites": "Megerősítés",
|
||||
"storage.megerositeshez_irja_be": "Megerősítéshez írja be:",
|
||||
"storage.megerositeshez_irja_be_a_csatlakoztatasi": "Megerősítéshez írja be a csatlakoztatási nevet:",
|
||||
"storage.megerositeshez_irja_be": "Megerősítéshez írd be:",
|
||||
"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_leszerelese": "Meghajtó leszerelése",
|
||||
"storage.meghajto_levalasztasa": "Meghajtó leválasztása",
|
||||
@@ -2252,7 +2296,7 @@
|
||||
"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_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_adatat_ide": "ÖSSZES adatát ide:",
|
||||
"storage.pl_mnt_hdd_1_a": "Pl. /mnt/hdd_1 — a meghajtónak már csatolva kell lennie",
|
||||
@@ -2275,7 +2319,7 @@
|
||||
"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.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.2_konfiguracio": "2. Konfiguráció",
|
||||
"storage_attach.a_meghajto_a_mnt_nev": "A meghajtó a /mnt/<név> útvonalra kerül.",
|
||||
@@ -2297,14 +2341,14 @@
|
||||
"storage_attach.meglevo_meghajto_csatolasa": "Meglévő meghajtó csatolása",
|
||||
"storage_attach.megnevezes": "Megnevezés",
|
||||
"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.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_a_beallitasokhoz": ". <a href=\"/settings\">Vissza a Beállításokhoz →</a>",
|
||||
"storage_init.1_meghajto_kivalasztasa": "1. Meghajtó kiválasztása",
|
||||
"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/<név> ú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_sikeresen_inicializalva_es": "A meghajtó sikeresen inicializálva és regisztrálva:",
|
||||
@@ -2325,18 +2369,18 @@
|
||||
"storage_init.kivalasztott_eszkoz": "Kiválasztott eszköz",
|
||||
"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.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.megnevezes": "Megnevezés",
|
||||
"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.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_megerositese": "Törlés és inicializálás megerősítése",
|
||||
"storage_init.uj_meghajto_inicializalasa": "Új meghajtó inicializálása",
|
||||
"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.vissza": "← Vissza",
|
||||
"storage_init.vissza_a_tarhelyhez": ". <a href=\"/storage\">Vissza a tárhelyhez →</a>",
|
||||
@@ -2347,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_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/<név> ú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_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.",
|
||||
@@ -2356,21 +2400,21 @@
|
||||
"storage_network.arva": "Árva",
|
||||
"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.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.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.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_jelenleg_keszenletben_igeny_sze": "Elérhető, jelenleg készenlétben (igény szerint csatlakozik)",
|
||||
"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_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.hiba": "Hiba:",
|
||||
"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.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…",
|
||||
@@ -2396,7 +2440,7 @@
|
||||
"storage_network.smb_felhasznalonev": "SMB felhasználónév",
|
||||
"storage_network.smb_jelszo": "SMB jelszó",
|
||||
"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/<mappa>) — 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/<mappa>) — ez kerül a\n fenti „Megosztás” mezőbe.",
|
||||
"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.technikai_reszletek": "Technikai részletek",
|
||||
@@ -2509,6 +2553,28 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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.",
|
||||
@@ -2542,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_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_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.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.",
|
||||
"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",
|
||||
@@ -2584,5 +2652,26 @@
|
||||
"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_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",
|
||||
"settings_system.megerositeshez_ird_be": "Megerősítéshez írd be:",
|
||||
"settings_system.a_gep_kikapcsolasa": "A gép kikapcsolása",
|
||||
"settings_system.a_gep_teljesen_kikapcsol": "A gép teljesen kikapcsol. Az alkalmazásaid és a mentések leállnak.",
|
||||
"settings_system.ujra_bekapcsolni_csak_otthon": "Újra bekapcsolni csak otthon lehet: nyomd meg a gép bekapcsológombját.",
|
||||
"settings_system.kikapcsolas_frissites_utan": "A gép kikapcsolásához a gép szoftverének frissítése kell. Ez magától megtörténik, addig ez a gomb nem látszik.",
|
||||
"settings_system.kikapcsolas_szo": "KIKAPCSOLÁS",
|
||||
"settings_system.kikapcsolas": "Kikapcsolás",
|
||||
"settings_system.biztosan_kikapcsolod_a_gepet": "Biztosan kikapcsolod a gépet? A Felhom leáll, és a gép kikapcsol. Távolról senki nem tudja újra bekapcsolni: csak otthon, a gép bekapcsológombjával.",
|
||||
"settings_system.a_gep_kikapcsol_folyamatban": "A gép kikapcsol. Néhány perc múlva teljesen leáll, és ez az oldal nem válaszol többé. Újra bekapcsolni: nyomd meg a gép bekapcsológombját.",
|
||||
"poweroff.confirm_required": "Írd be a megerősítő szót: KIKAPCSOLÁS.",
|
||||
"poweroff.agent_down": "A gépet most nem lehet kikapcsolni, mert a vezérlő nem éri el a gép kezelőjét. Próbáld újra néhány perc múlva.",
|
||||
"poweroff.agent_outdated": "A gép kikapcsolásához a gép szoftverének frissítése kell. Ez magától megtörténik.",
|
||||
"poweroff.refused.too_soon": "A gép most indult el. 10 perc múlva kapcsolhatod ki.",
|
||||
"poweroff.refused.already": "A kikapcsolás már folyamatban van.",
|
||||
"poweroff.refused.backup": "Most mentés vagy visszaállítás fut. Várd meg, amíg befejeződik, és próbáld újra.",
|
||||
"poweroff.refused.update": "Most frissítés fut. Várd meg, amíg befejeződik, és próbáld újra.",
|
||||
"poweroff.refused.drive": "A gép most egy meghajtón dolgozik. Várd meg, amíg befejeződik, és próbáld újra.",
|
||||
"poweroff.refused.other_guests": "Ezen a gépen más rendszer is fut, ezért innen nem lehet kikapcsolni.",
|
||||
"poweroff.refused.generic": "A gép most nem tudja biztonságosan kikapcsolni magát. Próbáld újra néhány perc múlva."
|
||||
}
|
||||
|
||||
@@ -26,8 +26,15 @@ type HealthReport struct {
|
||||
// Issues and Warnings only, so the hub report is unchanged — pinned by
|
||||
// TestR553_HubReportWarningsAreUnchangedOnTheWire.
|
||||
WarningKinds []string
|
||||
Info []string // informational items
|
||||
Timestamp time.Time
|
||||
// WarningMsgs / IssueMsgs are parallel to Warnings / Issues (R-516 item 10): the bundle key and
|
||||
// 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.
|
||||
@@ -54,8 +61,43 @@ const (
|
||||
// addWarning appends a warning together with its kind, so the two slices cannot drift apart. Every
|
||||
// warning goes through here; `WarningKindAt` reads them back.
|
||||
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.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
|
||||
@@ -89,109 +131,11 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
|
||||
sysInfo.CPUPercent, sysInfo.TemperatureCelsius, sysInfo.TemperatureSource)
|
||||
}
|
||||
|
||||
// 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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkResources(report, sysInfo, cfg, logger, debug)
|
||||
|
||||
// 5. Docker health
|
||||
if err := checkDocker(); err != nil {
|
||||
report.Issues = append(report.Issues, fmt.Sprintf("Docker: %v", err))
|
||||
addDockerIssue(report, err)
|
||||
if debug {
|
||||
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)
|
||||
}
|
||||
missingProtected := checkProtectedContainers(protected)
|
||||
for _, name := range missingProtected {
|
||||
report.Issues = append(report.Issues, fmt.Sprintf("Protected container not running: %s", name))
|
||||
}
|
||||
addProtectedIssues(report, missingProtected)
|
||||
if debug {
|
||||
if len(missingProtected) > 0 {
|
||||
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
|
||||
storageIssues, storageWarnings, storageKinds := checkStoragePaths(storagePaths)
|
||||
report.Issues = append(report.Issues, storageIssues...)
|
||||
for i, w := range storageWarnings {
|
||||
report.addWarning(w, storageKinds[i])
|
||||
}
|
||||
addStorage(report, storagePaths)
|
||||
|
||||
// Determine status
|
||||
if len(report.Issues) > 0 {
|
||||
@@ -279,6 +217,38 @@ func (r *HealthReport) FormatMessage() 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 {
|
||||
cmd := dockerexec.Command("docker", "info", "--format", "{{.ServerVersion}}")
|
||||
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
|
||||
// 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.
|
||||
func checkStoragePaths(paths []settings.StoragePath) (issues, warnings, kinds []string) {
|
||||
// find it by kind rather than by the words the sentence happens to contain today. issueMsgs and
|
||||
// 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 {
|
||||
// Skip decommissioned paths — no longer in active use
|
||||
if sp.Decommissioned {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// Path accessible?
|
||||
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
|
||||
}
|
||||
|
||||
// Mount point check — warning, not issue (avoids false FAIL on demo/test environments)
|
||||
if !system.IsMountPoint(sp.Path) {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
warnFmtStorageNotSeparate, sp.Path))
|
||||
kinds = append(kinds, WarnKindStorageNotSeparate)
|
||||
if !isMountPointFn(sp.Path) {
|
||||
warn(fmt.Sprintf(warnFmtStorageNotSeparate, sp.Path), WarnKindStorageNotSeparate,
|
||||
MsgRef{"health.storage_not_separate", []interface{}{sp.Path}})
|
||||
}
|
||||
|
||||
// Disk usage
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
if di := diskUsageFn(sp.Path); di != nil {
|
||||
if di.UsedPercent >= 95 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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: filepath.Join(dir, "kihagyott"), Decommissioned: true},
|
||||
}
|
||||
_, warnings, kinds := checkStoragePaths(paths)
|
||||
_, _, warnings, kinds, _ := checkStoragePaths(paths)
|
||||
if 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 {
|
||||
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) {
|
||||
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)
|
||||
}},
|
||||
{"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_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) }},
|
||||
|
||||
@@ -261,6 +261,13 @@ func (n *Notifier) PushEvent(eventType, severity, message string, details interf
|
||||
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{}) {
|
||||
// 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
|
||||
@@ -403,9 +410,55 @@ func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyBackupFailed sends a backup failure event.
|
||||
func (n *Notifier) NotifyBackupFailed(message, errMsg string) {
|
||||
n.PushEvent("backup_failed", "error", message, BackupDetails{Error: errMsg})
|
||||
// InterruptedOperation is the FACTS of an app-stopping operation a controller restart interrupted
|
||||
// (backup.AppStopRecovery.Facts): which kind it was, and how many apps were restarted, could not be
|
||||
// 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
|
||||
@@ -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 +
|
||||
// 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).
|
||||
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
|
||||
@@ -493,8 +555,9 @@ func (n *Notifier) NotifyOffboxEnlargeBlocked(message string) {
|
||||
// below are still emitted and consumed.)
|
||||
|
||||
// NotifyDBDumpFailed sends a DB dump failure event.
|
||||
func (n *Notifier) NotifyDBDumpFailed(message, errMsg string) {
|
||||
n.PushEvent("db_dump_failed", "error", message, DBDumpDetails{Error: errMsg})
|
||||
// R-585: the headline is a bundle key, so it follows the household's language; errMsg is the cause.
|
||||
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.
|
||||
@@ -503,8 +566,13 @@ func (n *Notifier) NotifyDBDumpCompleted(details DBDumpDetails) {
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -525,8 +593,16 @@ func (n *Notifier) NotifyOffsiteProofEmpty(message, detail string) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -960,11 +1036,16 @@ type preferencesRequest struct {
|
||||
Email string `json:"email"`
|
||||
EnabledEvents []string `json:"enabled_events"`
|
||||
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.
|
||||
// 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 {
|
||||
return util.MsgError("err.notify.hub_nem_konfiguralt")
|
||||
}
|
||||
@@ -974,6 +1055,7 @@ func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldow
|
||||
Email: email,
|
||||
EnabledEvents: enabledEvents,
|
||||
CooldownHours: cooldownHours,
|
||||
EmailCleared: emailCleared,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
@@ -1007,7 +1089,7 @@ func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldow
|
||||
if n.debug {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ var severityArg = map[string]int{
|
||||
"pushEventMsg": 1, // (eventType, severity, key, details, args...)
|
||||
"pushEventMsgSuffix": 1, // (eventType, severity, key, suffix, details, args...)
|
||||
"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
|
||||
@@ -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: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: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: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: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)) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,8 +145,12 @@ func TestNotify_BothFailingTiersAreReported(t *testing.T) {
|
||||
rec := &recNotifier{}
|
||||
l.SetTierNotifier(rec)
|
||||
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
// `09` §3 decision 156: one tier per window, so the second tier is reached by the NEXT cycle —
|
||||
// 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")
|
||||
if gotLocal != 1 || gotPBS != 1 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -131,6 +131,10 @@ type Loop struct {
|
||||
// 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.
|
||||
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.
|
||||
@@ -219,8 +223,9 @@ func (l *Loop) runOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// R-82: resolve EVERY due tier up front. This is the dedup rule (tiers.go): both tiers due on
|
||||
// the weekly night yields ONE window with two backups, never two stop/start cycles.
|
||||
// R-82: resolve EVERY due tier up front (the breaker, the catch-up wait and the window gate below
|
||||
// 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)
|
||||
if err != nil {
|
||||
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.
|
||||
if l.windowStartFn != nil {
|
||||
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)
|
||||
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
|
||||
@@ -425,10 +430,16 @@ func (l *Loop) TriggerNow() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), l.maxQuiesce+5*time.Minute)
|
||||
defer cancel()
|
||||
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
|
||||
// window. A manual "Mentés most" that silently skipped the DR tier would be the same
|
||||
// applied-and-empty fault in a different costume.
|
||||
if err := l.quiesceAndPollTiers(ctx, l.allTiersForManualRun(ctx)); err != nil {
|
||||
// Manual runs bypass due-ness (that is the point). Since `09` §3 decision 156 (R-518 option A)
|
||||
// the press makes the LOCAL (primary) copy only, with one short stop; the off-site tier is not
|
||||
// requested here — it stays due and the next scheduled night run takes it in its own window.
|
||||
// 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)
|
||||
}
|
||||
}()
|
||||
@@ -443,8 +454,59 @@ func (l *Loop) quiesceAndPoll(ctx context.Context) error {
|
||||
return l.quiesceAndPollTiers(ctx, []dueTier{{target: ""}})
|
||||
}
|
||||
|
||||
// allTiersForManualRun lists every tier a manual run should cover: all advertised tiers on an R-82
|
||||
// agent, or the single untargeted tier otherwise. Due-ness is deliberately NOT consulted.
|
||||
// manualRunTiers lists the tier a manual press backs up: on an R-82 agent ONLY the primary (local)
|
||||
// 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 {
|
||||
tb, ok := l.backend.(TieredBackend)
|
||||
if !ok {
|
||||
@@ -462,20 +524,26 @@ func (l *Loop) allTiersForManualRun(ctx context.Context) []dueTier {
|
||||
kept := l.skipAbsentTiers(tiers)
|
||||
out := make([]dueTier, 0, len(kept))
|
||||
for _, t := range kept {
|
||||
out = append(out, dueTier{target: t.Target})
|
||||
out = append(out, dueTier{target: t.Target, primary: t.Primary})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quiesceAndPollTiers is the R-82 multi-tier cycle: ONE marker, ONE stop, N backups run
|
||||
// SEQUENTIALLY inside the window, ONE resume, then the tail polled to completion.
|
||||
// quiesceAndPollTiers is ONE quiesce window: ONE marker, ONE stop, ONE backup, ONE resume at that
|
||||
// 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
|
||||
// finishes. Why the app stays down until the LAST tier snapshots: the whole point of quiescing is
|
||||
// app-consistency, and resuming after tier 1's snapshot would leave tier 2 capturing a RUNNING app.
|
||||
// Consequence, stated plainly because it is user-visible: on the both-due night downtime is
|
||||
// (first tier's full backup) + (last tier's snapshot), not one snapshot. Tier ORDER therefore
|
||||
// matters — see resolveDueTiers.
|
||||
// ONE STOP PER BACKUP TIER (`09` §3 decision 156, R-518 option A, 2026-10-06). This REVERSES R-82's
|
||||
// „ONE quiesce window for both due tiers (never two app outages for one night)": that window kept every
|
||||
// app down for the first tier's whole upload (5 min 47 s measured on demo-hp, 9 apps). Now the window
|
||||
// backs up only the FIRST tier in `tiers` that starts; the apps resume at its snapshot; any other due
|
||||
// tier stays due and a LATER cycle (the next poll, after this one has finished) takes it in its own
|
||||
// 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,
|
||||
// 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 {
|
||||
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()
|
||||
marker := Marker{Active: true, StartedAt: l.now(), StoppedStacks: running}
|
||||
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)
|
||||
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 {
|
||||
label := tierLabel(t.target)
|
||||
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.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 {
|
||||
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.
|
||||
default:
|
||||
l.noteTierSuccess(t.target, label)
|
||||
l.recordWholeGuestSuccess(t.target, IsManualTrigger(ctx)) // R-899 (nightowed.go)
|
||||
}
|
||||
if stillRunning {
|
||||
// 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)
|
||||
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,
|
||||
@@ -604,16 +688,17 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
|
||||
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
|
||||
// app-consistent while still costing exactly one stop/start pair. For a non-last tier the loop
|
||||
// waits for a TERMINAL phase (done/failed), because vzdump holds the guest lock and the next tier
|
||||
// cannot start until this one truly finishes.
|
||||
// The app resumes at `snapshotted` (8B.2 early resume) and the poll continues to the terminal phase
|
||||
// with the app running, so the outcome is still recorded (breaker, hub notice) and the cycle holds the
|
||||
// single-flight lock until the agent's job — and its guest lock — has finished. Since `09` §3
|
||||
// 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
|
||||
// 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,
|
||||
last bool, unquiesced *bool, unquiesce func(string)) (string, bool, error) {
|
||||
unquiesced *bool, unquiesce func(string)) (string, bool, error) {
|
||||
for {
|
||||
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",
|
||||
@@ -628,25 +713,18 @@ func (l *Loop) pollTier(ctx context.Context, target, jobID, label string, deadli
|
||||
}
|
||||
switch phase {
|
||||
case phaseSnapshotted:
|
||||
// 8B.2 early resume — but ONLY on the last tier. Resuming here on a non-last tier would
|
||||
// leave the following tier capturing a running app, losing app-consistency for exactly
|
||||
// the DR tier we most want it on.
|
||||
if last && !*unquiesced {
|
||||
l.logger.Printf("[INFO] [quiesce] tier %s: job %s snapshotted — resuming app early (8B.2)", label, jobID)
|
||||
unquiesce("snapshotted (early resume, last tier)")
|
||||
// 8B.2 early resume. Safe for every tier since decision 156: no other tier is started in
|
||||
// this window, so no later copy can capture a running app.
|
||||
if !*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)
|
||||
unquiesce("snapshotted (early resume)")
|
||||
}
|
||||
case phaseDone:
|
||||
if last {
|
||||
l.logger.Printf("[INFO] [quiesce] tier %s: backup job %s done", label, jobID)
|
||||
unquiesce("backup done")
|
||||
} else {
|
||||
l.logger.Printf("[INFO] [quiesce] tier %s: backup job %s done — next tier may start (app still quiesced)", label, jobID)
|
||||
}
|
||||
l.logger.Printf("[INFO] [quiesce] tier %s: backup job %s done", label, jobID)
|
||||
unquiesce("backup done")
|
||||
return phaseDone, false, nil
|
||||
case phaseFailed:
|
||||
if last {
|
||||
unquiesce("backup failed")
|
||||
}
|
||||
unquiesce("backup failed")
|
||||
return phaseFailed, false, nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// `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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,25 @@ import (
|
||||
"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
|
||||
// owns quiescing, so the multi-tier schedule has to be reconciled HERE: on the weekly night both
|
||||
// tiers come due at once, and running two quiesce cycles would mean **two app outages for one
|
||||
// night's work** — which would undo the entire argument for weekly-over-daily.
|
||||
// owns quiescing, so the multi-tier schedule has to be reconciled HERE. R-82 put both tiers into ONE
|
||||
// quiesce window ("never two app outages for one night"); that kept every app down for the whole
|
||||
// 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
|
||||
// ----------+---------+---------------------------------------------------------------
|
||||
// yes | no | one quiesce, local 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
|
||||
//
|
||||
// A manual press backs up the primary (local) tier only (manualRunTiers).
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// state (R-88 Part 2) disambiguates a nil ageSecs. Empty = legacy agent.
|
||||
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.
|
||||
//
|
||||
// Returns the due tiers IN AGENT ORDER (primary first). That order is deliberate and it is a
|
||||
// downtime decision, not cosmetics: tiers run SEQUENTIALLY because vzdump holds a guest lock, and
|
||||
// the app stays stopped until the LAST tier has snapshotted. Running the fast local tier first and
|
||||
// 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.
|
||||
// Returns the due tiers IN AGENT ORDER (primary first). Since `09` §3 decision 156 a window backs up
|
||||
// only the FIRST of them, so the order decides which tier goes first when both are due: the local
|
||||
// copy (the one a restore most often reads) is taken first, the off-site copy at the next cycle.
|
||||
//
|
||||
// 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) {
|
||||
@@ -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")
|
||||
return l.resolveUntargeted(ctx)
|
||||
}
|
||||
led := l.loadLedger() // R-899: read once per cycle
|
||||
for _, t := range tiers {
|
||||
isDue, age, wireState, derr := tb.DueFor(ctx, t.Target)
|
||||
if derr != nil {
|
||||
@@ -186,6 +194,12 @@ func (l *Loop) resolveDueTiers(ctx context.Context) (due []dueTier, degraded boo
|
||||
l.logAgeStateDegradeOnce()
|
||||
}
|
||||
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
|
||||
@@ -198,6 +212,10 @@ func (l *Loop) resolveUntargeted(ctx context.Context) ([]dueTier, bool, error) {
|
||||
return nil, true, err
|
||||
}
|
||||
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 []dueTier{{target: "", ageSecs: age}}, true, nil
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"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":
|
||||
// 1. On the both-due night there is EXACTLY ONE stop/start pair. Two would mean two app outages
|
||||
// for one night's work, undoing the whole argument for weekly-over-daily.
|
||||
// 1. Each window backs up ONE tier and resumes the apps at its snapshot; another due tier gets its
|
||||
// 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
|
||||
// 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".
|
||||
stacks *fakeStacks
|
||||
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 {
|
||||
return &tierBackend{
|
||||
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()
|
||||
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()
|
||||
defer b.mu.Unlock()
|
||||
if b.startErrOn == target {
|
||||
return "", fmt.Errorf("simulated start failure on %s", target)
|
||||
}
|
||||
b.started = append(b.started, target)
|
||||
b.manualStarts = append(b.manualStarts, IsManualTrigger(ctx))
|
||||
if b.stacks != nil {
|
||||
b.startsAtStart = append(b.startsAtStart, len(b.stacks.startedNames()))
|
||||
b.stopsAtStart = append(b.stopsAtStart, len(b.stacks.stoppedNames()))
|
||||
}
|
||||
return "job-" + target, nil
|
||||
}
|
||||
func (b *tierBackend) BackupStatusFor(_ context.Context, target string) (string, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.stacks != nil {
|
||||
b.statusRestarts[target] = append(b.statusRestarts[target], len(b.stacks.startedNames()))
|
||||
}
|
||||
seq := b.phases[target]
|
||||
i := b.phaseIdx[target]
|
||||
if i < len(seq) {
|
||||
@@ -108,6 +122,24 @@ func (b *tierBackend) restartsWhenEachTierStarted() []int {
|
||||
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 {
|
||||
b.mu.Lock()
|
||||
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 ----------------------------------------------------
|
||||
|
||||
// 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.
|
||||
// ---- R-518 option A — one stop per backup tier (`09` §3 decision 156) --------------------
|
||||
//
|
||||
// COMPANION RED-PROOF (observed): make runOnce call quiesceAndPollTiers once per due tier
|
||||
// (a per-tier cycle instead of one window) → stops/starts become 2/2 and this fails with
|
||||
// "want EXACTLY 1 stop and 1 start ... got stops=2 starts=2". Restored.
|
||||
func TestBothTiersDue_ExactlyOneQuiesceWindow(t *testing.T) {
|
||||
// Decision 156 REVERSES R-82's „ONE quiesce window for both due tiers (never two app outages for one
|
||||
// night)". The one long window kept every app down for the whole local upload (5 min 47 s measured on
|
||||
// demo-hp, 9 apps). Now each window runs ONLY the first due tier and resumes the apps at its
|
||||
// `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.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
||||
be.dueSet["local"] = true
|
||||
be.dueSet["felhom-pbs"] = true
|
||||
be.phases["local"] = []string{phaseDone}
|
||||
be.phases["local"] = []string{phaseSnapshotted, phaseDone}
|
||||
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
|
||||
|
||||
st := &fakeStacks{running: []string{"immich"}}
|
||||
@@ -151,14 +188,120 @@ func TestBothTiersDue_ExactlyOneQuiesceWindow(t *testing.T) {
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatalf("runOnce: %v", err)
|
||||
}
|
||||
stops, starts := len(st.stoppedNames()), len(st.startedNames())
|
||||
if stops != 1 || starts != 1 {
|
||||
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)",
|
||||
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
|
||||
t.Fatalf("one window: want EXACTLY 1 stop and 1 start, got stops=%d starts=%d (stopped=%v started=%v)",
|
||||
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()
|
||||
if len(got) != 2 || got[0] != "local" || got[1] != "felhom-pbs" {
|
||||
t.Fatalf("both tiers must back up, primary first: got %v", got)
|
||||
if len(got) != 2 || got[1] != "felhom-pbs" {
|
||||
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 --------------------------------------------
|
||||
|
||||
// The agent 404s /backup/tiers. The controller MUST degrade to the untargeted tier, LOG it, and
|
||||
|
||||
@@ -4,55 +4,64 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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 {
|
||||
CustomerID string
|
||||
RetrievalPassword string
|
||||
HubURL string
|
||||
SupportEmail 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.
|
||||
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)
|
||||
// recoveryInfoKey is the file's text in the bundle (hu.json / en.json), with ten %s, in order:
|
||||
// customer id, hub URL, retrieval password, support e-mail, support web, dashboard, restore page,
|
||||
// recovery page, generated-at, controller version.
|
||||
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)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(`Felhom Controller — Vészhelyzeti információk
|
||||
=============================================
|
||||
|
||||
Ü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
|
||||
`,
|
||||
dash := strings.TrimRight(info.DashboardURL, "/")
|
||||
return b.Msgf(i18n.Normalize(info.Lang), recoveryInfoKey,
|
||||
info.CustomerID,
|
||||
info.HubURL,
|
||||
info.RetrievalPassword,
|
||||
info.SupportEmail,
|
||||
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,
|
||||
)
|
||||
), 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")
|
||||
tmp := path + ".tmp"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func BuildReport(
|
||||
|
||||
// Storage — root filesystem + all registered storage paths
|
||||
r.Storage = []StorageReport{
|
||||
{Mount: "/", Label: "SSD", TotalGB: sysInfo.DiskTotalGB, UsedGB: sysInfo.DiskUsedGB, Percent: sysInfo.DiskPercent},
|
||||
rootStorageEntry(sysInfo),
|
||||
}
|
||||
for _, sp := range storagePaths {
|
||||
if sp.Decommissioned {
|
||||
@@ -173,6 +173,9 @@ func BuildReport(
|
||||
// consume-once ACK-flag pattern (selftail.go). nil in the steady state.
|
||||
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
|
||||
// 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
|
||||
@@ -325,3 +328,14 @@ func buildStacksReport(stackMgr *stacks.Manager) StacksReport {
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,14 @@ type PushResponse struct {
|
||||
// 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.
|
||||
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.
|
||||
type Pusher struct {
|
||||
hubURL string
|
||||
@@ -126,8 +132,10 @@ func (p *Pusher) Push(report *Report) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read response body to parse customer_blocked field
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
// Read response body to parse customer_blocked field. The cap was 4 KiB; an ACK over it fails to
|
||||
// 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()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,11 @@ type Report struct {
|
||||
// app-tail flow above is untouched).
|
||||
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
|
||||
// their own password). The hub ingests it SET-ONLY: a later false (wiped settings.json
|
||||
// after DR) never un-claims the customer hub-side.
|
||||
@@ -89,6 +94,11 @@ type StorageReport struct {
|
||||
Disconnected bool `json:"disconnected,omitempty"`
|
||||
Decommissioned bool `json:"decommissioned,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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
@@ -359,7 +360,74 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
|
||||
}
|
||||
job.Running = true
|
||||
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() {
|
||||
s.mu.Lock()
|
||||
job.Running = false
|
||||
@@ -369,8 +437,9 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
|
||||
// Panic recovery
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
s.mu.Lock()
|
||||
job.LastErr = fmt.Errorf("panic: %v", r)
|
||||
job.LastErr = err
|
||||
job.LastRun = time.Now()
|
||||
s.mu.Unlock()
|
||||
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)
|
||||
|
||||
start := time.Now()
|
||||
err := job.Fn(s.ctx)
|
||||
err = job.Fn(ctx)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
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
|
||||
// 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)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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,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
|
||||
// genuinely does have entries, so it is not the same defect.
|
||||
if !strings.Contains(refuseAppNamespaceNetwork, "Válasszon csatlakoztatott meghajtót") {
|
||||
// genuinely does have entries, so it is not the same defect. (R-516, 2026-10-06 night: the same
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,6 +622,28 @@ type NotificationPrefs struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
EnabledEvents []string `json:"enabled_events,omitempty"`
|
||||
CooldownHours int `json:"cooldown_hours,omitempty"` // default: 6
|
||||
// EmailCleared (R-922, operator ruling 2026-10-09 option A) marks a DELIBERATE clear: the household
|
||||
// saved an empty address where one was stored. Every hub push carries it as `email_cleared: true`
|
||||
// while the address stays empty, so the hub deletes the address it holds — and a push the hub missed
|
||||
// is repaired by the next one. A box that never had an address never sets it (the hub's no-clobber
|
||||
// guard must keep protecting a seeded address); a new address drops it. Set only by
|
||||
// web.settingsNotificationsHandler via ClearedBy. Pinned by internal/web TestR922_*.
|
||||
EmailCleared bool `json:"email_cleared,omitempty"`
|
||||
}
|
||||
|
||||
// ClearedBy reports whether saving `newEmail` over these stored prefs is (or keeps) a deliberate clear:
|
||||
// the new address is empty AND the stored one was not — or the stored prefs already record a clear.
|
||||
func (p *NotificationPrefs) ClearedBy(newEmail string) bool {
|
||||
if newEmail != "" || p == nil {
|
||||
return false
|
||||
}
|
||||
return p.Email != "" || p.EmailCleared
|
||||
}
|
||||
|
||||
// SyncOnStartup reports whether the startup push should run: an address to restore after a hub DB
|
||||
// rebuild, or a household clear the hub may not have received yet (R-922).
|
||||
func (p *NotificationPrefs) SyncOnStartup() bool {
|
||||
return p != nil && (p.Email != "" || p.EmailCleared)
|
||||
}
|
||||
|
||||
// DefaultEnabledEvents are the events enabled by default for new customers.
|
||||
@@ -642,6 +664,14 @@ var DefaultEnabledEvents = []string{
|
||||
"app_update_held",
|
||||
// v0.269.0 (`09` §3 decision 28): the box stopped an app that kept crashing / ran out of memory.
|
||||
"app_stopped_unhealthy",
|
||||
// v0.309.0 (R-388 slice 1, operator Q1 2026-10-12): the default completes the page's groups 1 and 3
|
||||
// („A gépem és a meghajtóim", „Amit mi intézünk" — on by default). Only households with NO saved list
|
||||
// follow this; no saved choice is changed and nothing is seeded into stored prefs.
|
||||
// Pinned by TestR388_DefaultListAsGroups.
|
||||
"storage_reconnected",
|
||||
"backup_integrity_failed",
|
||||
"crossdrive_failed",
|
||||
"health_recovered",
|
||||
}
|
||||
|
||||
// PendingEvent is an event queued for the next Hub push cycle.
|
||||
@@ -1876,6 +1906,11 @@ const (
|
||||
// HoldReasonUnhealthyStop (v0.269.0, `09` §3 decision 28): the box stopped an app in a crash loop or
|
||||
// an out-of-memory storm. Lifted by the household's Start (one more try); nothing else starts it.
|
||||
HoldReasonUnhealthyStop = "unhealthy_stop"
|
||||
// HoldReasonRestoreMixed (R-893, `09` §3 decision 192): an off-site restore of one app failed its database
|
||||
// replay AFTER the snapshot's definition was written or a named volume was replaced. The database was
|
||||
// rolled back, the live definition written back, and the app is held: its files and volumes are the
|
||||
// snapshot's, its database the pre-restore one. Cleared like a restore hold — by the operator.
|
||||
HoldReasonRestoreMixed = "restore_mixed"
|
||||
)
|
||||
|
||||
// SetRestoreHold records a hold. Modelled on SetDisconnected: a condition, plus what it is holding.
|
||||
@@ -2139,7 +2174,7 @@ func (s *Settings) DriveOfSubPath(path string) (string, bool) {
|
||||
// an internal path or field name.
|
||||
const (
|
||||
refuseAppNamespaceNetwork = "Hálózati tárhelyen (NAS) nem futtatható alkalmazás adatkönyvtára — " +
|
||||
"a NAS megosztás tallózásra és médiatárolásra használható. Válasszon csatlakoztatott meghajtót."
|
||||
"a NAS megosztás tallózásra és médiatárolásra használható. Válassz csatlakoztatott meghajtót."
|
||||
// ⚠ R-220 / SCENARIO G — THIS SENTENCE USED TO NAME AN IMPOSSIBLE ACTION.
|
||||
//
|
||||
// It said *"Válasszon a listából csatlakoztatott meghajtót"* — choose an attached drive from the
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const csrfCookieName = "felhom_csrf"
|
||||
const csrfFormField = "_csrf"
|
||||
|
||||
// generateCSRFToken creates a random 32-byte hex token.
|
||||
func generateCSRFToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand.Read failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// setCSRFCookie sets the CSRF cookie on the response.
|
||||
func setCSRFCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
HttpOnly: false, // JavaScript needs to read it for AJAX if needed
|
||||
})
|
||||
}
|
||||
|
||||
// validateCSRF checks that the form field matches the cookie.
|
||||
func validateCSRF(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
formToken := r.FormValue(csrfFormField)
|
||||
if formToken == "" {
|
||||
return false
|
||||
}
|
||||
return cookie.Value == formToken
|
||||
}
|
||||
|
||||
// ensureCSRFToken returns the existing CSRF token from the cookie, or generates a new one.
|
||||
func ensureCSRFToken(w http.ResponseWriter, r *http.Request) string {
|
||||
if cookie, err := r.Cookie(csrfCookieName); err == nil && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
}
|
||||
token := generateCSRFToken()
|
||||
setCSRFCookie(w, token)
|
||||
return token
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
||||
)
|
||||
|
||||
// TestSetupCSSServesEmbedded (Scenario E, TASK-D0): the setup wizard must serve
|
||||
// the web package's EMBEDDED stylesheet. The pre-fix handleCSS read a filesystem
|
||||
// path derived from dataDir, which does not exist inside the container image, so
|
||||
// production setup mode silently served the minimalCSS fallback.
|
||||
func TestSetupCSSServesEmbedded(t *testing.T) {
|
||||
// dataDir whose derived legacy path (internal/web/templates/style.css
|
||||
// relative to its grandparent) cannot exist.
|
||||
dataDir := filepath.Join(t.TempDir(), "nonexistent-nested", "data")
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
s := NewServer(&config.Config{}, dataDir, logger, "test")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/static/style.css", nil)
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.Bytes()
|
||||
if len(body) <= 10000 {
|
||||
t.Fatalf("body length = %d, want > 10000 (minimalCSS fallback served?)", len(body))
|
||||
}
|
||||
if !strings.Contains(string(body), "--bg-0: #0A1220") {
|
||||
t.Error("served CSS is missing the v2 token block (--bg-0)")
|
||||
}
|
||||
embedded, err := web.StyleCSS()
|
||||
if err != nil {
|
||||
t.Fatalf("web.StyleCSS() error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(body, embedded) {
|
||||
t.Error("served CSS differs from the embedded web stylesheet")
|
||||
}
|
||||
if strings.Contains(string(body), ".setup-container") {
|
||||
t.Error("minimalCSS fallback was served instead of the embedded stylesheet")
|
||||
}
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFS embed.FS
|
||||
|
||||
// Server handles the setup wizard HTTP routes.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
dataDir string
|
||||
logger *log.Logger
|
||||
tmpl *template.Template
|
||||
state *SetupState
|
||||
version string
|
||||
}
|
||||
|
||||
// NewServer creates a new setup wizard server.
|
||||
func NewServer(cfg *config.Config, dataDir string, logger *log.Logger, version string) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
dataDir: dataDir,
|
||||
logger: logger,
|
||||
state: LoadState(dataDir),
|
||||
version: version,
|
||||
}
|
||||
s.loadTemplates()
|
||||
|
||||
// Pre-seed setup state from env vars (set by docker-setup.sh --hub-customer)
|
||||
if cid := os.Getenv("FELHOM_SETUP_CUSTOMER_ID"); cid != "" {
|
||||
s.state.SetFormField("customer_id", cid)
|
||||
logger.Printf("[INFO] Setup: pre-seeded customer_id from env: %s", cid)
|
||||
}
|
||||
if pw := os.Getenv("FELHOM_SETUP_PASSWORD"); pw != "" {
|
||||
s.state.SetFormField("retrieval_password", pw)
|
||||
logger.Printf("[INFO] Setup: pre-seeded retrieval_password from env")
|
||||
}
|
||||
if s.isHubPreseeded() {
|
||||
s.state.Save()
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// isDebug returns true if logging level is "debug".
|
||||
func (s *Server) isDebug() bool {
|
||||
return s.cfg != nil && s.cfg.Logging.Level == "debug"
|
||||
}
|
||||
|
||||
// isHubPreseeded returns true if both customer_id and retrieval_password are
|
||||
// pre-seeded in setup state (set by docker-setup.sh --hub-customer).
|
||||
func (s *Server) isHubPreseeded() bool {
|
||||
return s.state.GetFormField("customer_id") != "" && s.state.GetFormField("retrieval_password") != ""
|
||||
}
|
||||
|
||||
func (s *Server) loadTemplates() {
|
||||
s.tmpl = template.Must(
|
||||
template.New("").Funcs(template.FuncMap{
|
||||
"timeNow": func() string { return time.Now().Format("2006-01-02 15:04") },
|
||||
}).ParseFS(templateFS, "templates/*.html"),
|
||||
)
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler for the setup wizard.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Disk-recovery setup paths (drive scan, infra-backup restore) have moved to the
|
||||
// host agent (slice 8C). The wizard now offers fresh install (from Hub) + manual.
|
||||
mux.HandleFunc("/", s.handleRoot)
|
||||
mux.HandleFunc("/setup", s.handleWelcome)
|
||||
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
|
||||
mux.HandleFunc("/setup/manual", s.handleManual)
|
||||
mux.HandleFunc("/setup/failed", s.handleFailed)
|
||||
mux.HandleFunc("/static/style.css", s.handleCSS)
|
||||
mux.HandleFunc("/static/felhom-logo.svg", s.handleLogo)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// --- Route Handlers ---
|
||||
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
domain := s.cfg.Customer.Domain
|
||||
ips := DetectLocalIPs()
|
||||
|
||||
var accessURLs []string
|
||||
if domain != "" {
|
||||
accessURLs = append(accessURLs, fmt.Sprintf("https://felhom.%s", domain))
|
||||
}
|
||||
for _, ip := range ips {
|
||||
accessURLs = append(accessURLs, fmt.Sprintf("http://%s%s", ip, s.cfg.Web.SetupListen))
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"AccessURLs": accessURLs,
|
||||
"Version": s.version,
|
||||
"HubMode": s.isHubPreseeded(),
|
||||
"HubCustomerID": s.state.GetFormField("customer_id"),
|
||||
}
|
||||
s.render(w, "setup_welcome", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.processFreshHub(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-process if credentials are pre-seeded (hub mode from docker-setup.sh)
|
||||
if s.isHubPreseeded() {
|
||||
customerID := s.state.GetFormField("customer_id")
|
||||
password := s.state.GetFormField("retrieval_password")
|
||||
s.autoProcessFreshHub(w, r, customerID, password)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"CustomerID": s.state.GetFormField("customer_id"),
|
||||
}
|
||||
s.render(w, "setup_fresh_hub", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleManual(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.processManual(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"FormData": s.state.FormData,
|
||||
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
||||
}
|
||||
s.render(w, "setup_manual", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleFailed(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
}
|
||||
s.render(w, "setup_failed", data)
|
||||
}
|
||||
|
||||
// --- Static Assets (reuse from web package embed) ---
|
||||
|
||||
func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve the web package's embedded stylesheet. The previous implementation
|
||||
// read a filesystem path derived from dataDir, which does not exist inside
|
||||
// the container image — setup mode silently fell back to minimalCSS.
|
||||
data, err := web.StyleCSS()
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] Setup: embedded style.css unavailable (%v) — serving minimal fallback CSS", err)
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Write([]byte(minimalCSS))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
fmt.Fprint(w, web.FelhomLogoSVG)
|
||||
}
|
||||
|
||||
// --- Auto-Processing (Hub pre-seeded mode) ---
|
||||
|
||||
// autoProcessHubRestore calls PullRecovery with pre-seeded credentials and
|
||||
// renders the confirmation page directly, skipping the manual form.
|
||||
// Falls back to the form with an error message on failure.
|
||||
// autoProcessFreshHub calls PullConfig with pre-seeded credentials and
|
||||
// proceeds with fresh install, skipping the manual form.
|
||||
func (s *Server) autoProcessFreshHub(w http.ResponseWriter, r *http.Request, customerID, password string) {
|
||||
hubURL := DefaultHubURL
|
||||
|
||||
s.logger.Printf("[INFO] Setup: auto-processing fresh hub install for %s (pre-seeded credentials)", customerID)
|
||||
|
||||
configYAML, err := report.PullConfig(hubURL, customerID, password)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] Setup: auto fresh hub failed: %v — falling back to form", err)
|
||||
// Clear pre-seeded password so form is shown on next attempt
|
||||
s.state.SetFormField("retrieval_password", "")
|
||||
s.state.Save()
|
||||
var msg string
|
||||
switch {
|
||||
case isError(err, report.ErrHubUnreachable):
|
||||
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
||||
case isError(err, report.ErrAuthFailed):
|
||||
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
||||
case isError(err, report.ErrNotFound):
|
||||
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
||||
default:
|
||||
msg = fmt.Sprintf("Hiba történt: %v", err)
|
||||
}
|
||||
s.renderError(w, r, "setup_fresh_hub", msg, customerID)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML))
|
||||
|
||||
if err := s.writeFreshConfig(configYAML, password); err != nil {
|
||||
s.logger.Printf("[ERROR] Setup: writeFreshConfig failed: %v", err)
|
||||
s.renderError(w, r, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
// --- Processing Logic ---
|
||||
|
||||
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
password := r.FormValue("password")
|
||||
hubURL := DefaultHubURL
|
||||
|
||||
s.state.SetFormField("customer_id", customerID)
|
||||
|
||||
if customerID == "" || password == "" {
|
||||
s.renderError(w, r, "setup_fresh_hub", "Kérem töltse ki mindkét mezőt.", customerID)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Setup: downloading config from Hub (%s) for customer %s", hubURL, customerID)
|
||||
configYAML, err := report.PullConfig(hubURL, customerID, password)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Setup: Hub config download failed: %v", err)
|
||||
var msg string
|
||||
switch {
|
||||
case isError(err, report.ErrHubUnreachable):
|
||||
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
||||
case isError(err, report.ErrAuthFailed):
|
||||
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
||||
case isError(err, report.ErrNotFound):
|
||||
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
||||
default:
|
||||
msg = fmt.Sprintf("Hiba történt: %v", err)
|
||||
}
|
||||
s.renderError(w, r, "setup_fresh_hub", msg, customerID)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML))
|
||||
|
||||
// Write config and finish setup
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] Setup: writing fresh config (%d bytes)", len(configYAML))
|
||||
}
|
||||
s.state.SetFormField("retrieval_password", password)
|
||||
if err := s.writeFreshConfig(configYAML, password); err != nil {
|
||||
s.logger.Printf("[ERROR] Setup: writeFreshConfig failed: %v", err)
|
||||
s.renderError(w, r, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
|
||||
// Save all form fields
|
||||
fields := []string{"customer_id", "display_name", "domain", "email",
|
||||
"cf_tunnel_token", "cf_api_token", "system_data_path",
|
||||
"password", "password_confirm",
|
||||
"git_repo_url", "git_username", "git_token"}
|
||||
for _, f := range fields {
|
||||
s.state.SetFormField(f, r.FormValue(f))
|
||||
}
|
||||
|
||||
// Validate
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
password := r.FormValue("password")
|
||||
passwordConfirm := r.FormValue("password_confirm")
|
||||
|
||||
var errs []string
|
||||
if customerID == "" {
|
||||
errs = append(errs, "Ügyfél-azonosító kötelező")
|
||||
}
|
||||
if domain == "" || domain == "homeserver.local" {
|
||||
errs = append(errs, "Érvényes domain szükséges")
|
||||
}
|
||||
if password != "" && len(password) < 8 {
|
||||
errs = append(errs, "A jelszó legalább 8 karakter legyen")
|
||||
}
|
||||
if password != passwordConfirm {
|
||||
errs = append(errs, "A jelszavak nem egyeznek")
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"FormData": s.state.FormData,
|
||||
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
||||
"Errors": errs,
|
||||
}
|
||||
s.render(w, "setup_manual", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate controller.yaml
|
||||
configYAML := s.generateManualConfig()
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] Setup: generated manual config (%d bytes) for customer %s", len(configYAML), customerID)
|
||||
}
|
||||
if err := s.writeFreshConfig(configYAML, ""); err != nil {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"FormData": s.state.FormData,
|
||||
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
||||
"Errors": []string{fmt.Sprintf("Konfigurációs hiba: %v", err)},
|
||||
}
|
||||
s.render(w, "setup_manual", data)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Setup: manual configuration completed for %s", customerID)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
// --- Restore Execution ---
|
||||
|
||||
func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error {
|
||||
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
||||
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
||||
return fmt.Errorf("writing controller.yaml: %w", err)
|
||||
}
|
||||
|
||||
// Create initial settings with password hash and retrieval password
|
||||
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
||||
if err != nil {
|
||||
sett = &settings.Settings{}
|
||||
}
|
||||
|
||||
// Hash the dashboard password if provided in form
|
||||
if pw := s.state.GetFormField("password"); pw != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
sett.SetPasswordHash(string(hash))
|
||||
}
|
||||
}
|
||||
|
||||
if retrievalPassword != "" {
|
||||
sett.SetRetrievalPassword(retrievalPassword)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) generateManualConfig() string {
|
||||
fd := s.state.FormData
|
||||
|
||||
customerID := fd["customer_id"]
|
||||
displayName := fd["display_name"]
|
||||
if displayName == "" {
|
||||
displayName = customerID
|
||||
}
|
||||
domain := fd["domain"]
|
||||
email := fd["email"]
|
||||
cfTunnelToken := fd["cf_tunnel_token"]
|
||||
cfAPIToken := fd["cf_api_token"]
|
||||
systemDataPath := fd["system_data_path"]
|
||||
if systemDataPath == "" {
|
||||
systemDataPath = "/mnt/sys_drive"
|
||||
}
|
||||
|
||||
// Generate session secret
|
||||
secretBytes := make([]byte, 32)
|
||||
crand.Read(secretBytes)
|
||||
sessionSecret := hex.EncodeToString(secretBytes)
|
||||
|
||||
// Generate password hash
|
||||
passwordHash := ""
|
||||
if pw := fd["password"]; pw != "" {
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost); err == nil {
|
||||
passwordHash = string(hash)
|
||||
}
|
||||
}
|
||||
|
||||
gitRepoURL := fd["git_repo_url"]
|
||||
if gitRepoURL == "" {
|
||||
gitRepoURL = "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
|
||||
}
|
||||
gitUsername := fd["git_username"]
|
||||
gitToken := fd["git_token"]
|
||||
|
||||
// Build YAML manually (simple key-value, no templates needed)
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by felhom-controller setup wizard\n")
|
||||
b.WriteString("customer:\n")
|
||||
fmt.Fprintf(&b, " id: %q\n", customerID)
|
||||
fmt.Fprintf(&b, " name: %q\n", displayName)
|
||||
fmt.Fprintf(&b, " domain: %q\n", domain)
|
||||
if email != "" {
|
||||
fmt.Fprintf(&b, " email: %q\n", email)
|
||||
}
|
||||
b.WriteString("\ninfrastructure:\n")
|
||||
if cfTunnelToken != "" {
|
||||
fmt.Fprintf(&b, " cf_tunnel_token: %q\n", cfTunnelToken)
|
||||
}
|
||||
if cfAPIToken != "" {
|
||||
fmt.Fprintf(&b, " cf_api_token: %q\n", cfAPIToken)
|
||||
}
|
||||
b.WriteString("\npaths:\n")
|
||||
b.WriteString(" stacks_dir: \"/opt/docker/stacks\"\n")
|
||||
b.WriteString(" data_dir: \"/opt/docker/felhom-controller/data\"\n")
|
||||
fmt.Fprintf(&b, " system_data_path: %q\n", systemDataPath)
|
||||
b.WriteString("\nsystem:\n")
|
||||
b.WriteString(" reserved_memory_mb: 384\n")
|
||||
b.WriteString("\nweb:\n")
|
||||
b.WriteString(" listen: \":8080\"\n")
|
||||
b.WriteString(" setup_listen: \":8081\"\n")
|
||||
if passwordHash != "" {
|
||||
fmt.Fprintf(&b, " password_hash: %q\n", passwordHash)
|
||||
}
|
||||
fmt.Fprintf(&b, " session_secret: %q\n", sessionSecret)
|
||||
b.WriteString("\ngit:\n")
|
||||
fmt.Fprintf(&b, " repo_url: %q\n", gitRepoURL)
|
||||
b.WriteString(" branch: \"main\"\n")
|
||||
b.WriteString(" sync_interval: \"15m\"\n")
|
||||
if gitUsername != "" {
|
||||
fmt.Fprintf(&b, " username: %q\n", gitUsername)
|
||||
}
|
||||
if gitToken != "" {
|
||||
fmt.Fprintf(&b, " token: %q\n", gitToken)
|
||||
}
|
||||
b.WriteString("\nstacks:\n")
|
||||
b.WriteString(" protected:\n")
|
||||
b.WriteString(" - \"traefik\"\n")
|
||||
b.WriteString(" - \"cloudflared\"\n")
|
||||
b.WriteString(" - \"felhom-controller\"\n")
|
||||
b.WriteString(" - \"filebrowser\"\n")
|
||||
b.WriteString("\nbackup:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" restic_password_file: \"/opt/docker/felhom-controller/data/restic-password\"\n")
|
||||
b.WriteString(" db_dump_schedule: \"02:30\"\n")
|
||||
b.WriteString(" restic_schedule: \"03:00\"\n")
|
||||
b.WriteString(" retention:\n")
|
||||
b.WriteString(" keep_daily: 7\n")
|
||||
b.WriteString(" keep_weekly: 4\n")
|
||||
b.WriteString(" keep_monthly: 6\n")
|
||||
b.WriteString(" prune_schedule: \"weekly\"\n")
|
||||
b.WriteString("\nmonitoring:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" healthchecks_base: \"https://status.felhom.eu\"\n")
|
||||
b.WriteString(" system_health_interval: \"5m\"\n")
|
||||
b.WriteString(" health_check_schedule: \"06:00\"\n")
|
||||
b.WriteString("\nhub:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" url: \"https://hub.felhom.eu\"\n")
|
||||
// Generate a Hub API key from customer ID
|
||||
apiKeyHash := sha256.Sum256([]byte(customerID + "-" + sessionSecret))
|
||||
fmt.Fprintf(&b, " api_key: %q\n", hex.EncodeToString(apiKeyHash[:]))
|
||||
b.WriteString(" push_interval: \"15m\"\n")
|
||||
b.WriteString("\nself_update:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" check_interval: \"6h\"\n")
|
||||
b.WriteString(" image: \"gitea.dooplex.hu/admin/felhom-controller\"\n")
|
||||
b.WriteString(" auto_update: false\n")
|
||||
b.WriteString(" health_timeout_seconds: 60\n")
|
||||
b.WriteString("\nlogging:\n")
|
||||
b.WriteString(" level: \"info\"\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func (s *Server) finishSetup() {
|
||||
s.state.Remove()
|
||||
s.logger.Printf("[INFO] Setup complete — restarting controller")
|
||||
os.Exit(0) // Docker restart policy will restart us
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
s.logger.Printf("[ERROR] Template %s render error: %v", name, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, tmpl, msg, customerID string) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"Error": msg,
|
||||
"CustomerID": customerID,
|
||||
}
|
||||
s.render(w, tmpl, data)
|
||||
}
|
||||
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, perm); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
// Rename fails on bind-mounted files (EBUSY). Fall back to direct write.
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isError(err, target error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), target.Error())
|
||||
}
|
||||
|
||||
// Minimal CSS for when the main stylesheet can't be loaded (v2 tokens)
|
||||
const minimalCSS = `
|
||||
:root { --bg-primary: #0A1220; --bg-card: #0F1B2E; --text-primary: #EDF2F9; --text-secondary: #94A6BF; --accent-blue: #0083D8; --border: #22344F; --green: #0083D8; --red: #E5534B; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
.setup-container { max-width: 700px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||
.setup-header { text-align: center; margin-bottom: 2rem; }
|
||||
.setup-header img { width: 120px; margin-bottom: 1rem; }
|
||||
.setup-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.setup-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 2px; padding: 1.5rem; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.2s; }
|
||||
.setup-card:hover { border-color: var(--accent-blue); }
|
||||
.setup-card h3 { margin-bottom: 0.5rem; }
|
||||
.setup-card p { color: var(--text-secondary); font-size: 0.9rem; }
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: var(--text-secondary); }
|
||||
.form-control { width: 100%; padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 2px; color: var(--text-primary); font-size: 0.9rem; }
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.6rem 1.5rem; border-radius: 2px; border: none; font-size: 0.9rem; font-weight: 500; cursor: pointer; text-decoration: none; }
|
||||
.btn-primary { background: var(--green); color: #fff; }
|
||||
.btn-primary:hover { background: #2EA8F5; }
|
||||
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); }
|
||||
.alert { padding: 0.75rem 1rem; border-radius: 2px; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
.alert-error { background: rgba(229,83,75,.12); color: #E5534B; border: 1px solid rgba(229,83,75,.3); }
|
||||
.alert-info { background: rgba(0,131,216,.13); color: #2EA8F5; border: 1px solid rgba(0,131,216,.3); }
|
||||
.info-box { background: rgba(0,131,216,.13); border: 1px solid rgba(0,131,216,.2); border-radius: 2px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--text-secondary); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; color: var(--text-secondary); font-size: 0.85rem; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; }
|
||||
.badge-ok { background: rgba(63,185,80,0.15); color: var(--green); }
|
||||
.badge-error { background: rgba(229,83,75,.12); color: var(--red); }
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.section { margin-bottom: 1.5rem; }
|
||||
.section-header { cursor: pointer; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 2px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.section-body { padding: 1rem; border: 1px solid var(--border); border-top: none; border-radius: 0 0 6px 6px; }
|
||||
.step-list { list-style: none; }
|
||||
.step-list li { padding: 0.5rem 0; display: flex; align-items: center; gap: 0.75rem; }
|
||||
.step-done { color: var(--green); }
|
||||
.step-running { color: var(--accent-blue); }
|
||||
.step-failed { color: var(--red); }
|
||||
`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user