Compare commits
445 Commits
430fb4448d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e1613aea8d | |||
| c9b4b82dc7 | |||
| 518a13592d | |||
| 062f9b41d9 | |||
| bb2809dc68 | |||
| 2449223f10 | |||
| b01bb4b88d | |||
| 321df1f87a | |||
| 046b808792 | |||
| bddc477e22 | |||
| 42cda07b42 | |||
| ed1011b088 | |||
| 60e487db51 | |||
| a9c1997fbc | |||
| 277e99839d | |||
| 63dcccfeec | |||
| 32e61d5d1d | |||
| caaa86eb3c | |||
| 23f77f93ba | |||
| 1babfe94f6 | |||
| 9d9e4041c9 | |||
| 943c517550 | |||
| 912e59210d | |||
| 66a69d4954 | |||
| df78b6caae | |||
| ae394670c1 | |||
| 03c0a693b8 | |||
| d5f88a2182 | |||
| 6a17c95815 | |||
| cbc944c3c0 | |||
| fc5a20f40a | |||
| add310c80a | |||
| 08fa440d04 | |||
| 7900a32695 | |||
| 1de8a559ed | |||
| f0abdd0eda | |||
| ba22f9b2d9 | |||
| 5776d54901 | |||
| 28b05d6d68 | |||
| 8c1e290f63 | |||
| 37b79caad9 | |||
| d582feb245 | |||
| 7848d4aaac | |||
| 5929b8e345 | |||
| 8f5bea0148 | |||
| 01c967e49e | |||
| 7ca59e2de3 | |||
| aa9e09e817 | |||
| 93fb251ea9 | |||
| ae2bfb2f7a | |||
| a03c25aa1f | |||
| c4b3b0a039 | |||
| 15ea002458 | |||
| db980ff2cc | |||
| 546eef37bd | |||
| e1a2abf1e3 | |||
| a6fa3c01d2 | |||
| c809dd3bc6 | |||
| a741bc1d0f | |||
| 601bdba253 | |||
| 9bdd8f8fe8 | |||
| ba4a6f060b | |||
| 8ae9178138 | |||
| 772ef36f5f | |||
| 615940de35 | |||
| 6c6bd87664 | |||
| 03ab121b25 | |||
| 2fe634141f | |||
| 5d67041d6d | |||
| e30091bf27 | |||
| 99f9117296 | |||
| 09ac87a843 | |||
| 36c4aa4e69 | |||
| 24d937d6e4 | |||
| 6d90374f63 | |||
| 71fbef6bbe | |||
| 0c6ce9500c | |||
| a4c0d546e5 | |||
| 67a4b452e2 | |||
| 8f029aa70b | |||
| 411d7761d9 | |||
| e3bb775ac5 | |||
| 3e8d027677 | |||
| 334df7db12 | |||
| 30956aabd2 | |||
| 3f0127778c | |||
| 55868cb3e0 | |||
| 85d60a1930 | |||
| 68af0e97f2 | |||
| 88aa65d595 | |||
| 6ed223886d | |||
| 94f44931dd | |||
| 7d41c2973c | |||
| 7600e6a8b7 | |||
| b69585789a | |||
| f75b3aa778 | |||
| 00a0d91976 | |||
| 8587198781 | |||
| fbcc3cf042 | |||
| 29a3ce8cf2 | |||
| e9d0d723c1 | |||
| 52619e89bd | |||
| fc77fcb89b | |||
| f037be7a6a | |||
| 8fe46a1313 | |||
| 7e791d9b5d | |||
| c02c9d833a | |||
| 1296e7aa46 | |||
| f30a80841e | |||
| 39bf56298d | |||
| 0f82eba7c6 | |||
| 7adbbb17c0 | |||
| d170a2aecf | |||
| 8d44416610 | |||
| 36da4089ca | |||
| a43d0423d5 | |||
| 123d2d553f | |||
| a86c864b06 | |||
| e7b64af1f0 | |||
| 0626b9fc8a | |||
| c6a567122e | |||
| 4261a38bf0 | |||
| f9151f0875 | |||
| ec438479e2 | |||
| 8c7aa10a6e | |||
| 9e68e526e9 | |||
| 02252556b3 | |||
| f908eee2a3 | |||
| 006ceab58a | |||
| 48d8674876 | |||
| 7f3e58ff0a | |||
| 9cc7ffcf3b | |||
| 83392064ae | |||
| db52f5a4cf | |||
| fe0228c877 | |||
| e66d4669d6 | |||
| 85f65c70d8 | |||
| 97c992ad44 | |||
| 17863fe8bc | |||
| e209a8d724 | |||
| cac92a407e | |||
| f327bc53a9 | |||
| 372d6e5994 | |||
| 37ffa3beb5 | |||
| a09cab124a | |||
| f580d88a14 | |||
| 78c894234c | |||
| 331be17e1e | |||
| 8bc7ec54cd | |||
| 9b95e8e09b | |||
| 3b85a0c29a | |||
| 8d862e4e69 | |||
| 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 | |||
| 034a2b7363 | |||
| daeed27223 | |||
| 8ce496f124 | |||
| 391967bcf1 | |||
| 074ddda352 | |||
| 7b8216abac | |||
| d15ad105be | |||
| 9271f33359 | |||
| f7c39c1700 | |||
| 1355391c78 | |||
| 0b349e2415 | |||
| 800b32ceec | |||
| f885100d29 | |||
| 4867ec1804 | |||
| 0b93e1ad1e | |||
| 27b373b93c | |||
| 6f1ba1fe43 | |||
| 1453cfc69b | |||
| 114ff2761a | |||
| e563733282 | |||
| df85a07c4c | |||
| 7690c27f86 | |||
| ff69074514 | |||
| 477e2548db | |||
| 635c33d381 | |||
| e730a629fd | |||
| 7861bf9dde | |||
| 69e914534f | |||
| 2a7f6c6c42 | |||
| 09e634de31 | |||
| badc9c2a0c | |||
| fc796dd95e | |||
| 99a1497560 | |||
| c1a73b24b3 | |||
| c4bf730637 | |||
| 2a2af2018d | |||
| 55bb6c3d32 | |||
| 09453325d1 | |||
| 580b656914 | |||
| 9821690980 | |||
| c8d5ee204d | |||
| 977665d8c0 | |||
| a4a753b9e2 | |||
| a28155f858 | |||
| 79ef715277 | |||
| d4167420d1 | |||
| 1e216d3468 | |||
| b53721db34 | |||
| 1e8d045815 | |||
| c1b123c649 | |||
| ac8ea72025 | |||
| a70c398dee | |||
| 00fddd3816 | |||
| 5a3437669f | |||
| d48da6c7f3 | |||
| 29d3c533aa | |||
| 6be6c53e29 | |||
| 1cfb1244d6 | |||
| 4110da50e9 | |||
| 6fc3059167 | |||
| 149467c795 | |||
| 3a12c7341a | |||
| 7fa8768cfd | |||
| 2548b4c924 | |||
| 0c702f834a | |||
| 2e9a948cbd | |||
| 7cba0bfca7 | |||
| 1ee6dbe184 | |||
| 6479933e8e | |||
| 0c1023e503 | |||
| 820e8efde1 | |||
| 54bb4da343 | |||
| 7fcda8f1a4 | |||
| b6810f14ff | |||
| fb2bcdd5d6 | |||
| 64cbefe317 | |||
| 064f23896a | |||
| 43e99d160c | |||
| 5a731b45b9 | |||
| 7162f489c9 | |||
| 1c5dd07604 | |||
| b6399fdf0b | |||
| 2caae38a71 | |||
| 3d49df1e5a | |||
| 44ae4dea70 | |||
| 71accca427 | |||
| cdfc817d5e | |||
| 9cf13a3add | |||
| 1495ca43fb | |||
| 504eae018b | |||
| 7c6140d95b | |||
| 5b1b191ffc | |||
| 3c6b49b31c | |||
| 7c3b3a9694 | |||
| 206b0357d1 | |||
| 80e6ad8c47 | |||
| 15e630aa02 | |||
| 964ae7538f | |||
| 411a43f8ef | |||
| 0054d4bd69 | |||
| 0a3026180a | |||
| bc278944a3 | |||
| c3a2aba0d2 | |||
| 2cd66663f3 | |||
| 5d38573a1a | |||
| 8fc2b4a1a9 | |||
| b9deec1907 | |||
| 3d41758be0 | |||
| b793638484 | |||
| 93cee16843 | |||
| 811f75736e | |||
| d0d431b42b | |||
| bdeb1c0d8e | |||
| 8f8a64cad7 | |||
| 19ef0329ab | |||
| 0fe04bb6d5 | |||
| f6909492cc | |||
| 7c4a33b6d1 | |||
| ecefdf2dc5 | |||
| eb806f5780 | |||
| 60f0a86bd4 | |||
| e81ad613ae | |||
| bef39598d0 | |||
| 8fb2f9ef9d | |||
| 2e9d40255b | |||
| 0b1486df8f | |||
| 48f3336956 | |||
| eb6aa58aa7 | |||
| 7c05b59708 | |||
| 5270bad76e | |||
| 736f54b496 | |||
| 0194342264 | |||
| f806baf4c7 | |||
| c00fed6db3 | |||
| e236dca486 | |||
| be2efe5624 | |||
| ac149c2aa4 | |||
| 0555091f83 | |||
| 11b790e314 | |||
| f8ebc47728 | |||
| ff68b0b433 | |||
| 6be55a0eb3 | |||
| 1af89a54af | |||
| cc2ad41516 | |||
| 612c417024 | |||
| 89dd3e94b1 | |||
| 29e2acb5f8 | |||
| 0fe315b759 | |||
| 714d5bce09 | |||
| ad398b60d9 | |||
| 2f8ff2414c | |||
| 383a30b3c0 | |||
| d3eacbb7cc | |||
| 843b319f35 | |||
| 406755fa8f | |||
| d698ce343b | |||
| c1f62ddae8 | |||
| 3e813307cc | |||
| 3013a1cc93 | |||
| 3b15ce101f | |||
| 21a9d35f70 | |||
| 24d7c54c79 | |||
| bdcbd50b42 | |||
| 0e3d831030 | |||
| b93c1543da | |||
| f946b0d0ca | |||
| cf8a371c39 | |||
| cbcca03061 | |||
| 129201abab | |||
| 0d402f711d | |||
| 1552716722 | |||
| 7410f1c841 | |||
| 42a73e667a | |||
| bab82c471e | |||
| 2a56f557d0 | |||
| 8a0e0a59ad | |||
| 998aa31958 | |||
| 38d28b5b62 | |||
| 32da46cd64 | |||
| 50312b63bc | |||
| 2fb558552a | |||
| 8025304acc | |||
| 960d29b061 | |||
| 22983885f1 | |||
| 670def61b4 | |||
| 681cc663ef | |||
| 3db62fc6b2 | |||
| 13dd00bcff | |||
| a4444088ad | |||
| a017367f9f | |||
| 4e13fdaaa0 | |||
| 62c6a8a98a | |||
| 8b55de734c | |||
| fcef8e069c | |||
| 9aea86cd48 | |||
| 79f853fbbd | |||
| 303129e3af | |||
| e43b5ec07d | |||
| 2d802d75e8 | |||
| 1cfdde968f | |||
| b48a7fa326 | |||
| 5429d651ee | |||
| 2358e561b7 | |||
| fed272e62a | |||
| 0957f43d10 | |||
| 8aa95b5831 | |||
| 4c8f0d2919 | |||
| 0f9b796615 | |||
| c732006d26 |
+33
-9
@@ -6,17 +6,19 @@ 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.
|
||||
- `--fast` selects the gates that touch no network and no container runtime; today that is all of them
|
||||
except `gate-decoys` (the decoy suite, ~22 s, edits real files in this tree for a moment; R-1116), which
|
||||
runs in the full run only — so the hook and CI do not run it.
|
||||
- **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
|
||||
@@ -54,3 +56,25 @@ a probe stayed in D state 3m50s after kill -9; a buffered write with no fsync bl
|
||||
needs journal access); and statfs/getdents returned HEALTHY on a namespace that EIOs every byte —
|
||||
fast, and wrong.
|
||||
-->
|
||||
|
||||
## A gate ships with a decoy test that has been seen to fail (R-421)
|
||||
|
||||
**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. `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
|
||||
2026-09-01 sweep read all 29 gate scripts and fooled 16 of them. The four shapes to test against:
|
||||
|
||||
1. **name-for-fact** — it matches a path or directory NAME while the fact lives inside the file.
|
||||
2. **substring-for-field** — it matches a token anywhere in a body instead of in the field carrying it.
|
||||
3. **declaration-for-reachability** — it checks a thing is declared, not that it RESOLVES.
|
||||
4. **constant-for-measurement** — it compares a value against itself.
|
||||
|
||||
**Scope is a fact too.** Eight of the sixteen were `os.listdir` (one level) where `os.walk` was meant:
|
||||
green and correct today, blind the moment anyone adds a subdirectory. Prefer `os.walk`, and prefer a
|
||||
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 `felhom.eu/documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
unconditional: true
|
||||
---
|
||||
# Unprompted work — rules for any session without a task file
|
||||
|
||||
> 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 `felhom.eu`, `felhom-controller`, `felhom-agent` and `app-catalog-felhom.eu` `.claude/rules/`, and in the workspace
|
||||
> root's unversioned `.claude/rules/`; change all five or none.
|
||||
|
||||
## 1. What you may pick up on your own
|
||||
|
||||
- A register row **you or another CC session filed**, with owner CC, at P3 or a bounded P2, that
|
||||
needs **no operator decision**, touches **no customer data by design**, and introduces **no
|
||||
mechanism nobody has measured**. Smallest first.
|
||||
- A defect you find while exercising the product, filed as a row **before** you fix it — **unless it is small**:
|
||||
a small finding is fixed in the session and never filed (the size rule, `OPEN-ITEMS.md` „How a row is filed").
|
||||
- Hygiene: register compression, stale citations, rows with no owner, documents that contradict
|
||||
live source.
|
||||
|
||||
**Not yours, ever, without a task file or an operator word:** money; anything that changes risk to
|
||||
customer data; anything that changes a promise the product makes to a customer; anything that
|
||||
reverses a documented design decision (`documentation/architecture/` — a design decision is not a
|
||||
defect, R-370); anything on DooPlex or ep0; baking or vouching a golden; promoting a
|
||||
catalog version; a new external dependency; **a hub image build or hub deploy in a session the operator does not
|
||||
attend** (operator ruling 2026-10-07, `09` §3 decision 162).
|
||||
|
||||
## 2. When you may decide instead of ask (operator grant, 2026-09-14)
|
||||
|
||||
You may take a decision yourself when **all** of these hold: the architecture folder and the register
|
||||
give a clear direction; your choice follows that direction; it is reversible without customer-data
|
||||
risk; and you can write it in the `09-update-architecture.md` §3 shape — one answerable sentence, the
|
||||
options, what each costs, why this one. **Then record it** as a dated decision in `CONTEXT.md` and
|
||||
the owning architecture document, tagged *decided by CC unattended — operator may reverse*, and put
|
||||
it **first** in the morning note. A decision you cannot write in that shape is one you do not take.
|
||||
|
||||
## 3. The discipline a task file used to carry
|
||||
|
||||
1. **Baselines first.** Read each repo's `main` hash and version from live source before touching it.
|
||||
2. **Read the architecture document for the area, and name it** in the report, before any claim.
|
||||
3. **Red-proof every correctness fix.** A test never seen failing has not been shown to test anything.
|
||||
4. **Live-validate on a Tier-0 box** through the endpoints the UI invokes. `demo-hp` is `ssh hp`.
|
||||
Throwaway apps only; the standing apps and `bentopdf` stay.
|
||||
5. **Evidence off the machine at the end of each phase**, before any revert (R-320).
|
||||
6. **One release per repo per session**, with a CHANGELOG entry (controller: with its `MinAgent`
|
||||
line) and REPORT overwritten. **A controller release needs no golden** (operator ruling 2026-10-15
|
||||
11:26, `09` §3 decision 250; decision 7 stands): push the image only when it is ready for EVERY box (the
|
||||
household's update button offers the highest tag); raise the floor, MinAgent declared, only after it ran
|
||||
on Tester 1, demo-hp and demo-felhom through the normal update path and the box walk's checks are green
|
||||
on all three — not at night unless the night brief says so. **Agent releases still stop for the
|
||||
operator's vouch.** The golden is baked weekly and vouched by the operator; CC never vouches. **No `--no-verify`.**
|
||||
**A version number is pushed once** — `felhom-controller/controller/build.sh` now refuses a second
|
||||
push of a tag that exists, and a registry it cannot reach is a refusal too. This is a FACT about
|
||||
what the build does, not a new rule: a fix for something already pushed is the next patch version,
|
||||
and a box on a bad image is moved by the normal update path, never by re-tagging. Earned on
|
||||
2026-10-15, when 0.315.0 was pushed three times from three different commits and a cache kept
|
||||
serving the first one (`09-update-architecture.md` §0).
|
||||
7. **An enumerated gap becomes a row in the same session — or, if it is small, is fixed in it** (the size rule).
|
||||
Prose is not a record.
|
||||
8. **Hungarian text is searched with ASCII fragments**, with a positive and a negative control.
|
||||
9. **Never leave a half-state.** If time runs out, revert to clean and say what was reverted.
|
||||
10. **Teardown, three layers, stated** — machine, host, hub — or "provisioned nothing".
|
||||
11. **Every helper prompt carries the brief's fences in full** (operator ruling 2026-10-07). A helper session (a
|
||||
subagent, a fork, a workflow agent) gets the brief's fence list word for word — every protected machine, every
|
||||
„no", every delivery and Docker limit — not a summary and not „the usual fences". Earned on 2026-10-06 night: two
|
||||
helpers whose prompts carried only part of the fences ran `docker volume prune` on the bench and a Docker-using
|
||||
gate on DooPlex.
|
||||
|
||||
## 4. The morning note
|
||||
|
||||
One screen, plain language, in this order: **decisions you took** (§2) first; what you exercised;
|
||||
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.
|
||||
@@ -16,7 +16,16 @@
|
||||
# A failing run must reach a person — a detector nobody hears is the defect R-29 filed, rebuilt one
|
||||
# layer up. That is the last step, and it runs ONLY on failure.
|
||||
name: gates
|
||||
on: [push]
|
||||
# R-1129: `workflow_dispatch` is the manual PROOF that the failure mail can be sent. A push run is
|
||||
# unchanged by it: the proof step below is skipped on a push, and `force_alarm` is then empty.
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_alarm:
|
||||
description: "Fail this run on purpose so the alarm step sends its real mail (a proof, not a gate result)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
@@ -43,8 +52,28 @@ jobs:
|
||||
- name: Run the gate entry point
|
||||
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy. The
|
||||
# exit code IS the result: no `|| true`, no pipe that could swallow it.
|
||||
env:
|
||||
# R-986: the security-scan gate fetches the PINNED gitleaks here (sha256-checked); no Go, so govulncheck says
|
||||
# NOT CHECKED in CI, out loud. Warn-first either way: the scan lists, it never fails this job.
|
||||
FELHOM_SCAN_FETCH: "1"
|
||||
run: cd ws/felhom-controller/controller && python3 scripts/controller_gates.py --fast
|
||||
|
||||
- name: Manual proof - alarm key present, optional forced failure (dispatch only, R-1129)
|
||||
# Runs ONLY on a manual dispatch, never on a push. It prints the key's LENGTH, never the key,
|
||||
# so "CI can see the secret" is an observable rather than an assumption. With
|
||||
# force_alarm=true it then fails on purpose so the alarm step below sends its real mail;
|
||||
# the commit itself stays green, because its push run skips this step.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
FORCE_ALARM: ${{ github.event.inputs.force_alarm }}
|
||||
run: |
|
||||
python3 -c 'import os;print("RESEND_API_KEY length: %d" % len(os.environ.get("RESEND_API_KEY","")))'
|
||||
if [ "$FORCE_ALARM" = "true" ]; then
|
||||
echo "force_alarm=true - failing on purpose so the alarm step runs (R-1129 proof)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Alarm on failure
|
||||
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
|
||||
# notification row and NO log line from Gitea itself — a red tick in a web UI nobody watches
|
||||
@@ -58,6 +87,8 @@ jobs:
|
||||
if: failure()
|
||||
env:
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
# Empty on a push, so a push run's mail is exactly what it was before R-1129.
|
||||
FORCE_ALARM: ${{ github.event.inputs.force_alarm }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, sys, urllib.request, urllib.error
|
||||
@@ -69,13 +100,23 @@ jobs:
|
||||
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "?")
|
||||
sha = os.environ.get("GITHUB_SHA", "?")
|
||||
run = os.environ.get("GITHUB_RUN_NUMBER", "?")
|
||||
srv = os.environ.get("GITHUB_SERVER_URL", "https://gitea.dooplex.hu")
|
||||
# R-1135: the link is for a PERSON, so it is the public address and the run ID.
|
||||
# NOT GITHUB_SERVER_URL: the runner registers at the in-cluster address, so that variable
|
||||
# reads http://gitea.gitea-system.svc.cluster.local:3000, which nobody outside the cluster
|
||||
# can open (forced-test mail, job 1997). NOT GITHUB_RUN_NUMBER: Gitea 1.26 reads
|
||||
# /actions/runs/<n> as a run id first, so a run number that is also an older run's id
|
||||
# opens THAT run (measured 2026-10-18: felhom.eu /actions/runs/1116 opened the run whose
|
||||
# id is 1116 = run number 613). The link is printed too, so the job log carries it.
|
||||
run = os.environ.get("GITHUB_RUN_ID", "?")
|
||||
srv = "https://gitea.dooplex.hu"
|
||||
print("ALARM-LINK %s/%s/actions/runs/%s" % (srv, repo, run))
|
||||
|
||||
body = json.dumps({
|
||||
"from": "Felhom CI <monitoring@felhom.eu>",
|
||||
"to": ["admin@felhom.eu"],
|
||||
"subject": "[felhom CI] gates FAILED in %s" % repo,
|
||||
"subject": ("[felhom CI] FORCED TEST, not a real failure - gates FAILED in %s"
|
||||
if os.environ.get("FORCE_ALARM") == "true"
|
||||
else "[felhom CI] gates FAILED in %s") % repo,
|
||||
"text": (
|
||||
"The gate entry point exited non-zero.\n\n"
|
||||
"Repository : %s\n"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
# Python bytecode from the gate scripts and their tests (R-404 added test_golden_notice.py,
|
||||
# which imports controller_gates.py and so writes controller/scripts/__pycache__).
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,7 @@
|
||||
# .security-accepted — hits security_scan_gate.py (felhom.eu/scripts, R-986) lists as ACCEPTED, not WARN.
|
||||
# One per line: <tool> <id> <where> # reason. govulncheck: OSV id + module dir; gitleaks: rule@<sha256[:12] of the line> + file.
|
||||
# The first scan's verdicts (2026-10-19): felhom.eu documentation/audits/night-2026-10-19/P7/triage.md
|
||||
gitleaks generic-api-key@93610586e88d controller/internal/stacks/samba_test.go # false: a test constant or a decoy value, no live secret
|
||||
gitleaks generic-api-key@a882b8f2ea6b controller/internal/backup/offbox_offer_shapec_r241_test.go # false: a test constant or a decoy value, no live secret
|
||||
gitleaks generic-api-key@b3c473a524c2 controller/scripts/test_gate_decoys.py # false: a test constant or a decoy value, no live secret
|
||||
gitleaks generic-api-key@c9c6aa2fcae6 controller/scripts/test_gate_decoys.py # false: a test constant or a decoy value, no live secret
|
||||
+4862
-1
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
|
||||
|
||||
+759
-1
@@ -1,5 +1,72 @@
|
||||
# CONTEXT.md — Project Memory
|
||||
|
||||
> **2026-10-24 night (unattended restore-holes run) — on `main`, NOT released (next v0.343.0, MinAgent 0.158.1 unchanged).**
|
||||
> R-1251: a pending put-back blocks every start (`backup/appstop_putback_wait.go`: restore hold `putback_pending`, the
|
||||
> hold store every start path honours; `ResumePutBack` in the boot recreate and `restartStacks`; `ResumeWaitingPutBacks`
|
||||
> at start and on a drive return; a failed put-back = the D20 hold; the copy's folders synced before `SetUndo`).
|
||||
> R-1252: `tier2CaptureSetFull` returns `classified`; reconcile only then; `BackupBlockRejected` via the stack adapter.
|
||||
> R-1253: `AppStopGuard.BusyWith` / `BeginExclusive`; the exporter refuses a stop-first export. Proven on Tester 1
|
||||
> (R-1251) and 9202 (R-1252, R-1253): felhom.eu `audits/restore-holes-2026-10-24/`. Release: Monday's list (STATUS).
|
||||
|
||||
> **2026-10-24 (attended release run) — v0.342.0 gains the TESTED line (operator rulings D41 = 50 %, D42, the iperf3 line
|
||||
> test; felhom.eu CONTEXT 07:22/07:26).** `agentapi.LineTest` reads the agent's `GET /host/line-test` (agent ≥ 0.164.0 —
|
||||
> its weekly 10-second iperf3 upload to ep0 inside the tunnel; demo-hp measured 90.7 Mbit/s where the push floor said 10).
|
||||
> qBittorrent's limit = 50 % of the TESTED line only; the watch uses the higher of test and floor; the system page names
|
||||
> the source. MinAgent stays 0.158.1 (an older agent = „not measured" = no limit). Release order: agent 0.164.0 (vouch),
|
||||
> hub 0.169.0, then this controller through the normal path.
|
||||
|
||||
> **2026-10-23 night — v0.342.0 on `main`, NOT released: the upload and CPU watch (`internal/upwatch`; operator ruling
|
||||
> 2026-10-23 19:22; felhom.eu `08` §9 [DESIGN — proposed], `audits/SPIKE-upload-watch-2026-10-23.md`).** Exact
|
||||
> per-container counters (Docker Engine API), per-app judging, explainers qBittorrent / Transmission / Plex, the
|
||||
> operator-only `app_unexplained_upload` / `app_unexplained_cpu` (+ `_cleared`; hub 0.169.0, also unreleased), the line
|
||||
> from a one-second sample of the off-site push (settings.json `upwatch`), qBittorrent's default limit (50 %, never over
|
||||
> the household's). `0.342.0-rc.1` ran on scratch 9202 (its off-site is off, so the line was seeded by hand there).
|
||||
> Open: R-1244 (release), R-1245 (limit size), R-1246 (who is told), R-1247 (active line test), R-1248 (a `tc` ceiling).
|
||||
|
||||
> **2026-10-23 (day, later) — v0.341.0: the night leg takes a `security_fix` ladder step (D39 = B, `09` §3 decision 255,
|
||||
> R-1239; operator ruling by starting the run).** `legCandidate`: verdict `proven` OR `security_fix`, every other guard
|
||||
> unchanged and in order; log `[update-leg] <app>: security fix <ids> taken (<from> → <to>)`; `LegStep.Security`. The
|
||||
> catalog side (format, gates, writer, `box_walk.py --security`) is catalog `3898593`. On ≤ 0.340.0 the leg skips such a
|
||||
> step (`not_proven`) and a hand press takes it — both proven on 9202 (felhom.eu `audits/security-lane-2026-10-23/`).
|
||||
|
||||
> **2026-10-22/23 night — 0.340.0 candidate on `main`, unreleased.** The stray-copy fix (`66a69d4`, `912e592`) and two
|
||||
> review rounds (`943c517` C5/C3/C6/C8; `9d9e404` N2–N5, N9; `1babfe9` two races): the capped copy's container has a
|
||||
> fixed name + label, is removed by name on any error, leftovers are removed at start before the scheduler/web server
|
||||
> (holding the destination's lock), one copy per destination at a time, `RunTier2` refuses during a restore of the app
|
||||
> in one step with its record (`beginTier2Run`), every leg asks again; a restore is refused while a copy runs or a stray
|
||||
> may run (per-name strays, cleared by a later removal). Proven on 9202 with rc.5 and rc.8; rc.8 runs on the test box
|
||||
> (Tester4-16fdef) for the night. Release: 0.340.0 with MinAgent 0.158.1, the operator's yes (STATUS morning list).
|
||||
|
||||
> **2026-10-22 night — four fixes on `main`, NOT released (night brief: no release). The next release is 0.340.0, MinAgent 0.158.1.** R-49 (D21 = B): `.felhom.yml` `backup.volumes` class `excluded` (`6a17c95`); R-1187(c): an import over an installed app that is not running is refused (`d5f88a2`, reproduced and proven fixed on 9202); R-1189(d): `nightchain.CatchUp.After` runs the off-site proof + integrity once after a catch-up (`d5f88a2`); R-1214: the second-drive copy runs in a 512 MB memory-capped container for clean `/mnt/` paths with no symlink (`03c0a69`, `ae39467`). 9202 ran rc.1–rc.4 and is back on 0.339.0. rc tags pushed: 0.340.0-rc.1..rc.4 (never offered: not X.Y.Z).
|
||||
|
||||
> **2026-10-20 — v0.336.0 is the floor (MinAgent 0.158.1, 08:54 UTC).** R-893 option A (operator D20): a failed off-site restore
|
||||
> copies the app's live volumes first and puts them back exactly, then starts the app (proven on 9202: schema, data and row-count
|
||||
> md5 equal before and after a forced failure); R-1169 the held sentence. Follow-up row: the put-back's ERROR log level and an
|
||||
> empty `backups/restore-undo/` folder.
|
||||
|
||||
|
||||
> **2026-10-19 (day) — v0.332.0 is the floor (MinAgent 0.158.1): one sign-in for the file manager (R-1100, D16) and the
|
||||
> typed-back escrow confirm (R-1151).** Every dashboard folder link → `fileBrowserOpenLink` → `GET /__files/open` (pass:
|
||||
> 60 s, one use, host + session bound; none for `Sec-Fetch-Site: cross-site`) → traefik forwardAuth `GET
|
||||
> /__felhom_gate/files` (`web/files_sso.go`) swaps it at `/_felhom/sso` for the host-only `felhom_files` cookie and then sets
|
||||
> the per-box SECRET header (`infra.LoadOrCreateFileBrowserSSOHeader`, `<data>/filebrowser-sso-header`, NEVER logged)
|
||||
> naming `felhom-household`. **A forwardAuth answer's redirect must be ABSOLUTE** (traefik resolves a relative Location
|
||||
> against the auth server — rc.2 sent browsers to `http://felhom-controller:8080/…`). Escrow: the claim sends
|
||||
> `{"defer_upload": true}`; `/api/escrow/confirm` forwards the two typed words; an agent < 0.160.0 needs no confirm.
|
||||
> Residuals R-1155 (FileBrowser's own token), R-1161 (the „live code" wording). felhom.eu `REPORT-day-2026-10-19.md`.
|
||||
> **2026-10-18 (evening) — v0.331.0: OnlyOffice opens and saves from FileBrowser (`server.internalUrl`) and Nextcloud
|
||||
> (`DocumentServerInternalUrl` with its slash); a FileBrowser restart runs the R-1126 gate at once (R-1018); a removal deletes volumes still labelled with the
|
||||
> app's project that the definition no longer declares (R-1117).** MinAgent 0.158.1.
|
||||
> **2026-10-18 (day) — v0.330.0: „anyway" asks the agent's disk list first and starts again the apps it stopped when the
|
||||
> agent refuses (R-1144).** MinAgent 0.158.1.
|
||||
> **2026-10-18 (night) — v0.329.0: each app reaches only its own `appdata/<folder>` (R-1142, compiled-in folder map:
|
||||
> paperless-ngx → paperless); the allow-list is EMPTY (homepage's socket removed, R-981); a drive retirement asks the agent
|
||||
> first and a refusal changes nothing (R-1063); a plain message for a virtio disk without a serial (R-1069); `:latest`
|
||||
> only for a release tag (R-1143).** MinAgent 0.158.1.
|
||||
> **2026-10-17 (late) — v0.328.0 is the floor (MinAgent 0.158.1): the catalog allow-list (R-971, operator decision D2 = A).**
|
||||
> `internal/privpolicy` refuses container rights not on the compiled list before ANY compose write; refusal = nothing
|
||||
> written + `app_definition_refused` (hub ≥ 0.157.0) + an app-page line. Three security-review rounds fixed before the
|
||||
> release; a live finding on 9201 (rc.4) fixed in rc.5. Follow-ups R-1142 (cross-app appdata), R-1143 (`:latest`).
|
||||
> This file serves as persistent project memory across Claude Code sessions.
|
||||
> It replaces the auto-generated "Memory" from the claude.ai Project.
|
||||
> **Update this file at the end of each working session** with current state,
|
||||
@@ -7,7 +74,698 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-08-31 (v0.228.0 — R-399/R-400: the check reads the data, the debug page stops lying)
|
||||
Last updated: 2026-10-17 (v0.327.0 — FileBrowser published only after its check; real memory amounts in the install check)
|
||||
|
||||
> **2026-10-17 (afternoon, 2) — v0.327.0.** R-1126 (D11 = A): FileBrowser off `traefik-public` until this run's default
|
||||
> login is checked (`stacks/filebrowser_gate.go`, connect by container ID); FileBrowser's own admin-password setting
|
||||
> re-applies every start, so it cannot carry ours. R-1125 (D10 = A): catalog requests = measured use; the refusal and the
|
||||
> install page say what to do; the page reads `stacks.InstallMemory` like the check. R-1129 was a misreading (CI mail
|
||||
> always worked).
|
||||
|
||||
> **2026-10-17 (afternoon) — v0.326.0.** R-977: `EnsureFileBrowserAdminPassword` probes the default login once per
|
||||
> FileBrowser RUN (`fbStartedAt`, `fbProbedRun`), with ~20 s for a new run to answer; measured on 9202 before (default 200
|
||||
> for 10 min after a re-created volume) and after (replaced in 2 s). The tunnel-before-claim question is the operator's
|
||||
> (R-1126). R-1116: `gate-decoys` in the full run (not `--fast`: 22 s, edits real files — R-1128).
|
||||
|
||||
> **2026-10-17 (day) — v0.325.0 (burndown 3).** R-1111 `reserveAttempt` is the one limiter body (dashboard sign-in,
|
||||
> share password, escrow re-auth). R-1112 `family.json` stores fingerprints; an old file is converted when the
|
||||
> controller STARTS (`webServer.OpenFamilyStore` — rc.1 opened it lazily, found live on 9201). R-1113 the household's
|
||||
> pass reuses its family session (`Store.HouseholdSession`, new one under half-life). R-1109 samba compose label
|
||||
> `felhom.smb-conf-sha256` (every box with sharing on recreates samba once). R-1110 `TestNoBareDrivePathToPathHelpers`.
|
||||
> R-1114 `scripts/shared_scripts.py` + `FELHOM_WORKSPACE`. Same day, catalog `5d9450b`: five apps' Redis → RDB
|
||||
> (R-1096), applied at each app's next start. Known flake filed: a stacks test writes `update-journal.json(.tmp)` into
|
||||
> the package folder (empty DataDir), and the R-578 walk can trip on it.
|
||||
|
||||
> **2026-10-17 (night) — v0.324.0 RELEASED to the three test boxes (per-box floors, 21:52 UTC).** R-1106 / R-127 b
|
||||
> (operator decision 1A): `appbackup.DBCredentialVars` + `DBDataVolumes`; `backup/restore_dbcreds.go` (pure
|
||||
> `planDBCredentials`, `dbCredentialPlanFor`, `offsiteCredentialOverlay`); `unitRestoreEnv` refuses a DB credential it
|
||||
> cannot source and O4 never generates one; the same-version off-site restore writes the snapshot's password via the
|
||||
> optional `secretValueSetter` → `stacks.SetStackSecretValues` (load-then-save; also marks the reinstall's `type:
|
||||
> password` logins as restored — R-926). R-1107: restic 0.19.1 pinned; fixtures for three versions. R-1108, R-1065,
|
||||
> R-1017 (+ `userdataKept` fix), R-983 (6), R-976 (2) — written by two helper sessions in detached worktrees, merged
|
||||
> and reviewed here. Proven live on demo-hp. **Global floor 0.324.0 / MinAgent 0.158.1 at 02:46 UTC 2026-10-17**, after all three test boxes
|
||||
> made the night's off-site backup on it; per-box floors cleared. Next: R-1096 (Redis plan in `audits/night-restore-2026-10-17/P6/`), R-1109…R-1115.
|
||||
|
||||
> **2026-10-16 (evening) — v0.323.0 RELEASED to the three test boxes ONLY (per-box floors); the GLOBAL floor stays
|
||||
> 0.322.0 until each test box has made one real night's off-site backup on 0.323.0** (the brief's rule; the check and
|
||||
> the floor command are in felhom.eu `STATUS.md`). R-1105: runtime `debian:13.7-slim` (digest-pinned), Docker repo
|
||||
> codename from `/etc/os-release`; restic is the official 0.18.1 release binary from a `restic` build stage (signed
|
||||
> `SHA256SUMS` + vendored key in `third_party/restic/`, published sum, pinned per-arch sum). Every restic assumption
|
||||
> re-proven with REAL outputs of 0.14.0 and 0.18.1 (`internal/backup/testdata/restic/`, `TestR1105_*`); the one that
|
||||
> moved is the forget plan — 0.17+ keeps each group's oldest snapshot while monthly slots are unused (only ever keeps
|
||||
> more). Removed packages the binary never runs (postgres/mysql clients, sqlite3, fdisk, e2fsprogs, parted, sshpass).
|
||||
> felhom-samba 1.2.0 (Alpine 3.24.2), and `build-samba-image.sh` refuses a reused tag. Live on demo-hp 9201: both
|
||||
> restore directions byte-identical on the real store; the drill's data came back; the full restore after a reinstall
|
||||
> leaves a DB app unusable (old volume, new password — R-1106, not restic). Open: R-1107 (restic 0.18.1's binary adds
|
||||
> 50 fixable scanner findings; 0.19.1 has 19 — the operator's choice), R-1108 (a share added before the first password
|
||||
> is not exported). **Decision (CC, inside the brief, reversible):** the packages above were removed though the brief
|
||||
> named only the two database clients — none is run by the binary (every exec call read), and the image carries fewer
|
||||
> known holes. Full report: felhom.eu `REPORT-debian13-restic-2026-10-16.md`.
|
||||
|
||||
> **2026-10-16 — v0.322.0 RELEASED by CC; the global floor 0.322.0 / MinAgent 0.158.1.** R-970: `/api/config`,
|
||||
> `/api/config/apply`, `/api/config/hash` are gone; the self-update and geo routes no longer take the box's hub key
|
||||
> (`selfUpdateAuthMiddleware` removed), and the CSRF Bearer exemption is gone. The route table is `buildMux` in `main.go`
|
||||
> so `TestR970_HubKeyOpensNoDoor` drives the real table. R-972: Go 1.27.2, x/crypto v0.58.0, runtime
|
||||
> `debian:12.13-slim` pinned by digest + `apt-get upgrade`. **Decision (CC, inside the brief, reversible): the runtime
|
||||
> stays on Debian 12** — Debian 13 brings restic 0.18.0 and the off-site code relies on measured 0.14.0 behaviour; the
|
||||
> move is R-1105. Data tab: the export field is `fab_encryption_key`, `autocomplete="new-password"`; the 2nd-copy
|
||||
> status rows are gone. R-1103: the installed-only branches of `app_fields.html` removed; their rules live in the
|
||||
> app-page and settings-tab tests. Full report: felhom.eu `REPORT-burndown2-2026-10-16.md`.
|
||||
|
||||
> **2026-10-16 — v0.321.0 RELEASED by CC; the global floor 0.321.0 / MinAgent 0.158.1.** R-1101: in `generated` the page asks
|
||||
> FileBrowser whether the stored password still logs in (`stacks.FileBrowserStoredPasswordRefused`, only a 401/403 counts)
|
||||
> and, refused, offers the reset; no „change it" line for FileBrowser; its config switches off its update banner and
|
||||
> „Install app" prompt. The Data tab's backup part is one section in plain words. R-1095: `/apps/{slug}/install` of an
|
||||
> installed app → 302 settings; the installed-only `app_fields.html` branches stay (R-1103). Catalog the same day: RomM's
|
||||
> saves in a named volume (R-1097), `default_creds` off eight apps (D10 = A). Next: R-1103; R-1067 waits on Tester-2 (0.292.0).
|
||||
|
||||
> **2026-10-16 — v0.320.0 RELEASED by CC; the global floor 0.320.0 / MinAgent 0.158.1.** A protected infra stack is
|
||||
> installed by definition on the app page (`Deployed || Protected`; v0.315.0 had shown FileBrowser „not installed" with
|
||||
> Install and no login); the deploy API and `DeployStack` refuse a protected stack. FileBrowser `operator` state →
|
||||
> „Új jelszó beállítása" (`stacks.ResetFileBrowserAdminPassword`: its CLI with the server stopped, verify, encrypt,
|
||||
> `generated`) — pressed on demo-hp; **the N100 waits for the operator's yes**. Every installed app has ONE login answer
|
||||
> (`web/app_login.go` `loginAnswerFor`; catalog `app_info.login_kind`). „Fájlok ↗" + per-drive media folders; a folder with
|
||||
> no FileBrowser source is text. Decisions inside the brief (felhom.eu `REPORT-logins-folders-2026-10-16.md` §0): no backup
|
||||
> sentence on media rows; no data tab for infra; „none" behind the family gate says the family sign-in. Next: R-1100
|
||||
> (one sign-in), R-1101 (stale FileBrowser password), R-1102 (eight „known password" lines, decision D10).
|
||||
|
||||
> **2026-10-16 — v0.319.0 RELEASED by CC; the global floor 0.319.0 / MinAgent 0.158.1.** App page style A (operator
|
||||
> 2026-10-16): the hero, the tab bar and every section are panels on `--bg-1`; the install page's form groups use the same
|
||||
> rule. „Belépés az alkalmazásba" pairs a `type: password` field with its `*_USER/_USERNAME/_EMAIL` name
|
||||
> (`web/app_login.go`); a field the box holds no value for is a sentence, never an eye — page and reveal endpoint share
|
||||
> `storedValuePresent`. Measured cause on demo-hp: Calibre-Web installed 2026-08-21, `ADMIN_PASSWORD` entered its template
|
||||
> 2026-09-29, and `InjectMissingFields` never fills a `type: password` field; its stored user name is real (renamed in the
|
||||
> app on 2026-10-01). The settings tab no longer renders `app_deploy_fields`; `/apps/{slug}/install` for an installed app
|
||||
> still does (R-1095). Evidence: felhom.eu `audits/app-page-tuning-2026-10-16/`.
|
||||
|
||||
> **2026-10-16 — v0.318.0 RELEASED; the global floor 0.318.0 / MinAgent 0.158.1 (set by the operator).** The internal data
|
||||
> place („Belső tárhely", agent ≥ 0.158.1 reports kind `internal`): bound on one drive-gate tick, registered on the next as
|
||||
> the default drive (web/internal_place.go); eject/decommission/wipe of it refused. Partitions in the format/attach wizards
|
||||
> (agent `formattable`: only while nothing on the disk is mounted); the whole disk under a registered part refused
|
||||
> server-side (`deviceBacksRegistered`). R-1084: the whole-box backup offer asks the same-disk question (`offerRelationFor`).
|
||||
> R-1082: `/mnt/sys_drive` never offered (`withoutSystemVolume`, by device). Live on a fresh one-disk scratch box: felhom.eu
|
||||
> `audits/storage-identity-buildB-2026-10-15/`. Next: R-1094 (the whole-disk refusal sentence), with the next release.
|
||||
|
||||
> **2026-10-15 (late evening) — v0.316.0/.1/.2 built and on demo-hp; .3 committed, NOT built.**
|
||||
> **Measured before fixing:** the tabs rendered unstyled through the public address because of a
|
||||
> CACHE, not the box — the stylesheet URL was `?v={{.Version}}`, identical across three different
|
||||
> 0.315.0 builds; the origin sends NO ETag and NO Last-Modified, so a copy can only expire; and
|
||||
> Cloudflare caches it while rewriting the browser TTL to four hours. Evidence and its caveat:
|
||||
> `audits/app-page-2026-10-15b/P0/VERDICT.md`.
|
||||
> **Two mechanisms now:** `build.sh` refuses a second push of a version (a registry it cannot reach
|
||||
> is a refusal too), and every shell's stylesheet address is a per-build tag.
|
||||
> **The app page is the approved one-column mockup**: one width, hairline sections, underlined tabs,
|
||||
> and a box only for „the app is waiting for you" (blue) or „this is risky" (amber). The
|
||||
> auto-update SUCCESS banner is now meta text.
|
||||
> **THE LESSON OF THE EVENING, and it cost a bad push:** I put the guard in `controller/build.sh`,
|
||||
> but the release runbook runs `build/felhom-controller/build.sh` — a SEPARATE COPY from
|
||||
> 2026-07-18. Proving the refusal against a real tag therefore re-pushed 0.315.0 a fourth time
|
||||
> (R-1081). The build directory's script is a symlink now. A guard in a script nobody runs is not a
|
||||
> guard. The same shape bit twice more in one evening: the asset tag shipped dead because only one
|
||||
> of TWO render paths set it, and both times a BROWSER found what the suite could not.
|
||||
> Not yet validated: the operator's own look; 0.316.3's two cosmetic fixes.
|
||||
|
||||
> **2026-10-15 (evening) — v0.315.0, ONE APP PAGE WITH FOUR TABS.** An app was six pages and every „Vissza" went to the
|
||||
> app LIST; it is now `/apps/{slug}/{settings,data,guide,logs}` plus `/apps/{slug}/install`, one shared header and one
|
||||
> set of banners (`internal/web/app_page.go`, `templates/app_shell.html`). Operator decisions recorded for this release:
|
||||
> **D1** — Export lives on „Adatok és mentés", not under Beállítások. **D2** — an after-start field's CARD is rendered
|
||||
> generically from the catalog's `after_start: true`, and its ACTION is Go code in a registry keyed by slug + env var
|
||||
> (`internal/web/after_start.go`), so customer text can never reach a catalog-defined command. **The FAQ is a section of
|
||||
> the guide, not a tab**, and **the guide tab replaces the details page**. **302, never 301**, for every old
|
||||
> `/stacks/{name}/…` GET — a cached 301 cannot be withdrawn; every POST on those paths is untouched.
|
||||
> New catalog block `app_info.guide` (sections + faq + `checked_version`), rendered only when `checked_version` names the
|
||||
> image tag its author checked it against; no template carries one yet (the content is the companion task). The Pi and
|
||||
> „csak x86" badges are gone; `PiCompatible` and the catalog key STAY this release, or a box on 0.314.0 would read
|
||||
> „csak x86" on every app.
|
||||
> **Found by a red-proof, and worth remembering:** the after-start handler's „the catalog must have DECLARED this field"
|
||||
> check could be deleted with the whole suite green — and a POST for an undeclared field then reached Plex and opened the
|
||||
> setup gate. Two guards that look like one are two tests.
|
||||
> **Two defects the whole test suite could not see, both found in Chrome on the workstation** (this is
|
||||
> the case for CC running on Windows): the tab bar had no CSS at all and the hero logo was unsized,
|
||||
> because the shell invented class names beside ones the design system already had rules for; and the
|
||||
> log tab's `<script>` was never closed, so the page rendered, every marker matched, and the live log
|
||||
> poller was dead ("SyntaxError: Unexpected token '<'"). A marker proves a string is present, not that
|
||||
> the browser will run it. `TestAppPage_ScriptTagsAreBalanced` now pins the second on all seven URLs.
|
||||
> Live on demo-hp 9201; the floor stays 0.314.0 until all three test boxes have taken it.
|
||||
> Not yet validated: the operator's own look at the page, and a real Plex claim through the new card.
|
||||
|
||||
> **2026-10-15 (evening) — v0.314.0, the floor since 13:11 UTC (raised by CC's token after the three test boxes; no golden).**
|
||||
> A restore puts the setup gate back as it was (`internal/stacks/restore_gate.go`, felhom.eu `09` §3 decision 253, R-1057);
|
||||
> `util.ParseVersionPre` puts `X.Y.Z-rc.N` below `X.Y.Z` for the floor ONLY — never for the tags offered (R-1053);
|
||||
> Prowlarr's start runs the media wiring (`linkOnlyApps`, R-1058); every Plex call goes to the container's address
|
||||
> (`mediawire.PlexURL`, R-1060 — Plex refuses the name `plex` as non-local). Still open: the real Plex claim on demo-hp
|
||||
> 9201 (the operator's code), R-1052's new-box proof (a throwaway customer is session only).
|
||||
|
||||
> **2026-10-15 (afternoon) — release rule `09` §3 decision 250:** a release needs no golden; the floor carries it after
|
||||
> the three test boxes; the golden is weekly. Test images use `-rc.N` tags — the household button skips them, but the
|
||||
> floor never moves a box running one (`maybeAutoUpdate: not parseable — skip`, R-1053): write the release tag back.
|
||||
|
||||
> **2026-10-15 — v0.313.0, the media build (R-1023 and the rows under it; `09` §3 decisions 245, 247, 248).** Media apps bind
|
||||
> every PRESENT drive through an owned `docker-compose.override.yml` (`media_library:` marker in the template); a real
|
||||
> restart (force-recreate + start times); the drive wiring (`internal/mediawire`, keys read at use — Plex's own token,
|
||||
> decision 248; Jellyfin's key named „felhom"); drive events with the notice, „restart now/later" and a stream check; safe
|
||||
> removal keeps the drive; Plex's code asked after the start (`after_start`). Floor 0.313.0 and golden 0.313.0 vouched by the operator 2026-10-15 11:29. Traps found on Tester 1 and fixed: qBittorrent 5 logs in with 204; the agent's disk
|
||||
> `state` stays „attached" for a pulled registered disk (presence = backing device); the drive gate raced a safe removal.
|
||||
> A Plex status probe (`/library/sections`, 401) OPENED an unclaimed Plex while Plex started — removed within the hour;
|
||||
> never gate on a status a starting app also returns.
|
||||
|
||||
> **2026-10-14 (night) — v0.312.0, https only (R-1033, P1; `09` §3 decision 244).** DECIDED BY CC UNATTENDED — operator
|
||||
> may reverse: the redirect lives in traefik on the box (`felhom-https-only` in the `felhom-forwarded` chain), not in
|
||||
> Cloudflare, because every box's `cf_api_token` lacks „Zone Settings" (403, measured on all three zones). Cloudflare's
|
||||
> „Always Use HTTPS" stays the operator's defence-in-depth option once the keys carry the permission. HSTS one day.
|
||||
|
||||
> **2026-10-08 (day) — UNRELEASED on main, ships with tomorrow's release (no release today: kernel night 8→9, `09` §3
|
||||
> decision 178).** R-899 (decision 177): a daytime press never cancels the night's whole-guest backup —
|
||||
> `internal/quiesce/nightowed.go` ledger, owed tiers never fire the valve; a press reaches the agent as `trigger=manual`.
|
||||
> R-304: the agent's new 424 `older_unchecked` → `RecoveryOlderUnchecked`, bundle key `recovery.older_unchecked` (hu+en).
|
||||
> MinAgent unchanged (0.131.0): both changes degrade safely against an older agent. Afternoon (`a40729a`): the
|
||||
> operator-only `recovery_older_package` event (decision 183; needs the hub of the same day), no eject/format on a
|
||||
> backup-target drive (R-298 side fix), R-717 comments.
|
||||
|
||||
> **2026-10-04 night — v0.293.0 (R-860; floor 0.293.0 on the demo customers only, golden 0.293.0 vouched):**
|
||||
> `internal/sockheal` — the controller exits after 60 s of socket REFUSALS (never timeouts, only after Docker answered
|
||||
> once) so Docker restarts it on the current socket, then restarts any other socket user on an older inode (traefik).
|
||||
> Measured: only a `docker.socket` restart re-creates the file. Live: 9202 104 s, demo-hp 9201 120 s, ids unchanged.
|
||||
> The global floor stays 0.292.0 until Tester 2 may move (operator ruling 97 limited Tester 2 to three acts).
|
||||
|
||||
> **2026-10-04 — v0.290.0 (floored, golden 0.290.0 vouched, needs hub v0.128.0):** `offsiteGuard` EXCLUDES a young
|
||||
> snapshot superseded the same day in its group (R-824) and REFUSES above the hub's `max_remove` (half); a due
|
||||
> abandonment on the pinned tier is handed to the hub (`OffsiteAbandonClient`), which deletes after 7 days unless
|
||||
> cancelled (decision 74); a recovery cancels at the hub. Weekly windows ON fleet-wide since 2026-10-04.
|
||||
|
||||
> **2026-10-03 (evening) — v0.289.0 + v0.289.1 (floored 0.289.1, golden 0.289.1 vouched, needs hub v0.127.0):** the
|
||||
> hub-provisioned off-site tier is reached through an APPEND-ONLY key the hub's registrar pins
|
||||
> (`offsiteapply.HubRegistrar`, `PinnedProber`); the box never receives the Storage Box password; transport `rclone:`
|
||||
> over ssh 23 (`OffboxTarget.Transport = "rclone-pinned"`; the household NAS stays sftp). Retention only inside a hub
|
||||
> window behind the fake-snapshot guard (`backup/offbox_window.go`) — weekly windows are OFF; the guard is too strict
|
||||
> after manual runs (R-824, next). Move-aside is the hub's; abandonment deferred to the operator (R-823). v0.289.1: the
|
||||
> provider rclone NOTICE line is stripped from restic output, and an unreadable snapshot count is never a measured 0
|
||||
> (it caused one false `offsite_snapshots_dropped` mail, R-825). Both demo boxes migrated live with history kept.
|
||||
> Evidence `felhom.eu/documentation/audits/offsite-lock-build-2026-10-03/`.
|
||||
|
||||
> **2026-10-02 (afternoon) — v0.288.0 (floored, golden 0.288.0 vouched):** `userdata_kept` in hdd-data and both remove
|
||||
> results; the dialog says the household's files stay and names them (decision 67). Rule for later work: a remove NEVER
|
||||
> deletes a `${USERDATA_PATH}` folder — only the app's `${HDD_PATH}` binds, volumes and backups.
|
||||
|
||||
> **2026-10-02 — v0.287.0 (floored, golden 0.287.0 vouched): the family gate.** `internal/family` (members, sessions asked
|
||||
> on every request), `stacks/family_gate.go` (door before the first start; anchored exceptions; record at install and at a
|
||||
> removed app's restore; `min_controller`), `web/family_gate.go` (forwardAuth, `/__family/*`, the Család card). The family
|
||||
> cookie is never accepted by RequireAuth. Rules for later work: a family door is never removed while the app is
|
||||
> installed; an exception is a literal prefix, anchored; never an exception on an app with no login. Live proof
|
||||
> `felhom.eu/documentation/audits/family-gate-2026-10-02/A/items.txt`. Catalog: Grimmory + MeTube published behind it.
|
||||
|
||||
> **2026-10-01 night — v0.286.0 (9202 only) → v0.286.1 (floored, golden 0.286.1 vouched).** Operator ruling `09` §3
|
||||
> decision 63: extend the box's own gate; first the box tells visitors apart (R-753). `internal/infra`: `felhom-tunnel`
|
||||
> network `172.16.253.0/29` (ip-range `.4/30`), cloudflared ALONE at `.2`, traefik at `.3`; traefik's websecure trusts
|
||||
> forwarded headers from `172.16.253.2/32` only and passes `felhom-forwarded@file` (removes client-writable
|
||||
> host/path/address headers, fixes `X-Forwarded-Port: 443`). `EnsureBaseStack` now RECONCILES a running traefik/cloudflared
|
||||
> whose rendered files changed (recreate; refuses dropping a certificate resolver). `internal/web/clientaddr.go`: `clientIP`
|
||||
> = the hop traefik saw (rightmost XFF, only when the peer is traefik), the tunnel hop → `CF-Connecting-IP`; `rateKey`
|
||||
> IPv6 per /64. **Readers take the visitor from the RIGHT, never the leftmost** — catalog apps that read the leftmost have
|
||||
> `<router>-xff` (19, catalog). R-772: `HealthProbeResult.NotChecked`, seen on the next tick (0.286.1). R-773: a removed
|
||||
> app restored gets its sign-up lock back (`SetupGateByRestore`). Open: R-776..R-779 (felhom.eu register). The permanent
|
||||
> family gate (R-780) was SPIKED and passed — not built; the operator decides.
|
||||
|
||||
> **2026-10-01 — v0.285.0.** Operator ruling `09` §3 decision 56 (R-745): `stacks/controller_image_retention.go` keeps
|
||||
> the running controller image + the previous (swap record `update-state.json` via `UpdateState.RecordedPrevious`; version
|
||||
> order as fallback) + anything above the running version + non-version tags; deletes older/untagged by exact name/ID,
|
||||
> 3 min after every start (main.go, after the one-time app sweep). Measured: the agent's swap rolls back to the RUNNING
|
||||
> image (what `/etc/felhom-controller-image` named), never to a controller-supplied previous. R-751: nil-stack panic in
|
||||
> `RetainImagesAfterUpdate` fixed; both retention seams are no-ops in the stacks tests (`TestMain`).
|
||||
|
||||
> **2026-09-30 late — v0.284.0.** Operator rulings: `09` §3 decision 53 (R-736 A: keep running + previous image per
|
||||
> service, delete older, never an image a container/compose/record names) → `stacks/image_retention.go`, with a
|
||||
> one-time sweep at start; R-741 → `stacks/install_hold.go` (after_install apps held behind the gate's door until the
|
||||
> login is replaced). Decision 52 (same-tag re-tests) needed NO controller change (measured by a unit walk).
|
||||
|
||||
> **2026-09-30 — v0.283.0 / v0.283.1.** Decision 50: `settings.DefaultOffboxOnForNewApp` from the deploy-done hook
|
||||
> (an earlier per-app choice wins); one press for older apps; `backup/offbox_fit.go` size card (estimate at each run
|
||||
> + background from the page, never `du` in a request). R-721: `stacks.Manager.WantsStopped` asked by the quiesce
|
||||
> resume, the volume dump (through `stackAdapter` — the v0.283.0 miss), the update leg and `AppStopGuard.Recover`.
|
||||
> R-724/R-725 page slips. Floor 0.283.1, MinAgent 0.131.0. No golden (the operator's choice before Tester-2).
|
||||
|
||||
> **2026-09-28 evening — v0.279.0.** `stacks/after_install.go` (decision 45: a fresh install replaces a known default
|
||||
> login; the deploy-done hook now set on every box), `web/known_login.go` (the page's default-login rule),
|
||||
> `backup/hollow_watch.go` (Part D: a running app's empty copy → digest + page), `web/night_chain.go` + `RunUpdateLegNow`
|
||||
> (R-705), `Router.removeVerificationCopy` (R-706). Also: v0.278.0 (R-704) earlier the same day.
|
||||
|
||||
> **2026-09-28 — v0.277.0 (MinAgent 0.131.0).** `backup/kept_load.go`: `KeptBestCopy` (local newest vs off-site —
|
||||
> off-site only when NEWER or the only copy), `KeptOffsiteCopies` (ONE `snapshots --json`, 20 s bound; `offsiteNewest`
|
||||
> now carries `paths`), `LoadKeptOffsite` (unit-only download via `restoreUnitReadOnly` into the PROOF scratch → judge:
|
||||
> `KeptCopyAt` + `data` block required + `unitVersionCheck` → `prepare` → `keptUnitRestore` (seam) → scratch removed
|
||||
> before the outcome). `KeptCopyKey` names a copy on both pages. Golden 0.276.0 baked + vouched with agent 0.137.0 the
|
||||
> same day.
|
||||
|
||||
> **2026-09-27 — v0.276.0, floor 0.276.0 (MinAgent 0.131.0).** `stacks/life_records.go` `carryLifeRecords` — the restore's fresh `app.yaml` keeps `desired_state`, `conversion_copy`, `earlier_conversion_copies`, `failed_update_step`, `last_update_undone`, `last_auto_update` (NOT the pin, NOT `installed_images`); `recordConversionCopy` moves a superseded copy to `earlier_conversion_copies`; `ReleaseConversionCopies` → `releaseOneConversionCopy` per kept copy. Drive move: `persistDriveFlip` (load-then-save, `HDD_PATH` only) + `upFromAppConfig`; `flipEnv` removed. **A new `app.yaml` writer that is not a new install must load-then-save** (R-697, R-700). Tests `r700_records_carried_test.go`.
|
||||
>
|
||||
> **2026-09-27 — v0.275.0, floor 0.275.0 (MinAgent 0.131.0).** `backup/data_versions.go`: `stampDataFile` (called by the
|
||||
> DB leg, the volume leg and `RunAppBackupNow` — a new writer of a unit data file MUST call it), `foldUnitData`,
|
||||
> `RecoveryManifest.Data`; `captureRecoveryUnit(stack, dataRun)` keeps `compose/` when the pins moved since the data
|
||||
> (frozen); `unitVersionCheck`/`ErrUnitVersionMismatch` in the unit restore and the off-site reconstitute (which now
|
||||
> writes the snapshot's definition when versions differ); `unitDataTime` = THE unit data time (+ proven flag, R-699);
|
||||
> Tier 2 `DataDate`/`DataUnproven`; Tier 3 `offsiteDataTime`; `stacks.DumpStampSource` (optional guards interface) +
|
||||
> `ConversionCopy.Service`; `stacks.RestoredVersionPosition` + the restore pages' first sentence; R-695 single-flight
|
||||
> FB sync; R-691 `keptReadGroups`/`group_add` (CC-unattended decision, `07` §6.5); R-694 `restored_logins`.
|
||||
> D4 (open) option A is the built behaviour — `07` §6.6.
|
||||
|
||||
> **2026-09-25 midday — v0.272.0, floor 0.272.0.** `web.noSpaceLine` (agent prefix `skipped: not enough space: `,
|
||||
> key `backup.tier.no_space`); `backup.SetUndoCopyRemover` → `stacks.RemoveUndoCopies` from `clearUpdateHoldAfterRestore`
|
||||
> (R-671); `stacks.LoadProbeMetadata` (R-670); `stacks.BehindSinceAge` in both badge producers (R-677). Rulings: Peti's
|
||||
> box retired; `09` decision 34 — no resume of the update leg after a restart.
|
||||
|
||||
> **2026-09-25 night — v0.271.0, floor 0.271.0 (MinAgent 0.131.0).** `stacks/unattended.go`: `RunUpdateLeg` — one
|
||||
> app at a time, ONE step per app per night (decision 33), only `proven` entries, never `needs_person`,
|
||||
> `files_may_change` only with `backup.FreshWholeCopy` (the hold's truth table), never the step in `app.yaml`
|
||||
> `failed_update_step` while the ladder print is unchanged (R-680), transient refusals retried once, no step at or
|
||||
> after W+5h (`backupwindow.UpdateLegStopOffsetMin`). Chained by `chainUpdateLeg` inside the `offbox-backup` job
|
||||
> (every path incl. panic). `quiesce.Options.UpdateLegFn` = decision 20 + 31 (the gate waits until W+5h, then only
|
||||
> for a step in flight, cap W+5h30m). Self-update waits for the whole leg (decision 32). Switch `settings.json`
|
||||
> `app_update.unattended`, absent = ON, card on /settings (`POST /settings/app-update`). Page line
|
||||
> `last_auto_update` („Automatikus frissítés %s-kor — sikeres."), report `update_leg`. R-678: `ScanStacks` before
|
||||
> `finishUpdate` on done/undone. `stacks.update_window` REMOVED. Decisions 31–33 taken unattended (operator may
|
||||
> reverse). Open: R-686 (no resume after a restart), R-687 (live-proof gaps), R-685 controller half (the backup
|
||||
> page line for a space skip). Record: `felhom.eu/documentation/audits/DRILL-night-2026-09-25.md`.
|
||||
|
||||
> **2026-09-24 evening — v0.270.0, floor 0.270.0.** `UpdatePreflight` refuses `already_current` (R-679).
|
||||
> `stacks/install_interrupted.go`: `.felhom-install-pending` marker + `RecoverInterruptedInstalls` at start (after the
|
||||
> deploy-done hook) → compose down, stale pins cleared, `app_deploy_failed`, `.felhom-install-interrupted` → the
|
||||
> apps page sentence (R-681). The recovery unit captures `stacks.AppliedMetaFile`; a restore calls
|
||||
> `RecordRestoredAppliedMeta` (R-669). Next: part 7 (the update leg, `09` §6.4.2), R-678, R-680, R-682.
|
||||
|
||||
|
||||
> **2026-09-24 night — v0.269.0 + v0.269.1. Fleet floor 0.269.1 (MinAgent 0.131.0; needs hub v0.123.0).**
|
||||
> `backup.RestoreTier2Whole` (`tier2_whole.go`, decision 26): `mergeRestoreFiles` by the four file rules
|
||||
> (never delete; never an older file over a newer one; bring back missing; an older differing live file kept
|
||||
> beside as `.felhom-<UTC>`), then `RestoreFromRecoveryUnitAtWith(AcceptMissingFiles)` from the MIRROR; refuses
|
||||
> before anything moves (not a file app / no proven openable mirror / < 2 GB). `sameDevice` fails CLOSED (R-668).
|
||||
> Decision 27: `RemoveKeepsDataOnly` → 409 `err.stacks.remove_keep_data_while_support`; `keep_data_only` on
|
||||
> hdd-data. Decision 28/29: `stacks.ObserveUnhealthy` (RestartCount delta ≥ 6 in 10 min, OOM ≥ 20 in 30 min)
|
||||
> → `stopUnhealthyApps` → `HoldUnhealthy` (`unhealthy_stop`, trip 2 within 24 h) → `app_stopped_unhealthy`;
|
||||
> Start lifts it. R-664/R-665: `entry.NewMeta` = the step's own `.felhom.yml`. §6.4 part 6 box half:
|
||||
> `stacks/digest.go` — `RenderWithLadderDigests` for update + fresh install; **`CarryDigests` for an installed
|
||||
> app's sync (v0.269.1, decision 30)**. Open next: R-669 (applied-meta after hold + restore), R-679/R-680 +
|
||||
> part 7 (the update leg, `09` §6.4.2), R-681/R-682 (interrupted install/remove). Record:
|
||||
> `felhom.eu/documentation/audits/DRILL-night-2026-09-24.md`.
|
||||
|
||||
> **2026-09-24 — v0.268.0 (R-658, R-659, R-660, R-651; `09` §6.4 part 5). Fleet floor 0.268.0.** The undo
|
||||
> selects volumes with `DeclaredVolumeNames` (the compose definition), never the project label; the restore
|
||||
> labels volumes (project/volume/version, no config-hash). The hold names the newest WHOLE copy
|
||||
> (`backup.WholeOnTier` = the restores' refusal predicate `DeclaredDriveFileLegs`; off-site only for file apps)
|
||||
> or, with none, `hold.update.no_whole_copy` + no Mentések button + `app_hold_no_whole_copy` (needs hub
|
||||
> v0.122.0). `UpdateHeldStacks` is the fourth app-down suppression. Remove deletes `applied-compose.yml` +
|
||||
> `applied-meta/`. The ladder: `stacks/ladder.go` — `nextLadderStep` picks the NEWEST entry whose `from` is
|
||||
> the pin; a non-head step pins `catalog-cache/templates/<app>/steps/<StepKey(to)>.yml`; missing/wrong →
|
||||
> `update.error.pin_failed` before anything moves; no match → template, logged. `LadderStepsLeft` on the
|
||||
> page. Open next to it: R-661 (Tier-2 no whole action), R-664 (a step has no .felhom.yml), R-665, R-666,
|
||||
> R-667 (crash-loop clock resets on a flapping container).
|
||||
|
||||
> **2026-09-23 (night) — v0.267.0 (R-650, R-640, R-499, R-518).** Every docker exec goes through
|
||||
> `internal/dockerexec`; under `go test` a real docker is refused (opt-in `FELHOM_TEST_REAL_DOCKER=1`; a
|
||||
> stub under `os.TempDir()` passes). `api`/`stacks`/`web` tests run under `RunWithStub` (TestMain). Restore:
|
||||
> `appbackup.CheckDumpComplete` — a dump without its engine's end marker is refused before the first
|
||||
> mutation (unit + off-site) and again before any load. Tier-2 page: `systemBackupFact` → four sentences.
|
||||
> Backup button: the measured ~8 min. R-626 not reproduced (closed by measurement).
|
||||
|
||||
> **2026-09-23 (late evening) — v0.265.0 (R-634, R-625, R-636, R-647).** A whole-box backup no longer
|
||||
> stops/restarts a DEPLOYING app (`ListDeployedStacks` skips `Deploying`; the volume leg re-asks via the
|
||||
> optional `deployingReporter`; `StopStack`/`StartStack` → `ErrStackDeploying`). Held apps: badge
|
||||
> `badge.update.held`, no Update button. OOM: the kernel `oom_kill` counter (the `OOMKilled` flag is sticky);
|
||||
> ≥20 kills / 30 min per container run → one `app_oom_storm`. Web `readerFuncs` render hold/error per reader
|
||||
> in EVERY language. Needs hub v0.121.0. Floor 0.265.0. Open: R-649 (operator), R-650 (tests on real docker).
|
||||
|
||||
> **2026-09-23 (evening) — v0.264.0 (`09` §6.4 parts 2–3; R-606, R-620, R-646).** Two events,
|
||||
> `app_update_undone` / `app_update_held`, one per app per outcome, via `Manager.SetUpdateEventSink`
|
||||
> (main.go) → `NotifyAppUpdateUndone` / `NotifyAppUpdateHeld`; on by default and seeded once on existing
|
||||
> boxes (`app_update_events_seeded`), with two toggles on the notifications page (the page pushes the
|
||||
> list to the hub on save). Update sentences are stored as key + args (`UpdateErrorKey/Args`,
|
||||
> `update.error.*`, `update.refusal.*`, `update.phase.*`, `hold.*`) and rendered per reader
|
||||
> (`updatePhaseText` / `updateErrorText` / `holdText`, `RestoreHoldForLang`, `UpdateErrorIn`); stored
|
||||
> Hungarian unchanged. Startup `BackfillAppliedMeta` (R-646). Disabled notifier WARNs once per type
|
||||
> (R-620). Needs hub v0.120.0. Floor 0.264.0. Leftovers: R-647. Evidence:
|
||||
> `felhom.eu/documentation/audits/undo-fleet-2026-09-23/`.
|
||||
|
||||
> **2026-09-23 — v0.263.0 → v0.263.2 (R-637, `09` §3 decisions 15 + 19).** A failed health check after
|
||||
> an update is now UNDONE: phase `copying` (after the pull, the app stopped anyway) copies every NAMED
|
||||
> volume `cp -a` into `<vol>.pre-update-<stamp>` (label `felhom.undo-copy-of`, finished-marker last);
|
||||
> on failure `undoing` validates every copy first, refills the volumes, puts back definition + pin +
|
||||
> the pinned version's `.felhom.yml` from the job's OWN copies, and checks the old version with ITS
|
||||
> probe → `undone` (`app.yaml` `last_update_undone`, one page line) or a HOLD prefixed with the undo's
|
||||
> failure and the data state. Bind folders are never touched. Journal recovery: `copying` → old version
|
||||
> back; `undoing` → resumed. **Two live-only lessons, both now in code:** (1) the periodic probe with
|
||||
> the CURRENT `.felhom.yml` flips the app `unhealthy`, so the undo's health wait must probe an
|
||||
> `unhealthy` app with its own override (`waitUpdateHealthyMeta`, v0.263.1); (2) `.felhom.yml` flows
|
||||
> into the stack dir on every catalog SYNC, so the pinned version's file must be recorded AT PIN TIME
|
||||
> — `<stack>/applied-meta/.felhom.yml`, written at deploy, adoption and pin advance (v0.263.2). Apps
|
||||
> pinned earlier have no record until their next pin (R-646). R-642: start/restart never say
|
||||
> "completed". **The fleet floor is still 0.262.1** — raising it is the operator's decision. Evidence:
|
||||
> `felhom.eu/documentation/audits/undo-bakeoff-2026-09-23/`, `…/undo-live-2026-09-23/`.
|
||||
|
||||
> **2026-09-21 (evening) — v0.261.0 (R-608 + R-609).** The controller self-updates daily at **04:30** by default and after ANY hub report once a floor sits above the box; that swap restarts the controller, which is the supervisor of a running app update. The window `09` §3b Q1 proposes for automatic app updates is 02:30-05:00. **It contains 04:30.** **A two-way lock, wired in `main.go` — `stacks` never imports `selfupdate`:** `Manager.AnyUpdating()` -> `Updater.SetAppUpdatingCheck` (a sibling of the existing `SetBackupRunningCheck`, consulted in the SAME three places), and `Updater.IsUpdateRunning` -> `Manager.SetSelfUpdatingCheck` with `UpdatePreflight` refusing `self_updating`. **MEASURED: the gap was NARROWER than assumed** — the update's `backing-up` phase already took the backup single-flight, so only the other six phases were exposed. The live probe landed in `safety-dump`, i.e. in the real gap. **THE PROPERTY THAT MATTERS: the lock must NOT latch** — `Stack.Updating` clears on done, failed AND held, so a held app does not block the controller's own updates for ever. **R-609:** the 409 now carries `data.reason` — transient (`busy`,`updating`,`deploying`,`migrating`,`self_updating`) vs terminal (`held`,`downgrade`). **Found while writing the test: the router refuses a HELD app on its OWN line BEFORE `UpdatePreflight`**, so `held` — the reason an unattended caller needs most — would have been the one missing. Five red-proofs, each seen to fail. **Proven live on guest 9202 with a negative control:** with no app update the manual swap gives the AGENT refusal; with one in flight it gives OURS. The sentence changing IS the proof. Deployed to **9202 only**; the fleet floor is 0.260.0 and 0.261.0 is a separate operator ask. Measurements: `felhom.eu/documentation/audits/update-arc-gaps-2026-09-21/`.
|
||||
|
||||
> **2026-09-21 — v0.260.0 (R-524, update arc).** A box that updated before the catalog was reverted under it read „Frissítés elérhető" over an Update that would have moved its pin BACKWARDS (measured BIGNIGHT Phase 6, privatebin 2.0.6 vs catalog 2.0.5). **The comparison gains a fourth verdict and MOVES OUT OF `web`:** `stacks.CatalogOrder` — Unknown/Current/Behind/**Ahead** — is read by BOTH the badge and `UpdatePreflight`, because a comparison implemented twice drifts. Ahead reads „Naprakész"/"Up to date" (`tag-ok`, same word and class as level — nothing for the household to do) with a title saying why; the Update is refused `downgrade` 409. **The API now renders update refusals through `errText`** — otherwise the new key would be a seam built and never wired. **Ahead is NARROW:** every differing service must be orderable AND newer, else Behind — this gate can BLOCK an update, so it errs towards letting one run. **THE TRAP, and the fixture caught it, not the design:** the first tag rule accepted only bare `X.Y.Z`, so every REAL catalog tag was unorderable and the refusal test failed with `got nil`. The rule now takes the version at the FRONT and requires the trailing suffix to be IDENTICAL on both sides — `31.0.14-apache → 31.0.15-apache` orders; `26.05.2-ls310 → -ls311`, `postgres:16-alpine`, `apache-2.57.0`, a date stamp and a digest pin do not. Ordering is `util.Version.Compare` and nothing else (one comparator, house rule). Three red-proofs, each seen to fail. **R-589 was NOT open** — it shipped in v0.258.0 and only its row was stale; a reviewer who reads ONE producer cannot see a SECOND that overrides it. **Floor NOT raised — the operator's step; and CONTEXT's own 0.257.0 was STALE — the hub says 0.259.0 (read live from `/configs`, never from a doc).** Deployed on demo-felhom 9201, demo-hp 9201 and demo-hp 9202 (scratch, upgraded from 0.245.0 for the live proof). Seven questions for Slices 6 and 7 are in `09` §3b; the state of the whole arc with drift numbers is `audits/UPDATE-ARC-STATE-2026-09-21.md`.
|
||||
|
||||
> **2026-09-21 — v0.259.0 (R-596 P1 + R-598, the drill's blockers).** The claim page's answers and the Backup page's protection warnings follow the reader's language. **Fourteen** live sites carrying **nine** messages, not the sixteen literals R-596 counted — and the sixteenth, `data["Title"]`, was **DEAD** (`claim.html` is standalone; `.Title` belongs to `layout.html`) and was DELETED rather than translated. `backup_handlers.go` had **nine** code literals, not twelve; three were inside comments. `degradedMessageFor` now returns a **KEY**, so the decision stays language-free in one place while the words are chosen by whoever knows the reader; `buildTierViews`/`backupTargetLabel`/`loadGuestBackup` take `lang` the `buildDataPathCards` way. **The anonymous cookie-less claim page's language chain** (`langFor` → `settings.GetLanguage` → `configLanguage` ← `cfg.Customer.Language`) **was an unpinned assumption and is now a test.** Six existing copy-contract tests were kept rather than weakened — each resolves its key through the real bundle, so they still convict on a reworded Hungarian sentence. **Two things the tests caught and review did not:** an apostrophe in an English value is escaped to `'` and NEVER matches on the page (the failure reads exactly like an unwired handler — R-603), and the i18n gate convicted two of my English sentences for saying "please". **Proven live on guest 9201 through the `felhom_lang` cookie** — and the lockout proved itself unasked: Hungarian attempts locked out the English request from the same source, so the counter is per SOURCE, not per language. **The instrument trap that nearly cost a second fix (R-602): the cookie works only on ANONYMOUS pages** — `langFor` step 2 means a signed-in request reads the household's setting and ignores the cookie, so `/backups?felhom_lang=en` returns HUNGARIAN and reads like an unfixed defect. Use `?lang=` behind auth. **Floor NOT raised — the operator's step**; it still stands at 0.257.0. Guest 9201 is on **felhom-pve**, and **demo-hp answers on no route** (R-601).
|
||||
|
||||
> **2026-09-20 — v0.258.0 (R-589/R-590/R-573/R-572, slice 6 Part 0).** The four leftovers slice 5's LIVE proof found, all Go-side: the update badge and the lifecycle badge through `localeFuncs` (reusing `compareInstalledToTemplate`/`EffectiveLifecycle`, so the DECISION cannot drift between languages — only the words do); the data-folder card's backup sentence through `s.msgLang`, with the test asserting the CONSEQUENCE in both languages AND that the two differ; the two channel banners keyed by the checker's own CLASSIFICATION with the composed sentence as a FAIL-OPEN fallback. **R-572 was not what its row said** — nothing called `pruneLabel`/`nextPruneLabel`, so the two dead Hungarian-producing func-map entries were DELETED; deletion is fail-loud and that was proven (a template naming a removed func panics `loadTemplates`). Five red-proofs; one of them convicted the GATE rather than the code — `localeFuncs` keys sit under `_preexisting` and are not measured against the base capture, so the citation to `TestLocaleFuncsHungarianBundleMatchesFuncMap` is what carries them, and that test was extended to make the citation true.
|
||||
> **THE WALK (slice 6 Part B) then judged it**, on a box installed from scratch that day by an English speaker: `felhom.eu/documentation/audits/DRILL-first-hour-en-0258-2026-09-20.md`. Verdict **NOT YET ready for an English-speaking tester** — `internal/web/claim.go` has **16 raw Hungarian literals**, every one a message on the one screen between a household and their box (**R-596, P1**), and `backup_handlers.go` + `backup_target_offer.go` carry the Backup page's two protection warnings (**R-598**). Both are the *composed-sentence-into-page-data* shape — **three instances on file now** (R-573, R-596, R-598), worth naming as a class. Everything else held with ZERO Hungarian: the download page, the bilingual console, all three customer mails, the bind page, the dashboard's first language from `customer.language` alone, both app pages, the catalog, the language switch. **R-214 closed as a side effect.** **Floor NOT raised — the operator's step.**
|
||||
|
||||
> **2026-09-20 — v0.257.0 (R-560 slice 5 Part A).** The catalog's `.felhom.yml` may now carry an `i18n: {en: …}` sibling block, and the dashboard reads it. `Metadata.For(lang)` merges FIELD BY FIELD (absent or blank English shows Hungarian, so a half-translated app ships); `For("hu")` is the parsed struct with `I18n` cleared and nothing else, deep-compared over all **53 real catalog files** copied into `internal/stacks/testdata/catalog/`; the three prose lists replace WHOLE and every other list is matched by its own key (`env_var`, option `value`, `match_group`, `target`, `path`) — **never by position**; `For` never writes through the manager's shared metadata. Pages reach copy only via `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`, and `TestNoDirectMetaCopyReadOnPages` AST-parses `internal/web` against a named allow-list so the NEXT page to read catalog copy fails the suite instead of rendering Hungarian to an English household. **Two of the nine red-proofs convicted a hollow TEST, not the code:** a struct copy shares its slices' backing arrays, so the obvious `DeepEqual` mutation check passed a deliberately broken merge; and a ONE-entry fixture cannot tell key matching from position matching. Both rewritten. Proven live on demo-hp (English pages English, seven Hungarian pages byte-identical apart from the CSRF token) and on demo-felhom **0.255.0** (block synced, pages identical, no parse warning in a 93-line log window). Gaps: R-589 (update badge still Hungarian in English), R-590 (the data-folder promise sentence), R-591 (`Stack.Copy()` does not deep-copy the new `Meta.I18n`), R-592 (three defects inside the new catalog gate, each found by its own decoy). **FLEET FLOOR RAISED to 0.257.0** the same session (operator asked), `min_agent` 0.131.0 declared — above the vouched golden 0.246.0, so the declaration carries it (R-472). Hub: `managed floor SERVED for demo-felhom … from declared`; **demo-felhom went 0.255.0 → 0.257.0 by itself in under 12 s**, healthy, `settle-gate: GO — at/above floor 0.257.0`, **and then rendered the English tagline** — the floor delivered the FEATURE, not a version string. Peti's box and tester-1 are DOWN and take it unattended when they return. The catalog was fully translated the same day (50 more apps in three pushes, 1 031 of 1 032 strings): **the English Apps list shows ZERO Hungarian app descriptions across all 53**. Gap added: R-594 (the catalog gate can convict a retrieval promise but cannot REGISTER a true one).
|
||||
|
||||
> **2026-09-18 — v0.255.0 (fixes the v0.254.0 globe).** The sign-in-flow pages requested `style.css` with **no `?v=`**, so a browser holding a pre-0.254.0 copy served CSS with no `.lang-globe` rules and the globe rendered as a bare unstyled `<details>` — **invisible to every test, because they all read markup and the fault was in which CSS file the browser fetched**. It was FIVE shells, not three (both guest share pages too), and `.Version` was missing from three of their data maps — now set once in `executeTemplateLang`. The globe also moved INSIDE the card, centred under the footer, menu opening upward via the shared rule. 15 shell fixtures re-captured, **zero dashboard ones**. R-579.
|
||||
|
||||
> **2026-09-18 — FLEET FLOOR RAISED to 0.254.0** (operator asked), `min_agent` 0.131.0 declared — above the vouched golden 0.246.0, so the declaration carries it (R-472). Hub: `managed floor SERVED … from declared`; demo-felhom went 0.253.0 -> 0.254.0 **by itself in ~40 s**, healthy, four other containers up, own log `settle-gate: GO — at/above floor 0.254.0`, and its sign-in page shows **one globe, zero old text links**. **Peti Proxmox (DOWN 65 d, 0.115.0) and Tester 1 (DOWN 1 d, 0.245.0) did NOT get it** and take it unattended when they return — untested on this version. Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/floor-raise-0.254.0.md`.
|
||||
|
||||
> **2026-09-18 — v0.254.0 (R-557 slice 2 release C — SLICE 2 CLOSED).** Saved notes follow the box language at WRITE time (§16 option 1): ~70 producers, and `EndRestoreOp` no longer takes a Hungarian literal from anywhere. The language switch is a **globe** (`<details>`, no script) in the sidebar footer and on the sign-in/claim/recovery pages; a visitor's choice lives in a display-only `felhom_lang` cookie that `langFor` reads ONLY when there is no session, and a claim carries it into the household setting on success. `POST /lang` is CSRF-exempt for a narrow reason written at the exemption; `safeBackPath` refuses `//evil.example` too. **Two parity exceptions, measured with a real diff: exactly two change shapes across 106 fixtures, 5 byte-identical.** **A DEADLOCK was introduced and caught by the suite hanging** — a note rendered inside `UpdateOffboxStatus`'s callback takes the settings read lock while the write lock is held; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` guards it now. 629 literals left, 0 errors, 0 saved notes. New row R-577.
|
||||
|
||||
> **2026-09-18 — FLEET FLOOR RAISED to 0.253.0** (operator asked), `min_agent` 0.131.0 declared — the floor is above the vouched golden 0.246.0, so the declaration is what carries it (R-472). Hub: `managed floor SERVED … from declared`; demo-felhom went 0.250.0 -> 0.253.0 **by itself in ~20 s**, healthy, its other four containers up, and its own log reads `settle-gate: GO — at/above floor 0.253.0`. Both live boxes run agent 0.132.0 (above the requirement). **Peti Proxmox (DOWN 65 d, 0.115.0) and Tester 1 (DOWN 1 d, 0.245.0) did NOT get it** and will take it unattended when they return — untested on this version. Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/floor-raise-0.253.0.md`.
|
||||
|
||||
> **2026-09-18 — v0.253.0 (R-557 slice 2 release B).** All **179** Hungarian error messages carry a key (`util.MsgError`): `Error()` is still the Hungarian byte for byte, `errors.Is` answers for the kind AND the wrapped cause, and an inner error argument renders recursively. 76 display sites go through `errText`, pinned by `TestNoErrErrorInPageOutput`. `memoryVerdict` returns an error, `UpdateRefusal` gained a `Cause`. Plurals: a key with `.one`/`.other` takes its count FIRST (bundle rule, not a call-site flag) — the guard caught a real key collision. **Two tooling defects found and recorded:** the bulk converter dropped multi-line concatenations (7 producers; the parity gate could not see it, two behaviour tests could), and my counter was case-sensitive and under-reported. 705 literals left, 0 of them errors.
|
||||
|
||||
> **2026-09-18 — v0.252.0 (R-557 slice 2 release A).** The sentences the program BUILDS now follow the language: 226 Go literals converted (flash lines, page data, API answers, alert banners, 237 country names, the four app-named page titles — R-566 closed). Flash travels as a KEY in the redirect URL with `fa` parameters; an old link's prose is still shown verbatim. New gate `i18n_go_parity.py` refuses any key whose Hungarian is not byte-identical to the base commit (7 467 literals frozen; 3 decoys). Wire goldens freeze the report warnings and the event messages the hub MAILS — those stay Hungarian until slice 3 (R-558). Remaining: 894 literals, 176 of them `fmt.Errorf` (release B); persisted text (release C, §16 option 1). New rows R-572/R-573/R-574.
|
||||
|
||||
> **2026-09-17 night — v0.251.0 (R-553 + R-563 CLOSED).** Five decisions that read their own Hungarian words now read a kind: deploy status sentinels (`internal/stacks/deploy_errors.go`), `backup.ErrOffsiteQuota`, `monitor.WarningKinds`, `settings.LastWarningKind` (legacy text fallback until R-570), and `data-status` on the remote-backup status. `util.KindErrorf` keeps message bytes identical. Slice 2 (R-557) is unblocked except the one producer named in R-570.
|
||||
|
||||
> **2026-09-17 night — v0.250.0 (R-556 release C, slice 1 CLOSED).** All templates converted; the language switch is on every dashboard page (decision 6 superseded); login/claim/guest/catch-all through `executeTemplateLang`; Go-side messages and three app-name titles stay Hungarian (slice 2).
|
||||
|
||||
> **2026-09-17 evening — v0.248.0 + v0.249.0 (R-556 releases A, B).** Apps/settings and backup pages
|
||||
> converted; `TestI18nParityCoversEveryMarker` (every marker rendered by a case); fixtures only written
|
||||
> when missing (`-update-i18n-golden`, `-i18n-golden-only`); `executeTemplateLang` for pages outside the
|
||||
> dashboard chrome (recovery now). Words a page COMPARES stay unconverted (R-563). Split Hungarian verbs
|
||||
> escape the retrieval gate's Hungarian stems (R-564).
|
||||
|
||||
> **2026-09-17 — v0.247.0 (localisation starter; design `felhom.eu/documentation/architecture/10-localisation.md`).**
|
||||
> Message bundles `internal/i18n/locales/{hu,en}.json`; templates carry `{{T "key"}}`, EXPANDED
|
||||
> before parsing — one template set per language (`loadTemplates` / `parseTemplateSet`); `s.tmpl` is
|
||||
> the Hungarian set. Converted: launcher, `/backups`, `/apps/<slug>`, `layout.html`. **Release gate for
|
||||
> every future slice: `TestI18nParity`** — Hungarian render vs fixtures captured from UNCONVERTED
|
||||
> templates; never regenerate fixtures to make a conversion pass. `settings.json` `language`,
|
||||
> `POST /settings/language`, `?lang=` override; switch shown only on non-Hungarian pages (decided by CC,
|
||||
> operator may reverse). Report carries `"language"`. Copy gates now read templates expanded
|
||||
> (`scripts/i18n_bundle.py`) — they had gone blind/stale when copy moved. New `i18n_missing_gate.py`
|
||||
> (ratchets: English gap 0, formal „ön" forms 6). Converting a page: `scripts/i18n_extract.py`, then
|
||||
> REVIEW what it left (ASCII-only Hungarian slips past it). Plan rows R-553..R-562 (R-553 first).
|
||||
|
||||
> **2026-09-13 night — v0.242.0 (R-487 / R-491 / R-490 / R-489 / R-476 / R-456).** The local backup
|
||||
> lists are keyed on the DRIVES, not on what is deployed (`ListRemovedAppUnits`, the R-237 rule one
|
||||
> tier down): a removed app whose unit was kept is listed with its restore, the picker and
|
||||
> `/api/backup/snapshots` answer for it, and the restore opens the unit where it sits
|
||||
> (`primaryUnitDirFor`). A removal clears an update hold. `/api/system/info` reaches the API router.
|
||||
> `volumes_removed` is a before/after difference — **but a volume recreated by a unit restore has no
|
||||
> compose label and is missed (R-489 open, residual: list by `<project>_` prefix too)**. Tier-2 copy
|
||||
> date = newest dump (`UnitDataDate`) unless the leg was preserved. **The scratch guest 9202 on demo-hp
|
||||
> is where releases are validated now** (hand-set image; hub/tunnel off) — `felhom.eu/CONTEXT.md`.
|
||||
|
||||
> **2026-09-13 — v0.237.0 (slice 4: R-448, R-443, R-439).** Update is a guarded 202 job:
|
||||
> refusals (hold, busy, memory, disk, no restorable Tier-2 copy) → backup-first if the proven copy is
|
||||
> older than `update.backup_max_age` (24h) → safety dump → pin → pull (failure: pin BACK) → up →
|
||||
> health (`.felhom.yml` check or 60 s settle, `update.health_timeout` 5m; failure: stop + HOLD,
|
||||
> `RestoreHold.Reason=update_failed`, pin stays). `UpdateStack` deleted. **The copy is aged by the last
|
||||
> successful Tier-2 copy, NOT the manifest `created_at`** — measured on demo-hp that `created_at` moves
|
||||
> only on definition changes. Journal `update-journal.json`; `RecoverUpdates` before the boot sweep.
|
||||
> Three unattended paths that ignored a hold now honour it (drive-return gate ×2, nightly volume dump);
|
||||
> capture and Tier-2 skip held apps. **No automatic rollback** — measured per-app; route back = restore.
|
||||
|
||||
> **2026-09-13 — v0.236.0 (R-442).** Removal resolves the drive from the app's OWN `app.yaml`
|
||||
> `HDD_PATH` (the `07` ~L437 rule), never the global `cfg.Paths.HDDPath` (set on no box); a data
|
||||
> removal it cannot resolve is REFUSED (409, exact Hungarian sentence, typed `RemoveRefusedError`)
|
||||
> before anything is touched and the app is kept. SSD app → `hdd_paths_removed: []`, never `null`.
|
||||
> Backup-path refusals reach the response. Proven live on demo-hp — see `REPORT.md`.
|
||||
|
||||
|
||||
> **2026-09-06 — v0.235.0. THE RULING, AND THE TRAP IT SET.**
|
||||
>
|
||||
> **1. OPERATOR RULING: freeze the version, keep the fixes flowing.** R-447 sat `BLOCKED` because
|
||||
> R-438 established that `RestartStack`'s `up -d` was a CHOSEN behaviour with its reason in its own
|
||||
> comment. Option 1 was taken: an app's version is frozen to what the customer has and only a
|
||||
> deliberate Update moves it, while health-check fixes, memory limits and self-healing keep arriving
|
||||
> on the 15-minute cycle. **Both halves of the old behaviour were examined; only the version change
|
||||
> was unwanted.**
|
||||
>
|
||||
> **2. THE MECHANISM IS A RENDER, NOT A GATE — and that distinction is the whole design.** Nothing was
|
||||
> added to any of the thirteen `compose up -d` call sites. Most of them are REPAIRS (boot reconciler,
|
||||
> drive-return gate, app-stop guard), and a repair that refuses to repair leaves a customer's app
|
||||
> down. They are made safe by removing the reason: the file they act on no longer changes version.
|
||||
>
|
||||
> **3. `pinned_images` IS INTENT; `installed_images` IS AN OBSERVATION. NEVER FEED ONE FROM THE
|
||||
> OTHER.** Letting a reading become a deployment is the R-166 category error one field over. They will
|
||||
> normally agree; when they disagree that is a signal.
|
||||
>
|
||||
> **4. THE TRAP THIS RELEASE SET FOR ITSELF, and it would have shipped silently.** `Stack.TemplateImages`
|
||||
> is read from the app's LIVE compose file — which is now the RENDERED one. On a frozen app that file
|
||||
> names the OLD version, so the update badge would have found installed == template and answered
|
||||
> **„Naprakész" on exactly the apps that are behind**, with every test still green, because the new
|
||||
> field has the same type and shape. The badge now reads `Stack.CatalogImages`, from the syncer's own
|
||||
> clone. **A feature that silently inverts a previous feature is the failure mode to look for whenever
|
||||
> a file changes meaning.**
|
||||
|
||||
> **2026-09-03 — v0.234.0. ONE GAP CLOSED, ONE TEST DEFECT OF MY OWN.**
|
||||
>
|
||||
> **1. A RECORD THAT ONLY THE BRING-UP PATHS WRITE NEVER REACHES A QUIET BOX.** v0.233.0 shipped with
|
||||
> "the record appears after the next lifecycle action" written down as a known limitation. One day
|
||||
> later the operator looked at demo-felhom and saw OpenGist — up 15 hours, running exactly the catalog
|
||||
> pin, **no badge at all**. The limitation WAS the feature not working. `BackfillInstalledImages` now
|
||||
> seeds the absences at startup by READING containers. **The general lesson: a feature that only fills
|
||||
> itself in on an event nobody triggers is, on the quiet installations, not shipped.**
|
||||
>
|
||||
> **2. THE BACKFILL IS STRICTER THAN THE BRING-UP PATHS, AND THE ASYMMETRY IS THE DESIGN.** The badge
|
||||
> reads a service-count mismatch as BEHIND. The bring-up recorder runs right after a SUCCESSFUL
|
||||
> `up -d`, where a missing container is real news; a backfill meets a box in whatever state it is in,
|
||||
> so a partial seed would render „Frissítés elérhető" over an app that is perfectly current. It
|
||||
> therefore refuses to seed anything it cannot observe COMPLETELY. **Same data, two writers, two
|
||||
> different admission rules — do not "make them consistent".**
|
||||
>
|
||||
> **3. A TEST THAT HARDCODES A DATE AND ASSERTS AN AGE IS GREEN ONLY ON THE DAY IT IS WRITTEN.**
|
||||
> `TestGroupD_BadgeRendersOnBothSurfaces` pinned `catalog_since: "2026-07-18"` and the string
|
||||
> "46 napja". The pure tests inject a clock; the RENDER path goes through the funcmap and reads
|
||||
> `time.Now()`. It passed on 2026-09-02 and was red on 2026-09-03. Now derived from the same clock the
|
||||
> code reads. **R-457** names six other test files that mix a literal date with `time.Now()` — as
|
||||
> unchecked candidates, not accusations.
|
||||
|
||||
> **2026-09-02 — v0.233.0. TWO DECISIONS, AND ONE LIMITATION THAT IS NOT A DEFECT.**
|
||||
>
|
||||
> **1. `installed_images` is an OBSERVATION, so a failed write NEVER refuses the action — deliberately
|
||||
> the opposite of `desired_state`.** `SetDesiredState` refuses the act when the record fails, because
|
||||
> intent that could not be recorded recreates the exact ambiguity R-166 closed. `recordInstalledImages`
|
||||
> does the reverse: refusing to start a customer's app because we could not write down which version
|
||||
> it is trades a real outage for a bookkeeping gap. **The rule is "refuse on intent, log on
|
||||
> observation", and the reason is in both code comments** so neither gets "made consistent" later.
|
||||
>
|
||||
> **2. THE RECORD READS THE CONTAINER, NEVER THE COMPOSE FILE — and the file is not a lesser source,
|
||||
> it is a WRONG one.** The catalog syncer overwrites a deployed app's `docker-compose.yml` on a
|
||||
> 15-minute cycle with no deployed check at all; the spike measured the file saying `v2.8.5` while the
|
||||
> container ran `v2.8.6` for 25 minutes. Anything derived from that file answers "what will happen
|
||||
> next time something runs `up -d`", which is a different question from "what is running".
|
||||
>
|
||||
> **3. THE LIMITATION, STATED: 23 catalog pins FLOAT, so „Naprakész" CAN BE FALSE.** The comparison is
|
||||
> reference-to-reference and queries no registry (a box must not need eight upstream registries to
|
||||
> render a page). For `postgres:16-alpine`, `mariadb:11.6` and 21 others the ref can be identical while
|
||||
> the image behind it has moved — the spike caught `mariadb:11.4` and `mariadb:12.3` already moved.
|
||||
> **This is a known gap with a register row, not an oversight.** Digest-level comparison needs a
|
||||
> registry query and is deferred.
|
||||
>
|
||||
> **Nothing about updating changed.** No behaviour, no new endpoint, no auto-update, and the three
|
||||
> lifecycle buttons are byte-identical (`TestScenarioE_TheUpdateButtonIsUntouched`). The behaviour work
|
||||
> is slice 3 and needs an operator ruling. The whole arc's reasoning now has a home:
|
||||
> `felhom.eu/documentation/architecture/09-update-architecture.md` (R-438 — its absence was a finding).
|
||||
|
||||
|
||||
> **2026-09-01 — v0.232.0. THREE RULINGS.**
|
||||
>
|
||||
> **1. `restic stats` TAKES A REPOSITORY LOCK, and that is the fact the whole R-411 chain rested on.**
|
||||
> Nobody had it. Clean-room measured on demo-hp 2026-08-31: nothing else running, four invocations,
|
||||
> the sampler reads `locks=1`. A customer FULL restore shells `stats` in its size probe, so it holds a
|
||||
> lock — and until v0.232.0 it held no single-writer flag, so the integrity check was not blocked, ran,
|
||||
> met that lock, and `resticStep` removed it with `unlock --remove-all` while logging *"a stale
|
||||
> exclusive lock left by a previous crash"*. There was no crash. Also measured, and recorded so the
|
||||
> next reader does not re-derive it: `restic check` takes a lock; `restic snapshots` and `restic list`
|
||||
> do **not**.
|
||||
>
|
||||
> **2. THE INVARIANT IS PINNED BY A WALK, NOT BY A COMMENT — and the walk is the deliverable, not the
|
||||
> acquire.** `offbox_integrity.go:28` asserted *"Every off-site operation takes `acquireRunning`"* from
|
||||
> v0.227.0 and it was false for months, which is the ninth instance of this project's most-repeated
|
||||
> class. `TestR408_EveryOffsiteEntryPointTakesTheFlagOrIsRegistered` is an AST pass over
|
||||
> `internal/backup` — deliberately not `strings.Contains`, because a commented-out call still contains
|
||||
> the string. **On its first run it found three entry points nobody had named**:
|
||||
> `OffboxRestorePrepareFull` (the request the customer's UI reaches FIRST, and the one that shells
|
||||
> `stats`), `RestoreSharesScratch` (R-411's exact shape on the shares tier, with a live caller) and
|
||||
> `RestoreOffbox` (no caller today). All four now take the flag. `OffsiteInventoryList` is registered
|
||||
> EXEMPT with its reason — `snapshots` only, measured not to lock, and flagging it would make a page
|
||||
> refuse to load during a backup for no safety gain. **Adding a line to `offsiteExempt` is a deliberate
|
||||
> act and belongs in the commit that adds it.**
|
||||
>
|
||||
> **3. R-414's BRANCH, AND THE EVIDENCE FOR IT.** The question was whether `offboxRestoreScratchDir`
|
||||
> was MISSED by R-356's unification or EXCLUDED on purpose. **Neither label fits: it was consciously
|
||||
> OUT OF SCOPE.** R-356's own commit (`08eb1a6`) says so in its test comments — *"the prepared scratch
|
||||
> still resolves to the registered storage path … only the DESTINATION moves, which is precisely what
|
||||
> this change is about"* — and every one of its fixtures assumed a registered storage path exists.
|
||||
> `demo-felhom`, with `storage_paths: []`, is the case it never had. It was **never ruled out on
|
||||
> state-only grounds**: the one comment about a `systemDataPath` fallback belonged to
|
||||
> `PlaceOffsiteRestore`, concerned bulk **userdata**, and R-356 deleted it deliberately. This
|
||||
> function's own documented exclusion is `cfg.Paths.DataDir` — the **rootfs** — a different filesystem.
|
||||
>
|
||||
> **So §6.3's `[DESIGN]` rule applies and now has a FOURTH consumer.** But the fallback is **SCOPED**,
|
||||
> because the two callers ask different questions and one predicate answering both is the R-356 defect
|
||||
> itself: **unit-only** may fall back (§7 records as `[FACT]` that a driveless app's unit already lives
|
||||
> on `systemDataPath` indefinitely and that the same-device placement is *"intended, not a defect"*);
|
||||
> **full** keeps the R-252 refusal, because it pulls bulk userdata onto a state-only tier (§2.2).
|
||||
>
|
||||
> **AND THE SILENCE ENDS EITHER WAY.** `ProofResultCannotRun` is recorded through
|
||||
> `RecordProofVerdict`, so `last_proof_result` is never ABSENT — absent already means *"controller
|
||||
> older than v0.231.0"*, and giving one field two meanings is the `StatsKnown` trap one level up. It
|
||||
> does **not** advance per-snapshot due-ness: nothing was proved, and marking one proved would stop the
|
||||
> app being retried once a drive is finally registered.
|
||||
>
|
||||
> **A MISTAKE OF MINE, RECORDED BECAUSE LIVE VALIDATION IS WHAT CAUGHT IT.** The fallback resolved a
|
||||
> scratch that `removeProofScratch` then refused to delete — its accepted-roots list is built from
|
||||
> REGISTERED drives, and a driveless box has none. Observed on `demo-felhom`: *"refusing to remove …
|
||||
> it is not inside a proof root"*, with the copy still on disk. Every nightly proof would have left one
|
||||
> behind, on exactly the boxes the fallback exists for. **The unit tests all registered a drive, so
|
||||
> none of them could see it.** Fixed, and pinned by a pair — one that the copy IS removed on a
|
||||
> driveless box, one that a path outside every proof root is still REFUSED, so the fix is not a
|
||||
> widening into uselessness.
|
||||
|
||||
|
||||
> **2026-08-31 — v0.231.0. FOUR RULINGS, recorded so none is re-litigated.**
|
||||
>
|
||||
> **1. THE ACCEPTANCE RULE IS NOT "EVERY DECLARED FILE IS PRESENT".** That is the spike's own one-line
|
||||
> summary and taken literally it is worthless: a hollow unit declares nothing, so everything it
|
||||
> declares is present, and the check passes on exactly the shape it exists to catch. The rule has TWO
|
||||
> parts and needs both — (1) everything declared is present, AND (2) the manifest declares what the app
|
||||
> is SUPPOSED to have. **Part 2 is the whole value; part 1 alone is the trap.**
|
||||
> `TestR87_HollowUnitForAnAppWithADatabaseFAILS` is the fence and its red-proof models the naive rule.
|
||||
>
|
||||
> **2. THE EXPECTATION COMES FROM INSIDE THE UNIT, NEVER FROM THE LIVE BOX.** R-403's guard could tell
|
||||
> hollow from legitimately-empty because it had TWO copies to compare; this has ONE. The snapshot may
|
||||
> predate the app's current shape, and the point is to judge the snapshot on its own terms — so the
|
||||
> source is the unit's own `compose/docker-compose.yml`, and `GetDockerVolumes` (live Docker,
|
||||
> `backup.go`) is explicitly NOT it. Database half: `DBServiceNames`, the same discriminator
|
||||
> `RestoreFromRecoveryUnit` already uses, so this cannot disagree with the restore path about what an
|
||||
> app is. Volume half: `ParseComposeNamedVolumes`.
|
||||
>
|
||||
> **THE VOLUME HALF IS AN EXISTENCE CHECK AND NOT A NAME MATCH, and that half-rule is deliberate.**
|
||||
> Volume tars are `<project>_<volume>.tar`; `ResolveDockerVolumeNames` derives the project from
|
||||
> `filepath.Base(filepath.Dir(composePath))`, which inside a unit is the literal string `compose`, not
|
||||
> the stack. Measured 2026-08-31 on all eight real units on demo-hp: the counts match exactly
|
||||
> (bookstack 2/2, docmost 3/3, kimai 2/2, opengist 1/1, privatebin 1/1, calibre-web 1/1, paperless-ngx
|
||||
> 3/3, romm 3/3) and `<stack>_<volume>.tar` held in every case. **"Held on eight" is not "derivable"**
|
||||
> — R-355's standing rule is that a claim about the app must never be inferred from a counter. Half a
|
||||
> rule that is true beats a whole rule that is invented. Name-level matching is available the moment
|
||||
> the capture records the project, and is not worth inventing before then.
|
||||
>
|
||||
> **3. THE PROOF'S RESTORE IS A READ-ONLY VARIANT, NOT A CHANGE TO THE CUSTOMER'S PATH.**
|
||||
> `RestoreOffboxScratch` is the customer's restore, it is proven, and a customer restore taking a lock
|
||||
> is correct — so it was NOT changed. What was shared instead of forked: the scratch-dir resolver is
|
||||
> now parameterised on its ROOT builder (`offboxScratchDirIn`), so the drive-preference rules, the
|
||||
> network-storage refusal and the R-252 wording have exactly one implementation; and the unit-only
|
||||
> headroom gate is extracted to `unitOnlyHeadroom` so both paths refuse at the same floor with the same
|
||||
> Hungarian sentence. The proof's own three differences are the ones that must differ: `--no-lock`, no
|
||||
> `unlockStale`, and `m.runner()` instead of `resticStep` so the `unlock --remove-all` escalation is
|
||||
> unreachable rather than unlikely (REUSE.md's rule: replacing `resticStep` would hide the escalation
|
||||
> from the assertion that must see it).
|
||||
>
|
||||
> **THE PROOF SCRATCH IS A SEPARATE ROOT (`backups/offsite-proof`) AND THAT IS A SAFETY DECISION, NOT
|
||||
> TIDINESS.** The proof deletes its copy on every path including failure. Sharing
|
||||
> `backups/offsite-restore/<app>` would mean a nightly background job deleting the verification copy a
|
||||
> CUSTOMER made and is looking at — a poorer actor destroying a richer one, R-403's shape in different
|
||||
> clothes. The separate root also keeps the proof copy invisible to `DeleteOffsiteRestoreCopy`, the
|
||||
> copy listing and `OffboxFullScratchReady`, so it can never be offered for placement into a live app.
|
||||
>
|
||||
> **4. THE ALARM IS A NEW EVENT TYPE, AND REUSING `backup_integrity_failed` WOULD HAVE BEEN WRONG.**
|
||||
> That type is the nearest existing one and it carries a hub-side Hungarian template saying the
|
||||
> integrity check found an error — i.e. **the store is damaged**. Here the store is sound and the
|
||||
> CONTENT is missing: a different fact, a different cause, a different customer action, and telling
|
||||
> someone their backups are damaged when they are not is the more expensive mistake (the same asymmetry
|
||||
> `looksLikeRepositoryDamage` is shaped around). So `offsite_proof_empty` was minted, severity `error`,
|
||||
> with NO `customerMessages` entry so the controller's dynamic Hungarian survives, and **the hub half —
|
||||
> `allowedEventTypes` plus `operatorOnlyEvents` — ships in the same commit**, because an unallowlisted
|
||||
> type is answered 400 and vanishes, and a missing customerMessages entry is not a routing block.
|
||||
> **This widened the task's stated scope to `felhom.eu/hub/`** and the reason is recorded here rather
|
||||
> than left as an unexplained diff.
|
||||
>
|
||||
> **DELIBERATELY NOT DONE:** `07` §8 matrix row 4 was NOT moved. This proves the snapshot CONTAINS a
|
||||
> recoverable unit; it does not prove a restore puts data back into a running app. R-408 (the missing
|
||||
> `acquireRunning` on `RestoreOffboxScratch`) was NOT fixed — the job takes the flag itself and the row
|
||||
> stays open.
|
||||
|
||||
|
||||
> **2026-08-31 — v0.230.0. THREE RULINGS, recorded so none is re-litigated.**
|
||||
>
|
||||
> **1. HOLLOWNESS IS A MANIFEST QUESTION, NEVER A SIZE QUESTION.** `unitCarriesData` asks whether the
|
||||
> unit's manifest lists any database dump or any volume tar, and nothing else. `dirSizeBytes` lives
|
||||
> two files away and is the obvious wrong answer: a unit with a fat compose capture and no dumps is
|
||||
> exactly the shape that deleted 120 MB on `demo-hp`, and a 360-byte unit belonging to a tiny app is
|
||||
> perfectly healthy. Size answers *how big*; the question is *is there anything to recover*. Absent or
|
||||
> unparseable manifest ⇒ hollow, fail closed: a unit whose contents cannot be vouched for must never
|
||||
> authorise a delete of one whose contents can. `TestR403_SizeIsNeverConsulted` is the fence.
|
||||
>
|
||||
> **2. THE GUARD FENCES ONE SHAPE, NOT SHRINKING — because the derived-copy rebuild is a DESIGN
|
||||
> DECISION.** `07-backup-architecture.md` §8 row 5 records that the secondary is a derived copy,
|
||||
> rebuilt on the next run, and `tier2.go`'s own header records that a classified app's copy
|
||||
> legitimately shrinks as `export` drops out of its class set. `rsync -a --delete` stays, the data legs
|
||||
> are untouched, and complete→hollow and hollow→hollow both still mirror. The ONLY refusal is a source
|
||||
> carrying no data over a destination that carries some. Widening this to "the secondary never shrinks"
|
||||
> would be calling a decision a defect; `TestR403_DataLegShrinkIsUnaffected` is the guard on the guard.
|
||||
>
|
||||
> **3. THE REHYDRATE HAPPENS INSIDE THE RESTORE, BECAUSE A FOLLOW-UP JOB RACES THE CAPTURE.** The
|
||||
> hollow manifest was written **two seconds** after a Tier-2 unit restore, by the 5-minute
|
||||
> `backup-cache` job. A goroutine, a scheduled refresh or a "do it on the next run" would each lose
|
||||
> that race some of the time, and the failure mode is silent. `RestoreTier2Unit` refills the primary
|
||||
> before it returns, and the test asserts ORDERING rather than sleeping.
|
||||
> **And the capture is deliberately NOT guarded:** a capture that describes an empty drive as empty is
|
||||
> CORRECT. With the primary refilled there is no hollow state left to describe. Guarding the capture
|
||||
> would have made the manifest lie, which is the opposite of every other fix this week.
|
||||
>
|
||||
> **A defect the LIVE run caught and the unit tests did not, worth remembering as a shape.** The first
|
||||
> draft of `UnitRestoreDate` also flagged "the package is older than the run" by comparing their dates
|
||||
> — and a unit is ALWAYS captured shortly before the run that mirrors it, so it was true for every
|
||||
> healthy app on the box. Four apps would have been told their package was stale. **A warning that
|
||||
> fires on everything costs the same as the comforting lie it replaces.** The flag is now
|
||||
> `UnitLegPreserved` and nothing else.
|
||||
>
|
||||
> **The credential rider.** `felhom.eu/scripts/read_credential.py` is now the one place that value is
|
||||
> read. Three occurrences (2026-07-20, and twice on 2026-08-31, the third of which rewrote a live box's
|
||||
> password hash) happened while the project already had a memory file, a worked recipe and a session
|
||||
> report describing the mistake. **A note is read by whoever thinks to look; a check runs whether or
|
||||
> not anyone remembers.**
|
||||
|
||||
> **2026-08-31 — v0.229.0. THREE RULINGS, recorded so none is re-litigated.**
|
||||
>
|
||||
> **1. The source moves; the destination does not.** `RestoreFromRecoveryUnitAt(stack, unitDir)` takes
|
||||
> the recovery-unit DIRECTORY, so the same restore reads a unit from the primary drive or from the
|
||||
> Tier-2 mirror on the second drive. What it must NEVER take is a destination: data still lands in the
|
||||
> live Docker volumes and the live database container, and the definition in the guest, resolved by
|
||||
> `GetAppDrivePath` exactly as the capture is. A restore that also relocated an app's data would be a
|
||||
> migration wearing a restore's label, and the customer pressed a button that said neither.
|
||||
>
|
||||
> **2. Two predicates, never one wider one — and it is the SECOND time this is written down.**
|
||||
> `Tier2Coverage.CanRestore()` answers *"can the additive file restore run?"* and nothing else;
|
||||
> `CanRestoreUnit()` answers *"can the unit restore open this copy?"*. `HasUnit` keeps its third,
|
||||
> distinct meaning: *"is there captured data the file restore is not looking at?"* — true even for a
|
||||
> half-copied mirror the unit restore refuses, because the disclosure is still owed. The temptation is
|
||||
> always to widen the predicate already there. **R-356 is what that costs:** one predicate meaning both
|
||||
> *"has this app a drive?"* and *"is this app installed?"* refused 40 running apps for months, while
|
||||
> they were running, with a message telling their owners to reinstall them somewhere those apps never
|
||||
> offer.
|
||||
>
|
||||
> **3. A destructive operation reached from a non-destructive surface must carry the difference in the
|
||||
> CONFIRM, not in the label.** „Teljes visszaállítás a másolatból" sits beside „Fájlok
|
||||
> visszaállítása" on the same row; one overwrites the app's database and internal volumes, the other
|
||||
> only adds files that are missing and never overwrites anything. The confirm says exactly that, names
|
||||
> the copy's date, and says so DIFFERENTLY when that date is only an attempt clock (R-101). It is built
|
||||
> from named Go constants (`tier2UnitConfirmBase` / `…DateFmt` / `…DateUnprovenFmt` / `…Contrast`) and
|
||||
> asserted verbatim, because a sentence assembled inside an HTML attribute cannot be pinned and R-364
|
||||
> makes grepping accented Hungarian out of rendered markup unreliable on top of that.
|
||||
>
|
||||
> **The count is settled and must not be re-derived.** At catalogue `459766cb1639`, by the production
|
||||
> rule: **A = 7 · B = 45 · C = 1**. The C9-F1 Phase-0 count (9/43/1) was wrong by two — **radarr and
|
||||
> sonarr**, whose `${USERDATA_PATH}` binds are WRITABLE (so the `:ro` default rule Phase 0 applied does
|
||||
> not catch them) and are excluded by an explicit `class: excluded` entry instead. C is **bentopdf**.
|
||||
>
|
||||
> **R-403, filed and NOT fixed.** Two seconds after a restore that ran with the primary unit absent, the
|
||||
> 5-minute status refresh (`captureAllRecoveryUnits`) rewrote the primary unit from a drive with no
|
||||
> dumps, yielding `"db_dumps": []` / `"volume_dumps": null`. Measured on demo-hp 2026-08-31. The
|
||||
> dangerous half — that the next Tier-2 run would mirror that hollow unit over the good secondary copy,
|
||||
> `rsyncMirror` carrying `--delete` — **was not tested and is recorded as unverified.**
|
||||
|
||||
> **2026-08-31 — v0.228.0. TWO RULINGS, recorded so neither is re-litigated.**
|
||||
>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# REPORT — the app page in one column, and one image per version number (2026-10-15)
|
||||
|
||||
**A non-overwritten sibling of `REPORT.md`.** Two sessions released this repo on the same evening;
|
||||
`REPORT.md` is per-session and overwritten, and it now holds v0.317.0's (the storage work). This
|
||||
file holds the app-page arc so it stops being clobberable — the convention `felhom.eu/CLAUDE.md`
|
||||
prescribes for parallel sessions, applied here.
|
||||
|
||||
## 1. Baselines
|
||||
|
||||
| Repo | `main` at start | Version at start |
|
||||
|---|---|---|
|
||||
| felhom-controller | `8339206` | v0.315.0 |
|
||||
| felhom.eu | `340804f9` | docs only |
|
||||
|
||||
Architecture read before any claim: `02-controller-module-map.md` „The app page",
|
||||
`09-update-architecture.md`, the `felhom-ui-design` skill, `CLAUDE.md` + `.claude/rules/*`.
|
||||
Edited on the Windows workstation; built and tested on DooPlex in an isolated clone; the browser
|
||||
work is on the workstation, which is why CC runs here.
|
||||
|
||||
## 2. PART 0 — the diagnosis, before any fix
|
||||
|
||||
**Verdict: (a) a cache.** Evidence: `felhom.eu/documentation/audits/app-page-2026-10-15b/P0/VERDICT.md`.
|
||||
|
||||
**(b) is ruled out.** Exactly one guest ran 0.315.0 — demo-hp 9201 — on digest `8887343011d6`,
|
||||
identical to the registry's 0.315.0 at the time of measuring. The **global floor was 0.314.0** (read
|
||||
from the hub's `/configuration`), so no box could have taken 0.315.0 by itself. demo-hp 9202 and
|
||||
felhom-pve (N100) 9201 were both on 0.314.0; Tester 1 (`c11-appliance`) was unreachable and, by the
|
||||
floor, cannot have had it.
|
||||
|
||||
**The mechanism, in four measured facts:**
|
||||
|
||||
1. `layout.html` asked for `/static/style.css?v={{.Version}}` → `?v=0.315.0` for **all three**
|
||||
different 0.315.0 builds (`e66d466`, `fe0228c`, `db52f5a`); the first had no `.app-tabs` rules.
|
||||
2. The origin sends **no validator**: on the box, `ETag present = 0`, `Last-Modified present = 0`.
|
||||
A cached copy cannot be revalidated — only expire.
|
||||
3. Cloudflare caches that URL (`cf-cache-status: HIT`, `age: 99 → 100` over three requests) and
|
||||
**rewrites the browser TTL from the origin's 3600 to 14400** — four hours.
|
||||
4. The session's first request returned **`cf-cache-status: EXPIRED`**, which only happens when the
|
||||
edge holds an entry that has aged past its TTL.
|
||||
|
||||
**The caveat, stated rather than glossed:** origin, edge and box now agree byte-for-byte
|
||||
(`sha256 c1774f63…`) and all carry the rules. The stale object itself is **no longer observable** —
|
||||
that first probe forced its revalidation, which is what `EXPIRED` records — so no `HIT` with a bad
|
||||
body can be produced. What is shown is every precondition, the proof an aged entry existed, and the
|
||||
elimination of the only other candidate. **No cache was purged by hand.**
|
||||
|
||||
## 3. What shipped
|
||||
|
||||
| Area | What |
|
||||
|---|---|
|
||||
| Push-once | `build.sh` refuses `--push`/`--multiarch` for a tag already in the registry, before building. A registry it cannot reach is a refusal too. Local builds untouched. |
|
||||
| Clean tree | `build.sh` also refuses a tree that is dirty, not at `origin/main`, or not a git checkout. Added after it was needed — see §6. |
|
||||
| Asset tag | `assetTag()` hashes version + `GitCommit` + `BuildTime`; **all nine shells** use it (seven asked for `?v={{.Version}}`). Opaque 8 hex, because the stranger-facing share pages use the same value. `Cache-Control` unchanged — the new address is the fix. |
|
||||
| Layout | The approved one-column mockup: 820px column, hairline sections, underlined tabs, no hero box, 200px label grid. Two boxes only: blue „the app is waiting for you", amber „this is risky". |
|
||||
| Banner | The auto-update SUCCESS banner is meta text in the hero. Every banner that needs a decision is untouched above the tabs (R-571). |
|
||||
| Leftovers | „2. mentés beállítása — <app>" and „<app> — Exportálás" removed; „Cím és kulcsok" with one address row replaces the duplicate disabled subdomain field; the „already installed, read-only" banner is now the hint under the fields; the install wizard gets the same back link, hero and column. |
|
||||
|
||||
## 4. Commits on `main`
|
||||
|
||||
`9cc7ffc` push-once + per-build asset tag · `7f3e58f` the one-column layout · `48d8674` v0.316.0
|
||||
CHANGELOG + the rule-file fact · `006ceab` v0.316.1 (the tag reaches the dashboard) · `f908eee`
|
||||
settings tab de-boxed · `9e68e52` two data-tab leftovers · `8c7aa10` v0.316.3 CHANGELOG · `f9151f0`
|
||||
**the padding fix** · `4261a38` `FELHOM_REPO_DIR` + the build names its tree · `c6a5671` v0.316.4
|
||||
the clean-tree gate · `e7b64af` `workspace/` gitignored.
|
||||
felhom.eu: `b6638ac4` (design docs + the audit), `22cd7c51` (R-1080 closed, R-1081 filed).
|
||||
|
||||
## 5. Tests and red-proofs — every break seen failing, then reverted
|
||||
|
||||
| # | Break | Result |
|
||||
|---|---|---|
|
||||
| B1 | the registry check deleted | 5 of 6 cases fail; a present tag builds AND pushes |
|
||||
| B2 | an unreachable registry treated as absent | the two unanswered-registry cases fail |
|
||||
| B3 | the clean-tree gate deleted | all four of its cases fail |
|
||||
| C1 | `assetTag` from the version only | `TestAssetTag_ChangesPerBuild` fails on both same-version cases |
|
||||
| C2 | one shell back on `?v={{.Version}}` | `TestAssetTag_EveryShellUsesIt` fails naming layout.html |
|
||||
| C3 | `addShellAssetData` removed from `executeTemplate` | `TestAssetTag_ReachesTheRenderedPage`: empty buster on four pages |
|
||||
| D1 | the success banner restored | `TestAppPage_AutoUpdateSuccessIsMetaNotBanner` fails |
|
||||
| D2 | one tab loses its column | `TestAppPage_OneColumnLayout` fails naming the tab |
|
||||
| D3 | a section drawn as a card again | same test fails on two tabs |
|
||||
| E | an informational section drawn as a blue callout | `TestAppPage_CalloutsOnlyForAskAndWarn` fails on both counts |
|
||||
|
||||
New: `scripts/test_build_push_once.sh` (**ten** cases, driving the real `build.sh` with a fake
|
||||
`docker` on PATH — nothing is ever pushed),
|
||||
`TestAssetTag_{ChangesPerBuild,EveryShellUsesIt,ReachesTheRenderedPage}`,
|
||||
`TestAppPage_{OneColumnLayout,AutoUpdateSuccessIsMetaNotBanner,CalloutsOnlyForAskAndWarn}`.
|
||||
|
||||
Full gate: `go build ./... && go vet ./... && go test ./...` → **exit 0, 37 packages ok, zero
|
||||
failures**, read from `go test`'s own exit code, not a pipeline's. All controller gates OK; 53
|
||||
decoys behaved.
|
||||
|
||||
## 6. THREE INCIDENTS, one shape
|
||||
|
||||
All three are the same defect: **a guard that is not on the path the work actually takes.** Two of
|
||||
them I caused while fixing the first.
|
||||
|
||||
**(i) I re-pushed 0.315.0 a fourth time.** The push-once guard went into
|
||||
`controller/build.sh` in the repo; the documented release command runs
|
||||
`$FELHOM_ROOT/build/felhom-controller/build.sh`, which was a **separate copy taken 2026-07-18** with
|
||||
no guard. The first thing I did with the new refusal — run it against a real existing tag to prove
|
||||
it fires — therefore rebuilt and re-pushed 0.315.0 with v0.316.0's code under it
|
||||
(`8887343011d6` → `b809201841f7`). Root cause fixed: the build directory's script is a symlink to
|
||||
the repo's, the stale copy kept as `build.sh.stale-copy-2026-07-18`, and the refusal then proven
|
||||
from the documented path. **R-1081.**
|
||||
|
||||
**(ii) The asset tag shipped dead in 0.316.0.** Every shell asked for `?v={{.AssetTag}}` and the
|
||||
template test passed, but `addShellAssetData` was only ever called by `executeTemplateLang` — the
|
||||
stranger-facing pages. The dashboard path had never needed it, because `layout.html` used to ask for
|
||||
`{{.Version}}`, which `baseData` supplies. The first live load rendered `/static/style.css?v=` —
|
||||
**empty, which is a constant, and a constant busts nothing.** Found in a browser. Fixed in 0.316.1;
|
||||
`TestAssetTag_ReachesTheRenderedPage` now asserts the rendered value, not the markup.
|
||||
|
||||
**(iii) Building 0.316.3 published another session's unreleased work.** The symlink from (i) points
|
||||
into the shared clone's **working copy**, which predated the `FELHOM_REPO_DIR` override — so the
|
||||
override was silently ignored and the build copied the shared tree, which held 31 uncommitted files
|
||||
of the physical-disk storage work. The banner said `Commit: 0225255` with no `Repo:` line; the
|
||||
evidence was on screen and the image was already pushed. **Nobody deployed it — that was luck, not a
|
||||
control.** The clean-tree rule had existed only as a sentence in a runbook. It is now two questions
|
||||
the build asks and refuses on, plus a refusal for a source that is not a git checkout at all; it
|
||||
immediately caught its own next build (the scratch `workspace/` beside the script, now gitignored).
|
||||
|
||||
**`0.316.3` must never be deployed.** It is neither re-pushed nor deleted: a version number is
|
||||
pushed once, and that applies to a number I spoiled.
|
||||
|
||||
## 7. Deployed, and what happened to 0.316.4
|
||||
|
||||
**demo-hp guest 9201 runs `0.317.0`, healthy.** That is the other session's release, built from
|
||||
`main` at a commit that contains all of this work — verified: `f9151f0` (the padding fix) and
|
||||
`c6a5671` (the clean-tree gate) are both ancestors of it.
|
||||
|
||||
**`0.316.4` was built but never published, and was then dropped.** Its first build attempt was
|
||||
refused by its own new clean-tree gate (the `workspace/` directory); by the time that was fixed,
|
||||
`0.317.0` was already in the registry. Publishing 0.316.4 would have put a *lower* number in the
|
||||
registry carrying nothing 0.317.0 does not already have. The household's button offers the highest
|
||||
tag, so it would have been invisible anyway.
|
||||
|
||||
**The floor stays 0.314.0** — raising it needs all three test boxes and a green box walk, and this
|
||||
evening two sessions released the same component.
|
||||
|
||||
| tag | state |
|
||||
|---|---|
|
||||
| 0.316.0 / .1 / .2 | published, superseded |
|
||||
| **0.316.3** | published, **CONTAMINATED — never deploy** |
|
||||
| 0.316.4 | never published (superseded) |
|
||||
| **0.317.0** | published, deployed on demo-hp, carries this work |
|
||||
|
||||
## 8. The browser check, through the PUBLIC address
|
||||
|
||||
Chrome on the workstation, `https://felhom.enkisfelhom.hu`, **normal loads — no hard refresh**:
|
||||
|
||||
- The stylesheet URL is a real 8-hex per-build tag and the loaded sheet carries the `.app-tabs`
|
||||
rules. **This is the fix working:** before it, the same page needed a hand-forced refetch.
|
||||
- `.app-tab-active` computes `border-bottom: 2px rgb(46,168,245)` (`--blue-bright`); column
|
||||
`max-width: 820px`; hero `border-top-width: 0px`.
|
||||
- Across settings / data / guide / logs / plex / grocy / install: **1 column each, 1 `aria-current`
|
||||
on an installed app and 0 on a not-installed one, 0 `.app-info-card`, 0 success banners.**
|
||||
- Data tab: exactly **1 amber callout** (the same-disk warning). Settings, guide, grocy: none —
|
||||
correct, nothing is asked and nothing is risky.
|
||||
- **After the operator's report:** `.app-page` padding `0px 0px 32px`, `.content` padding `32px`,
|
||||
measured gap **32px from the sidebar** (was 80) and **36px from the top** (was 64+).
|
||||
- **Console clean on a fresh load**, on 0.316.2 and again on 0.317.0.
|
||||
|
||||
Screenshots: `felhom.eu/documentation/audits/app-page-2026-10-15b/browser/` —
|
||||
`01-settings-boxes-still-there`, `02-settings-one-column-0.316.2`, `03-data-tab-amber-callout`,
|
||||
`04-guide-tab-padding-fixed-0.317.0`.
|
||||
|
||||
**Scenario E's blue callout was NOT exercised live.** Plex's setup gate is open (the operator's real
|
||||
claim, R-1060), so `Needed()` is false and the card is correctly hidden. Showing it live would mean
|
||||
un-claiming the operator's real Plex server; not done. The branch is covered by
|
||||
`TestAppPage_CalloutsOnlyForAskAndWarn` against a harness Plex with a closed gate.
|
||||
|
||||
## 9. Parity fixtures
|
||||
|
||||
Recaptured (the pages this task restyles): `app_settings_*` ×21, `app_data_*` ×9, `app_guide_*` ×4,
|
||||
`app_logs`, `deploy_new{,_blocked,_memory_blocked}`. **All other fixtures are byte-identical** — and
|
||||
now permanently independent of build identity: the harness normalises the asset tag on **both sides**
|
||||
of the comparison, as it already did for the per-session CSRF token, so a build can no longer churn
|
||||
148 files. The same normalisation was applied at the second comparison site in `i18n_wiring_test.go`,
|
||||
which had the same assumption.
|
||||
|
||||
## 10. NOT yet validated
|
||||
|
||||
1. **The operator's own look** at AdventureLog and Plex on demo-hp through the public address. The
|
||||
layout judgement is theirs. (The padding complaint from the first look is fixed and live.)
|
||||
2. **A blue callout in a real browser** (§8).
|
||||
3. **A catalog guide rendered from a real template** — no template carries `app_info.guide` yet.
|
||||
|
||||
## 11. Teardown — three layers
|
||||
|
||||
- **Machine:** nothing provisioned, no app installed, no throwaway Plex. Probe scripts, fetched
|
||||
pages and the password file removed from guest 9201, demo-hp and DooPlex `/tmp`; the dashboard
|
||||
password went file→file, was never printed, and no copy reached Windows or git.
|
||||
- **Host:** both scratch clones and the `felhom.eu` sibling symlink deleted. The shared DooPlex
|
||||
checkout was never written to by me — which is how the other session's work was distinguishable
|
||||
from mine. `build.sh` in the build directory is now a symlink to the repo's (deliberate), with the
|
||||
stale July copy kept beside it.
|
||||
- **Hub:** nothing created; no floor change.
|
||||
|
||||
## 12. Observations
|
||||
|
||||
- **FILED: R-1081** — the registry's `0.315.0`, and now also `0.316.3`, hold content that is not
|
||||
what their numbers say. Both caused by this session; both fixed at the root; no box affected.
|
||||
- **NOT-A-FINDING (fixed in session):** the asset tag shipped dead — incident (ii).
|
||||
- **NOT-A-FINDING (fixed in session):** the clean-tree rule had no mechanism — incident (iii).
|
||||
- **NOT-A-FINDING (fixed in session):** `app_row_dedup_gate` convicted my `.app-row`, correctly —
|
||||
that class already means „a row in the app list". Renamed `.app-field-row`; an over-broad rename
|
||||
briefly took the list's own rule with it and was restored.
|
||||
- **NOT-A-FINDING (fixed in session):** the one-column padding doubled against `.content`'s own
|
||||
2rem. The mockup's numbers were the total the page should show, not an amount to add.
|
||||
- **NOT-A-FINDING:** `build.sh`'s `workspace/` dirtied any clone it was run from; gitignored.
|
||||
- **Register: 169 before → 170 after; 1 opened (R-1081), 1 opened-and-closed (R-1080).**
|
||||
- **System poster:** no fact in `felhom-system-poster.facts.md` changed. Neither file edited.
|
||||
- **Instruction files:** `.claude/rules/unprompted-work.md` item 6 in **all four** versioned copies
|
||||
gains „a version number is pushed once", stated as a fact about what the build now enforces, not
|
||||
as a new rule. The workspace-root copy is unversioned and not on this machine.
|
||||
- **Worth the operator's attention:** two sessions released this repo within an hour, and the
|
||||
second overwrote the first's `REPORT.md`. This file is the workaround; whether `REPORT.md` should
|
||||
stay a single overwritten file in a repo two sessions can release is a question for the operator,
|
||||
not a change I should make unasked.
|
||||
@@ -1,451 +1,21 @@
|
||||
# REPORT — controller v0.228.0 (R-399 + R-400), 2026-08-31
|
||||
# REPORT — the restore and backup holes (2026-10-24 night, unattended; commit only, NOT released)
|
||||
|
||||
**The check now reads the data, and the debug page no longer lies.** Built, pushed, deployed to
|
||||
`demo-hp`, and proven there at both depths with the restic argument list read off the running process.
|
||||
Brief: fix R-1251, R-1252, R-1253 (and R-1254, R-1257 in their repos); live-prove the recovery-code changes; nothing
|
||||
released. Full record: felhom.eu `documentation/audits/restore-holes-2026-10-24/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Confirmed baselines — re-checked at the start, no drift
|
||||
|
||||
| Repo | `main` @ start | matched `origin/main` | version |
|
||||
|---|---|---|---|
|
||||
| `felhom-controller` | `300d7e87d7cfcbb6dd355594f5d8936c06184a27` | yes | v0.227.1 → **v0.228.0** |
|
||||
| `felhom.eu` | `db0812b6f261dbb925ef89ed27bfc2d2d1d3b5b9` | yes | docs only |
|
||||
|
||||
`git status --porcelain` was empty in both. `MinAgent` stays **0.129.0**. No drift to report.
|
||||
|
||||
---
|
||||
|
||||
## 2. Files created / modified / deleted
|
||||
|
||||
**Created (6)**
|
||||
|
||||
- `controller/internal/backup/r399_depth_test.go` — Group A (8 tests)
|
||||
- `controller/internal/backup/r399_slow_notice_test.go` — Group B1–B5
|
||||
- `controller/cmd/controller/r399_no_event_test.go` — B6, the non-effect
|
||||
- `controller/internal/web/r400_debug_routes_test.go` — Group D
|
||||
- `controller/scripts/debug_route_gate.py` — the gate
|
||||
- `controller/scripts/test_debug_route_gate.py` — Group C, incl. both red-proofs
|
||||
|
||||
**Modified (17)**
|
||||
|
||||
`controller/internal/backup/offbox_integrity.go` · `controller/internal/backup/offbox.go` ·
|
||||
`controller/internal/settings/settings.go` · `controller/internal/config/config.go` ·
|
||||
`controller/cmd/controller/main.go` · `controller/internal/web/handler_debug.go` ·
|
||||
`controller/internal/web/templates/debug.html` · `controller/internal/report/types.go` ·
|
||||
`controller/configs/controller.yaml.example` · `controller/scripts/controller_gates.py` ·
|
||||
`controller/scripts/test_controller_gates.py` · `controller/internal/backup/r359_integrity_test.go` ·
|
||||
`CHANGELOG.md` · `CONTEXT.md` · `REUSE.md` · `controller/README.md` · `.claude/rules/gates.md`
|
||||
|
||||
`felhom.eu`: `STATUS.md` · `documentation/architecture/00-capability-map.md` ·
|
||||
`documentation/architecture/07-backup-architecture.md` · `documentation/backlog/OPEN-ITEMS.md` ·
|
||||
`documentation/backlog/CLOSED-ITEMS.md` · `scripts/wire_contract_gate.py`
|
||||
|
||||
**DELETED — half this task, so listed explicitly**
|
||||
|
||||
*From `debug.html`:* six `/api/debug/...` references, their buttons and result spans, the entire
|
||||
„Tárhely teszt" card (`section-storage`), the `dr-status` panel, and the JavaScript functions
|
||||
`loadWatchdogStatus`, `renderWatchdogStatus`, `simulateDisconnect`, `simulateReconnect`, `loadDRStatus`,
|
||||
plus their two `loadSectionData` cases. Template shrank 49 081 → 42 646 bytes.
|
||||
|
||||
*From the test tree:* `TestR359_StructureCheckPassesNoReadDataFlag` and
|
||||
`TestR359_MalformedReadDataSubsetIsTreatedAsOff` — both asserted the ruling this release reverses.
|
||||
They are **replaced, not weakened**, and a paragraph stands where each was naming its successor, so a
|
||||
later reader does not re-derive the old ruling from an absence. See §4.
|
||||
|
||||
*From `controller/README.md`:* the „Tárhely teszt" debug-section row, and the `infra-push` /
|
||||
`dr/infra-status` route mentions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Commits pushed to `main`
|
||||
|
||||
| Repo | Commit | What |
|
||||
| Commit | What | CI |
|
||||
|---|---|---|
|
||||
| `felhom-controller` | `3c49dc8ea42df6c84a4bc9495d0d8a7662163ae4` | the whole of Parts 1–3 + tests + gate + repo docs |
|
||||
| `felhom.eu` | `77a5a115` | STATUS, capability map, 07 §10.2, register, wire allowlist |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tests — per group, and the red-proofs by name
|
||||
|
||||
Green gate: `go build ./... && go vet ./... && go test ./...` → **all three exit 0**, full suite.
|
||||
`python3 scripts/controller_gates.py --fast` → **13/13 OK**.
|
||||
|
||||
| Test | Result |
|
||||
|---|---|
|
||||
| A1 `TestR399_AbsentConfigRunsFullDepth` | PASS |
|
||||
| A2 `TestR399_EmptyStringIsNotOff` | PASS |
|
||||
| A3 `TestR399_OffTokenRunsStructureOnly` | PASS |
|
||||
| A4 `TestR399_OffTokenIsCaseInsensitive` | PASS |
|
||||
| A5 `TestR399_ExplicitValueWins` | PASS |
|
||||
| A6 `TestR399_MalformedFallsBackToTheDefault` | PASS |
|
||||
| A7 `TestR399_DepthIsStatedInTheOutcome` | PASS |
|
||||
| A8 `TestR399_DefaultResolvesThroughTheRealConfigPath` (the seam) | PASS |
|
||||
| B1 `TestR399_SlowCheckWarns` | PASS |
|
||||
| B2 `TestR399_FastCheckIsSilent` | PASS |
|
||||
| B3 `TestR399_SkipNeverWarns` | PASS |
|
||||
| B4 `TestR399_UnreachableNeverWarns` | PASS |
|
||||
| B5 `TestR399_SlowAndFailedProducesBoth` | PASS |
|
||||
| B6 `TestR399_SlownessRaisesNoHubEvent` (AST, with a positive control) | PASS |
|
||||
| C1 gate on the shipped tree | PASS — `18 referenced address(es), all dispatched, none orphaned` |
|
||||
| C2 `test_fails_on_an_unwired_reference` | PASS |
|
||||
| C3 `test_fails_on_an_unreached_handler` | PASS |
|
||||
| C4 `test_gate_is_registered_in_the_runner` (AST over `GATES`) | PASS |
|
||||
| D `TestR400_DeletedControlsAreGoneFromTheTemplate` · `…LeftNoPanelOrScript` · `…CrossDriveRouteDispatches` · `…CrossDriveReportsWhichAppsItStarted` | PASS |
|
||||
| D3 `TestBackupReport_DeadFieldsStayZero` | PASS, **unmodified** |
|
||||
| E1 full existing suite | PASS — see §4a for the two superseded tests |
|
||||
|
||||
### Red-proofs — mutate, confirm failure, revert
|
||||
|
||||
| # | Mutation | Result |
|
||||
|---|---|---|
|
||||
| **A1** | `defaultIntegrityReadDataSubset = ""` | `--- FAIL: TestR399_AbsentConfigRunsFullDepth` |
|
||||
| **A6** | malformed falls back to `""` instead of the default | `--- FAIL: TestR399_MalformedFallsBackToTheDefault` |
|
||||
| **B1** | `noticeIfSlow` returns immediately | `--- FAIL: TestR399_SlowCheckWarns` **and** `--- FAIL: TestR399_SlowAndFailedProducesBoth` |
|
||||
| **C2** | a `/api/debug/storage/simulate-disconnect` reference added to a sandbox copy | gate exit 1, naming `storage/simulate-disconnect` |
|
||||
| **C3** | a `ghost/handler` case added to a sandbox copy | gate exit 1, naming `ghost/handler` |
|
||||
|
||||
Each was reverted from a pre-mutation copy and the suite re-run green. C2/C3 mutate a **throwaway copy
|
||||
of the two files**, never the repo — a red-proof that leaves a broken tree behind when an assertion
|
||||
fires mid-test is its own hazard.
|
||||
|
||||
### 4a. Two existing tests were superseded — stated rather than absorbed
|
||||
|
||||
§9 rule E1 says to stop and report if an existing test needs editing. Two did, and the reason is not
|
||||
that they were inconvenient: **they asserted the ruling this release reverses.**
|
||||
|
||||
- `TestR359_StructureCheckPassesNoReadDataFlag` asserted an unconfigured box passes NO `--read-data`
|
||||
flag. Correct on 2026-08-30; overturned on 2026-08-31 by measurement and by Viktor's ruling.
|
||||
Replaced by **A1**, and the off token it left room for by **A3**.
|
||||
- `TestR359_MalformedReadDataSubsetIsTreatedAsOff` — **its name was the defect.** Treating a typo as
|
||||
"off" is the quiet downgrade. The half that still holds (nothing malformed reaches restic; it WARNs)
|
||||
is asserted by **A6**, which also pins the new direction.
|
||||
|
||||
Nothing else in the suite was touched.
|
||||
|
||||
### Test count
|
||||
|
||||
| | Go test/fuzz/bench funcs | gate test files |
|
||||
|---|---|---|
|
||||
| before (`300d7e8`) | 1 590 | 1 |
|
||||
| after | **1 606** (+18 new, −2 superseded) | **2** (+4 tests) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Deployed version
|
||||
|
||||
```
|
||||
$ ssh hp "pct exec 9201 -- docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
|
||||
gitea.dooplex.hu/admin/felhom-controller:0.228.0 Up 16 seconds (healthy)
|
||||
```
|
||||
|
||||
Image digest `sha256:ed28f160fac742dc6d6b22ccee3b7774b49b4f31d67d18099c342f5ce10f0c8d`, 150 MB.
|
||||
Deploy path: `docker pull` → `/etc/felhom-controller-image` → restart
|
||||
`felhom-controller-bootstrap.service`. Before: 0.227.1 (up 13 h, healthy).
|
||||
|
||||
**DELIVERED the same session, on the operator's instruction.** Golden **0.228.0** baked, published,
|
||||
round-trip verified and vouched; the fleet floor raised 0.227.1 → 0.228.0. `demo-felhom` then
|
||||
self-updated in under a minute and re-registered `offsite-integrity` by itself — so both demo boxes
|
||||
now re-read their whole off-site store weekly, and only `demo-hp` was ever touched by hand. Full
|
||||
evidence: `felhom.eu/documentation/tests/golden-0.228.0-2026-08-31/`. See §15.
|
||||
|
||||
---
|
||||
|
||||
## 6. The threshold I chose for the slow-check notice, and why
|
||||
|
||||
**`integritySlowNoticeThreshold = 5 * time.Minute`.**
|
||||
|
||||
The only full-depth number that exists is **39.2 s**, on a 134 MB store. Five minutes is ≈7.6× that,
|
||||
so it cannot fire on anything resembling today's fleet — and it is well under `integrityCheckTimeout`
|
||||
(30 min), so the operator hears *"this is getting slow"* long before a check is killed for running too
|
||||
long.
|
||||
|
||||
**Deliberately imprecise, and that is the argument.** A notice changes no behaviour, so an imprecise
|
||||
number costs nothing; a precise-looking threshold derived from one measurement on one small store
|
||||
would be the exact shape of the four production designs this project has already specced against
|
||||
unvalidated mechanisms. The number that matters is not 5 minutes — it is that *something* says the
|
||||
setting needs revisiting before a customer's upload does.
|
||||
|
||||
It is a WARN in the operator log and nothing else: no hub event, no customer alarm, and it never
|
||||
changes the depth by itself. An event type would cost the severity contract, the grain table and three
|
||||
registers to say "this took a while", against 08 §6.2's coarse-by-default rule.
|
||||
|
||||
---
|
||||
|
||||
## 7. NOT yet live-validated — stated, not implied
|
||||
|
||||
1. **A real WEEKLY firing at the new depth.** The job is confirmed REGISTERED on the box
|
||||
(`Daily job offsite-integrity scheduled for 2026-09-01 06:00 CEST`, `totalJobs=13`). That is not the
|
||||
same claim as observing it fire. Both live runs below were hand-forced through the debug route,
|
||||
which is the same code path with due-ness skipped and every other guard intact.
|
||||
2. **Everything about a LARGE store.** There is one data point, on 134 MB. The slow notice has never
|
||||
fired on real hardware — nothing on this fleet is slow enough. R-401 owns this.
|
||||
3. **The slow-notice WARN text as rendered on a box.** Proven in tests through the production path;
|
||||
not observed live, because no live check exceeds the threshold.
|
||||
4. **The `crossdrive` route's zero-app branch on a real box.** demo-hp had three eligible apps, so the
|
||||
non-empty branch was proven live and the empty one only in tests.
|
||||
|
||||
---
|
||||
|
||||
## 8. The depth evidence — argv verbatim, and the new wall-clock
|
||||
|
||||
The box's `controller.yaml` was confirmed to have **no `integrity:` key at all** before run 1 — the
|
||||
Scenario A condition, live, on a real box.
|
||||
|
||||
**Run 1 — the default, no config:**
|
||||
|
||||
```
|
||||
restic -r sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo -o sftp.command=ssh
|
||||
u629488-sub3@u629488-sub3.your-storagebox.de -p 23 -oBatchMode=yes -oConnectTimeout=10
|
||||
-oStrictHostKeyChecking=yes -oUserKnownHostsFile=/opt/docker/felhom-controller/data/offbox/known_hosts
|
||||
-i /opt/docker/felhom-controller/data/offbox/ssh_key -s sftp check --read-data-subset=100%
|
||||
```
|
||||
|
||||
```json
|
||||
{"data":{"depth":"100%","duration_ms":38745,"ok":true,"read_data_subset":"100%","skipped":false,"unreachable":false},
|
||||
"message":"Az ellenőrzés rendben lezajlott","ok":true}
|
||||
```
|
||||
|
||||
```
|
||||
[INFO] [offbox] integrity: check PASSED in 43s (structure, index, and 100% of the pack data re-read)
|
||||
[INFO] Event pushed: backup_integrity_ok (info) — A távoli mentés ellenőrzése rendben lezajlott.
|
||||
(43s, a mentett adatok 100%-át újraolvasva)
|
||||
```
|
||||
|
||||
**Run 2 — `read_data_subset: "off"` added to the box's `controller.yaml`, container restarted:**
|
||||
|
||||
```
|
||||
restic -r sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo -o sftp.command=ssh
|
||||
u629488-sub3@u629488-sub3.your-storagebox.de -p 23 -oBatchMode=yes -oConnectTimeout=10
|
||||
-oStrictHostKeyChecking=yes -oUserKnownHostsFile=/opt/docker/felhom-controller/data/offbox/known_hosts
|
||||
-i /opt/docker/felhom-controller/data/offbox/ssh_key -s sftp check
|
||||
```
|
||||
|
||||
```json
|
||||
{"data":{"depth":"structure","duration_ms":34742,"ok":true,"read_data_subset":"","skipped":false,"unreachable":false},
|
||||
"message":"Az ellenőrzés rendben lezajlott","ok":true}
|
||||
```
|
||||
|
||||
```
|
||||
[INFO] [offbox] integrity: check PASSED in 35s (structure and index only — no pack data was downloaded)
|
||||
```
|
||||
|
||||
**The config was then put back** (the `integrity:` block removed, container restarted, `grep -c
|
||||
integrity` = 0), and the scratch backup file on the guest deleted.
|
||||
|
||||
| | 2026-08-30 (0.227.x) | 2026-08-31 (0.228.0) |
|
||||
|---|---|---|
|
||||
| structure depth | 35.0 s | **34.7 s** (via `off`) |
|
||||
| 100% re-read | 39.2 s | **38.7 s** · 39.8 s · 43.2 s over three runs |
|
||||
|
||||
The numbers reproduce. The 43.2 s run was the first after a container restart, with a cold SFTP path.
|
||||
|
||||
**Method:** endpoint-level, via `POST /api/debug/backup/integrity` — the exact route the „Restic
|
||||
integritás" button invokes. No browser is available on DooPlex. The argv was read from the **guest's**
|
||||
process table while each check was in flight (`pct exec 9201 -- ps -eo args | grep '[r]estic'`); the
|
||||
container has no `ps`. All evidence was captured **before** the config revert (R-320).
|
||||
|
||||
---
|
||||
|
||||
## 9. The seven controls — disposition, by name
|
||||
|
||||
| # | control | invoked by | disposition | why |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `backup/crossdrive` | button „Csak cross-drive" | **IMPLEMENTED** | `Manager.RunTier2(stackName)` is live at `tier2.go:289` and already called from the app config page. Only the route was missing. Async (a Tier-2 sweep is bounded by disk, not by a timeout) and it answers with the app LIST, because zero apps and eight apps are different facts |
|
||||
| 2 | `backup/infra` | button „Infra mentés" | **DELETED** | no backing function exists. `backup.go:22`: disk-tier backup (restic, cross-drive, drive-recovery, **infra-backup**) moved to the host agent in slice 8C |
|
||||
| 3 | `hub/infra-push` | button „Infra backup küldése" | **DELETED** | `report/pusher.go:170`: *"PushInfraBackup removed 2026-06-16 — the infra-backup mechanism was retired hub-side. It was dead since slice 8C, had no callers, and pushed plaintext secrets to the hub."* |
|
||||
| 4 | `dr/infra-status` | **fetch on page LOAD** | **DELETED** | it rendered per-drive infra backups and the hub infra push — the two mechanisms above, both retired. The panel had been permanently blank |
|
||||
| 5 | `storage/watchdog-status` | **fetch on page LOAD, twice** (initial + 5 s poll) | **DELETED** | `web/server.go:254`: the slice-8C watchdog is retired and the drive-gate reconcile replaced it. Nothing publishes a per-path probe status; the panel had been permanently blank |
|
||||
| 6 | `storage/simulate-disconnect` | button in the watchdog table | **DELETED** | no backing capability at all, **and it writes storage state**. A debug button that fakes a drive disconnect on a customer's machine is a foot-gun — that is where drives get unenrolled and data gets stranded. No live need was shown |
|
||||
| 7 | `storage/simulate-reconnect` | button in the watchdog table | **DELETED** | same |
|
||||
|
||||
No control was left in the third state. Each deletion took its panel and its JavaScript; the
|
||||
„Tárhely teszt" section had nothing left and went entirely.
|
||||
|
||||
**Counts, so the gate has a baseline:**
|
||||
|
||||
| | references in `debug.html` | cases in `handler_debug.go` |
|
||||
|---|---|---|
|
||||
| before | 24 | 17 |
|
||||
| after | **18** | **18** |
|
||||
|
||||
**Proven live on the served page** (`GET /debug`, 200, 75 430 bytes, ASCII-only fragments per R-364):
|
||||
`backup/infra`, `hub/infra-push`, `dr/infra-status`, `storage/watchdog-status`,
|
||||
`storage/simulate-disconnect`, `storage/simulate-reconnect`, `watchdog-status`, `simulateDisconnect`,
|
||||
`loadDRStatus`, `section-storage` → **0 occurrences each**. Positive controls on the same fetch:
|
||||
`backup/crossdrive` 1, `backup/integrity` 1, `dr/trigger-setup` 1, `btn-dr-trigger` 5.
|
||||
|
||||
**The implemented control, invoked live:**
|
||||
|
||||
```json
|
||||
{"data":{"apps":["calibre-web","paperless-ngx","romm"],"count":3},
|
||||
"message":"Cross-drive mentés elindítva 3 alkalmazásra","ok":true}
|
||||
```
|
||||
|
||||
and it did the work, which is the positive observable — not an absent error:
|
||||
|
||||
```
|
||||
[INFO] [backup] Tier 2 copied calibre-web → …/backups/secondary/calibre-web (5.6 MB, 1 leg(s))
|
||||
[INFO] [backup] Tier 2 copied paperless-ngx → …/backups/secondary/paperless-ngx (79.6 MB, 1 leg(s))
|
||||
[INFO] [backup] Tier 2 copied romm → …/backups/secondary/romm (176.2 MB, 0 leg(s))
|
||||
[INFO] [web] debug cross-drive run for {calibre-web,paperless-ngx,romm} completed
|
||||
```
|
||||
|
||||
**The gate, and its red-proof:**
|
||||
|
||||
```
|
||||
### gate on the shipped tree
|
||||
debug route gate OK - 18 referenced address(es), all dispatched, none orphaned
|
||||
rc=0
|
||||
### red-proofs
|
||||
Ran 4 tests in 0.106s — OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Teardown — all three layers
|
||||
|
||||
- **DooPlex.** Nothing provisioned. One image built and pushed to the registry
|
||||
(`felhom-controller:0.228.0`), which is the deliverable, not scratch. Evidence and logs live in the
|
||||
session scratchpad and are reproduced verbatim in this report; nothing was left in `/tmp` beyond it.
|
||||
- **`demo-hp` (host).** Nothing provisioned. No storage added, no VM, no guest.
|
||||
- **Guest 9201.** One scratch file, `/root/controller.yaml.pre-r399` (the config backup taken before
|
||||
the off-token run) — **deleted**, absence confirmed. `controller.yaml` restored byte-for-byte and the
|
||||
restored state verified (`grep -c integrity` = 0). The off-site store was **read** at full depth
|
||||
three times and never written, pruned, unlocked or forgotten.
|
||||
- **Not touched at all:** `demo-felhom`, `ep0`, DooPlex's own k3s/Longhorn/PBS, Peti's box.
|
||||
|
||||
---
|
||||
|
||||
## 11. Register
|
||||
|
||||
| Row | Action |
|
||||
|---|---|
|
||||
| **R-399** | **CLOSED** — controller v0.228.0. Moved to `CLOSED-ITEMS.md` with its reasoning kept |
|
||||
| **R-400** | **CLOSED** — controller v0.228.0. Moved to `CLOSED-ITEMS.md` with its reasoning kept |
|
||||
| **R-401** | **FILED** — revisit the depth when a real store is large. **Trigger is the slow-check WARN firing, not a date.** Owner CC |
|
||||
| **R-402** | **FILED** — the integrity verdict AND its depth are on the wire and no hub surface reads either. See §12 |
|
||||
| **R-87** | **UNTOUCHED and still OPEN.** Reading the bytes back out of the store is not a restore. Restated in `07 §10.2` where the two rows sit adjacent |
|
||||
|
||||
**Register size:** `OPEN-ITEMS.md` 166 → **165** rows (−2 closed, +2 filed). `CLOSED-ITEMS.md`
|
||||
148 → **150** rows. Each closed entry names `300d7e8` as the commit whose
|
||||
`git show 300d7e8:documentation/backlog/OPEN-ITEMS.md` returns the original text verbatim.
|
||||
|
||||
---
|
||||
|
||||
## 12. Observations
|
||||
|
||||
1. **FILED: R-402 — a wire field with no receiver, caught by a gate, not by me.**
|
||||
`scripts/wire_contract_gate.py` convicted the new `offsite.last_integrity_depth`: the literal
|
||||
string occurs nowhere in the hub, so `encoding/json` discards it on arrival. Its sibling
|
||||
`offsite.last_integrity_ok` has been in the same state since v0.227.0 and was already allowlisted
|
||||
**with its reason**. I added the new field to that allowlist beside it and filed the row rather than
|
||||
modelling it hub-side, because that is a hub release this task does not authorise and R-331 ruled
|
||||
the *display* a decision for the operator. **This is the opposite order to the one that produced
|
||||
R-331:** publish the value first, build the screen when someone decides what it should say.
|
||||
|
||||
2. **NOT-A-FINDING: `integrityCheckTimeout`'s comment was made false by this change, and I rewrote it
|
||||
rather than leaving it.** It said read-data "ships OFF (R-399); whoever turns it on must revisit
|
||||
this number, and this comment is the note that says so." Both halves stopped being true in this
|
||||
release. It is now the number a large store will meet first, and it says so, pointing at R-401.
|
||||
Strictly outside the listed scope; leaving it would have been the R-395 family defect this task
|
||||
itself corrects three instances of.
|
||||
|
||||
3. **NOT-A-FINDING: `.claude/rules/gates.md` said "all seven local gates" while nine were registered.**
|
||||
Found while registering the tenth. Corrected to point at the runner's `GATES` table instead of
|
||||
listing them again — the duplicate list is what drifted.
|
||||
|
||||
4. **NOT-A-FINDING: `demo-hp`'s dashboard password is NOT stale — I made the exact mistake the
|
||||
project already has a memory about.** `POST /login` returned 200-with-login-page and the box logged
|
||||
`Failed login`, which matches the documented "the password drifted" symptom exactly. It had not:
|
||||
values in `~/.config/credentials` are **single-quoted**, my extraction stripped only `"`, and the
|
||||
two `'` characters were being sent as part of the password. With both quote characters stripped,
|
||||
`POST /login` → 302 + `felhom_session`. **Nothing on the box was changed.** The memory
|
||||
`credentials-file-values-are-quoted` states this correctly, gives the right recipe
|
||||
(`tr -d "\"'"`), and records the identical misdiagnosis from 2026-07-20 — where it was written up
|
||||
three times as "the stored password is stale" before being caught. The memory is right; I did not
|
||||
follow it. Its own lesson is the one that applies: **an auth failure is evidence about the bytes
|
||||
you sent, not proof about the stored secret.**
|
||||
|
||||
5. **NOT-A-FINDING: the two `storage/simulate-*` controls are the only deletions that removed a
|
||||
capability someone might want back.** They wrote state, so §2.1's rule deleted them absent a shown
|
||||
live need. If drive-absent behaviour ever needs exercising by hand again, that is a new feature
|
||||
with a gate in front of it, not a restored button — and the drive-gate reconcile it would be
|
||||
testing did not exist when those buttons were written.
|
||||
|
||||
---
|
||||
|
||||
## 13. One push bypassed a gate, deliberately — and the gate is now green
|
||||
|
||||
The **first** `felhom.eu` docs push (`77a5a115`) used `git push --no-verify`. At that moment
|
||||
`golden-currency` was **RED and correctly so**: v0.228.0 was released and the newest golden bake was
|
||||
0.227.1, so a machine installed right then would have received 0.227.1. That was not a defect in the
|
||||
work — it is the state the gate exists to make visible. It was not a waiver case either: a golden was
|
||||
genuinely owed, so recording one would have been false. The hook sanctions `--no-verify` on condition
|
||||
that the session report says so; this is that sentence.
|
||||
|
||||
**It is no longer red.** The golden was baked and vouched later in the same session (§15), the gate
|
||||
went red → green, and the follow-up push (`1623a4d5`) passed the hook normally with **all 12 gates
|
||||
OK**. Both `felhom-controller` pushes used the hook normally, 13/13.
|
||||
|
||||
---
|
||||
|
||||
## 14. What Viktor is owed
|
||||
|
||||
**Nothing.** No customer action, no data migration, no credential change. The debug page is
|
||||
operator-only and no customer sees any part of it. The delivery that §13 recorded as owed was carried
|
||||
out on the operator's instruction and is verified in §15.
|
||||
|
||||
---
|
||||
|
||||
## 15. Delivery — golden 0.228.0 baked, vouched, and the floor raised
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GOLDEN_VERSION` | **0.228.0** |
|
||||
| `GOLDEN_SHA256` | `76a3a98b9e7cc23bf8ae51b38a6272f576df285cb34cd22235ac3f06a31e53ec` |
|
||||
| size | 658 079 744 B |
|
||||
| script | `build-golden.sh v3.0.0`, drill VM reverted to `virgin` and cold-booted |
|
||||
| template | `debian-13-standard_13.6-1_amd64.tar.zst`, after `pveam update` |
|
||||
|
||||
**Acceptance markers, counted:** `docker OK (overlay2` 1 · `including mount point rootfs` 1 ·
|
||||
`including mount point mp0` 1 · `upload OK (HTTP 201)` 1 · `excluding` 0 · `FATAL` 0 ·
|
||||
`mount point mp1` 0. `felhom-controller:0.228.0` appears 4× in the bake log.
|
||||
|
||||
**The 404 pre-gate was proven before its 404 was believed:** the target URL returned 404 while the
|
||||
existing 0.227.1 package returned 200 on the same command. A 404 from a check that cannot see
|
||||
anything is not a measurement.
|
||||
|
||||
**The evidence is the round trip.** Downloaded size and sha match the bake exactly, and
|
||||
`./etc/felhom-controller-image` read **out of the downloaded archive** says
|
||||
`gitea.dooplex.hu/admin/felhom-controller:0.228.0` — the golden naming its controller from the bytes a
|
||||
customer's box would actually fetch.
|
||||
|
||||
**The vouch — three fields together:** `golden_version` 0.228.0, `agent_version` 0.130.0,
|
||||
`min_agent` 0.129.0 (read from this release's CHANGELOG header, not assumed), `wrapper_sha256`
|
||||
carried through explicitly because the handler clears it when omitted. `agent ≥ min_agent`, so **not
|
||||
the R-216 shape**. **Verified by RE-READING the manifest, never by the flash.** The R-120 gate passed
|
||||
rather than being bypassed.
|
||||
|
||||
**The floor is ACTING, not merely set.** Impact preview `{"below":3,"valid":true,"version":"0.228.0"}`;
|
||||
re-read after the POST confirms `DB override: v0.228.0`. Then, with nobody touching it:
|
||||
|
||||
```
|
||||
[INFO] [selfupdate] Post-update startup: update successful (0.227.1 → 0.228.0)
|
||||
[INFO] [scheduler] Daily job offsite-integrity scheduled for 2026-09-01 06:00 CEST
|
||||
[INFO] [offsite-apply] settle-gate: GO — at/above floor 0.228.0 (we are 0.228.0)
|
||||
```
|
||||
|
||||
That is `demo-felhom`. **The second line is the one worth keeping:** a box nobody deployed to now runs
|
||||
the deeper off-site check on its own schedule — R-399 reaching the fleet, observed rather than assumed.
|
||||
|
||||
**Token hygiene and teardown** are recorded in full at
|
||||
`felhom.eu/documentation/tests/golden-0.228.0-2026-08-31/README.md`. In short: file → file `scp`, a
|
||||
runner script inside the VM so the token never reached a command line (`systemctl show … | grep -c`
|
||||
→ 0), the committed log's leak grep **proven with a planted copy (1) before its 0 was believed**,
|
||||
build guest `9100` purged, secrets shredded after the log was copied out (standing rule 5), VM
|
||||
powered off and the disk reverted to `virgin`.
|
||||
|
||||
**Still NOT live-validated,** unchanged from §7: a real weekly firing at the new depth (next is
|
||||
2026-09-01 06:00 CEST, now on both boxes), and everything about a large store (R-401).
|
||||
| `bb2809d` | R-1251 (P1): a pending put-back blocks every start; H2 a failed put-back holds; H7 Stop still gets the put-back | 2301 / #482 success |
|
||||
| `062f9b4` | R-1251: the copy's folders synced before the marker names it (found by the Tester 1 proof: `undo.json.tmp` after a cut); the restore record says the put-back waits | 2302 / #483 success |
|
||||
| `518a135` | R-1252 (P2): reconcile only under a valid classification; a rejected block is said and signalled; kept copies stay kept; Remove drops kept records | 2303 / #484 success |
|
||||
| `c9b4b82` | R-1253 (P2): a stop-first export refuses while another operation holds the marker | 2304 / #485 success |
|
||||
| (close-out) | README, CONTEXT, this report; the interrupted-restore log line names a waiting put-back | final report |
|
||||
|
||||
**Proofs:** R-1251 on Tester 1 — a local restore of MeTube (1.5 GB volume) cut by powering the VM off, the data disk
|
||||
taken off and put back: held while away, put back BEFORE the start, the newest data there, a later restart poured
|
||||
nothing. R-1252 on 9202 — the review's audiobookshelf steps: kept after a bogus path. R-1253 on 9202 — the export refused
|
||||
during a restore, ran after it. Every rule red-proved (each test names its red-proof).
|
||||
|
||||
**Images:** dev images `0.343.0-dev.r1251a/b`, `r1252a`, `r1253a` built locally and loaded into the test guests only;
|
||||
never pushed. **Machines:** Tester 1 and 9202 put back (both: the controller on 0.342.0; leftover anonymous volumes of
|
||||
the R-1267 class named in the audit). **Release:** Monday — controller v0.343.0 (with R-1250), MinAgent 0.158.1.
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `NamespaceRoot` | controller/internal/appbackup/paths.go | `(drivePath string, inGuestDrive bool) string` | Resolve felhom-data root for a drive | `inGuestDrive=true` returns path AS-IS (Model A: guest mount IS the ns root); false appends `felhom-data`. Never double-nest |
|
||||
| `PrimaryBackupPath` / `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` | controller/internal/appbackup/paths.go | `(nsRoot[, stackName]) string` | All backup dir layout | Take the NAMESPACE ROOT, not a bare drive path |
|
||||
| `PrimaryBackupPath` / `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` | controller/internal/appbackup/paths.go | `(nsRoot[, stackName]) string` | All backup dir layout | Take the NAMESPACE ROOT, not a bare drive path. Since v0.229.0 the last three are thin wrappers over the unit-directory-relative primitives below and return byte-identical strings (`TestR102_PathWrappersAreByteIdenticalToToday`) |
|
||||
| `UnitComposeDir` / `UnitManifestFile` / `UnitDBDumpDir` / `UnitVolumeDumpDir` (R-102, v0.229.0) | controller/internal/appbackup/paths.go (re-exported by `backup/appbackup_bridge.go`) | `(unitDir string) string` | naming a leg inside a recovery unit that is NOT under `backups/primary/` | **These take the recovery-unit DIRECTORY itself.** The `(nsRoot, stackName)` form joins a hard-coded `primary`, which was the MECHANISM of R-102: the Tier-2 mirror at `<dest>/backups/secondary/<app>/recovery-unit/` was written nightly and readable by nothing. Use these whenever the unit's location is an argument; do NOT add a second copy of a layout join |
|
||||
| `AppDBDumpPath` / `AppVolumeDumpPath` / `AppDataDir` | controller/internal/appbackup/paths.go | `(nsRoot, stackName) string` | Per-app dump/data dirs | Same nsRoot contract. `AppDataDir`'s final segment is the app's real appdata dir NAME — NOT always the stack name (paperless-ngx → `paperless`); resolve via `AppDataDirNames` first (F-S2/F-S3) |
|
||||
| `AppDataDirNames` / `AppDataBindsPresent` | controller/internal/appbackup/paths.go | `(hddPath, stackName string, hddMounts []string) []string` / `(hddPath, hddMounts) bool` | Resolve the real `appdata/<name>` dir(s) from compose `${HDD_PATH}` binds (F-S2/F-S3) | `hddMounts` = ParseComposeHDDMounts shape. Deduped+sorted; falls back to `[stackName]` when no appdata bind. Tier-2 (`backup.Manager.tier2AppDataName`) refuses N>1; migrate (`stacks.Manager.ResolveAppDataDirNames`) loops N. `BindsPresent` drives the WARN-on-missing-declared-dir |
|
||||
| `UserdataDir` / `ImportDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(nsRoot)` / `(nsRoot, dirs []string)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention. **R-75:** `ImportDir` is the CANONICAL drop-zone (`<nsRoot>/userdata/import`) and callers MUST resolve it against the SYSTEM namespace, never an app's HDD_PATH — use `stacks.Manager.GetImportRoot()`. `EnsureUserdataSkeleton` now takes the dir set: build it with `BuildUserdataSkeleton(DeriveUserdataDirs(stacksDir))`, or via `Manager.EnsureUserdataSkeleton` / `web.Server.ensureUserdataSkeleton`. | Linux-only chown via build-tag twin userdata_linux.go. **The set MUST stay sorted** — `fbNeedsRecreate` force-recreates FileBrowser on any byte diff and the naive map-order derivation measured 20/20 distinct (SPIKE P6). `UserdataSkeletonCarry()` is the old hardcoded list, retained forever so derivation can only ADD (zero removals). |
|
||||
@@ -19,10 +20,27 @@
|
||||
| `appbackup.ValidateRelPath` / `ValidRoot` | controller/internal/appbackup/classify.go | `(root, path)` / `(root)` | THE single path-safety refusal set for every `${VAR}`-relative catalog path | Shared by `backup:` and `data_paths:`. **Do not write a second path validator.** |
|
||||
| `stacks.ValidateDataPaths` | controller/internal/stacks/datapaths.go | `(entries, binds, appName, logger)` | `data_paths:` annotation validation | ASYMMETRIC on purpose (Fork-3): malformed PATH ⇒ whole-block reject (data handling, `backup:` precedent); unknown ROLE ⇒ fails OPEN, one WARN (presentation, `Lifecycle` precedent). |
|
||||
| `web.fileBrowserLink` / `importFolderLink` | controller/internal/web/filebrowser_link.go | `(domain, sourceName, relPath)` | FileBrowser Quantum deep link | Template read out of the shipped router (SPIKE P2). **`url.PathEscape` per segment — NEVER `QueryEscape`** (space→`+` is a literal plus in a path). Let `html/template` do the attribute escaping; do not pre-escape. |
|
||||
| `web.fileBrowserOpenLink` · `web.ServeFilesOpen` / `ServeFilesAuth` · `infra.LoadOrCreateFileBrowserSSOHeader` / `FileBrowserSSOFor` (v0.332.0, R-1100) | controller/internal/web/filebrowser_link.go · controller/internal/web/files_sso.go · controller/internal/infra/filebrowser_sso.go | `(domain, sourceName, relPath)` · handlers · `(dataDir[, domain])` | A dashboard link to a FileBrowser folder that opens it signed in (the one sign-in) | **Every dashboard folder link uses `fileBrowserOpenLink`** (through `/__files/open`), never `fileBrowserLink` raw. The header NAME is the box's secret: only FileBrowser's config and traefik's route carry it — never a log, a page or a report. Never widen the dashboard cookie; the files cookie is derived per host from the session fingerprint (the R-976 shape) |
|
||||
| `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) |
|
||||
| `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/<n>` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert |
|
||||
| `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) |
|
||||
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, legacy felhom-data) | Consult before ANY recursive delete under a drive |
|
||||
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, kept, legacy felhom-data) | Consult before ANY recursive delete under a drive |
|
||||
| `stacks.OldAppDataPaths` / `Manager.ListKept` / `KeepAside` / `DeleteKept` / `FindKept` | controller/internal/stacks/kept.go | `(composePath, hdd)` / `(drives)` / … | Kept data (`09` §3 decision 36): what counts as an app's old data (ONLY `<hdd>/appdata/…` binds), the list, start-fresh, the household's delete | **An action names a kept item by path only through `FindKept`** — `DeleteKept` refuses anything not listed. `KeepAside` is a rename on one drive; never copy, never `RemoveAll` in a rollback (`removeEmptyDirs`) |
|
||||
| `stacks.RunAfterInstall` / `expandAfterInstall` / `web.defaultLoginInEffect` (v0.279.0, decision 45) | controller/internal/stacks/after_install.go · controller/internal/web/known_login.go | `(name, wait)` / `(cmd, allowed, env)` / `(meta, cfg, installed)` | A fresh install replaces a known default login; the page says when a default is still in effect | **Only from the deploy-done hook** — never after a restore/kept load (R-694). A `success:` marker is required (exit 0 lies). Never log the expanded command |
|
||||
| `stacks.OpenSetupGate` / `SetupGateTick` / `SetupGateHost` · `web.ServeGateAuth` / `ServeGateStart` (v0.280.0, decision 46) | controller/internal/stacks/setup_gate.go · controller/internal/web/setup_gate.go | `(name, by)` / `()` / `(host)` · handlers | The setup gate: a `setup_gate: true` install is closed to everyone but the household until its probe or the household's press opens it | **Write the gate BEFORE the first start** (spike F2). Open = record first, then remove the file. Never widen the dashboard cookie — the handshake mints a host-bound one-use token |
|
||||
| `stacks.OpenSignupWindow` / `SignupBlocked` / `SetupGateProbe` · `web.ServeSignupClosed` (v0.281.0, decision 47) | controller/internal/stacks/signup_block.go · controller/internal/web/setup_gate.go | `(name)` | Sign-up closed at the app's own address once the gate opens; the household's 15-minute window; the press asks the probe | **The block goes up BEFORE the gate comes down** (a failed write keeps the gate closed). Never on an app this box did not gate |
|
||||
| `family.Store` · `stacks.FamilyGateHost` / `familyGateTick` / `FamilyExceptRegexp` · `web.ServeFamilyGateAuth` / `ServeFamilyStart` / `ServeFamilyLogin` / `ServeFamilyLogout` (v0.287.0, decisions 63/64) | controller/internal/family/family.go · controller/internal/stacks/family_gate.go · controller/internal/web/family_gate.go | store `Add/AddPerson/People/Reset/Remove/Verify/NewSession/HouseholdSession/Valid/EndSession/Find/EndAll` + `family.Fingerprint` + `family.LoginFrom` · `(host)` / `()` / `(prefix)` · handlers | The PERMANENT family gate: family members with their own logins in front of a `family_gate: true` app | **A family cookie never opens the dashboard** (RequireAuth reads only `felhom_session`); the app cookie carries a token DERIVED per app host from a STORE session (`familyAppToken`, R-976 — never the session id itself), so the store is asked on every request (reset/remove/logout end access at once); **every exception goes through `FamilyExceptRegexp`** (anchored — never a hand-written PathPrefix); door written before the first start, like the setup gate. `familyStoreOverride` is the test seam. Fifth atomic-write helper (family.json, fsync) — see §6. **R-1112: a session is stored as its FINGERPRINT (`Session.FP`, the R-35 shape), never the cookie value** — `NewSession` returns the value once; `Valid`/`EndSession` take the value and hash it; `Find` and `familyAppToken` work on the fingerprint. An old file (`"id"` = the value) is converted and rewritten in `Open`; a failed rewrite is an error. **R-1113: the household path uses `HouseholdSession`** (its live session, a new one only past half its life) — never `NewSession("")` per pass. **W12: a typed name goes through `AddPerson` (keeps the display, makes the login with `LoginFrom`, numbers a taken login); `Add` stays the strict login-only primitive. Any place that turns a typed member name into a login calls `LoginFrom` — never a second fold table** |
|
||||
| `stacks.OpenInstallHold` / `installHoldTick` (v0.284.0, R-741) | controller/internal/stacks/install_hold.go | `(name, by)` / `()` | An `after_install` app held behind the setup gate's door until its known login is replaced | **Written before the first start**, like the gate; opens on `after_install` success or the household's "I changed it"; the door (`SetupGateHost`) reads holds first |
|
||||
| `stacks.RetainImagesAfterUpdate` / `RetainImagesAfterRemove` / `RunImageRetentionOnce` (→ `(deleted, done)` since v0.294.0) / `RunImageRetentionOnceUntilDone` · seam `imageDocker` (v0.284.0, decision 53) | controller/internal/stacks/image_retention.go | `(name, previous)` / `(name, repos)` / `()` | Deletes an app's images older than its running + previous one | **The keep set is box-wide and read at delete time** (containers, installed composes, installed/previous records); exact id, never forced or pruned; skipped while any update runs AND while any image-pulling compose command runs (R-863, `dockerexec.TryImageCleanup`); the one-time pass writes its marker only after a pass that RAN; tests use the `imageDocker` seam, never Docker |
|
||||
| `dockerexec.BeginImageWork(args)` / `TryImageCleanup()` / `ImagePulling(args)` (v0.294.0, R-863) | controller/internal/dockerexec/imagework.go | `defer dockerexec.BeginImageWork(args)()` | **Every NEW place that runs `compose up/pull/create/run` must hold it** (the stacks compose helpers, appexport's restore and the FileBrowser sync already do) | An image pulled by compose is named by no container until compose creates one; a clean-up pass in that window deleted it (measured, R-863). The pass takes the lock exclusively without waiting; pulling commands wait for a running pass (seconds) |
|
||||
| `nightchain.Ledger` / `CatchUp` / `ResumeWatch` / `ComputeBanner` (v0.295.0, R-871) | controller/internal/nightchain/ | `Open(path, now)`, `MarkEnded(leg, t)`, `Missed(now, W, loc)`, `Evaluate(ctx, why)` | "was a night missed?" and making it up; the missed-backup banner's rule | The ledger is an ATTEMPT record (ran to its end) — never evidence a backup exists; a NEW nightly backup leg must be wrapped by `withLeg` in main.go and listed in `nightchain.Order`, or the catch-up never makes it up; never add the update leg to `catchUpLegs` |
|
||||
| `scheduler.DailyLateLimit` (v0.295.0) | controller/internal/scheduler/scheduler.go | — | a daily timer firing > 60 min late (host suspend) is skipped | do not "fix" a late fire by running it: the app-update leg would run at noon |
|
||||
| `stacks.RetainControllerImages(ControllerImageRecord)` · `selfupdate.UpdateState.RecordedPrevious` (v0.285.0, decision 56) | controller/internal/stacks/controller_image_retention.go | `({Repo, Running, Previous})` | Deletes controller images older than the running + previous one | The previous comes from the SWAP RECORD (success onto the running version), version order only as the fallback; versions above the running one and non-version tags are kept; skipped while the controller swaps itself; same `imageDocker` seam |
|
||||
| `stacks.CloseSignupNow` / `CloseSignupOffered` / `applyNativeLock` (v0.282.0, decisions 47/49) | controller/internal/stacks/after_setup.go | `(name)` | The app's own sign-up switch after the setup; "close sign-up now" for an app installed before the rule | **Check the installed compose reads the variable** (an old install carries the old compose until its next update) — never record a lock that is not there. One run per app at a time (`nativeLockBusy`) |
|
||||
| `backup.judgeCopy` / `HollowCopies` / `SetHollowCopyNotify` (Part D, v0.279.0) | controller/internal/backup/hollow_watch.go | `(app, tier, unitDir)` | A RUNNING app whose newest copy holds no data → operator digest once/day + page sentence | Uses `unitCarriesData` (the manifest, never size); a stopped held app is never flagged |
|
||||
| `web.nightChain` (R-705, v0.279.0) | controller/internal/web/night_chain.go | `POST /api/debug/backup/night-chain` | The night's four legs now, in order | Refuses while any op/update/chain runs; the leg uses `RunUpdateLegNow` |
|
||||
| `Router.dropLeftoverHold` + `settings.ClearUpdateHold` (R-704, v0.278.0) | controller/internal/api/router.go · controller/internal/settings/settings.go | `(name, why)` / `(stack) (bool, error)` | A new install (plain or "use my kept data") and a removal clear the update / crash-loop hold of the app's install | **A hold belongs to an INSTALL; the name is all the next install shares with it.** Never clears an R-379 restore hold (operator-only) |
|
||||
| `backup.KeptBestCopy` / `KeptDBCopy` / `KeptOffsiteCopies` / `KeptCopyAt` / `LoadKeptApp` / `LoadKeptOffsite` / `KeptCopyKey` | controller/internal/backup/kept_load.go | `(ctx, app, drive)` / `(app, drive)` / `(ctx, apps)` / `(unitDir, drive, tier)` / … | Which copy can load kept files (local tiers + since v0.277.0 the off-site copy, R-691 (2)), the load as a restore op, the copy's name for the page | A copy counts only with data (DB dump or volume tar) AND its app.yaml `HDD_PATH` = this drive. Installed apps are never offered. **The off-site copy is judged only after its unit is downloaded** — `LoadKeptOffsite` refuses an unversioned unit (`07` §6.6) BEFORE `prepare` (a dated folder's move-back); ask the repository once per page (`KeptOffsiteCopies`), never per row. Both pages name a copy through `KeptCopyKey` |
|
||||
|
||||
### Subprocess + timeout + exit-code discipline
|
||||
|
||||
@@ -36,6 +54,19 @@
|
||||
| `parseWWWAuthenticate` + `fetchAnonymousToken` | controller/internal/selfupdate/updater.go | Bearer-challenge parse + anonymous Docker v2 token | Any credential-free registry API access | realm comes FROM THE HEADER (never hardcode a token URL); denial = errAnonymousDenied, never "credentials missing" |
|
||||
| `Syncer.runGit` / `runGitInDir` | controller/internal/sync/sync.go | `(args...) error` | git CLI ops | Credentials masked in logs via `maskRepoURL` |
|
||||
|
||||
### Error kinds — never branch on a customer-facing sentence (R-553)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `util.KindErrorf` / `util.KindError` | controller/internal/util/errkind.go | `(kind error, format string, a ...interface{}) error` | ANY refusal a caller must tell apart: build the message exactly as `fmt.Errorf` would AND carry a sentinel for `errors.Is` | The message bytes are unchanged (pinned by tests); never `fmt.Errorf("%w: …")`, which would prepend the sentinel's own text to the customer's sentence |
|
||||
| `stacks.ErrAlreadyDeployed` / `ErrRequiredField` / `ErrPathMissing` / `ErrNotEnoughMemory` | controller/internal/stacks/deploy_errors.go | sentinels | the API's deploy status code (`api.deployStatusFor`) | 409 / 400 / 400 / 400. Do NOT add a text signature beside them |
|
||||
| `stacks.ErrStackNotFound` / `ErrProtectedStack` / `ErrNotDeployed` / `ErrStillRunning` / `ErrNotOrphaned` | controller/internal/stacks/stack_errors.go | sentinels | the API's stop/start/restart/update, remove and delete status code (`api.stackOpStatusFor`, R-569) | 404 / 403 / 409 / 409 / 409. Only the producers in manager.go and delete.go carry them; a new refusal on those paths must carry one or it answers 500 |
|
||||
| `backup.ErrOffsiteQuota` | controller/internal/backup/offbox.go | sentinel | `ClassifyOffsiteFailure` telling a quota over-run apart | The other arms of that switch stay TEXT matches on purpose — they are restic's and ssh's own English output, which we neither write nor translate |
|
||||
| `monitor.WarnKind*` + `HealthReport.addWarning` / `WarningKindAt` | controller/internal/monitor/healthcheck.go | `(text, kind string)` | a health warning whose PLACEMENT the dashboard decides | Internal only: `internal/report/builder.go` copies Status/Issues/Warnings, so kinds never reach the hub (pinned) |
|
||||
| `monitor.MsgRef` + `HealthReport.addWarningMsg` / `addIssue` / `WarningMsgAt` / `IssueMsgAt` | controller/internal/monitor/healthcheck.go | `MsgRef{Key, Args}` beside the wire text | a health warning/issue whose BANNER must follow the household's language (R-516 item 10) | The wire text (report health.*) stays byte-identical; `AlertManager.Refresh` renders the key and falls back to the text when Key is empty. Every issue goes through `addIssue` so the parallel slices cannot drift (pinned) |
|
||||
| `AlertManager.GetBannerAlerts(page, lang)` + `bannerShows` (R-906, 2026-10-08) | controller/internal/web/alerts.go | the layout banner's list for ONE page, capped AFTER the page filter | any page that fills `data["Alerts"]` (`baseData` does it for you) | `GetAlerts` keeps Inline and other pages' alerts (debug dump, placement tests) and must not feed a banner: its cap counted alerts the layout then dropped, so a page showed „+ 5 további figyelmeztetés" and nothing above it. `bannerShows` mirrors the layout's own filter (pinned: `TestR906_*`) |
|
||||
| `settings.OffboxTarget.LastWarningKind` + `backup.OffboxWarnNoAppsSelected` | controller/internal/settings/settings.go | persisted string | the Távoli mentés page's stale-note substitution | Written and cleared with `LastWarning`; the page decides on the kind ONLY — the legacy text fallback was removed when R-570 closed (2026-10-10) |
|
||||
|
||||
### HTTP/JSON envelopes + flash messages
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
@@ -49,16 +80,26 @@
|
||||
| `restoreOpInFlight` + `hasRecentRestoreResult` | controller/internal/web/restore_wizard.go | `(backup.RestoreOpStatus) bool` / `(st, app, now) bool` | THE "is a restore running / did one just finish" display reads | **TRAP (v0.154.0 shipped this bug): `Manager` has TWO running flags.** `IsRunning()` reads the CONCURRENCY flag, acquired inside the goroutine — and `RestoreOffboxScratch` never acquires it, so it is false for the whole verification restore. Display must read `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`). Read the status ONCE per render or the strip and the suppression can disagree. `hasRecentRestoreResult` is app-bound and window-bounded — a process-wide result must not light another app's „Eredmény" |
|
||||
| `restoreWizardPath` / `deriveWizardStep` / `resolveWizardApp` | controller/internal/web/restore_wizard.go | `(app) string` / `(restoreWizardInput) restoreWizardView` / `([]OffboxAppRow, name) *OffboxAppRow` | R-48 offsite restore wizard: URL builder + the PURE step/unlock derivation + the app-resolution refusals | The step is **never** taken from the request. Precedence is load-bearing: op-running outranks a stale `?full_prep=`, else a commit button reappears mid-restore. Truth table + red-proof: `restore_wizard_test.go`. Adding a form here that posts anywhere new breaks `TestRestoreWizard_NoNewMutationEndpoints` **by design** — R-48 adds no mutation surface |
|
||||
| `restoreOpBlocked` | controller/internal/web/restore_wizard.go | `() (msg string, blocked bool)` | THE refusal gate before starting ANY restore | **Use this, never a bare `IsRunning()`.** It reads BOTH flags: `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`, true for the whole off-box restore) and `IsRunning()` (the concurrency flag, the only one the nightly backup holds). **R-351: all seven handlers read only `IsRunning()`, which the goroutine acquires AFTER the handler returns — a second press started a second run and was told „…elindult".** Returns the Hungarian refusal, which names the running app and a route | **R-360 (v0.226.0): `offboxVerifyCopyDeleteHandler` was the LAST holdout and its doc comment claimed it already did this — the sentence is why nobody looked. `IsRunning()` is FALSE for the whole of a verification restore, so the copy a restore was writing into could be deleted from the UI (observed live 2026-08-21 22:35). No app-name comparison: refusing during ANY restore is stronger and uniform.**
|
||||
| `Manager.RestoreFromRecoveryUnit` + `UnitRestoreResult` (R-353, v0.226.0) | controller/internal/backup/restore_unit.go | `(stack string) (UnitRestoreResult, error)` | THE local recovery-unit restore, and the facts its surface must state | **Returns a RESULT, not just an error** — volumes replayed, DBs replayed, and what the manifest LISTED. The Manifest* counts are load-bearing: zero-replayed has two causes (the backup held no data / the backup listed data that did not come back) and they are opposite news. Pair it with `unitRestoreOutcomeMsg`; do NOT write a new sentence. **A claim about the APP is forbidden on this path** — it has no `SafetyDump` discriminator, unlike the off-site twin (CONTEXT.md ruling, 07-backup-architecture §6.3). `restoreDockerVolumes` now returns `(int, error)`; `restoreDockerVolumesFrom` is unchanged and still the shared implementation |
|
||||
| `Manager.RestoreFromRecoveryUnitAt` / `RestoreFromRecoveryUnit` + `UnitRestoreResult` (R-353 v0.226.0; R-102 v0.229.0) | controller/internal/backup/restore_unit.go | `(stack, unitDir string) (UnitRestoreResult, error)` / `(stack string) (…)` | THE local recovery-unit restore, and the facts its surface must state | **Returns a RESULT, not just an error** — volumes replayed, DBs replayed, and what the manifest LISTED. The Manifest* counts are load-bearing: zero-replayed has two causes (the backup held no data / the backup listed data that did not come back) and they are opposite news. Pair it with `unitRestoreOutcomeMsg`; do NOT write a new sentence. **A claim about the APP is forbidden on this path** — it has no `SafetyDump` discriminator, unlike the off-site twin (CONTEXT.md ruling, 07-backup-architecture §6.3). `restoreDockerVolumes` now returns `(int, error)`; `restoreDockerVolumesFrom` is unchanged and still the shared implementation. **R-102: `…At` holds the whole body and takes the unit DIRECTORY; the one-argument form is the thin caller naming the primary unit.** THE SOURCE MOVES; THE DESTINATION DOES NOT — `GetAppDrivePath` still resolves where the data lands. The R-47 mutation order, the unit-over-guest secret precedence, the fail-closed data-key gate and the no-unit `RestoreApp` fallback are all pinned and unchanged. The volume leg goes through the R-354 `volumeReplayFrom` seam so the source directory is assertable without Docker |
|
||||
| `Manager.putBackAfterFailedRestore` + `liveVolumeCopy` (R-893 option A, v0.336.0) | controller/internal/backup/offbox_restore_undo.go | `(stack, cause, live, copy, volumesMoved, filesPlaced, restart, holdKey) error` | THE restore undo — off-site (v0.336.0) and, since D22 (unreleased), the LOCAL unit restore (`RestoreFromRecoveryUnitAtWith`, so also the second drive's and a kept load): the live volumes it will replace, measured before the stop (`planLiveVolumeCopy`), copied right after it (`copyLiveVolumes`), poured back on any failure | One copy per restore under `<ns>/backups/restore-undo/<app>/` (no capture set reads `backups/`); the put-back uses the restore's own `volumeReplayFrom`/`restoreDockerVolumesFrom`, so there is one volume-replay implementation. A put-back that fails sets `keep` and HOLDS (option C) — never start on volumes that are not known to be back. Seams for tests without Docker: `undoVolumeProbeFn`, `undoVolumeTarFn`, `undoVolumeRemoveFn`; operator notice `SetRestorePutBackNotify` (leg `restore-put-back`). Pinned by `r893_putback_test.go` and `r1177_local_putback_test.go` |
|
||||
| `CrashPutBack` + `Manager.persistUndoForCrash` + `AppStopGuard.SetUndo` (D22 / R-1195, v0.338.0) | controller/internal/backup/restore_crash_putback.go | `NewCrashPutBack(stop, writeDef, logger)`; `PutBackInterruptedRestore(stack, undoDir) error` | A restore cut by a power loss is put back at start: the copy (volumes + `definition/` + `undo.json`) is recorded in the app-stop marker once whole; `Recover` pours it back BEFORE the restart | Call `persistUndoForCrash` right after `copyLiveVolumes` in any new restore path. `Recover` runs before the backup manager exists, so the put-back is wired in main.go with `stackMgr.StopStack` and the stack adapter's definition writer. A failed put-back = a FAILED recovery (app not started, marker kept). The household reads `note.restore.interrupted_put_back` (`LoadRestoreRecordAfterRecovery`). Pinned by `TestR1195_*` |
|
||||
| `tier2CaptureSetFull(...) (legs, missing, warns, classified)` + `keptCopyRels` + `dropKeptCopiesUnder` (R-1252, unreleased) | controller/internal/backup/tier2_capture.go, tier2_kept.go | `classified` = the legs come from a VALID backup block | **The second-drive reconcile deletes only under a valid classification**; a rejected or absent block keeps every dest dir; kept copies (D25) are always in the keep set; a mirror deleted by Remove drops its kept records | A new reconcile caller must take `classified` and the kept rels too — never reconcile against the legacy legs. A rejected block is told by `Manager.BackupBlockRejected` (stacks; via the adapter's optional `BackupBlockRejected` method) and signalled once a day (`SetTier2SettingsUnreadableNotify`). Pinned by `r1252_rejected_block_keeps_copies_test.go` |
|
||||
| `AppStopGuard.ResumePutBack` / `ResumeWaitingPutBacks` + `HoldReasonPutBackPending` (R-1251, unreleased) | controller/internal/backup/appstop_putback_wait.go | `ResumePutBack(stack) bool` (true = nothing owed, the caller may start); `ResumeWaitingPutBacks() []string` (puts back, then starts through the gated starter) | A cut restore's copy on a drive that is not bound is WAITING: Recover writes the restore hold `putback_pending` (copy in `SafetyDump`) and starts nothing; the put-back runs when the drive binds, before any start | **Any new path that starts an app when its drive arrives calls `ResumePutBack` first** (the boot recreate and `restartStacks` do, in web via `Server.resumePutBack`). Every other start path is covered by the hold store it already honours — do not add a second check. A failed put-back (at start or on resume) writes the D20 hold (`restore_mixed`) and drops the app from the marker. Needs `SetHolds` + `SetUndoReachable` before `Recover` (main.go). Pinned by `r1251_putback_waits_test.go`, `TestR1251_DriveStartPathsPutBackBeforeTheyStart`, `TestR1251_MainWiresThePutBackWait` |
|
||||
| `Manager.RestoreTier2Unit` + `Tier2Coverage.CanRestoreUnit` / `Tier2CopyDate` (R-102/R-103, v0.229.0) | controller/internal/backup/tier2_restore.go | `(stack string) (UnitRestoreResult, error)` / `() bool` / `() (string, bool)` | THE full restore from the SECOND DRIVE's mirror, and the predicate that gates it | **Do NOT widen `CanRestore()`** — it answers only "can the additive file restore run?" and one predicate answering two questions is R-356. Fail-closed: `tier2UnitIsOpenable` requires a parseable manifest, because a directory is not a package. The single-writer flag is taken INSIDE `RestoreFromRecoveryUnitAt` — a second `acquireRunning()` here would refuse the restore it guards. `Tier2CopyDate` prefers the SUCCESS anchor over the attempt clock (R-101) and reports which it returned |
|
||||
| `unitRestoreOutcomeMsg` + its three message constants (R-353, v0.226.0) | controller/internal/web/handlers.go | `(app string, res backup.UnitRestoreResult) string` | THE customer sentence for a completed LOCAL restore | Twin of `reconstituteOutcomeMsg`; copy its SHAPE (clauses earned by having done the thing, no filesystem path, base names only), never its text. The constants are named because `r353_unit_outcome_test.go` asserts them verbatim — a silent edit is how an honest message drifts back into a comforting one, which is the documented history of the sentence it replaces |
|
||||
| `offsiteNoSpaceMsgFmt` + `offsiteSizeUnknownMsg` (R-357, v0.226.0) | controller/internal/backup/offbox_restore.go | two consts | EVERY headroom refusal on the off-site restore surface | **All four gates share these** (prepare, scratch, place, and the destructive reconstitute). A customer meeting one wording on one path and a different one on another has to work out whether it is the same problem. **The reconstitute gate uses NO ×1.1 margin** — it copies a measured tree; `OffboxRestorePrepareFull`'s ×1.1 predicts a download. **Fail closed when either probe reads ≤ 0**: `free < need` with `need == 0` is FALSE, so an unmeasurable input sails through — a gate present and inert |
|
||||
| `Manager.OffboxFullScratchReady` + the scratch marker (R-358, v0.226.0) | controller/internal/backup/offbox_restore.go | `(stack) bool`; `.felhom-restore-complete.json` | THE gate for place-to-live and reconstitute | **It answers "did the run FINISH and was it FULL", not "are there files".** The old non-empty check passed a part-copy from a failed restic run, and the old doc comment ("PlaceOffsiteRestore re-validates per-path completeness") is what made it look adequate — that call stats top-level placements, not files. Marker written 0600 atomically AFTER restic returns nil; stale one cleared BEFORE it starts; both orders pinned by an AST test because `resticStep` is not a seam. Anything else — absent, unreadable, wrong schema, `full:false` — is NOT ready, with a WARN naming which. **Unit-only and full restores write the SAME directory**, so `full` is the only separator (R-396) |
|
||||
| `JudgeRestoredUnit` + `UnitProofResult` (R-87, v0.231.0) | controller/internal/backup/r403_hollow.go | `(unitDir string) UnitProofResult` | THE question "does this app's backup contain what THIS APP should have" | **The rule has TWO parts and part 1 alone is the trap:** "everything declared is present" passes a HOLLOW unit, because a hollow unit declares nothing — the exact shape it exists to catch. Part 2 is the expectation, and it comes from the unit's **own** captured compose file (`UnitComposeDir(unitDir)` + the compose filename), NEVER from live Docker (`GetDockerVolumes` describes the running app; the snapshot may predate it). Database half is `DBServiceNames`, the same discriminator `RestoreFromRecoveryUnit` uses, so this cannot disagree with the restore path about what an app is. **The volume half is an EXISTENCE check, not a name match** — `ResolveDockerVolumeNames` derives the project from the compose file's parent dir, which inside a unit is the literal string `compose`. **THREE outcomes:** pass / fail / **cannot judge**, and the third is never collapsed. Size is never consulted (`TestR87_SizeIsNeverConsulted`) |
|
||||
| `Manager.ProveOffboxUnit` + `ProofResult` (R-87, v0.231.0) | controller/internal/backup/offbox_proof.go | `(ctx) ProofResult` | THE nightly off-site content proof — one app, its newest snapshot, restored read-only and judged | **It NEVER writes to the repository and that is asserted on the ARGV:** `--no-lock`, no `unlockStale`, and `m.runner()` rather than `resticStep` so the `unlock --remove-all` escalation is unreachable. **It takes `acquireRunning` ITSELF** because `RestoreOffboxScratch` does not (R-408) — do not remove that. **Due-ness is per SNAPSHOT** (`ProvedSnapshots[stack]`), never a timestamp: a timestamp re-proves the same snapshot forever AND breaks the rotation. **Its scratch is a SEPARATE root** (`offsiteProofRootFor`, `backups/offsite-proof`) — sharing the customer's `offsite-restore` root would let a nightly job delete a copy the customer is looking at. A skip, a missing snapshot and a restore error reach NO verdict and do not advance due-ness |
|
||||
| `unitOnlyHeadroom` + `offboxScratchDirIn` (R-87, v0.231.0) | controller/internal/backup/offbox_restore.go | `(free int64) error`; `(stack, rootFor)` | The unit-only free-space gate and the drive-preference resolver, **shared** by the customer restore and the nightly proof | Extracted rather than forked so the two paths cannot drift on the parts that must not differ — the floor, the Hungarian refusal (`offsiteNoSpaceMsgFmt`), the network-storage refusal and the R-252 wording. `rootFor` is the ONLY difference between the two scratch paths. **Fail-closed on an unmeasurable probe:** `offboxFree` returns 0 when it cannot read, and `0 < floor` refuses — the inverse of the R-357 shape where a gate went inert |
|
||||
| `Manager.CheckOffboxIntegrity` + `IntegrityResult` + `IntegrityDue` (R-359, v0.227.0) | controller/internal/backup/offbox_integrity.go | `(ctx) IntegrityResult`; `(now) (due bool, last time.Time)` | THE off-site integrity check, and the only place `restic check` is run | **It TAKES `acquireRunning` and SKIPS rather than waits — never remove that guard.** `resticStep` escalates to `unlock --remove-all` on a lock error and is only safe because every caller holds the single-flight mutex; a check without it can strip a LIVE prune's lock. **THREE outcomes, not two:** `Skipped`, `Unreachable` and failed are different facts — a timeout is unreachable, NEVER damage, and only a failure notifies. A skip and an unreachable repo do **not** advance due-ness; a failure does. **DUE-NESS, NOT A WEEKDAY** (R-341). **`looksLikeRepositoryDamage` matches PHRASES, not words** — bare `pack `/`tree `/`snapshot ` appear in restic's ordinary progress output and made a healthy run look corrupt |
|
||||
| `runOffsiteIntegrityCheck` + `integrityFailedMsg` / `integrityOKMsg` (R-397, v0.227.0) | controller/cmd/controller/main.go | `(ctx, mgr, notifier, logger, force) backup.IntegrityResult` | THE one caller of the check — the scheduled job AND the debug button both go through it | ONE function so the hand-run cannot drift from the scheduled one; `force` skips due-ness and **nothing else**. Wired to `NotifyIntegrityOK` / `NotifyIntegrityFailed`, which existed with no caller since the notifier did (sixth built-but-never-wired instance). **`ok` is severity `info` and therefore mails NOBODY by design** — a weekly success e-mail is how alerts stop being read. The customer gets a sentence; restic's output goes to the log truncated (R-379) |
|
||||
| **`SetOffboxRunner` / `m.runner()` — the restic exec seam, and it has ALWAYS existed** | controller/internal/backup/offbox.go (~L52, ~L386) | `offboxRunner func(ctx, env, args...) ([]byte, error)` | Driving ANY restic-backed path under test | **R-398 claimed there was no such seam and was WRONG — do not re-file it.** `resticStep` is not itself overridable, but the layer it calls is, and tests have driven restic paths through it since the off-site tier shipped. **Use this rather than adding a `resticStepFn`:** replacing `resticStep` would hide its `unlock --remove-all` escalation from exactly the assertions that must see it (R-359's lock-safety tests assert `unlock` never appears in any argv) |
|
||||
| `Manager.SetOffboxLatestSnapshotFn` (R-357, v0.226.0) | controller/internal/backup/offbox_restore.go | `(fn func(ctx, stack) (id string, paths []string, err error))` INIT/TEST-ONLY | Overriding the restic snapshot lookup in tests | Exists because R-357's gate could not otherwise be tested at the level that matters: reaching it requires getting past `offboxLatestSnapshot`, which shells to restic. **The assertion the seam enables is `StopStack` call count == 0** — a gate placed after the stop returns the right sentence and still takes the outage. Nil in production |
|
||||
| `CheckPlacement` + `PlacementMismatchMessage` | controller/internal/backup/offbox_placement.go | `(*RecoveryManifest, liveDrive, liveNS) PlacementCheck` / `(stack, PlacementCheck) string` | Comparing where a backup SAYS the data lived against where a restore is about to write | Pure and total — nil/empty/blank manifest all give the same honest "not known, no mismatch". **An UNKNOWN is never a mismatch** (refusing on an absence strands every pre-field unit). Compares the DRIVE only (the namespace root is derived from it), Cleaned, so a trailing slash is not a difference. The message names BOTH values on purpose |
|
||||
| `RecordedUnitForStack` + `RecordedAddress` | controller/internal/backup/offbox_placement.go | `(stack) (RecordedPlacement, RecordedAddress, bool)` | Reading back the address + data folder a backup recorded, for a reinstall prefill | Local file reads over every readable namespace root — **no network, no restic, no restore**; it exists for the NOT-INSTALLED case where `GetStackHDDPath` is `""`. **`RecordedAddress.Known()` requires BOTH halves:** an absent `SUBDOMAIN` makes the live deploy path fall back to the CATALOG default (`stacks/deploy.go:88-90`), and offering that back as "what your backup says" is a fabricated fact |
|
||||
| **`Metadata.For(lang)` + `LocalizeStacks` / `LocalizeStackPtr` / `Stack.MetaFor` (R-560, v0.257.0)** | controller/internal/stacks/metadata_i18n.go | `(lang string) Metadata`; `([]Stack, lang) []Stack`; `(*Stack, lang) *Stack` | THE only way a page may reach catalog COPY — the app description, tagline, use cases, first steps, prerequisites, deploy-field labels and descriptions, optional-config text, integration labels, data-path labels and the initial-credentials note | Reading `stack.Meta.<copy>` off the manager renders HUNGARIAN to an English household, with no error and nothing wrong-looking on the page — `TestNoDirectMetaCopyReadOnPages` keeps the named, reasoned allow-list of every direct read in `internal/web`. **`For("hu")` is the parsed struct with `I18n` cleared** and nothing else (pinned over all 53 real catalog files), so a translation cannot change the Hungarian product. **Fallback is FIELD BY FIELD** — absent or blank English shows Hungarian, which is what makes a half-translated app shippable. **Lists replace WHOLE; every other list is matched by its own key** (`env_var`, option `value`, `match_group`, `target`, `path`) — position matching mistranslates silently the first time a field is inserted. **It never writes through:** the metadata is shared by concurrent requests, so an in-place merge leaks one household's language into another's page |
|
||||
| `AppInfo.WithAddress` (R-498) | controller/internal/stacks/appinfo_address.go | `(subdomain, domain string) AppInfo` | Filling the catalog copy's `<label>.DOMAIN` / bare `DOMAIN` placeholder with the household's real address (effective subdomain + customer domain) | Returns a COPY (the metadata is shared); `\bDOMAIN\b` leaves `SUBDOMAIN`/`MAIL_DOMAIN` alone; no domain → unchanged. Apply AFTER `LocalizeStackPtr`. The deploy page still fills its post-deploy steps in JS (`deploy.html`), `knownLoginLine` its own line |
|
||||
| `Metadata.HasDeployField` | controller/internal/stacks/metadata.go | `(envVar string) bool` | "Does this app have somewhere to PUT a recorded value?" | **13 of 53 templates declare `HDD_PATH`; 40 do not** (measured 2026-08-21). For the 40 a recorded placement is a FACT TO STATE, never a value to write into a field that does not exist |
|
||||
| `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention |
|
||||
| `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
|
||||
@@ -74,10 +115,11 @@
|
||||
| `settings.HubEscrowKeySHA256` + `SetHubEscrowKeySHA256` / `GetHubEscrowKeySHA256`, and `OffsiteRecoveryOffer` **shape (c)** (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/backup/offbox.go | `(sha, checkedAt string) error` / `() (string, string)` | **THE DISCRIMINATOR the recovery screen asks** — does the hub hold a package for a key other than the one we use? | **The comparison was ALREADY computed on every ACK since SLICE 3 and persisted nowhere** — that is R-241's second half. Wire the recorder in `main.go`'s `EscrowAutoConfirmer` literal or shape (c) reads an empty hash for ever and the fix ships INERT (pinned by `TestMainWiresRecordEscrowKeyHash`). **§7.2 staleness, decided:** a KNOWN DIFFERENCE offers **however old the reading** — age is deliberately NOT gated on, because gating makes a box offline from the hub silently stop offering; an **ABSENT hash falls back to (a)/(b)** and does NOT offer, because `""` is the hub positively saying its package seals no key (legacy hash-less escrow), not an unknown. `CheckedAt` is for diagnosis, never a gate |
|
||||
| `backup.AbandonStatus` / `AbandonSweep` / `CancelAbandon` / `ClearAbandonPurgeIfConfirmed` / `ExtendAbandon` / `StopAbandon` + `AbandonGraceDays` (R-241, v0.206.0) | controller/internal/backup/offbox_abandon.go | see file | **The 14-day abandonment countdown** — the ONLY thing in the product that deletes a customer's off-site history | **BOTH HALVES OR NEITHER.** The set-aside store and the sealed package that protects it are two halves of one thing; removing only one leaves a package that opens nothing, or ciphertext nobody can decrypt. Not atomic across two machines, so it is a **two-phase commit**: delete the store, set `AbandonPurgeRequested`, and keep declaring it until the hub's ACK stops reporting a superseded package — the confirmation rides the SAME ACK as the request. **The countdown starts in `ResetOrphanedRepo`, NOT in the shared `resetOrphanedRepo`** — the helper is also the UNCLAIMED auto-reset, where nobody decided anything. **The recovery offer stays reachable for the whole grace** (a grace in which recovery is impossible is decorative). **Drive it with `SetOffboxClock`, never a shortened live timer** (§7.4). A transport failure leaves the countdown DUE so tomorrow retries; the operator levers REFUSE rather than no-op when nothing is running or the store is already gone |
|
||||
| `settings.SyncRecoveryOfferEpoch` / `PostponeRecoveryNoticeForEpoch` / `OptOutRecoveryRemindersForEpoch` + `web.recoveryBannerCookie` (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/web/recovery_handlers.go | `(offered bool, now) (RecoveryOfferView, error)` | **The offer EPOCH** — "once per entry into the offered state", not once ever | **Sync the epoch FIRST and UNCONDITIONALLY in `recoveryInterrupts`.** The first draft returned early when the offer was false, so the FALLING edge was never recorded, `RecoveryOfferActive` stayed true through a settled period, and the next entry counted as a continuation — **the exact defect the epoch exists to fix, reintroduced inside the fix**. Dismissals are recorded against the epoch they were made in, so a fresh entry resets them **by arithmetic**, with nothing to clear. **Three levers, three scopes, and NONE removes the entry point on `/backups/remote`:** the banner cookie is a browser SESSION cookie (no MaxAge — cleared on login) and persists nothing; the reminder opt-out is durable but silences the BANNER ONLY; "most nem" suppresses the full page only |
|
||||
| `Manager.keepCopy` / `releaseKeptCopy` / `RemoveKeptCopy` / `KeptCopies` + `settings.KeptCopy` (D25, R-1175/R-1181, 2026-10-21) | controller/internal/backup/tier2_kept.go, controller/internal/settings/kept_copies.go | see files | **A second-drive copy whose SOURCE is gone** (drive away/decommissioned, declared folder missing) — recorded, listed on the restore page, removed only by the household's „Eltávolítás" | **Never remove a kept copy by size, name or pattern** — `RemoveKeptCopy` takes the RECORD and checks path-inside-tree + device/inode; a swapped directory refuses. A share/leg reconcile walks ONLY its own key/legs (R-1175: walking every key with one group's legs deleted the other groups' copies). `emptiedSourceRefused` stops `rsync --delete` from emptying a full copy |
|
||||
| `stacks.SaveImportedAppConfig` + `PrepareImportedApp` (R-1191, 2026-10-21) | controller/internal/stacks/import_record.go | `(stackDir, env) (fresh bool, error)`; `(stackDir, env, fresh) error` | The `.fab` import's app record + doors (family gate, sign-up lock) BEFORE the start | Reuses `carryLifeRecords` / `prepareFamilyGate` / `restoreSignupLock` — do not copy their logic. `PrepareImportedApp` must also update the IN-MEMORY record, or the gate reconcile removes the file on its next tick |
|
||||
| `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 |
|
||||
@@ -85,11 +127,14 @@
|
||||
| `Manager.buildSharesPayload` / `classifiedShares` | controller/internal/backup/shares_payload.go | `() (dir, passdbOK, error)` / `() []classifiedShare` | the definitions+credential payload and the availability-filtered share set both tiers read | payload is SECRET-BEARING (0600 passdb.tar) — never log its bytes/name at INFO. `classifiedShares` is the single place a dead mount is dropped, so both jobs agree |
|
||||
| `Manager.selectTier2TargetFrom` | controller/internal/backup/tier2.go | `(stack, sourceDrive, fullSize, stateOnlySize) (*Tier2Target, error)` | tier-2 target choice with the source drive supplied EXPLICITLY | the seam the shares job reuses — NEVER fork the headroom math; `selectTier2Target` is now a thin wrapper over it |
|
||||
| `Manager.tier2ReconcileRoots` | controller/internal/backup/tier2.go | `(destBase, roots, legRels)` | staleness pruning with explicit dest roots | pure extraction from `tier2Reconcile` (which now calls it with `hdd`/`userdata`); reuse it rather than writing a second pruner |
|
||||
| `backup.Tier2CopyBlocksRestore` / `ClearLeftoverTier2Copies` / `MarkTier2CopyForTest` + the `tier2Docker` seam (the stray-copy fix, v0.340.0) | controller/internal/backup/tier2_copy_lifecycle.go | `(m) Tier2CopyBlocksRestore() (app, blocked)`; `ClearLeftoverTier2Copies(ctx) error`; `MarkTier2CopyForTest(app) func()` | a restore's refusal while a capped second-drive copy runs or a stray may run; the start-up removal of labelled copy containers; the test marker; `tier2Docker` is the docker seam (tests never reach real docker) | Row added 2026-10-24 (review round 2). The capped copy runs `rsync --delete` in its own container — never call `docker` directly for it; go through `tier2Docker`. |
|
||||
| `Manager.liveShareRootOK` / `scratchJoin` | controller/internal/backup/shares_restore.go | `(dst) bool` / `(scratch, abs) string` | THE place guard for shares restore + scratch path reconstruction | a snapshot is UNTRUSTED layout input: require a STRICT descendant of a live registered root, refuse `..` and the drive root itself. `scratchJoin` strips the volume name — plain `filepath.Join` splices a drive letter mid-path |
|
||||
| `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. |
|
||||
@@ -99,12 +144,17 @@
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `stacks.Manager.admitCompose` / `privpolicy.Admit` (v0.328.0, R-971) | controller/internal/stacks/allowlist.go, controller/internal/privpolicy/privpolicy.go | `admitCompose(name, data, env) error` | THE question before ANY write of an app compose (and before a settings change or restore commits an env): the box's catalog allow-list on the RENDERED compose | A new compose writer must call it first — `TestAllowList_IsWiredIntoEveryComposeWriter` fails otherwise; a refusal records the page line + one hub event per definition; the allow-list is `privpolicy/allowlist.json` (exact app+service+setting; EMPTY since v0.329.0) plus `appdata_folders` (an app's `${HDD_PATH}/appdata/<folder>` when the folder is not its name — `privpolicy.AppdataFolder`, R-1142), and the catalog's `scripts/privilege-allowlist.json` must stay byte-identical |
|
||||
| `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure |
|
||||
| `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `<project>_<key>`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only |
|
||||
| `renderDigests` / `RenderWithLadderDigests` / `CarryDigests` / `StripDigest` (v0.269.0/.1) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update; the sync for an app NOT yet installed) — an INSTALLED app's sync uses `CarryDigests` (v0.269.1), which keeps the digest the app runs | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin |
|
||||
| `mergeRestoreFiles` / `RestoreTier2Whole` (v0.269.0) | controller/internal/backup/tier2_whole.go | mirror subtree → live subtree, four rules, counts | ANY restore that brings files back over live ones | Never `rsyncMirror` (--delete) in the restore direction; never overwrite a newer live file; a replaced file's old copy stays beside |
|
||||
| `nextLadderStep` / `StepKey` / `StepFile` (v0.268.0) | controller/internal/stacks/ladder.go | `(templateDir, pinned) (LadderStep, error)` | which definition ONE guarded-update press pins (`09` §3 decision 14) | `StepKey` must equal the catalog's `ladder.step_key` (TestLadder_StepKeyMatchesTheCatalog). A missing/wrong step file is an ERROR, never a jump |
|
||||
| `Manager.RedeployFromEnv` | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | Re-up with changed env (migration flip, config edits) | `compose up -d`, never `restart` (restart won't pick up images/env) |
|
||||
| `Manager.PersistUnitRedeployConfig` (R-47, v0.153.0) | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | the PERSIST half of `RedeployFromEnv` — app.yaml + locked fields + in-memory flags, **starts nothing** | **TRAP: the restore paths must use THIS, never `RedeployFromEnv`.** RedeployFromEnv ends in a full `up -d`, which before the replay IS the H4 race. RedeployFromEnv is now literally this + the unchanged up-and-report tail |
|
||||
| `Manager.StartStackServices` (R-47, v0.153.0) | controller/internal/stacks/manager.go | `(name string, services []string) error` | scoped `compose up -d <svc>...` — the DB-only window a dump is replayed in | **REFUSES an empty list** (argument-less `up -d` is a FULL start — the one silent fall-through that would reintroduce the race). No `logPostStartStatus`: the app containers are absent on purpose. Never `RestartStack` here — it is a full up in disguise |
|
||||
| `appbackup.DBServiceNames` / `dbTypeForImage` (R-47, v0.153.0) | controller/internal/appbackup/dbservices.go | `(composePath string) ([]string, error)` | naming the compose SERVICE(s) holding a database, sorted | yaml.v3 `services:` MAP parse — **never a line scan** (immich's top-level `immich_ml_cache:` / `immich_postgres_data:` volume keys look exactly like services). `dbTypeForImage` is shared with `DiscoverDatabases`, which is what makes "a dump exists ⇒ a service can be named" hold. An error means CANNOT-TELL, never "no database" — callers refuse when a dump exists |
|
||||
| `Manager.StartStack/StopStack/RestartStack/UpdateStack` | controller/internal/stacks/manager.go | `(name string) error` | Lifecycle | Protected stacks refuse stop; all funnel through composeExec. **NOT writers of desired state (R-166)** — 14 call sites, only 2 are the customer; recording intent here would make a nightly backup indistinguishable from the customer pressing Stop. Use `SetDesiredState` at the intent point instead |
|
||||
| `Manager.StartStack/StopStack/RestartStack` | controller/internal/stacks/manager.go | `(name string) error` | Lifecycle | **`UpdateStack` was DELETED in v0.237.0 — use `StartGuardedUpdate` (update.go).** Protected stacks refuse stop; all funnel through composeExec. **NOT writers of desired state (R-166)** — 14 call sites, only 2 are the customer; recording intent here would make a nightly backup indistinguishable from the customer pressing Stop. Use `SetDesiredState` at the intent point instead |
|
||||
| `Manager.SetDesiredState` / `DesiredStateOf` / `BackfillDesiredState` (R-166, v0.189.0) | controller/internal/stacks/desiredstate.go | `(name, desired string) error` / `(Stack) string` / `() int` | THE customer-intent record — `app.yaml` `desired_state`, tri-state `""`/`running`/`stopped` | **ONE OWNER: the customer's action.** Writers are the API action switch, `DeployStack`, `UpdateOptionalConfig`'s redeploy branch, and the `.fab` restore adapter — nothing else, ever. **`""` (absent) means UNKNOWN, never "running"**: every pre-v0.189.0 app.yaml reads absent, so treating it as running would start every deliberately-stopped app on upgrade. Write intent BEFORE the act and REFUSE the act if it fails (§8.2). Backfill is **running-only** — never infer `stopped` from zero containers, that inference IS the defect |
|
||||
| `Manager.DriveLive` (R-171, v0.190.0) | controller/internal/stacks/deploy.go | `(hddPath string) bool` | is an app's data drive a live mountpoint RIGHT NOW | Wraps the **same** `isMountPoint` seam the userdata belt uses (`manager.go`) — never write a second liveness check, the two would drift invisibly. The system/local path is legitimately not a mountpoint and returns true |
|
||||
| `bootrecon.StartGate` (R-171, v0.190.0) | controller/internal/bootrecon/bootrecon.go | `MayStart(stack) (bool, reason)` | THE one question the boot sweep asks before starting anything | **Fail-safe: cannot determine ⇒ return FALSE.** One seam for all three holders (absent drive · quiesce · an in-flight app-data operation) because they differ only in the reason string. Implemented in `main.go` (`bootDriveGate`) reusing `quiesce.SuppressedStacks()`, `AppStopGuard.HeldStacks()` and `Manager.DriveLive` — never re-derive any of them. Held apps go to `Result.HeldByDrive`, **never** `StillDown` (that is the dead-app alarm's bucket) |
|
||||
@@ -112,8 +162,25 @@
|
||||
| `backup.AppStopGuard` (`Begin`/`End`/`Recover`) (R-166, v0.189.0) | controller/internal/backup/appstop_marker.go | `(opID, reason, stacks) error` / `()` / `() *AppStopRecovery` | THE crash marker for stop→work→start windows (volume dump, offbox reconstitute, `.fab` export) | Its **own** file (`appstop-state.json`), never quiesce's — one file, one writer. **A `defer` is NOT the mechanism** (Campaign 8 fault 10: SIGKILL runs no defer); the marker is. Written BEFORE the stop, cleared ONLY after a restart that succeeded; a FAILED restart deliberately KEEPS it. `Recover` RETURNS its outcome rather than notifying, because it must complete before the boot reconciler while the notifier does not exist yet |
|
||||
| `backup.AppStopGuard.SuppressedStacks` + `markStopped` / `releaseStarted` / `ReleaseFailed` (R-330, v0.224.0) | controller/internal/backup/appstop_suppress.go | `() map[string]bool` (nil-safe on a nil *AppStopGuard); `ReleaseFailed(stacks ...string)` | **R-330** — an app a PER-APP operation is holding stopped (nightly volume dump, offbox reconstitute, `.fab` export) is not a fault | The **twin** of `quiesce.Loop.SuppressedStacks` above, and the two are unioned by `unionSuppressed` in main.go before `classifyRunStates` — **consult BOTH or the bug comes back**: R-330 shipped because the alarm read only the quiesce set while the per-app legs stopped apps through a different path. Rides `Begin`/`End`, so all three call sites got it with no call-site change. Grace is `appStopAlarmGrace` = 180 s, deliberately the SAME constant and derivation as quiesce's — two windows over one alarm that disagreed would be a bug on whichever path used the shorter one. **It must never latch**, and unlike quiesce's loop `End()` runs ONLY on a restart that succeeded: (1) every failure path calls `ReleaseFailed`, which drops the entry IMMEDIATELY so the app alarms on the next scan; (2) `Begin` REPLACES the set (one marker file = one operation); (3) `appStopMaxHold` (6 h) caps an open-ended hold and logs at WARN. **Deliberately NOT persisted** — after a crash the guard holds nothing and a down app must alarm. `ReleaseFailed` drops the suppression and KEEPS the durable marker; the two are independent and a test pins that |
|
||||
| `backup.ErrStartRefused` + `AppStopRecovery.Refused`/`Alarming()` (R-174, v0.191.0) | controller/internal/backup/appstop_marker.go | `errors.Is(err, ErrStartRefused)` / `() bool` | THE refusal-vs-failure split in the app-stop crash recovery | **A gated starter's refusal is NOT a restart failure.** `Recover`'s starter MUST be the gated `gatedAppStopStarter` (cmd/controller/main.go), never the raw `stacks.Manager` — that was the v0.189.0 defect, which started apps onto ABSENT drives at boot (R-171 one path over). A refusal goes to `Refused` (marker KEPT, silent), a real error to `Failed` (marker kept, ALARMS). Collapsing them routes a deliberate hold into `NotifyBackupFailed`, a customer-enabled type — the R-171 false alarm again. `main.go` must guard the notify with `Alarming()`, not `!= nil` |
|
||||
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll |
|
||||
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never a global (`cfg.Paths.HDDPath` was deleted in R-492); a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** |
|
||||
| `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix |
|
||||
| `Manager.recordInstalledImages` (v0.233.0) | controller/internal/stacks/installed.go | `(name, stackDir string, env []string)` | writing down what each compose SERVICE is ACTUALLY running, into `app.yaml.installed_images` | Called after a successful compose up from `StartStack`/`RestartStack`/the deploy path/the end of the guarded update and of the undo (`UpdateStack` was deleted in v0.237.0). **Reads the CONTAINER, never `docker-compose.yml`** — that file is the value the syncer has already moved (spike §3: 25 minutes of disagreement). **A failed write NEVER refuses the action** — the deliberate OPPOSITE of `SetDesiredState`: intent refused, observation logged at ERROR. **NOT from `StartStackServices`** (the R-47 DB-only window would overwrite a complete record with a partial one). Skips the write when ref+digest are unchanged, and carries `at` forward so it means "running since". Its OWN seam (`installedExecFn`) with a **context + 30 s timeout** — the two existing exec helpers have neither |
|
||||
| `Manager.SetPin` / `AdoptPins` / `RenderPlanFor` / `AppliedComposePath` (v0.235.0) | controller/internal/stacks/pin.go | `SetPin(name, stackDir, pin, composeSrc) error` | THE version freeze — `app.yaml.pinned_images` + the stored `applied-compose.yml` | **`PinnedImages` is INTENT, `InstalledImages` is an OBSERVATION — never feed one from the other** (the R-166 category error, one field over). Four writers only: deploy, the guarded update (via `advancePinToCatalog`, which advances the pin and re-renders BEFORE the pull, and REFUSES the update if the pin cannot be written; a failed pull puts the pin BACK via `SetPin`), the restore adapter (this is what closes R-441), and `AdoptPins`. `AdoptPins` reuses `observationCoversTemplate` — do NOT write a second completeness rule — and skips loudly rather than inventing a pin. Absent pin = pre-v0.235.0 behaviour |
|
||||
| `Manager.UpdatePreflight` / `StartGuardedUpdate` / `RecoverUpdates` / `ResumeInterruptedUpdates` (v0.237.0) | controller/internal/stacks/update.go | `UpdatePreflight(name) *UpdateRefusal`; `StartGuardedUpdate(name) error` | THE update — refusals, then a 202 job with phases on `Stack.Updating/UpdatePhase/UpdateError` | **Never report an update complete before health is known (R-443).** Every cheap refusal runs BEFORE the intent write. Safety dump BEFORE the pin moves; pin BEFORE pull; pull failure → pin back; health failure → stop + HOLD, pin stays. Journal-before-mutate (`update-journal.json`); `RecoverUpdates` MUST run before the boot sweep and `ResumeInterruptedUpdates` AFTER `SetUpdateGuards`. Seams: `updateComposeFn`, `updateHealthFn`, `updateMemoryFn`, `updateDiskFreeFn`, `updateNowFn` (R-457: the age check and the test read ONE clock). Unwired guards ⇒ every update refused |
|
||||
| `stacks.UpdateGuards` + `updateGuardsAdapter` (v0.237.0; tiers v0.239.0) | controller/internal/stacks/update.go, controller/cmd/controller/main.go | `HoldFor`, `Busy`, `RestorePoints`, `CanBackUp`, `BackupNow`, `SafetyDump`, `HoldAfterFailedUpdate` | the ONLY bridge from the update job to the backup side (stacks cannot import backup) | Wired by `stackMgr.SetUpdateGuards` — pinned by `TestSlice4_UpdateGuardsAreWiredAtStartup`. Add a guard HERE, never by importing backup into stacks |
|
||||
| `backup.Manager.Tier2UnitRestorePoint` + `Tier2RestorePoint.ProvenCopyTime` (v0.237.0) | controller/internal/backup/update_guard.go | `(stack) (Tier2RestorePoint, error)` | "can this app be restored from Tier 2, and from when" — the predicate that gates BOTH the „Teljes visszaállítás" action and an update | **ONE predicate, two callers** (extracted from `buildAppBackupRows`, not copied). `CopyDate` is what the page NAMES (the package date, R-403); `ProvenCopyTime` is how OLD the data is — the last successful copy, because the manifest's `created_at` moves only when the DEFINITION changes (measured: a fresh dump under a 22-h-older manifest). Do not age a copy by `CopyDate`. **Since v0.239.0 the UPDATE no longer calls it directly** — it goes through `UpdateRestorePoints` (next row); the page still does |
|
||||
| `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. **R-645: every night leg (DB dump, volume dump, capture, Tier-2) also skips an app whose pin is not what it runs (`versionSkip`, wired by `SetVersionCheck` → `stacks.Manager.PinNotRunning`); unknown never skips. The page line is `VersionSkipFor`** |
|
||||
| `stacks.Manager.memoryVerdict` (v0.237.0) | controller/internal/stacks/deploy.go | `(newReq, newLimit, releasedReq, releasedLimit int) (refusal, warning string)` | the deploy's memory check, shared with the update | An update RELEASES the app's current request first. Deploy passes `0, 0` and is byte-identical in wording and log line |
|
||||
| `Syncer.SetRenderPlanFn` + `renderSource` (v0.235.0) | controller/internal/sync/sync.go | `func(appName string) stacks.RenderPlan` | the catalog render table | **NIL-SAFE: no seam = copy verbatim = the old product.** Catalog images == pin → verbatim (fixes flow + self-healing, both deliberately kept); differ → the WHOLE stored definition, **never a ref substitution into a newer template** (`wger 2.6`). `.felhom.yml` always verbatim (R-458). The syncer must NEVER read app.yaml. Re-reads the applied file before writing it — a test caught it writing an empty compose over a live app |
|
||||
| `Stack.CatalogImages` vs `Stack.TemplateImages` (v0.235.0) | controller/internal/stacks/manager.go | both `map[string]string` | badge input vs "what the next `up -d` gives this app" | **THE TRAP: same type, same shape, opposite meaning after the freeze.** `TemplateImages` reads the LIVE (possibly frozen) compose file; `CatalogImages` reads the syncer's clone. `web.compareInstalledToTemplate` MUST use `CatalogImages` or it answers „Naprakész" on exactly the apps that are behind, with every test green. Red-proved |
|
||||
| `Manager.BackfillInstalledImages` (v0.234.0) | controller/internal/stacks/installed.go | `() int` | seeding `installed_images` for apps that have NO record — call ONCE at startup | Beside `BackfillDesiredState` in `cmd/controller/main.go`, after it and BEFORE the boot reconciler (pinned by an AST-walking test that asserts the ORDER). **READS only** — starts nothing, writes no compose file. **Never overwrites an existing record** (an app that has one is not even observed). **REFUSES a partial observation** (`observationCoversTemplate`): `web.compareInstalledToTemplate` reads a service-count mismatch as BEHIND, so seeding a degraded app from what is visible renders „Frissítés elérhető" over an app that is current. The bring-up paths may write a partial because they follow a SUCCESSFUL `up -d` where a gap is real news; a backfill meets any state and must be stricter |
|
||||
| `stacks.ParseComposeImages` (v0.233.0) | controller/internal/stacks/installed.go | `(composePath string) (map[string]string, error)` | compose SERVICE name -> the image the FILE pins; feeds `Stack.TemplateImages` and the update badge | yaml.v3 `services:` MAP parse, never a line scan (same reason as `DBServiceNames`). An error means CANNOT-TELL — `ScanStacks` leaves `TemplateImages` nil and the badge renders NOTHING, never "current" |
|
||||
| `stacks.Manager.AnyUpdating` / `SetSelfUpdatingCheck` + `selfupdate.Updater.SetAppUpdatingCheck` (v0.261.0, R-608) | controller/internal/stacks/update.go, internal/selfupdate/updater.go, wired in cmd/controller/main.go | `() bool` callbacks, both directions | THE two-way lock between the CONTROLLER's own swap and a guarded APP update | **Wire BOTH halves or neither** — they are wired together in `main.go`, the only place holding both objects; **`stacks` must never import `selfupdate`**. The app-update side is a sibling of `SetBackupRunningCheck` and is consulted in the SAME three places (dry run, `TriggerUpdate`, `maybeAutoUpdate`) — do not add a fourth pattern. **`AnyUpdating` MUST answer false for a HELD app** (`Stack.Updating` is cleared on done/failed/held): a latching gate would block the controller's own updates for ever, including the one that fixes the hold. Nil callbacks are SAFE and mean pre-v0.261.0 behaviour — never fail closed on an unwired gate |
|
||||
| `stacks.CatalogOrder` / `CompareImageRefs` (v0.260.0, R-524) | controller/internal/stacks/updateorder.go | `(Stack) UpdateOrder` — Unknown/Current/Behind/**Ahead** | THE one "how does this app stand against the catalog?" verdict | **Both the badge AND `Manager.UpdatePreflight`'s `downgrade` refusal read it — never re-implement the comparison.** `web.compareInstalledToTemplate` is a thin wrapper. Ahead is NARROW: every differing service must be orderable AND newer, else Behind. Ordering is `util.Version.Compare` behind a tag normaliser (`X.Y`/`X.Y.Z`, optional `v`, suffix must be IDENTICAL on both sides) — **never add a second comparator**. Queries NO registry; absent record = Unknown, never „Naprakész" |
|
||||
| `web.updateBadge` / `updateBadgeAt` / `Metadata.CatalogSince` + `CatalogSinceAge` (v0.233.0) | controller/internal/web/updatebadge.go, controller/internal/stacks/metadata.go | `(stacks.Stack) *MetaBadge` | THE "is this app current?" label — „Naprakész" / „Frissítés elérhető — N napja" | The SECOND `*MetaBadge` user the type was built for: existing `meta_badge` partial, **no new markup or CSS**. **NO RECORD RENDERS NOTHING — absent means UNKNOWN, never current** (R-166 applied to an observation; red-proved). **No version number reaches the customer** and **no registry is queried**. `catalog_since` is tolerant in the `lifecycle` style — absent/empty/malformed/**future** all degrade to a badge with no age + one WARN. LIMITATION: for the **10** floating pins (recounted 2026-09-21; the old "23" matched no definition the catalog supports) the ref can match while the image has moved — 6 of the 7 measurable ones HAVE moved — so those read „Naprakész" when they may not be |
|
||||
| `Manager.logPostStartStatus` | controller/internal/stacks/manager.go | `(name, stackDir, env)` | Async post-start verification | compose up exits 0 on crash-loops; this is the detection. Goroutine + 3s, never blocks |
|
||||
| `Manager.EnsureBaseStack` | controller/internal/stacks/infra.go | `() error` | Traefik/cloudflared/FileBrowser infra convergence | Renders from `internal/infra` templates |
|
||||
| `appbackup.ClassifyBinds` / `ValidateBackupSpec` | controller/internal/appbackup/classify.go | `(spec, binds) ([]ClassifiedBind, bool)` / `(spec, binds) error` | Backup-classification (Task 2, referential coupling) — pure | Two-level default: explicit wins over `:ro`; unlisted writable→mandatory, unlisted `:ro`→excluded; nil spec→legacy/false. Validate REJECTS the WHOLE block on any defect (whole-block semantics). INERT — no tier consumes it yet |
|
||||
@@ -122,9 +189,13 @@
|
||||
| `Manager.ClassifiedBinds` + `StackDataProvider.GetStackClassifiedBinds` | controller/internal/stacks/metadata.go, appbackup/appdata.go | `(name) ([]appbackup.ClassifiedBind, bool)` | Per-stack classification through the REAL LoadMetadata validate path | The wired seam Task 3 consumes; LoadMetadata is the SINGLE validation choke point (bad block → nil + one ERROR → legacy) |
|
||||
| `backup.Manager.DumpAppVolumesSafe` | controller/internal/backup/backup.go | `(stackName) error` | Volume tar of a live app | Stops → dumps → restarts; surfaces BOTH errors (app may be left stopped). Check `GetDockerVolumes()!=0` + `IsProtectedStack` BEFORE calling — it stops the stack before its own volume check (see `runVolumeDumps`) |
|
||||
| `backup.Manager.ListRestorePoints` | controller/internal/backup/restore_points.go | `(stackName) ([]RestorePoint, bool)` | Restorable keep-side backups (the /api/backup/snapshots payload) | ONE point per app (the current unit); tier always 1 — never list Tier-2 (not restorable via /backup/restore) |
|
||||
| `backup.Manager.stampDataFile` / `foldUnitData` / `unitNewestArtifact` / `RecoveryManifest.Data` (v0.275.0, R-696) | controller/internal/backup/data_versions.go, removed_units.go | `(stack, unitDir, rel)` / … / `(unitDir) (time.Time, bool)` | A backup's data and its version travel together: the leg stamps each file it writes, the capture folds the stamps into `data` and keeps the definition the data belongs to; `unitNewestArtifact` is THE unit data time for every tier and list | **A new writer of a unit's data file MUST call `stampDataFile` right after the file reaches its final name** — an unstamped file makes the whole unit's versions unknown (restores as before, WARN). Never date a unit by its manifest; never count `pre-restore-*` as data. `definitionPins` is the ONE source of "current pins" (the stack's compose parse) |
|
||||
| `backup.unitVersionCheck` / `ErrUnitVersionMismatch` (v0.275.0) | controller/internal/backup/restore_unit.go | `(stack, *RecoveryManifest, composeDir) ([]string, error)` | A restore never starts data with a definition it does not belong to — the check every unit restore and the off-site restore make before the first mutation | Branch with `errors.Is`; the sentence is the bundle's (`err.backup.unit_version_mismatch` / `_versions_mixed`). Unknown (`nil, nil`) restores as before |
|
||||
| `backup.Manager.RestoreTier2Files` | controller/internal/backup/tier2_restore.go | `(stackName) (filesRestored int, err error)` | In-place ADDITIVE-ONLY class-C file restore from the recorded Tier-2 copy (`POST /backup/tier2/restore`) | Never overwrites/deletes live files; refusals (Hungarian) before any stop; source = recorded `DestinationPath`, never re-selected. **C9-F1 (v0.183.0): reads `hdd/` + `userdata/` ONLY — never `recovery-unit/`.** For 43 of 53 catalog apps that is a guaranteed no-op, so it now refuses with `ErrTier2NoRestorableData` BEFORE stopping the app. Ask `Tier2RestoreCoverage` first |
|
||||
| `backup.Manager.Tier2RestoreCoverage` | controller/internal/backup/tier2_restore.go | `(stackName) (Tier2Coverage{Legs, HasUnit}, error)` | Answers what a Tier-2 restore CAN and CANNOT return for an app, from the RECORDED copy on disk | **C9-F1.** `Legs` = subtrees the restore reads; `HasUnit` = the copy also holds DB dumps + volume tarballs it will NEVER read. Use it to refuse up front and to decide whether the success message must disclose uncovered data. Judged from the copy, not the catalog, so a retemplated app is judged by what it actually has |
|
||||
| `Manager.acquireRunning`/`releaseRunning`, `acquireMigrating` | controller/internal/backup/backup.go, controller/internal/stacks/migrate.go | `() error` | Single-flight for long ops | Copy this mutex-flag pattern for any new long-running manager op |
|
||||
| `stacks.Manager.WantsStopped` (v0.283.0, R-721) | controller/internal/stacks/desiredstate.go | `(name) bool` | The household's recorded intent is „stopped" | ASK IT before any machine restarts an app it stopped for its own purpose (quiesce, volume dump, update leg) — a Stop pressed meanwhile must hold. Never gate the household's own Start on it |
|
||||
| `settings.DefaultOffboxOnForNewApp` / `AppsWithoutOffbox` / `EnableOffboxFor` (v0.283.0, decision 50) | controller/internal/settings/offbox_default.go | `(stack) (bool, why)` | A fresh install joins the off-site copy when the customer has off-site | Call only from the deploy-done hook; it never overrides an existing per-app choice |
|
||||
|
||||
### Secrets hygiene
|
||||
|
||||
@@ -142,6 +213,12 @@
|
||||
| `maskRepoURL` | controller/internal/sync/sync.go | `(url) string` | Logging git URLs | Strips embedded credentials |
|
||||
| `metrics.RedactLine` | controller/internal/metrics/redact.go | `(s string) string` | ANY log line shipped off-box (issue context, log tails) | Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative |
|
||||
| `settingsRetrievalPasswordRevealHandler` | controller/internal/web/handlers.go | `POST /settings/retrieval-password/reveal` | **THE PATTERN for showing a secret in the UI** — an XHR that returns only the value | **Never template a secret into a page and hide it with CSS.** `display:none` / `hidden` / `type="password"` stop a browser DRAWING the value; the plaintext is still in the response body, so a `curl` of the page returns it, and it reaches caches, history and any screen-share of the source. R-249 shipped exactly that for two months and was found by it landing in a transcript. The page carries a **boolean** (`HasRetrievalPassword`); the value comes from a POST (CSRF-covered, uncacheable) and the reveal is **logged as an act**. `escrow_handlers.go` states the same rule for R. **Test on the RESPONSE BODY** — a test asserting what the customer *sees* cannot see this class at all. **Both R-254 sites are now FIXED the same way** — `POST /apps/<slug>/initial-credentials/reveal` (re-reads the container, never a cached copy) and `POST /stacks/<name>/auto-field/reveal` (authorised on the field being a `type: secret` auto-field of that stack). **Per-secret, never one generic reveal-any-named-secret endpoint.** The PRE-DEPLOY hidden input is deliberate and untouched — a form must carry what it submits (README §318). Enforced by `scripts/secret_in_markup_gate.py`, whose measured blind spot (a secret under a neutral page-data key) is in its docstring; runtime body-assertion covers 4 of 27 pages — R-255. |
|
||||
| `storedValuePresent` / `appLoginFields` | controller/internal/web/app_login.go | the settings tab's login panel and „Cím és kulcsok" rows (v0.319.0) | **A field the box holds no value for is a sentence, never an eye.** `storedValuePresent` is the ONE definition of „stored", read by the page AND by `appAutoFieldRevealHandler`'s 404 branch — so the page cannot offer a reveal the endpoint refuses. The page gets a boolean per login field, never the value (R-254/R-709). Pinned by `TestAppPage_NoStoredValueShowsSentence`. |
|
||||
| `loginAnswerFor` | controller/internal/web/app_login.go | the settings tab's ONE login answer (v0.320.0) | `fields` · `initial` · the catalog's `app_info.login_kind` via `AppInfo.EffectiveLoginKind` (`first_visit` default; an unknown value reads as it). An infra stack with no login gets no panel. Pinned by `TestAppPage_EveryInstalledAppHasOneLoginAnswer`. |
|
||||
| `filesButtonLink` / `mediaFolderCards` / `drivePresent` | controller/internal/web/datapath_card.go | the „Fájlok ↗" target and the media folder rows (v0.320.0) | **A folder whose drive is not bound into FileBrowser now is TEXT, never a link** — `drivePresent` reads `stacks.PresentMediaDrives` (the same gates `buildFileBrowserPaths` applies); tests seam it with `Server.presentDrivesFn`. Links only through `fileBrowserOpenLink` (R-1100; the folder path built by `fileBrowserLink`). Pinned by `TestAppPage_FilesButtonTarget`, `_UnknownSourceIsTextNotLink`. |
|
||||
| `infraMetaIn` | controller/internal/web/inframeta.go | an infra stack's curated name/description in a language | The ONE place for both the `infraMeta` template func and the app page. |
|
||||
| `Manager.FileBrowserStoredPasswordRefused` | controller/internal/stacks/filebrowser_password.go | is the stored FileBrowser password still valid (v0.321.0, R-1101) | TRUE only on FileBrowser's own 401/403 in the `generated` state; FileBrowser not answering is FALSE (an unproven refusal must not tell the household its password changed). 3 s bound. Web seam: `Server.fbStoredRefused`. |
|
||||
| `Manager.ResetFileBrowserAdminPassword` | controller/internal/stacks/filebrowser_password.go | FileBrowser's admin password without the old one (v0.320.0) | Only in `operator`; holds `infraMu` (the base tick must not start the stopped server), and the web handler holds `fileBrowserMu` (the mount sync must not recreate it). Stop → `docker run --rm --volumes-from filebrowser … set -u admin,<pw>` → start (detached from the request's cancel) → verify → store encrypted. TRAP: the CLI opens the DB with no lock timeout — never run it against a running server. Seam: `fbSetPassword`. |
|
||||
|
||||
### Storage registry + mount detection
|
||||
|
||||
@@ -152,6 +229,10 @@
|
||||
| `registerStoragePath` | controller/internal/web/storage_handlers.go | `(where, label, setDefault) error` | Post-enroll registration | The single funnel used by init/attach/manual-add |
|
||||
| `system.IsMountPoint` / `IsWritable` / `PathsOverlap` | controller/internal/system/mounts_linux.go | `(path) bool` | Mount checks | `_other.go` stubs return permissive values — Linux behavior is the real one |
|
||||
| `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) |
|
||||
| `backup.Manager.diskRelation` | controller/internal/backup/backup.go | `(a, b) (DiskRelation, reason)` — DiskDifferent / DiskSame / DiskUnknown | EVERY Tier-2 or one-copy "same disk?" decision (v0.317.0, felhom.eu `16-storage-physical-identity.md` §4) | Asks the host agent (`SetDiskJudge`, FeatureSameDisk ≥ agent 0.157.0); an older agent or an `unmapped` path → the old `sameDevice`/st_dev rule. Never call `sameDevice` directly for a Tier-2 decision — st_dev calls two partitions of one disk "different". Pinned by diskrelation_test.go |
|
||||
| `web.Server.ensureInternalPlace` / `refuseInternalLifecycle` | controller/internal/web/internal_place.go | drive-gate tick hook; `(w, r, where) bool` | the internal data place (v0.318.0, `16` §5): bind on one tick, register (kind internal, default when none) on the next; refuse eject/decommission/wipe | Only an agent row of kind `internal` is enrolled without a press — a household drive never is. Pinned by internal_place_test.go + stacks d4_needs_drive_test.go (a media app lands on it) |
|
||||
| `web.Server.offerRelationFor` | controller/internal/web/backup_target_offer.go | `(ctx, drive) offerRelation` (sameDiskFn seam) | the whole-box backup offer's disk question (R-1084) | Older agent → today's offer; an error → no promise. Pinned by r1084_offer_identity_test.go |
|
||||
| `web.withoutSystemVolume` / `deviceBacksRegistered` / `freePartitions` | controller/internal/web/attach_sources.go, agent_disk_handlers.go | pure filters | R-1082 (sys_drive never offered, by device) and partitions (`16` §6: free parts of an in-use disk stay, the whole disk never; server-side refusal) | Pinned by r1082_sysdrive_hidden_test.go + partitions_test.go |
|
||||
| `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — |
|
||||
| `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var |
|
||||
| `stacks.ExportDataMounts` | controller/internal/stacks/delete.go | `(composePath, hddPath) []string` | THE .fab-export mount discovery (v0.130.0 C6B-F1) | Unions `${HDD_PATH}` binds + the `${USERDATA_PATH}` ROOT (single `userdata` entry — basename must round-trip the import's `<HDD_PATH>/<subdir>` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it |
|
||||
@@ -168,7 +249,7 @@
|
||||
| `agentapi.New` | controller/internal/agentapi/client.go | `(endpoint, token, fingerprintHex) (*Client, error)` | Pinned-TLS client to felhom-agent | Leaf-DER SHA-256 pin replaces chain verify; fails closed. Bounded idle pool (leak fix) |
|
||||
| `Server.agentClient` | controller/internal/web/agent_disk_handlers.go | `() (*agentapi.Client, error)` | THE memoized client accessor | Always use this, never a fresh `agentapi.New` per request (§3) |
|
||||
| `Server.ProbeAgentChannel` | controller/internal/web/agent_disk_handlers.go | `(ctx) (constructionErr bool, err error)` | Channel health probe | Probes via the PRODUCTION client on purpose (self-heals, mirrors UI) |
|
||||
| `Client.FormatDisk` | controller/internal/agentapi/client.go | `(ctx, device, fstype, confirmed, durableID)` | ONLY format/wipe entry | Sentinels: `ErrNeedsConfirmation` (user-data, resubmit confirmed+durableID) / `ErrFormatRefused` (system/backup — operator opsign only). Agent re-checks role server-side |
|
||||
| `Client.FormatDisk` | controller/internal/agentapi/client.go | `(ctx, device, fstype, confirmed, durableID)` | ONLY format/wipe entry | Sentinels: `ErrNeedsConfirmation` (user-data, resubmit confirmed+durableID) / `ErrFormatRefused` (system/backup — operator opsign only) / `ErrNoDurableID` (v0.329.0, R-1069: a blank disk with no durable id, e.g. virtio without a serial — show `disk.err.no_durable_id`). Agent re-checks role server-side. `Client.Decommission` returns `*DecommissionRefusedError{Status 403|409}` for the agent's two policy refusals (R-1063) — ask the agent BEFORE marking anything |
|
||||
| `Client.EjectDisk` / `Decommission` / `AssignDisk` / `GuestAttach` / `ListCandidates` | controller/internal/agentapi/client.go | disk lifecycle | Delegate ALL disk ops to agent | Controller holds no Proxmox creds — never shell out to disk tools in-guest |
|
||||
| `Client.AddNetStorage/ListNetStorage/RemoveNetStorage` | controller/internal/agentapi/client.go | NAS mounts (A1) | Network storage | Password passes through to agent's 0600 cred file; controller NEVER persists it |
|
||||
| `agentapi.StatusError` | controller/internal/agentapi/client.go | `{Path, Code}` typed non-2xx GET error | Distinguishing HTTP statuses from transport errors (`errors.As`) | NEVER string-match agent error text — the capability probe keys on `Code==404` |
|
||||
@@ -185,34 +266,48 @@
|
||||
|---|---|---|---|---|
|
||||
| `Notifier.PushEvent` | controller/internal/notify/notifier.go | `(eventType, severity, message, details)` | Hub events | Async goroutine, 3 attempts/3s backoff. NEW event types MUST be added to hub `allowedEventTypes` or POST /event 400s. **SEVERITY IS AN EXACT WIRE CONTRACT: `{"info","warning","error","critical"}` and nothing else.** The hub silently COERCES any other string to `"info"` (`hub/internal/api/handler.go`, the ingest severity switch) and `severityNotifies` (`hub/internal/notify/dispatcher.go`) emails only warning/error/critical — so a typo'd severity is stored and delivered to NOBODY, with no error anywhere. **`"warn"` is not a severity.** It shipped on `disk_health_degraded` (fixed v0.215.0, R-328) and is STILL live on `app_start_failed` (R-329) |
|
||||
| `notify.DiskAlert` / `DiskAlertKind` / `DiskAlertKind.Severity()` | controller/internal/notify/notifier.go | `NotifyDiskHealthDegraded(DiskAlert)` | The disk-health alert payload + its five Hungarian message shapes | The notifier owns customer copy — pass a `DiskAlert`, never a pre-formatted string, or Hungarian scatters across packages. `Severity()` is the ONE mapping kind→hub severity and is exported so any package can assert the contract instead of duplicating the literal |
|
||||
| `Notifier.PushEventHousehold` (v0.309.0, R-521 F7) | controller/internal/notify/notifier.go | `(eventType, severity, message, household, details)` | A producer that renders BOTH languages itself because its sentence carries figures it computed (fillwatch) | Prefer `pushEventMsg` (one key, both renderings) inside notify; this is for a sentence built outside. `household` "" for a Hungarian household. Registered in the R-329 severity contract (`severityArg`) |
|
||||
| `Notifier.Notify*` convenience methods | controller/internal/notify/notifier.go | typed wrappers (backup/DB/storage/channel/DR…) | Standard events | Add a typed wrapper rather than raw PushEvent calls |
|
||||
| `report.BuildReport` / `Pusher.Push` | controller/internal/report/builder.go + pusher.go | periodic hub report | Box→hub reporting | ACK carries `config_version` → `ConfigRefresher.Reconcile` |
|
||||
| `report.Trigger` (`NewTrigger`/`Fire`/`Run`) | controller/internal/report/trigger.go | `Fire()` after a hub-relevant user action | THE out-of-cycle report push (v0.139.0) — fire via `api.Router.reportPushNow` / `web.Server.reportTriggerNow`, both nil-safe | Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the `internal/sync` REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit |
|
||||
| `report.SetPendingLogTails` + `buildLogTailsSection` | controller/internal/report/logtail.go | ACK `log_tail_requests` → next report `log_tails` | THE pull-based ACK-flag pattern (hub asks, controller pushes next cycle) — copy for any new hub→box request | Consume-once drain at BuildReport; failed push re-arms from the hub's still-pending request; NEVER add a hub→controller push channel |
|
||||
| `metrics.FetchContainerLogTail` | controller/internal/metrics/logscanner.go | `(name, tailLines) (string, error)` | Raw per-container `docker logs --tail=N` | 15s timeout; caller caps/redacts (capTailLines) |
|
||||
| `ConfigRefresher.Reconcile` | controller/internal/report/config_refresh.go | `(ackVersion int)` | Pull-based config refresh | Re-pulls controller.yaml (re-merging local_api), then graceful self-restart; first-run = baseline, no restart |
|
||||
| `offsiteapply.HubRegistrar` / `HubWindowClient` / `PinnedProber` (v0.289.0, decisions 68–69) | controller/internal/offsiteapply/seams.go | `Register(ctx,pub)(fp,err)` · `Confirm` · `MoveAside` · `Open/Close` window · `Probe(ctx,host,user,port,kh,privPEM) bool` | EVERY off-site key install, the hub move-aside, the clean-up window | **The box never handles the sub-account password** — there is no consume path any more. `PinnedProber` is a POSITIVE observable (exit 0 + rclone output); "authenticates" is NOT enough — an unpinned key authenticates and can delete. |
|
||||
| `Manager.offsiteWindowRetention` + `offsiteGuard` (v0.289.0, R-822) | controller/internal/backup/offbox_window.go | `(ctx, base, env, why)` · pure `(all, plan, now, newestAllowed, max) (ids, refuse)` | THE retention step for both callers (after a run, over quota) | Pinned tier: no window → nothing deleted; the guard runs BEFORE any forget; "young" = fewer than `keepDaily` CALENDAR days old (v0.294.0, R-867 — the line is built from the same constants as `retentionPolicy`; never an hour count); a young snapshot superseded the same day is EXCLUDED, any other young removal / future date / plan above `max_remove` REFUSES; tests run restic 0.14.0's policy itself (`restic0140Plan`, proven identical to the binary); forget is by explicit ids, oldest first. A due abandonment goes to `OffsiteAbandonClient` (hub, 7-day wait). NAS tier: the old SP-2 policy, unchanged. |
|
||||
| `offsiteapply.SettleProvider` / `SettleFunc` / `Bridge.AwaitSettle` / `ReconcileWhenSettled` (R-71a, v0.162.0) | controller/internal/offsiteapply/offsiteapply.go + seams.go | `SettleState() (version, floor string, updateRunning, floorKnown bool)` | THE settle-gate: defers the offsite one-time-password consume past a managed day-0 floor-update (the F10 race). Wire the `SettleFunc` adapter over `updater.GetFloor()`/`IsUpdateRunning()` — **the updater's knowledge is the ONE floor source; never fetch the floor a second way**. Gate ONLY the bridge goroutine, and only when an updater exists (nil `Settle` = reconcile immediately). Bounds `settlePoll`/`settleFloorSubBound`/`settleOverallBound`; the floor is in-memory (report-ACK-derived, ~5–10 s), NOT persisted → unknown until the first ACK on any restart. Inject `Now`/`Sleep` in tests (no real sleeps). B′: at/above-floor GOes on the first poll, zero wait. Do NOT touch the consume/persist order or the 404 contract — ordering only |
|
||||
| `bootstrap.MaybeIngest` / `RefreshConfig` | controller/internal/bootstrap/bootstrap.go | bootstrap.json → controller.yaml | Day-0 + refresh | Overwrites controller.yaml, NEVER settings.json |
|
||||
| `api.GracefulSelfRestart` | controller/internal/api/selfrestart.go | `(logger)` | Controller self-restart | Detached exit; bootstrap unit re-runs the image |
|
||||
| `settings.NotificationGroups` + `NotificationGroupState` / `NotificationPrefs.GroupState` (R-388 slice 1, v0.309.0) | controller/internal/settings/notif_groups.go | `(enabled []string, key) "on"\|"off"\|"custom"` | THE `08` §8.1 group membership of every customer-switchable detector — the notification page's three switches | A group is a VIEW over `enabled_events`, never a stored field. A new detector with a page switch MUST join exactly one group (`TestR388_EveryPageSwitchIsInExactlyOneGroup` fails otherwise). The save rule lives in `Server.applyNotificationGroupSwitches`: only a MOVED group switch overrides the per-detector switches — an untouched „egyedi" group is saved back unchanged |
|
||||
| `Settings.AddPendingEvent/DrainPendingEvents` | controller/internal/settings/settings.go | offline event queue | Events while hub unreachable | — |
|
||||
| `Manager.SetUnitNotify` + `UnitSpace` (R-158/R-167, v0.191.0) | controller/internal/backup/recovery_unit.go | `(func(stack string, err error, *UnitSpace))` | THE per-app Tier-1 recovery-unit capture failure alert — fires PER APP from `captureAllRecoveryUnits`, loop continues | **OPERATOR-TIER** (`recovery_unit_capture_failed`, in the hub's `operatorOnlyEvents`). **NEVER route it to `backup_failed`** — that type is in `DefaultEnabledEvents` and carries Hungarian copy, so it emails the CUSTOMER about a failure they cannot act on (D-c; R-158's own proposal said `backup_failed` and D-c overrides it). `UnitSpace` is **nil when the target filesystem is unreadable** and renders as *"unavailable"*, never as zeros — "0 GB free" and "we could not look" are opposite diagnoses. No controller-side cooldown: the hub owns it |
|
||||
| `Manager.beginRunSummary` / `noteFailure` / `noteAttempted` / `emitRunSummary` / `SetRunSummaryNotify` (R-182, v0.194.0) | controller/internal/backup/runsummary.go | `(kind, runID) func()` / `(app, leg, reason)` / `(RunSummary)` | **THE per-run operator digest.** One `backup_run_failures` event at the end of a run listing every failed app, its leg and its reason — emitted ONLY when something failed | **The RECORD and the NOTIFICATION are different things and must stay so.** The per-app `recovery_unit_capture_failed` event is the record (hub routes it *record-only*, stored + logged every time); this digest is the notification. Before R-182 one event was both, and did neither: nine arrived, two were mailed, seven vanished before `LogNotification`. **Lifetime is `admissionSet`'s exactly** — absent collector means "no run in flight", never a stale answer. **A refusal is noted ONCE, inside `admitApp` where the verdict is taken**, not at the three legs that consult it: R-181's one-verdict-covers-all-three contract makes per-leg noting produce "2 of 1 apps failed". **Deliberate skips (disconnected / decommissioned) must NEVER be noted** — they have their own alert and a nightly digest about an unplugged drive is an ignored digest. **A clean run emits NOTHING**; silence is safe only because the hub's deadline check (`monitor/deadline.go:396,417`) raises a missed backup from report freshness independently — if that is ever weakened this design loses its footing. **`run_id` is unique per real run** (so the hub's 1-h cooldown cannot collapse a manual run into the nightly one) and **deliberately EMPTY on the periodic refresh sweep**, which must stay under that cooldown or a polled status page becomes a mail flood |
|
||||
| `Manager.admitApp` / `beginAdmissionRun` / `decideAdmission` / `estimatedWriteBytes` (R-181, v0.193.0) | controller/internal/backup/admission.go | `(stackName) bool` / `() func()` | **THE reserve gate. Call it before ANY per-app backup write** — one verdict per app per run, covering the DB dump, the volume dump and the unit capture (all three write under one per-app root) | **Decided LAZILY at the app's first write, never once at run start** — app A's dump can put app B under the reserve, so a run-start verdict reads a disk that no longer exists. **Never re-decided between an app's own legs**: that is exactly the split R-181 closed (bulk written, capture refused). **Reset per run** via the closer `beginAdmissionRun` returns. **Must sit ahead of `DumpAppVolumesSafe`**, which stops the stack as its first act — a refusal decided inside it has already bounced the app. Fires **exactly one** `unitNotify` per refused app per run. Nil admission set (periodic status refresh) → decides fresh, which is still once per app per sweep. Wiring pinned by an **AST walk** in `TestAdmission_IsWiredIntoEveryProductionWriteLeg`, not `strings.Contains` |
|
||||
| `Manager.floorVerdict` + `FloorUsedPercent`/`FloorFreeGiB` / `ErrCaptureFloor` / `floorReason` (R-165 B2 v0.192.0, size term R-181 v0.193.0) | controller/internal/backup/recovery_unit.go | `(*UnitSpace, estGiB float64) (*UnitSpace, floorReason)` | The pure two-question predicate behind `admitApp`: is the filesystem already below the reserve (`floorHeadroom`), and would THIS app's write take it below (`floorSize`)? | **Headroom is about the FILESYSTEM, never a per-unit cap** — a size cap is R-163 rebuilt inside one volume; the size term bounds the *delta*, not the unit. **REFUSES, never deletes:** nothing here is generational (a unit is one fixed path per app, a DB dump one fixed name), so pruning could only destroy a DIFFERENT app's only local copy — **never repurpose `pruneStalePrimaryDirs`**, which removes ORPHANED dirs from an app that moved drives and has no notion of age. Two terms (97% / 1 GiB) in `fillwatch`'s shape, deliberately BEYOND its critical band (95% / 2 GiB) so the customer is always warned first — pinned by `TestFloorSitsBelowTheCriticalWarningBand`. **`estGiB == 0` degrades to headroom-only on purpose** — refusing an app with no history makes the FIRST backup the one that can never happen. A nil reading neither refuses nor warns (§8.4). Inject `unitSpaceFn` in tests rather than manufacturing occupancy on a real disk |
|
||||
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space |
|
||||
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space. **Both languages (v0.309.0, R-521 F7):** the sentence is the bundle's `event.fill.*`, the household's rendering rides as `message_customer` via `Notifier.PushEventHousehold` (`SetLang`; `SetExtra` takes the language); a built-in area carries a `LabelKey`, a household's own drive label is used as written. Hungarian pinned byte-for-byte by `TestMessageHungarianWireText` (v0.309.0 dropped the formal „Kérjük," — R-516) |
|
||||
|
||||
### Scheduler / time / UI
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `Scheduler.RunNow` / `scheduler.OnDemand` | controller/internal/scheduler/scheduler.go | `RunNow(name) error`; `OnDemand(ctx) bool` | Run a registered job once now (operator action `run_job`, decision 185) | Refuses unknown and already-running jobs; a job that checks due-ness reads `OnDemand(ctx)` to skip that check on demand |
|
||||
| `report.OperatorActions` | controller/internal/report/opactions.go | `Reconcile(reply)` → results in the next report | Hub-requested actions in the report reply — the `selftail.go` pattern plus results | The action table is CLOSED and pinned by `TestOpActions_ClosedList`; once per id in memory; every entry must be safe to repeat |
|
||||
| `web.sessionFingerprint` / `loadSessions` / `saveSessionsLocked` / `saveSessionsRevokingLocked` | controller/internal/web/session_store.go | dashboard sessions on disk (R-35) | Any session lookup | The map is keyed by sha256(cookie), NEVER the cookie — hash with `sessionFingerprint` first; a path that ENDS sessions uses the revoking save |
|
||||
| `web.endEverySession` / `rotateGateKey` (R-976, v0.324.0) | controller/internal/web/auth.go · controller/internal/web/setup_gate.go | `(why string)` / `()` | A dashboard password change or claim/reset — ANY act that must end every way in | Ends the dashboard sessions, every family session (`family.Store.EndAll`) AND every setup-gate pass / family app cookie (the gate key is replaced; a failed save REMOVES the old key file so a restart cannot bring old passes back). Call this, never `invalidateAllSessions` alone — that ends only the dashboard's. Pinned by `TestR976_PasswordChangeEndsFamilyAndGateSessions` / `TestR976_ClaimResetEndsFamilyAndGateSessions` |
|
||||
| `web.reserveLoginAttempt` / `reserveAttempt` / `shareReserve` / `familyReserve` / `allowResetRequest` (R-983, v0.324.0; R-1111) | controller/internal/web/auth.go · share.go · family_gate.go · claim.go | `(key) → may go ahead` | Any limiter in front of a slow check (bcrypt, a hub call) | **Check and count in ONE locked step, BEFORE the slow work** — a count taken after the compare lets N parallel tries all pass the check (the shape R-983 removed from the dashboard and family sign-ins, and R-1111 from the share password and the escrow re-auth). A success clears the count afterwards. `reserveAttempt(mu, &map, key)` is the one body of the 5-per-minute password limiters — a new one passes its OWN map to it, never a third shape; the escrow re-auth counts on the login map (`reserveLoginAttempt`), the share password on its own (`shareReserve`). The compare goes through `comparePassword`, so `passwordCompare` (the slow-compare seam) reaches all three |
|
||||
| `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" |
|
||||
| `getBudapestLocation` | controller/internal/scheduler/scheduler.go | `() *time.Location` | Local-time math | web has its own `getTimezone` (§6); quiesce has its own `budapestLocation` (window gate) — 3rd copy, see §6 |
|
||||
| `Server.templateFuncMap` | controller/internal/web/funcmap.go | template.FuncMap | ALL template functions | `stateColor` outputs v2 suffixes `run/progress/warn/neutral/off`; stopped = NEUTRAL not red (operator-approved); `stateLabel` copy is frozen byte-identical (unit-tested) |
|
||||
| `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 |
|
||||
@@ -236,6 +331,7 @@
|
||||
| Streaming subprocess progress | controller/internal/backup/offbox_progress.go | `offboxStreamRunner` seam (stdout scanned line-by-line, stderr buffered, output tail-bounded) + a PURE line parser + a mutex-guarded published snapshot. Traps it encodes: a source reporting nothing is **normal** (restic sends 0 bytes for a whole incremental run) and the progress source may only update on unit completion — degrade bytes → files → current item + elapsed, never fake a percentage |
|
||||
| Post-start async verification | controller/internal/stacks/manager.go `logPostStartStatus` | goroutine + sleep, INFO log, never blocks/fails the operation |
|
||||
| Startup wiring order | controller/cmd/controller/main.go | init-only setters (`SetStackProvider` M2 contract: exactly once, before scheduler/HTTP), scheduler registration block |
|
||||
| Tabbed page (one subject, several URLs) | controller/internal/web/app_page.go + templates/app_shell.html | ONE data builder for what every tab shows (`appCommonData`), one shell partial (header + page-level banners + tab bar), one page template per tab calling `{{template "app_shell_start" .}}`. Tabs are `<a>` links with `aria-current`, never hidden panels — a link in an alert must land ON a tab and a poller must run on its tab only. The default tab is RENDERED at the bare URL, never redirected to. A key the tab owns (`Page`, `Title`, `TitleKey`, `TitleArgs`, `ActiveTab`, `AppTabs`) is listed in `appPageDataKeysOwnedByTab` so a merged data builder cannot overwrite it |
|
||||
|
||||
## 3. Dangerous lookalikes — do NOT reuse
|
||||
|
||||
@@ -247,9 +343,11 @@
|
||||
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` |
|
||||
| `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) |
|
||||
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
|
||||
| `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else |
|
||||
| `config.LoadPermissive` | Skips validation — first boot only (customer.id/domain may be unset until `bootstrap.WaitForConfig` gets the configuration, R-554) | `config.Load` everywhere else |
|
||||
| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
|
||||
| any settings getter/setter or helper that ends in one (`m.note`, `boxLang`) INSIDE a `settings.UpdateOffboxStatus`/`UpdateCrossDriveStatus` callback (R-578) | The callback runs under the settings WRITE lock and `sync.RWMutex` is not reentrant — it DEADLOCKS holding the lock and wedges settings.json for the box | Resolve the value BEFORE the callback (`lang := m.boxLang()`); `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` (controller/internal/settings/r578_callback_lock_gate_test.go) convicts it in every package |
|
||||
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
|
||||
| `backup.WholeOnTier` vs `UpdateCopyHolds` (R-659) | `UpdateCopyHolds` says what a copy HOLDS; it does not say the restore will ACCEPT it — a file app's second-drive copy holds the files and its unit restore still refuses | `WholeOnTier` (asks `DeclaredDriveFileLegs`, the refusal's own predicate) before a sentence names a copy as a way back |
|
||||
|
||||
## 4. Seams & interfaces (testing + cross-repo)
|
||||
|
||||
@@ -257,10 +355,14 @@
|
||||
|---|---|---|---|
|
||||
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
|
||||
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
|
||||
| `healthSettle` / `healthInterval` / `healthTimeout` (package vars, R-488) | controller/internal/backup/restore.go | production 3 s / 5 s / 90 s — `waitForHealthy`'s clock, used by every restore path | shortened ONCE in controller/internal/backup/main_test.go `TestMain` (0 / 2 ms / 50 ms; the package went 444 s → ~2 s); `TestR488_HealthWaitDefaultsAreProduction` pins the production values. A new real-clock wait in this package goes behind the same kind of var, not a per-test sleep. Also: `newOffboxManager` installs a failing `SetOffboxSSH` fake, so no backup test spawns a real `ssh` |
|
||||
| `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) |
|
||||
| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated |
|
||||
| `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) |
|
||||
| `Manager.sambaUpFn` / `sambaPasswdFn` / `sambaRunFn` / `sambaAddrFn` (func seams) | controller/internal/stacks/manager.go (fields) + samba.go | nil → `composeUp` / `docker exec smbpasswd` (STDIN) / `containerRunning("felhom-samba")` / `docker exec felhom-samba ip -4 -o addr show eth0` | injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called **zero** times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker. **`sambaRunFn` has an EXPORTED setter (`SetSambaRunProbe`)** — internal/web's status-contract tests need a live-container world from another package. `sambaAddrFn` backs `SambaLANAddress()` (v0.151.0); its parse is separately pinned in samba_lanaddr_test.go and it returns "" on any failure — the page omits a line rather than printing a wrong address |
|
||||
| `volumeCopier` + `Manager.undoCopier` / `updateUndoHealthFn` (v0.263.0) | controller/internal/stacks/undo.go | nil → `dockerVolumeCopier` (alpine helper: `cp -a` named volume → `<vol>.pre-update-<stamp>`, finished-marker LAST; restore re-checks the marker in the same shell) / nil → `waitUpdateHealthyMeta` with the OLD `.felhom.yml` | `fakeCopier` in controller/internal/stacks/undo_test.go — volume CONTENT as strings, so "the data came back" is a compare; a cut-off copy is a copy without its marker. **Judge a copy by the helper's own exit + the marker, never by the client** (killing `docker run` leaves the container copying — measured) |
|
||||
| `pgConverter` + `Manager.pgConv` / `convertFreeFn` (v0.273.0) | controller/internal/stacks/pgconvert.go | nil → `dockerPGConverter` (docker exec psql/pg_dumpall over 127.0.0.1 — the entrypoint's temporary init server listens on the socket only; `Empty` re-checks the undo copy's marker in the same helper) / nil → statfs of the stack dir | `fakePG` in controller/internal/stacks/pgconvert_test.go — works on `fakeCopier`'s volume strings. **Never convert without the ladder's `engine_conversion` mark** (`planEngineConversion` refuses a PostgreSQL major move without it); `isPostgresImage` must match `appbackup.dbTypeForImage` (pinned by a source-reading test) |
|
||||
| `Manager.composeExecFn` / `stacks.setupGateProbeGet` / `Server.gateClock` (v0.280.0) | controller/internal/stacks/manager.go + setup_gate.go · controller/internal/web/server.go | nil → the real compose call / an HTTP GET (5 s) / `time.Now` | controller/internal/stacks/setup_gate_test.go (with a docker STUB on PATH — R-650) · controller/internal/web/setup_gate_test.go |
|
||||
| `Manager.SambaLANAddress()` | controller/internal/stacks/samba.go | `() string` — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) | Read from the SAMBA container's netns (`network_mode: host`), never `net.InterfaceAddrs()` — the controller is on a docker BRIDGE and would answer 172.x (the same trap `setup.DetectLocalIPs` needs `HOST_IP` for). **NEVER cache/persist it** — the guest holds it by DHCP (S-5); callers re-derive per render. `""` = omit the line |
|
||||
| `Server.sambaAddrFn` (func seam) | controller/internal/web/server.go (field) + sharing_handlers.go `sambaLANAddress()` | nil → `stackMgr.SambaLANAddress()` | The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease |
|
||||
| `Manager.guestNetExecFn` (func seam) + `GuestGateway()` / `GuestNetSnapshot()` | controller/internal/stacks/manager.go (field) + guestnet.go | nil → `docker exec felhom-samba <args>` — ONE seam for all R-66 guest-netns reads (route/link/addr/resolv.conf); tests script canned outputs per argv | guestnet_test.go. **The netns door rule:** the controller's OWN netns is the docker bridge, so any in-process read (`net.Interfaces`, `/proc/net/route`, its own `/etc/resolv.conf` = 127.0.0.11) is the S-2 wrong answer — guest-net reads MUST go through the samba (`network_mode: host`) exec door. Megosztás off ⇒ door closed ⇒ "" / per-item error strings; NEVER substitute an in-process value. Same S-5 law as SambaLANAddress: live per render, never cached/persisted. Parsers (`parseDefaultRoute`, `parseGuestInterfaces`, `parseResolvConf`) are pure + separately pinned |
|
||||
@@ -269,6 +371,7 @@
|
||||
| `Server.guestGatewayFn` / `guestNetFn` (func seams) | controller/internal/web/server.go (fields) + sharing_handlers.go accessors | nil → `stackMgr.GuestGateway` / `stackMgr.GuestNetSnapshot` | network_card_test.go — the counted-fn freshness test (2 renders ⇒ 2 resolves) is what stops anyone memoizing a DHCP lease; the Hálózati név row is gated on `smb.Enabled` (red-proven: gate dropped ⇒ \\FELHOM rendered while samba is down) |
|
||||
| `sambaEnsureState.consumeIfRunning()` | controller/internal/web/samba_ensure_job.go | serve-once `snapshot()` for terminal `running` only | `/sharing/status` carries a job EDGE (`phase`) and a service LEVEL (`running`) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers `phase=="running"` with `location.reload()`, so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). `failed`/`needs_password`/in-flight are NOT consumed |
|
||||
| `infra.SambaHostInterface` | controller/internal/infra/samba.go | the guest LAN nic name (`eth0`) | Single source for smb.conf's `interfaces =`, the container's `FELHOM_IFACE`, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart |
|
||||
| `Manager.installedExecFn` (func seam, v0.233.0) | controller/internal/stacks/manager.go (field) + installed.go | nil → `defaultExecRunner` (`exec.CommandContext`, 30 s) | the installed-images recorder's ONLY process boundary — `docker compose ps` + `docker inspect` + `docker image inspect` | Deliberately NOT `execFn`/`composeExecCustomEnv`: those are already load-bearing elsewhere and **neither carries a context or a timeout**, and a bookkeeping read must never wedge a lifecycle action. Injected in controller/internal/stacks/installed_test.go, which FAILS the test on an argv it does not recognise, so a change to the commands issued cannot pass silently. The wiring test stubs the compose binary on PATH instead, so `RestartStack` is reached for real |
|
||||
| `Manager.sambaImgFn` (func seam) | controller/internal/stacks/manager.go (field) + samba.go | nil → `docker image inspect <infra.SambaImage>` | drives the 4b card's pulling-vs-starting decision, which MUST be taken before `compose up` (afterwards the image is always present) |
|
||||
| `Manager.offboxStreamRunner` + `SetOffboxStreamRunner` | controller/internal/backup/offbox_progress.go | nil → `defaultOffboxStreamRunner` (real `restic`, stdout scanned live) | streaming sibling of `offboxRunner`; fakes emit canned `--json` status lines in offbox_progress_test.go, so the whole progress path runs with no restic, network or repo |
|
||||
| `Manager.offsitePreDumpFn` + `SetOffsitePreDumpFn` (R-44, v0.148.0) | controller/internal/backup/offbox_reconstitute.go (seam) + offbox.go (call site) | nil → `runDBDumpsInternal` under the SAME running flag | THE dumps-before-capture ordering seam. Extracted so the order is observable without Docker/restic — an ordering guarantee no test can see is one refactor from silently reverting to the DIAG-immich-restore-2026-07-19 behaviour. Red-proof: moving the capture first yields `[capture dump]` |
|
||||
@@ -281,9 +384,16 @@
|
||||
| `Manager.execFn` (func seam) + `restartPolicyLookup` / `inspectRestartPolicyFn` (R-51, v0.156.0) | controller/internal/stacks/manager.go | nil → real `exec.Command` / `docker inspect -f {{.HostConfig.RestartPolicy.Name}}` | `scriptedDocker` in controller/internal/stacks/degraded_test.go drives the WHOLE production path (docker ps → aggregateState → docker inspect) — an aggregateState-only test proves the function, not the caller. Policy answers are cached per container+state and pruned to the live `docker ps` set; a FAILED inspect is deliberately never cached (a hiccup must not pin a container to "unknown") and reads as SUPERVISED, i.e. fail-closed — the opposite of `IsDownState`'s fail-open, because there the state is ambiguous while here a member is known dead |
|
||||
| `bootrecon.StackProvider` (R-52, v0.156.0) | controller/internal/bootrecon/bootrecon.go | `*stacks.Manager` (GetStacks/StartStack/RefreshStatus) | `fakeStacks` counts StartStack per app; the load-bearing assertion is the NEGATIVE — a zero-container stack (a UI Stop = `compose down` = containers removed) must record **0** starts, while a boot orphan (containers present, Exited) records exactly 1. `Reconciler.sleep` is injected so the 30 s gap costs nothing |
|
||||
| `bootReconcileFn` + `runBootReconcile` (package-main seam, v0.156.0) | controller/cmd/controller/main.go | `bootrecon.New(mgr, logger).Run` | controller/cmd/controller/bootrecon_wiring_test.go. **The wiring itself is asserted by an AST walk** over `func main()`, not a `strings.Contains` — the substring version passed its own red-proof because a commented-out call still contains the string. Comments are not callers |
|
||||
| `crashboot.Probe` / `crashboot.Grace` (R-856) | controller/internal/crashboot/crashboot.go | `crashboot.AgentProbe(agentClient.CrashGuard)` (GET /host/crash-guard) | `fixed(Fact, err)` in crashboot_test.go. The dead-app check gates on `bootGrace.Within(now)` — 90 s normally, `CrashGrace` (15 min) when the HOST's last boot was unclean and within `BootWindow` of the controller start. **Unknown (no agent, 404, no state) is a normal boot** — never a longer silence on a guess. Asserted by AST walk in cmd/controller/r856_wiring_test.go |
|
||||
| `classifyRunStates` (pure fix-3 derivation, v0.164.0) | controller/cmd/controller/main.go | `([]stacks.Stack, quiesced, failedRestart map[string]bool, now time.Time)` → `(dead []web.DeadApp, states []notify.AppRunState)` | classify_runstates_test.go. **THE single fix-3 rule: down = `(IsDownState(st.State) || st.CrashLooping(now)) && !userStopped && !quiesced`.** **`quiesced` is a UNION of TWO suppression sets** (R-330, v0.224.0): `quiesce.Loop.SuppressedStacks()` (whole-guest vzdump/PBS) and `backup.AppStopGuard.SuppressedStacks()` (per-app volume dump / offbox reconstitute / `.fab` export), merged by `unionSuppressed` in `scanDeployedAppRunStates`. **Adding a third way to stop an app means adding its set here** — R-330 was 61 false customer e-mails caused by exactly that omission, with a working suppressor sitting three lines away. C9-F2 (v0.183.0) added the crash-loop term: `restarting` is NOT in `IsDownState` and must not be — adding it alarms on every deploy and update fleet-wide — so a SUSTAINED restarting run (`stacks.crashLoopAfter` = 5 m, above the 120 s deploy timeout, Mealie's 60 s start_period AND R-97b's 180 s grace) becomes down instead. `now` is injected so the threshold is a testable contract. A deliberate UI stop (`compose down` → zero containers → StateStopped, I1) must not alarm — banner OR email — while faults (Exited/Degraded) alarm byte-identically; I2 (P2 census: all catalog services `unless-stopped`) is why a crash never rests at stopped. **Do NOT touch `IsDownState`** (other callers rely on stopped=down) and do NOT filter in `buildDeadAppAlerts`/`NotifyAppStartFailures` — one derivation point. If I1 or I2 changes, revisit the suppression |
|
||||
| `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go |
|
||||
| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
|
||||
| `util.ParseVersionPre` | controller/internal/util/version.go | the same comparator, reading `X.Y.Z-rc.N` as BELOW `X.Y.Z` (v0.314.0, R-1053) — ONLY for „is this box below the floor?" | never use it to pick a tag to offer: the button must not see a test build |
|
||||
| `upwatch.Source` / `upwatch.Explainer` / `Watcher.Now` (v0.342.0, the upload watch) | controller/internal/upwatch/ | `DockerAPI` (Engine API on the socket, exact counters) / the qBittorrent, Transmission and Plex clients | `fakeSource`, `fakeApp` and a minute clock in watch_test.go drive the WHOLE judge path; the app clients are tested against httptest servers that behave as measured on 9202. **Exact counters come from the Engine API, never from `docker stats` text** (three significant digits) |
|
||||
| `upwatch.QBittorrent.SetUploadLimit` + `ApplyQBLimit` | controller/internal/upwatch/qblimit.go | the one writer of qBittorrent's global upload limit | compare against the value qBittorrent STORED (KiB/s rounding), never the requested one; a limit the household touched is theirs for good |
|
||||
| `upwatch.Service.Measured` / `RefreshMeasured` / `MeasuredLine` + `agentapi.Client.LineTest` (R-1247) | controller/internal/upwatch/service.go · controller/internal/agentapi/linetest.go · cmd/controller/upwatch_wiring.go `agentLineTest` | GET /host/line-test (agent ≥ 0.164.0): the host's weekly iperf3 line test | **qBittorrent's limit reads `MeasuredLine` only — never `Line()`** (the push floor is a far lower bound; D41). `Line()` = the higher of the two, for the watch. Read at start + every 15 min; a failed read KEEPS the last value; 404 / present:false = not measured. Pinned by `TestService_QBLimitFromTheTestedLineOnly` |
|
||||
| `mediawire.PlexURL` | controller/internal/mediawire/plexaddr.go | Plex's base URL BY ADDRESS, resolved at each call (v0.314.0, R-1060) — every controller call to Plex | Plex refuses the Host name `plex` as non-local (401); never cache the address |
|
||||
| `stacks.GateCapture` / `stacks.ReadGateAtBackup` | controller/internal/stacks/restore_gate.go | the setup gate a per-app backup records, and the restore's read of it (v0.314.0, R-1057) | the unit `app.yaml` key is `setup_gate:` — the live file's keys, so either reads the same |
|
||||
| `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line |
|
||||
| `netProbeReadBack` (package var) | controller/internal/web/netprobe.go | `os.ReadFile` | overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production |
|
||||
| `system.ClassifyPathFS(Timeout)` + `netProbeFSClass` / `Server.classifyFSPath` / `Router.classifyFSPath` | controller/internal/system/fsclass*.go (+ web/netprobe.go, web/server.go, api/router.go seams) | statfs f_type → network/autofs/stub/unknown in THIS namespace (RCA fix 2) | idle autofs = HEALTHY, never force-mount; unknown = fail OPEN; seams injected in netprobe_stub_test.go / networkstub_test.go / deploygate_test.go |
|
||||
@@ -310,6 +420,7 @@ Cross-repo edges:
|
||||
## 5. Extension points (where new features plug in)
|
||||
|
||||
- **New storage web endpoint**: switch in `ServeStorageAPI` (controller/internal/web/storage_handlers.go); disk ops in `ServeDiskAPI` (controller/internal/web/agent_disk_handlers.go); backup in `ServeBackupAPI`; export in `ServeExportAPI`; debug in `handleDebugAPI` (debug-mode gated).
|
||||
- **A gate that must REPORT and never REFUSE (R-404, 2026-09-01)**: register it in `controller/scripts/controller_gates.py` with the fifth field `blocking=False` — its exit code then never reaches the runner's verdict and it prints as `ADVISORY`. `golden_notice.py` is the only one, and `test_golden_notice.py` asserts it stays the only one. **Use this instead of giving a notice the power to refuse a push**: the golden notice must fire at the moment a release is committed, when the golden legitimately cannot exist yet. A cross-repo gate IMPORTS the sibling's script (see `golden_notice.py` loading `felhom.eu/scripts/golden_currency_gate.py`) and never copies it — a copy recreates the drift these gates exist to detect.
|
||||
- **New debug-page control (R-400, v0.228.0)**: the control in `controller/internal/web/templates/debug.html` AND the `subpath == "…"` case in `controller/internal/web/handler_debug.go` are ONE change — `controller/scripts/debug_route_gate.py` fails on either half alone, in both directions (a reference with no case, and a case with no reference). Keep the dispatcher's EXACT-match switch with its `http.NotFound` default; a prefix match is what would have hidden the original defect. Handler shape: `debugTriggerDBDump` for a fire-and-forget trigger, `debugRunIntegrityCheck` for one the operator pressed to learn an ANSWER (synchronous). **Before this gate the page referenced 24 addresses and 17 were answered, and three of the seven dead ones fetched on page LOAD** — those panels were permanently blank on the page an operator opens when something is already wrong.
|
||||
- **New REST endpoint**: path dispatch in `Router.ServeHTTP` (controller/internal/api/router.go); use `writeJSON` + `limitBody`.
|
||||
- **New background job**: `sched.Every`/`sched.Daily` registration block in controller/cmd/controller/main.go.
|
||||
@@ -323,21 +434,24 @@ Cross-repo edges:
|
||||
- **New hub event**: typed `Notify*` wrapper on Notifier + hub allowlist entry (cross-repo).
|
||||
- **New app integration**: `integrations.Manager.RegisterHandler` with `IntegrationKey(provider, target)`.
|
||||
- **New startup self-check**: append check fn in `selftest.Run` (controller/internal/selftest/selftest.go).
|
||||
- **Converting a template to the bundle (i18n, v0.247.0)**: `python3 controller/scripts/i18n_extract.py <template>` moves every Hungarian run into `hu.json` and leaves `{{T "key"}}`; then **review what it LEFT** (it matches Hungarian by accented letter plus an ASCII word list — eight ASCII-only strings were missed on the first three pages), add the English to `en.json`, add fixture states for the page to `i18n_parity_test.go` **captured from the unconverted template first**, and run `controller/scripts/i18n_missing_gate.py`. Copy gates judge the page through `controller/scripts/i18n_bundle.py` (`read_template(path, lang)`), never the raw template.
|
||||
- **New settings field**: struct + accessor pair in controller/internal/settings/settings.go following the Lock→mutate→save pattern.
|
||||
- **A value asked AFTER the app starts (v0.315.0)**: add a `DeployField` with `after_start: true` to the catalog template AND an entry to `afterStartRegistry` in `controller/internal/web/after_start.go`, keyed `slug + "/" + ENV_VAR`, with `Needed(stack) bool` and `Apply(s, r, stack, value) error`. **The catalog may never carry the action** — not a command, not a URL, not a container to exec: the household's text is an argument to a function in this repository, which is the whole point of the design (D2 = A). Both halves are required and each is checked: a declared field with no entry renders no card and 404s its POST; an entry whose field the catalog does not declare also 404s (`TestAfterStart_RegistryAloneIsNotAnInvitation` — that second half was green and unpinned until a red-proof showed a POST for an undeclared field reaching Plex). `Apply` returns `*afterStartError{Status, MsgKey}` to show a sentence of its own inline, and **never logs the value** (it is a credential).
|
||||
- **A tab on the app page (v0.315.0)**: a `case` in `appPageHandler`'s switch, a `render<Tab>Tab` that adds only its own keys to `appCommonData`'s map, a template starting with `layout_start` + `app_shell_start`, and the tab's key in `appTabOrder` with a literal `app_page.tab_<key>` locale key resolved in `appTabLinksLang`. **Resolve the label in Go, not with a composed `{{T (printf …)}}`** — a composed key is invisible to `TestI18nParityCoversEveryMarker` and a missing translation would render the key to the household.
|
||||
|
||||
## 6. Known duplication (observed — NOT fixed)
|
||||
|
||||
| Duplication | Locations |
|
||||
|---|---|
|
||||
| Atomic write ×4 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/setup/handlers.go `atomicWriteFile`; controller/internal/api/router.go `writeConfig0600` |
|
||||
| String truncation ×3 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based, widely used); controller/internal/backup/offbox.go `truncate` |
|
||||
| Atomic write ×3 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/api/router.go `writeConfig0600` (the setup wizard's `atomicWriteFile` went with it, R-554) |
|
||||
| String truncation ×4 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based HEAD, widely used) and `tailStr` (rune-safe TAIL, v0.294.0 R-864 — use it for a command's stderr: the error is printed last); controller/internal/backup/offbox.go `truncate` |
|
||||
| humanizeBytes ×3 | controller/internal/appbackup/appdata.go `HumanizeBytes` (canonical) + private twin; controller/internal/appexport/estimate.go `humanizeBytes`; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge) |
|
||||
| copyFile ×2 | controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go |
|
||||
| dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` |
|
||||
| timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) |
|
||||
| CSRF ×2 | controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled |
|
||||
| Budapest timezone loader ×3 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
|
||||
| Budapest timezone loader ×4 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` vs controller/internal/api/kept_install.go `budapest` (v0.277.0, the kept choice's dates) (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
|
||||
| JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` |
|
||||
| Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) |
|
||||
| DB wait/import ×2 | controller/internal/appbackup/dbdump.go `waitDBReady`/`ImportDump` vs controller/internal/appexport/restore.go `waitForDB`/`importDBDump` |
|
||||
| qBittorrent Web API login ×2 | controller/internal/mediawire/mediawire.go `qbLogin` (writes the drive wiring) vs controller/internal/upwatch/explain.go `QBittorrent.login` (reads every minute; keeps its session cookie, which mediawire's per-run login does not need) |
|
||||
| compose exec ×2 | controller/internal/stacks/manager.go `composeExecCustomEnv` vs controller/internal/appexport/restore.go `composeExecEnv` (the latter has ctx+timeout; the former has the userdata belt) |
|
||||
|
||||
@@ -41,4 +41,8 @@ restic-password
|
||||
# App assets (synced from felhom.eu website at build time)
|
||||
assets/*.svg
|
||||
assets/*.png
|
||||
assets/*.webp
|
||||
assets/*.webp
|
||||
# build.sh's scratch directory. It is created beside the script, so a build run from a CLONE of
|
||||
# this repo (which is what the clean-tree gate pushes you to when the shared tree is busy) would
|
||||
# otherwise dirty the very tree the gate is checking. Generated output, never committed.
|
||||
workspace/
|
||||
|
||||
+65
-33
@@ -5,12 +5,12 @@
|
||||
# =============================================================================
|
||||
|
||||
# --- Build stage ---
|
||||
FROM golang:1.24-bookworm AS builder
|
||||
# R-972 (v0.322.0): a supported, patched Go, pinned by version AND digest (no moving tag). Go 1.24 had no security fixes
|
||||
# left (go.dev/doc/devel/release#policy). The builder's Debian does not reach the runtime (a static CGO-free binary).
|
||||
FROM golang:1.27.2-trixie@sha256:47667934874913a449b96faa7ef4777fa285d9ec29126ee80ed61c45b7cccf0e AS builder
|
||||
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=dev
|
||||
ARG GIT_COMMIT=unknown
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -24,6 +24,13 @@ COPY . .
|
||||
# Generate go.sum and ensure all deps are fetched
|
||||
RUN go mod tidy
|
||||
|
||||
# R-208: the per-build values are declared HERE, just above the only step that reads them. A RUN's
|
||||
# cache key includes the stage's ARG environment, so declared above `go mod download` a fresh VERSION
|
||||
# re-ran the module download on every build (208 cache records, each used once, ~440 MB apiece).
|
||||
# Pinned by TestR208_DockerfileVersionArgsSitBelowModuleDownload.
|
||||
ARG VERSION=dev
|
||||
ARG GIT_COMMIT=unknown
|
||||
|
||||
# Build static binary
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||
-ldflags="-s -w \
|
||||
@@ -33,48 +40,73 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
|
||||
-o /build/felhom-controller \
|
||||
./cmd/controller/
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM debian:bookworm-slim
|
||||
# --- restic: the official release binary, pinned by version AND checksum (R-1105, v0.323.0) ---
|
||||
# Never again „whatever the OS ships": Debian 12 → 13 would have swapped restic 0.14.0 → 0.18.0 under the off-site code
|
||||
# without anyone choosing it. The version below is a decision; moving it is a re-proof (07-backup-architecture.md §6.8).
|
||||
# Three checks, each fails the build: restic's SHA256SUMS carries a good signature from restic's release key (vendored
|
||||
# in third_party/restic/, fingerprint CF8F 18F2 8445 7597 3F79 D4E1 91A6 868B D3F7 A907 — no keyserver at build
|
||||
# time); the download matches the published sum; and it matches the sum pinned HERE, so even a re-signed release
|
||||
# cannot swap the binary. Pinned by TestR1105_ResticIsPinnedOutsideTheOS.
|
||||
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f AS restic
|
||||
ARG TARGETARCH
|
||||
ARG RESTIC_VERSION=0.19.1
|
||||
ARG RESTIC_SHA256_AMD64=f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c
|
||||
ARG RESTIC_SHA256_ARM64=a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465
|
||||
COPY third_party/restic/restic-release-key.asc /tmp/restic-release-key.asc
|
||||
RUN set -eu; \
|
||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg gpgv bzip2; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) want="${RESTIC_SHA256_AMD64}" ;; \
|
||||
arm64) want="${RESTIC_SHA256_ARM64}" ;; \
|
||||
*) echo "no pinned restic checksum for architecture '${TARGETARCH}'" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
f="restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2"; \
|
||||
url="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}"; \
|
||||
cd /tmp; \
|
||||
curl -fsSL -o "$f" "$url/$f"; curl -fsSL -o SHA256SUMS "$url/SHA256SUMS"; curl -fsSL -o SHA256SUMS.asc "$url/SHA256SUMS.asc"; \
|
||||
gpg --dearmor < /tmp/restic-release-key.asc > /tmp/restic-release-key.gpg; \
|
||||
gpgv --keyring /tmp/restic-release-key.gpg SHA256SUMS.asc SHA256SUMS; \
|
||||
grep " ${f}\$" SHA256SUMS | sha256sum -c -; \
|
||||
echo "${want} ${f}" | sha256sum -c -; \
|
||||
bunzip2 -c "$f" > /usr/local/bin/restic; chmod 0755 /usr/local/bin/restic; \
|
||||
/usr/local/bin/restic version | grep -q "^restic ${RESTIC_VERSION} "
|
||||
|
||||
# Install runtime dependencies:
|
||||
# - docker-cli: for "docker compose" commands
|
||||
# - ca-certificates: for HTTPS (healthchecks pings, git)
|
||||
# - postgresql-client: for pg_dump
|
||||
# - default-mysql-client: for mysqldump
|
||||
# - sqlite3: for SQLite backup
|
||||
# - git: for stack sync from Gitea
|
||||
# - curl: for health pings and debugging
|
||||
# - util-linux: lsblk, blkid, mount (storage init)
|
||||
# - fdisk: sfdisk (partition table writing — separate from util-linux on Debian)
|
||||
# - e2fsprogs: mkfs.ext4 (filesystem formatting)
|
||||
# - rsync: for data migration between storage paths
|
||||
# - parted: partprobe (partition table re-read after sfdisk)
|
||||
# - restic: encrypted off-box (NAS) backup over SFTP (Part B; version pinned by the Debian release)
|
||||
# - openssh-client: restic's sftp backend shells out to `ssh` (the SFTP transport); also ssh-copy-id/sftp
|
||||
# for the SLICE-2 offsite apply-bridge key install
|
||||
# - sshpass: feeds the one-time storage-box password to ssh-copy-id -s -f (offsite apply-bridge)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# --- Runtime stage ---
|
||||
# Debian 13 (R-1105, v0.323.0), pinned by point release AND digest. It waited on Debian 12 until the off-site code's
|
||||
# restic assumptions were re-proven on 0.18 (07-backup-architecture.md §6.8); restic itself no longer comes from here.
|
||||
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f
|
||||
|
||||
# Install runtime dependencies — each one is a program the controller binary runs (R-1105 re-checked every exec):
|
||||
# - docker-cli + compose plugin: every stack operation (below)
|
||||
# - ca-certificates: for HTTPS (healthchecks pings, git, the hub)
|
||||
# - curl: the image's HEALTHCHECK, and the docker repo key below
|
||||
# - gnupg: dearmors docker's repo key below
|
||||
# - git: catalog sync
|
||||
# - util-linux: findmnt (mount reads)
|
||||
# - rsync: data migration between storage paths
|
||||
# - openssh-client: restic's sftp/rclone transports shell out to `ssh`; the off-site key probe
|
||||
# v0.323.0 (R-1105) REMOVED, because the binary never runs them: postgresql-client and default-mysql-client (every
|
||||
# dump and import runs INSIDE the app's own database container — `docker exec`, appbackup/dbdump.go), sqlite3,
|
||||
# fdisk, e2fsprogs, parted (disk work moved to the host agent in v0.37.0), sshpass (the ssh-copy-id key install it
|
||||
# fed was replaced by the hub's key registrar in v0.289.0). Fewer packages, fewer known holes.
|
||||
RUN apt-get update && apt-get upgrade -y --no-install-recommends && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
postgresql-client \
|
||||
default-mysql-client \
|
||||
sqlite3 \
|
||||
util-linux \
|
||||
fdisk \
|
||||
e2fsprogs \
|
||||
rsync \
|
||||
parted \
|
||||
restic \
|
||||
openssh-client \
|
||||
sshpass \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install docker-cli (without daemon)
|
||||
COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic
|
||||
|
||||
# Install docker-cli (without daemon). The repo's codename comes from the image's own /etc/os-release, never a
|
||||
# hard-coded name (a hard-coded `bookworm` would have kept Debian 12's packages on a Debian 13 base).
|
||||
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg \
|
||||
&& . /etc/os-release \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
|
||||
https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
+1342
-198
File diff suppressed because it is too large
Load Diff
+106
-13
@@ -12,11 +12,18 @@
|
||||
# ./build.sh 0.1.0 # Build with version tag
|
||||
# ./build.sh 0.1.0 --push # Build + push to Gitea registry
|
||||
# ./build.sh 0.1.0 --multiarch # Build amd64+arm64 + push
|
||||
# ./build.sh 0.1.0 --print-tags # Print the tags a build would carry, and stop (the R-1143 test)
|
||||
#
|
||||
# :latest is carried ONLY by a release tag (x.y.z). An -rc, dev or any other tag never moves it (R-1143).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# --- Configuration (edit these if your paths differ) ---
|
||||
REPO_DIR="/mnt/5_hdd/felhom.eu/git/felhom-controller"
|
||||
# The default is the shared DooPlex clone, and the release runbook names it. It is overridable
|
||||
# because that clone is shared: when another session has work in progress in it, the clean-tree gate
|
||||
# rightly forbids building over them, and the way to ship is a clean clone at the pushed commit —
|
||||
# not stashing somebody else's work. Set FELHOM_REPO_DIR to that clone.
|
||||
REPO_DIR="${FELHOM_REPO_DIR:-/mnt/5_hdd/felhom.eu/git/felhom-controller}"
|
||||
CONTROLLER_SRC="${REPO_DIR}/controller"
|
||||
WEBSITE_ASSETS_DIR="/mnt/5_hdd/felhom.eu/git/felhom.eu/website/assets"
|
||||
REGISTRY="gitea.dooplex.hu/admin"
|
||||
@@ -37,6 +44,21 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
|
||||
|
||||
# --- The tags this build carries (R-1143, 2026-10-18) ---
|
||||
# Until v0.328.0 every build moved :latest, release candidates included (rc.1–rc.5 each did on 2026-10-17). Nothing
|
||||
# current pulls :latest — the update button takes the highest x.y.z tag — but a legacy-shaped box following it would
|
||||
# have taken an rc. So :latest moves with a RELEASE only. Pinned by scripts/test_build_push_once.sh.
|
||||
TAGS=("${IMAGE}:${VERSION}")
|
||||
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TAGS+=("${IMAGE}:latest")
|
||||
fi
|
||||
TAG_ARGS=()
|
||||
for t in "${TAGS[@]}"; do TAG_ARGS+=(-t "$t"); done
|
||||
if [[ "${ACTION}" == "--print-tags" ]]; then
|
||||
printf '%s\n' "${TAGS[@]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
if [[ ! -d "${CONTROLLER_SRC}" ]]; then
|
||||
error "Controller source not found: ${CONTROLLER_SRC}"
|
||||
@@ -61,11 +83,90 @@ info "║ felhom-controller image builder ║"
|
||||
info "╚══════════════════════════════════════╝"
|
||||
info "Version: ${VERSION}"
|
||||
info "Commit: ${GIT_COMMIT}"
|
||||
info "Repo: ${REPO_DIR}"
|
||||
info "Source: ${CONTROLLER_SRC}"
|
||||
info "Build dir: ${BUILD_DIR}"
|
||||
info "Image: ${IMAGE}:${VERSION}"
|
||||
echo ""
|
||||
|
||||
# =========================================================================
|
||||
# A VERSION NUMBER IS PUSHED ONCE (v0.316.0)
|
||||
# =========================================================================
|
||||
# 0.315.0 was built and pushed THREE times from three different commits. A box that pulled between
|
||||
# the first and the last runs different code under the same number — and will never fetch the fix,
|
||||
# because to the box, and to the floor, that number is already current (`09-update-architecture.md`:
|
||||
# a box compares VERSIONS, and re-pulls a tag it already has only when the tag changes). Nothing in
|
||||
# the build stopped it. This does.
|
||||
#
|
||||
# The check runs BEFORE the build, so a refusal costs no minutes. A registry that cannot be reached
|
||||
# is a REFUSAL too: the point is to prove the tag is new, and "I could not ask" is not that proof.
|
||||
# Local builds (no --push) are unaffected — reusing a version while iterating is the normal case.
|
||||
#
|
||||
# `docker manifest inspect` is the probe, so a test can put a fake `docker` on PATH
|
||||
# (scripts/test_build_push_once.sh).
|
||||
if [[ "${ACTION}" == "--push" || "${ACTION}" == "--multiarch" ]]; then
|
||||
step "0/4 — Checking the registry: is ${VERSION} already published?"
|
||||
# `set -e` is on, and a failing command substitution would abort the script before the rc is
|
||||
# ever read — which would turn "the registry said no such tag" into a silent exit. The `|| true`
|
||||
# keeps the failure for us to classify; that classification is the whole check.
|
||||
probe_rc=0
|
||||
probe_out=$(docker manifest inspect "${IMAGE}:${VERSION}" 2>&1) || probe_rc=$?
|
||||
if [[ ${probe_rc} -eq 0 ]]; then
|
||||
error "${IMAGE}:${VERSION} already exists in the registry — bump the version."
|
||||
error "A version number is pushed once. Two images under one number mean a box can run"
|
||||
error "either of them and never learn the difference. If this is a fix for something already"
|
||||
error "pushed, release it as the next patch version instead."
|
||||
exit 1
|
||||
fi
|
||||
# Absent is the ONLY other acceptable answer. Anything that is not a clean "not found" (the
|
||||
# registry is down, the login expired, DNS failed) leaves the question unanswered, and an
|
||||
# unanswered question is a refusal.
|
||||
if ! grep -qiE 'no such manifest|manifest unknown|not found|does not exist|NAME_UNKNOWN|MANIFEST_UNKNOWN' <<<"${probe_out}"; then
|
||||
error "Could not establish whether ${IMAGE}:${VERSION} exists — refusing to build."
|
||||
error "The registry answered: ${probe_out}"
|
||||
error "Proving the tag is new is the point of this check; 'I could not ask' is not that proof."
|
||||
exit 1
|
||||
fi
|
||||
info "${VERSION} is not present in the registry — proceeding."
|
||||
|
||||
# AND THE TREE IT BUILDS FROM MUST BE CLEAN AND PUSHED.
|
||||
#
|
||||
# Until v0.316.3 this was a sentence in a runbook, and a rule without a mechanism is a wish. It
|
||||
# cost a bad push the same evening it was written down: the shared clone had 31 files of another
|
||||
# session's work in progress, and a build from it put their unreleased code into a published
|
||||
# image under my version number. Nobody deployed it; that was luck, not a control.
|
||||
#
|
||||
# Two questions, both of which must be YES:
|
||||
# is every tracked file committed? — otherwise the image holds something git does not
|
||||
# is HEAD what origin/main has? — otherwise the image holds something nobody can fetch
|
||||
# „An unpushed change does not exist" (workspace CLAUDE.md), and an image built from one is a
|
||||
# thing that exists and cannot be reproduced.
|
||||
if [[ -d "${REPO_DIR}/.git" ]]; then
|
||||
dirty=$(cd "${REPO_DIR}" && git status --porcelain 2>/dev/null | wc -l)
|
||||
if [[ "${dirty}" -ne 0 ]]; then
|
||||
error "${REPO_DIR} has ${dirty} uncommitted change(s) — refusing to push a build from it."
|
||||
error "An image built from a dirty tree cannot be reproduced, and on a SHARED clone it"
|
||||
error "may carry work that is not yours. Commit and push, or build from a clean clone"
|
||||
error "with FELHOM_REPO_DIR pointing at it."
|
||||
(cd "${REPO_DIR}" && git status --porcelain | head -10 | sed 's/^/ /')
|
||||
exit 1
|
||||
fi
|
||||
head_sha=$(cd "${REPO_DIR}" && git rev-parse HEAD 2>/dev/null || echo unknown)
|
||||
main_sha=$(cd "${REPO_DIR}" && git rev-parse origin/main 2>/dev/null || echo unknown)
|
||||
if [[ "${head_sha}" != "${main_sha}" ]]; then
|
||||
error "${REPO_DIR} is not at origin/main — refusing to push."
|
||||
error " HEAD ${head_sha}"
|
||||
error " origin/main ${main_sha}"
|
||||
error "An unpushed change does not exist; an image built from one cannot be fetched again."
|
||||
exit 1
|
||||
fi
|
||||
info "Source tree is clean and at origin/main."
|
||||
else
|
||||
error "${REPO_DIR} is not a git checkout — refusing to push a build whose source cannot be identified."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# Step 1: Prepare clean build workspace
|
||||
# =========================================================================
|
||||
@@ -160,14 +261,10 @@ BUILD_ARGS=(
|
||||
case "${ACTION}" in
|
||||
--push)
|
||||
info "Building for current platform + pushing..."
|
||||
docker build "${BUILD_ARGS[@]}" \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
.
|
||||
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
|
||||
|
||||
info "Pushing..."
|
||||
docker push "${IMAGE}:${VERSION}"
|
||||
docker push "${IMAGE}:latest"
|
||||
for t in "${TAGS[@]}"; do docker push "$t"; done
|
||||
;;
|
||||
|
||||
--multiarch)
|
||||
@@ -183,18 +280,14 @@ case "${ACTION}" in
|
||||
|
||||
docker buildx build "${BUILD_ARGS[@]}" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
"${TAG_ARGS[@]}" \
|
||||
--push \
|
||||
.
|
||||
;;
|
||||
|
||||
*)
|
||||
info "Building for current platform (local only)..."
|
||||
docker build "${BUILD_ARGS[@]}" \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
.
|
||||
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.279.0 (decision 45): the deploy-done hook — the ONE place a fresh install ends — runs after_install, and
|
||||
// the hook is set on every box, not only inside `if notifier != nil` (a box with no hub still installs apps).
|
||||
// COMPANION RED-PROOF: move SetDeployDoneHook back inside `if notifier != nil` → the second assertion fails;
|
||||
// drop the RunAfterInstall call → the first fails.
|
||||
func TestAfterInstall_IsWiredToEveryFreshInstall(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hook *ast.CallExpr
|
||||
var insideNotifierIf bool
|
||||
var walk func(n ast.Node, underNotifier bool)
|
||||
walk = func(n ast.Node, underNotifier bool) {
|
||||
ast.Inspect(n, func(x ast.Node) bool {
|
||||
if ifs, ok := x.(*ast.IfStmt); ok {
|
||||
if be, ok := ifs.Cond.(*ast.BinaryExpr); ok {
|
||||
if id, ok := be.X.(*ast.Ident); ok && id.Name == "notifier" && be.Op == token.NEQ {
|
||||
walk(ifs.Body, true)
|
||||
if ifs.Else != nil {
|
||||
walk(ifs.Else, underNotifier)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if c, ok := x.(*ast.CallExpr); ok {
|
||||
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && hook == nil {
|
||||
hook, insideNotifierIf = c, underNotifier
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
walk(f, false)
|
||||
if hook == nil {
|
||||
t.Fatal("SetDeployDoneHook is never called")
|
||||
}
|
||||
calls := false
|
||||
ast.Inspect(hook, func(x ast.Node) bool {
|
||||
if s, ok := x.(*ast.SelectorExpr); ok && s.Sel.Name == "RunAfterInstall" {
|
||||
calls = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !calls {
|
||||
t.Fatal("the deploy-done hook does not run RunAfterInstall")
|
||||
}
|
||||
if insideNotifierIf {
|
||||
t.Fatal("SetDeployDoneHook is set only when a notifier exists — a box with no hub would never run after_install")
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
body := mainBody(t)
|
||||
names := callsInMain(t, body)
|
||||
|
||||
if indexOfCall(names, "NotifyBackupFailed") < 0 {
|
||||
if indexOfCall(names, "NotifyInterruptedOperation") < 0 {
|
||||
t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " +
|
||||
"controller died mid-backup and nobody is told (§2.4)")
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
//
|
||||
// R-174 STRENGTHENED THIS. `!= nil` alone is no longer sufficient, because Recover now returns a
|
||||
// non-nil result for a recovery that merely REFUSED starts (an absent data drive) — the drive
|
||||
// gate working as designed. `NotifyBackupFailed` sends `backup_failed`, which is customer-enabled
|
||||
// gate working as designed. `NotifyInterruptedOperation` (R-585; was `NotifyBackupFailed`) sends `backup_failed`, which is customer-enabled
|
||||
// by default (settings.DefaultEnabledEvents), so a nil-only guard would email the customer
|
||||
// "A biztonsági mentés sikertelen!" about an app nothing is wrong with. The guard must consult
|
||||
// Alarming().
|
||||
@@ -156,7 +156,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
}
|
||||
carries := false
|
||||
for _, name := range callsInMain(t, ifst.Body) {
|
||||
if name == "NotifyBackupFailed" {
|
||||
if name == "NotifyInterruptedOperation" {
|
||||
carries = true
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
if !guardedByAlarming {
|
||||
t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " +
|
||||
"recovery that only REFUSED starts (drive absent) would be reported through " +
|
||||
"NotifyBackupFailed, a customer-enabled event type, telling the customer their backup " +
|
||||
"NotifyInterruptedOperation (backup_failed), a customer-enabled event type, telling the customer their backup " +
|
||||
"failed when the drive gate was simply doing its job (R-174)")
|
||||
}
|
||||
}
|
||||
@@ -493,3 +493,30 @@ func assignedIdentsIn(body *ast.BlockStmt) []string {
|
||||
})
|
||||
return names
|
||||
}
|
||||
|
||||
// R-1251: the guard's hold store and drive question are wired BEFORE Recover (a waiting put-back would otherwise be
|
||||
// read as „gone" and nothing would hold the app), and the waiting put-backs are resumed after it. COMPANION
|
||||
// RED-PROOF (2026-10-24): delete SetHolds or SetUndoReachable, or move either below Recover → this fails.
|
||||
func TestR1251_MainWiresThePutBackWait(t *testing.T) {
|
||||
names := callsInMain(t, mainBody(t))
|
||||
rec := indexOfCall(names, "Recover")
|
||||
for _, n := range []string{"SetHolds", "SetUndoReachable"} {
|
||||
if i := indexOfCall(names, n); i < 0 || i > rec {
|
||||
t.Errorf("func main() must call appStopGuard.%s before Recover (at %d, Recover at %d)", n, i, rec)
|
||||
}
|
||||
}
|
||||
if i := indexOfCall(names, "ResumeWaitingPutBacks"); i < rec {
|
||||
t.Errorf("func main() must resume the waiting put-backs after Recover (at %d, Recover at %d)", i, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1252: the second-drive copy asks the stack adapter (by an optional interface) whether a block was REJECTED. An
|
||||
// adapter that lost the method would make every rejected block read as absent — quiet, with no alarm. Pinned at
|
||||
// compile time here; the decision is tested in internal/backup.
|
||||
var _ interface{ BackupBlockRejected(string) string } = (*stackAdapter)(nil)
|
||||
|
||||
func TestR1252_MainWiresTheUnreadableSettingsSignal(t *testing.T) {
|
||||
if indexOfCall(callsInMain(t, mainBody(t)), "SetTier2SettingsUnreadableNotify") < 0 {
|
||||
t.Fatal("func main() no longer wires SetTier2SettingsUnreadableNotify — a rejected block would keep the copies silently")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestConvert_ReleaseIsWiredAtStartup — v0.273.0 (`09` §6.4 part 10, B5): the kept pre-conversion datadir
|
||||
// copy is released only by ReleaseConversionCopies, so main.go must CALL it (the seam-built-but-never-wired
|
||||
// class). COMPANION RED-PROOF (REPORT.md): delete the `conversion-copy-release` job — this fails.
|
||||
func TestConvert_ReleaseIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["ReleaseConversionCopies"]) == 0 {
|
||||
t.Fatal("ReleaseConversionCopies is never called — a converted app's old datadir copy would stay on disk for ever")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// `09` §3 decision 28 (v0.269.0) — the box stops a crash loop / OOM storm; never inside a backup's,
|
||||
// a quiesce's or an update's own stop, never twice, never an app already held.
|
||||
func TestD28_StopHoldTellInOrder_AndTheSkips(t *testing.T) {
|
||||
var calls []string
|
||||
d := unhealthyDeps{
|
||||
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
|
||||
return []stacks.UnhealthyVerdict{
|
||||
{Stack: "gokapi", Kind: stacks.UnhealthyCrashLoop, Count: 7, Window: 10 * time.Minute},
|
||||
{Stack: "quiesced", Kind: stacks.UnhealthyCrashLoop, Count: 7},
|
||||
{Stack: "busy", Kind: stacks.UnhealthyCrashLoop, Count: 7},
|
||||
{Stack: "held", Kind: stacks.UnhealthyOOMStorm, Count: 20},
|
||||
}
|
||||
},
|
||||
suppressed: func() map[string]bool { return map[string]bool{"quiesced": true} },
|
||||
busy: func(n string) (bool, string) { return n == "busy", "a restore is running" },
|
||||
holdKind: func(n string) string {
|
||||
if n == "held" {
|
||||
return "update_failed"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
stop: func(n string) error { calls = append(calls, "stop:"+n); return nil },
|
||||
hold: func(n, k string, _ time.Time) (int, error) { calls = append(calls, "hold:"+n+":"+k); return 1, nil },
|
||||
notify: func(v stacks.UnhealthyVerdict, trip int) { calls = append(calls, "notify:"+v.Stack) },
|
||||
}
|
||||
stopped := stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
|
||||
if strings.Join(calls, ",") != "stop:gokapi,hold:gokapi:crash_loop,notify:gokapi" {
|
||||
t.Fatalf("calls = %v — want gokapi stopped, held, told; the quiesced, busy and held apps untouched", calls)
|
||||
}
|
||||
if len(stopped) != 1 || stopped[0] != "gokapi" {
|
||||
t.Fatalf("stopped = %v", stopped)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed stop is neither held nor announced (the household would read "we stopped it" over a running app).
|
||||
func TestD28_AFailedStopIsNotAnnounced(t *testing.T) {
|
||||
var calls []string
|
||||
d := unhealthyDeps{
|
||||
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
|
||||
return []stacks.UnhealthyVerdict{{Stack: "x", Kind: "crash_loop"}}
|
||||
},
|
||||
suppressed: func() map[string]bool { return nil },
|
||||
busy: func(string) (bool, string) { return false, "" },
|
||||
holdKind: func(string) string { return "" },
|
||||
stop: func(string) error { return io.ErrUnexpectedEOF },
|
||||
hold: func(string, string, time.Time) (int, error) { calls = append(calls, "hold"); return 1, nil },
|
||||
notify: func(stacks.UnhealthyVerdict, int) { calls = append(calls, "notify") },
|
||||
}
|
||||
stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("calls = %v after a failed stop", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// The wiring: main's dead-app loop calls stopUnhealthyApps with the detector.
|
||||
func TestD28_TheLoopStopsUnhealthyApps(t *testing.T) {
|
||||
found, withDetector := false, false
|
||||
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "stopUnhealthyApps" {
|
||||
found = true
|
||||
ast.Inspect(call, func(m ast.Node) bool {
|
||||
if sel, ok := m.(*ast.SelectorExpr); ok && sel.Sel.Name == "ObserveUnhealthy" {
|
||||
withDetector = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found || !withDetector {
|
||||
t.Fatalf("main calls stopUnhealthyApps=%v with ObserveUnhealthy=%v — decision 28 is built and never run", found, withDetector)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.317.0 (`16` §4): the agent's same-disk verdict reaches the backup side. A seam built and never wired
|
||||
// has shipped in this repo seven times; this walks main.go for the call AND for the agent call inside it.
|
||||
// COMPANION RED-PROOF: delete the SetDiskJudge block → "not wired"; replace ac.SameDisk with a constant →
|
||||
// "never asks the agent".
|
||||
func TestDiskJudge_IsWiredAtStartup(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var judge *ast.CallExpr
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if c, ok := n.(*ast.CallExpr); ok {
|
||||
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDiskJudge" {
|
||||
judge = c
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if judge == nil {
|
||||
t.Fatal("backupMgr.SetDiskJudge is not wired in main.go — Tier-2 would keep the st_dev rule for ever")
|
||||
}
|
||||
asks := false
|
||||
ast.Inspect(judge, func(n ast.Node) bool {
|
||||
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "SameDisk" {
|
||||
asks = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !asks {
|
||||
t.Fatal("SetDiskJudge is wired but never asks the agent (no SameDisk call inside it)")
|
||||
}
|
||||
}
|
||||
+1387
-246
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.283.0 (decision 50, R-720): the deploy-done hook's SUCCESS branch — the one place a fresh install ends —
|
||||
// calls sett.DefaultOffboxOnForNewApp. The rule itself is pinned in internal/settings/offbox_default_test.go;
|
||||
// this pins that it is reached.
|
||||
// COMPANION RED-PROOF: delete the call from the hook → this test fails.
|
||||
func TestDecision50_DefaultIsWiredIntoTheDeployDoneHook(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
c, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && len(c.Args) == 1 {
|
||||
ast.Inspect(c.Args[0], func(m ast.Node) bool {
|
||||
if cc, ok := m.(*ast.CallExpr); ok {
|
||||
if ss, ok := cc.Fun.(*ast.SelectorExpr); ok && ss.Sel.Name == "DefaultOffboxOnForNewApp" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("the deploy-done hook does not call DefaultOffboxOnForNewApp — a new app would start with off-site OFF (R-720)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
)
|
||||
|
||||
// opActionReportFire holds the out-of-cycle report trigger once main has built it (func()). The
|
||||
// executor is built earlier than the trigger, so it reads it through this holder.
|
||||
var opActionReportFire atomic.Value
|
||||
|
||||
// operatorActionHandlers wires the four operator actions (R-314/R-279/R-177, `09` §3 decision 185)
|
||||
// to the running process. ONE wiring site, called from main and from the wiring test, so the test
|
||||
// exercises the doors production uses (the seam-built-but-never-wired trap).
|
||||
//
|
||||
// offsite_backup_now makes the SAME four checks as the household's „back up now"
|
||||
// (offboxRunHandler) and then runs the same call, so an operator press can never do what the
|
||||
// household's own press cannot.
|
||||
func operatorActionHandlers(backupMgr *backup.Manager, sched *scheduler.Scheduler, resultReady func(), logger *log.Logger) report.OperatorActionHandlers {
|
||||
h := report.OperatorActionHandlers{ResultReady: resultReady}
|
||||
if sched != nil {
|
||||
h.RunJob = sched.RunNow
|
||||
}
|
||||
if backupMgr == nil {
|
||||
return h
|
||||
}
|
||||
h.OffsiteBackupNow = func() (string, func(ctx context.Context) error) {
|
||||
switch {
|
||||
case !backupMgr.OffboxConfigured():
|
||||
return "no off-site target is set on this box", nil
|
||||
case !backupMgr.OffboxRunnable():
|
||||
return "the off-site key step (escrow) is not done yet — the run is refused until it is", nil
|
||||
case backupMgr.OffboxOrphaned():
|
||||
return "the off-site store is orphaned (written with an older key) — a run cannot write to it", nil
|
||||
case backupMgr.IsRunning():
|
||||
return "a backup run is already in flight", nil
|
||||
}
|
||||
return "", func(ctx context.Context) error {
|
||||
if logger != nil {
|
||||
logger.Printf("[INFO] [opaction] off-site backup started at the operator's request")
|
||||
}
|
||||
err := backupMgr.RunOffboxBackupWithProgress(ctx)
|
||||
if errors.Is(err, backup.ErrOffboxRunInFlight) {
|
||||
return fmt.Errorf("%w: %v", report.ErrActionRefused, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
h.AbandonStop = backupMgr.StopAbandon
|
||||
h.AbandonExtend = func(days int) (time.Time, error) { return backupMgr.ExtendAbandon(days) }
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// `09` §3 decision 185 (D1), the design's red test (1): a reply carrying abandon_stop during a
|
||||
// countdown stops it IN THE RUNNING MANAGER — and settings.json on disk agrees. This is the reason
|
||||
// the action rides the report reply instead of the CLI lever: the CLI runs a SECOND process, whose
|
||||
// write the running controller can overwrite (the lost update, main.go's R-241 block).
|
||||
func TestD1_AbandonStopActsOnTheRunningManagerAndDisk(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
dir := t.TempDir()
|
||||
settingsPath := filepath.Join(dir, "settings.json")
|
||||
sett, err := settings.Load(settingsPath, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dir
|
||||
cfg.Paths.SystemDataPath = filepath.Join(dir, "sys")
|
||||
mgr := backup.NewManager(cfg, sett, logger)
|
||||
// No test reaches a real ssh or restic (R-488 / R-650).
|
||||
mgr.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
|
||||
t.Fatal("abandon_stop must issue no remote command")
|
||||
return nil, nil
|
||||
})
|
||||
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
|
||||
t.Fatal("abandon_stop must run no restic")
|
||||
return nil, nil
|
||||
})
|
||||
due := time.Now().Add(10 * 24 * time.Hour).UTC()
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
||||
EscrowState: "escrowed",
|
||||
AbandonRepoPath: "/srv/repo.orphaned-20261001",
|
||||
AbandonStartedAt: time.Now().Add(-4 * 24 * time.Hour).UTC().Format(time.RFC3339),
|
||||
AbandonAt: due.Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !mgr.AbandonStatus().Active {
|
||||
t.Fatal("fixture: a countdown must be running")
|
||||
}
|
||||
|
||||
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
|
||||
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpAbandonStop}})
|
||||
|
||||
if mgr.AbandonStatus().Active {
|
||||
t.Fatal("the running manager still counts down after abandon_stop")
|
||||
}
|
||||
onDisk, err := settings.Load(settingsPath, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tg := onDisk.GetOffboxTarget(); tg == nil || tg.AbandonAt != "" || tg.AbandonRepoPath == "" {
|
||||
t.Fatalf("settings.json disagrees: %+v (want no due date, the set-aside path kept)", tg)
|
||||
}
|
||||
rs := o.Results()
|
||||
if len(rs) != 1 || rs[0].Outcome != report.OutcomeDone {
|
||||
t.Fatalf("result = %+v", rs)
|
||||
}
|
||||
|
||||
// Control: the same countdown, a second press → nothing to stop, reported as such (not „done").
|
||||
o.Reconcile([]report.OperatorAction{{ID: 2, Action: report.OpAbandonStop}})
|
||||
if r := o.Results(); len(r) != 1 || r[0].ID != 2 || r[0].Outcome != report.OutcomeFailed {
|
||||
t.Fatalf("second stop: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// offsite_backup_now refuses exactly where the household's „back up now" refuses: no target here.
|
||||
func TestD1_OffsiteBackupNowRefusesWithoutATarget(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
dir := t.TempDir()
|
||||
sett, err := settings.Load(filepath.Join(dir, "settings.json"), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dir
|
||||
mgr := backup.NewManager(cfg, sett, logger)
|
||||
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
|
||||
t.Fatal("no restic may run")
|
||||
return nil, nil
|
||||
})
|
||||
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
|
||||
o.Reconcile([]report.OperatorAction{{ID: 9, Action: report.OpOffsiteBackupNow}})
|
||||
if r := o.Results(); len(r) != 1 || r[0].Outcome != report.OutcomeRefused {
|
||||
t.Fatalf("result = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// run_job reaches the scheduler's RunNow: a registered job runs; a job not registered on this box is
|
||||
// refused.
|
||||
func TestD1_RunJobUsesTheScheduler(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
sched := scheduler.New(logger)
|
||||
ran := make(chan bool, 1)
|
||||
sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { ran <- scheduler.OnDemand(ctx); return nil })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sched.Start(ctx)
|
||||
defer sched.Stop()
|
||||
o := report.NewOperatorActions(ctx, operatorActionHandlers(nil, sched, nil, logger), logger)
|
||||
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpRunJob, Arg: "fill-watch"}, {ID: 2, Action: report.OpRunJob, Arg: "offsite-proof"}})
|
||||
select {
|
||||
case od := <-ran:
|
||||
if !od {
|
||||
t.Fatal("the job did not see itself as on-demand")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("fill-watch never ran")
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for len(o.Results()) < 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
got := map[int64]string{}
|
||||
for _, r := range o.Results() {
|
||||
got[r.ID] = r.Outcome
|
||||
}
|
||||
if got[1] != report.OutcomeDone || got[2] != report.OutcomeRefused {
|
||||
t.Fatalf("outcomes = %v, want 1:done 2:refused (offsite-proof not registered here)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// main.go hands the reply's list to the executor and the trigger to its holder — one call each
|
||||
// (the seam-built-but-never-wired trap: a green executor that main never calls proves nothing).
|
||||
func TestD1_MainWiresTheExecutor(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := map[string]int{}
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch fn := call.Fun.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if x, ok := fn.X.(*ast.Ident); ok {
|
||||
found[x.Name+"."+fn.Sel.Name]++
|
||||
}
|
||||
case *ast.Ident:
|
||||
found[fn.Name]++
|
||||
}
|
||||
return true
|
||||
})
|
||||
for _, want := range []string{"opActions.Reconcile", "report.SetOperatorActions", "operatorActionHandlers", "opActionReportFire.Store"} {
|
||||
if found[want] != 1 {
|
||||
t.Errorf("main.go calls %s %d time(s), want exactly 1", want, found[want])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-1199 (rehearsal 2026-10-20) and R-1199 REOPENED (2026-10-22). A fresh box shows the household its DEFAULT
|
||||
// notification list, but the hub mails only by its OWN stored list. v0.338.0 pushed that default at every start when
|
||||
// nothing was saved on the box — and on a REBUILT box that overwrote the list the household had saved on its old box
|
||||
// (demo-felhom, 07:54:55 UTC 2026-10-22: its own 13 events became the 17 defaults), again at every start, because the
|
||||
// push created no local save. `08` „the household decides".
|
||||
//
|
||||
// Now, at start:
|
||||
// - a list saved on this box: as before (R-922 — re-pushed when it carries an address or a clear), as the household's;
|
||||
// - nothing saved here: READ the hub's list first. One the household saved (on any box) is ADOPTED as this box's
|
||||
// list — nothing is pushed; otherwise the box's default is pushed once, as a default, and saved here, so the next
|
||||
// start sends nothing. A hub that cannot be read (an older hub, no network) gets nothing; the next start asks again.
|
||||
//
|
||||
// The hub refuses a default over a household's list on its own side too (felhom.eu hub household_prefs.go), which also
|
||||
// stops a box still on v0.338.0. Pinned by TestR1199Reopened_* (prefs_sync_wiring_test.go).
|
||||
|
||||
type prefsHub interface {
|
||||
FetchPreferences() (*notify.HubPrefs, error)
|
||||
SyncPreferencesAs(source, email string, enabledEvents []string, cooldownHours int, emailCleared bool) (string, error)
|
||||
}
|
||||
|
||||
type prefsStore interface {
|
||||
GetNotificationPrefs() *settings.NotificationPrefs
|
||||
NotificationPrefsSaved() bool
|
||||
SetNotificationPrefs(*settings.NotificationPrefs) error
|
||||
}
|
||||
|
||||
// startupPrefsSync runs the start-up step above and returns what it did (for the log and the tests): "pushed",
|
||||
// "adopted", "default-pushed", "default-kept", "nothing", "no-hub" or "error".
|
||||
func startupPrefsSync(hub prefsHub, st prefsStore, logger *log.Logger) string {
|
||||
logf := func(f string, a ...any) {
|
||||
if logger != nil {
|
||||
logger.Printf(f, a...)
|
||||
}
|
||||
}
|
||||
prefs := st.GetNotificationPrefs()
|
||||
if st.NotificationPrefsSaved() {
|
||||
if !prefs.SyncOnStartup() {
|
||||
return "nothing"
|
||||
}
|
||||
if _, err := hub.SyncPreferencesAs(notify.PrefsSourceHousehold, prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil {
|
||||
logf("[WARN] Failed to sync notification preferences on startup: %v", err)
|
||||
return "error"
|
||||
}
|
||||
return "pushed"
|
||||
}
|
||||
hp, err := hub.FetchPreferences()
|
||||
if err != nil {
|
||||
logf("[WARN] notification prefs: no list saved on this box and the hub's could not be read (%v) — nothing pushed; asked again at the next start (R-1199)", err)
|
||||
return "no-hub"
|
||||
}
|
||||
if hp.Exists && hp.HouseholdSaved {
|
||||
adopted := &settings.NotificationPrefs{Email: hp.Email, EnabledEvents: hp.EnabledEvents, CooldownHours: hp.CooldownHours}
|
||||
if adopted.EnabledEvents == nil {
|
||||
adopted.EnabledEvents = []string{}
|
||||
}
|
||||
if err := st.SetNotificationPrefs(adopted); err != nil {
|
||||
logf("[ERROR] notification prefs: adopting the household's list from the hub failed: %v", err)
|
||||
return "error"
|
||||
}
|
||||
logf("[INFO] notification prefs: adopted the household's saved list from the hub (%d events, address set=%t) — nothing pushed (R-1199)", len(adopted.EnabledEvents), adopted.Email != "")
|
||||
return "adopted"
|
||||
}
|
||||
status, err := hub.SyncPreferencesAs(notify.PrefsSourceDefault, "", prefs.EnabledEvents, prefs.CooldownHours, false)
|
||||
if err != nil {
|
||||
logf("[WARN] notification prefs: pushing the default list failed: %v", err)
|
||||
return "error"
|
||||
}
|
||||
if status == "kept" {
|
||||
logf("[INFO] notification prefs: the hub kept the household's own list over this box's default — adopted at the next start (R-1199)")
|
||||
return "default-kept"
|
||||
}
|
||||
local := &settings.NotificationPrefs{EnabledEvents: prefs.EnabledEvents, CooldownHours: prefs.CooldownHours}
|
||||
if err := st.SetNotificationPrefs(local); err != nil {
|
||||
logf("[ERROR] notification prefs: saving the pushed default on this box failed: %v", err)
|
||||
return "error"
|
||||
}
|
||||
logf("[INFO] notification prefs: no household list anywhere — pushed this box's default once and saved it here (R-1199)")
|
||||
return "default-pushed"
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-1199 REOPENED (2026-10-22) — the brief's four cases, against the real settings file and a hub that behaves like
|
||||
// hub ≥ 0.165.0 (it keeps a household's list over a default push).
|
||||
// RED-PROOF: with v0.338.0's rule restored (no hub read: a box with nothing saved pushes its default at every start)
|
||||
// case A fails (the default reaches the hub) and case D fails (a second push) — seen failing 2026-10-22.
|
||||
|
||||
type fakeHub struct {
|
||||
stored *notify.HubPrefs
|
||||
readErr error
|
||||
pushes []string // the source of every push
|
||||
lastEvents []string
|
||||
}
|
||||
|
||||
func (f *fakeHub) FetchPreferences() (*notify.HubPrefs, error) {
|
||||
if f.readErr != nil {
|
||||
return nil, f.readErr
|
||||
}
|
||||
if f.stored == nil {
|
||||
return ¬ify.HubPrefs{Exists: false}, nil
|
||||
}
|
||||
cp := *f.stored
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeHub) SyncPreferencesAs(source, email string, ev []string, cd int, cleared bool) (string, error) {
|
||||
f.pushes = append(f.pushes, source)
|
||||
if source != notify.PrefsSourceHousehold && f.stored != nil && f.stored.HouseholdSaved {
|
||||
return "kept", nil
|
||||
}
|
||||
if f.stored == nil {
|
||||
f.stored = ¬ify.HubPrefs{Exists: true}
|
||||
}
|
||||
f.stored.EnabledEvents, f.lastEvents = ev, ev
|
||||
if email != "" {
|
||||
f.stored.Email = email
|
||||
}
|
||||
if source == notify.PrefsSourceHousehold {
|
||||
f.stored.HouseholdSaved = true
|
||||
}
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
func newSettings(t *testing.T) *settings.Settings {
|
||||
t.Helper()
|
||||
s, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var familyList = []string{"backup_failed", "disk_warning", "node_down"}
|
||||
|
||||
// A: the household saved on an earlier box; this box has no local save → nothing reaches the hub, the box adopts the
|
||||
// list and its page (GetNotificationPrefs) shows it.
|
||||
func TestR1199Reopened_A_ARebuiltBoxAdoptsTheHouseholdsList(t *testing.T) {
|
||||
hub := &fakeHub{stored: ¬ify.HubPrefs{Exists: true, HouseholdSaved: true, Email: "family@example.hu", EnabledEvents: familyList, CooldownHours: 12}}
|
||||
st := newSettings(t)
|
||||
if got := startupPrefsSync(hub, st, nil); got != "adopted" {
|
||||
t.Fatalf("startup = %q, want adopted", got)
|
||||
}
|
||||
if len(hub.pushes) != 0 {
|
||||
t.Fatalf("a box with no list of its own pushed %v over the household's list", hub.pushes)
|
||||
}
|
||||
p := st.GetNotificationPrefs()
|
||||
if !st.NotificationPrefsSaved() || len(p.EnabledEvents) != 3 || p.Email != "family@example.hu" || p.CooldownHours != 12 {
|
||||
t.Fatalf("the page must show the household's list: %+v", p)
|
||||
}
|
||||
// D after A: the next start re-pushes the adopted list as the household's (it carries an address, R-922) — the
|
||||
// same list, never the default.
|
||||
startupPrefsSync(hub, st, nil)
|
||||
if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceHousehold || len(hub.lastEvents) != 3 {
|
||||
t.Fatalf("after adoption: pushes %v events %v", hub.pushes, hub.lastEvents)
|
||||
}
|
||||
}
|
||||
|
||||
// B: no household ever saved (a seeded row, or none) → the default goes once, is saved here, and the hub mails by it.
|
||||
func TestR1199Reopened_B_NoHouseholdListAnywhereTakesTheDefaultOnce(t *testing.T) {
|
||||
for _, stored := range []*notify.HubPrefs{nil, {Exists: true, Email: "seed@example.hu", EnabledEvents: []string{"node_down"}}} {
|
||||
hub := &fakeHub{stored: stored}
|
||||
st := newSettings(t)
|
||||
if got := startupPrefsSync(hub, st, nil); got != "default-pushed" {
|
||||
t.Fatalf("startup = %q, want default-pushed", got)
|
||||
}
|
||||
if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceDefault || len(hub.stored.EnabledEvents) != len(settings.DefaultEnabledEvents) {
|
||||
t.Fatalf("the default must reach the hub once, as a default: %v %v", hub.pushes, hub.stored.EnabledEvents)
|
||||
}
|
||||
if !st.NotificationPrefsSaved() {
|
||||
t.Fatal("the pushed default must be saved on the box")
|
||||
}
|
||||
// D: a restart sends nothing (a local save exists, with no address).
|
||||
if got := startupPrefsSync(hub, st, nil); got != "nothing" || len(hub.pushes) != 1 {
|
||||
t.Fatalf("restart after B: %q, pushes %v — want nothing", got, hub.pushes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// C: the household saves on the box → the page save pushes as the household's (web handler, unchanged path); at start
|
||||
// a saved list with an address is re-pushed as the household's (R-922).
|
||||
func TestR1199Reopened_C_ASavedListIsTheHouseholds(t *testing.T) {
|
||||
hub := &fakeHub{stored: ¬ify.HubPrefs{Exists: true, HouseholdSaved: true, EnabledEvents: familyList}}
|
||||
st := newSettings(t)
|
||||
st.SetNotificationPrefs(&settings.NotificationPrefs{Email: "new@example.hu", EnabledEvents: []string{"node_down"}})
|
||||
if got := startupPrefsSync(hub, st, nil); got != "pushed" || hub.pushes[0] != notify.PrefsSourceHousehold {
|
||||
t.Fatalf("a saved list: %q %v", got, hub.pushes)
|
||||
}
|
||||
if len(hub.stored.EnabledEvents) != 1 {
|
||||
t.Fatalf("the household's new choice must reach the hub: %v", hub.stored.EnabledEvents)
|
||||
}
|
||||
}
|
||||
|
||||
// A hub that cannot be read gets nothing (never a blind default); a hub that keeps its list over a race is not
|
||||
// overwritten and the box saves nothing (it adopts at the next start).
|
||||
func TestR1199Reopened_AnUnreadableHubGetsNothing(t *testing.T) {
|
||||
hub := &fakeHub{readErr: errors.New("hub answered HTTP 404")}
|
||||
st := newSettings(t)
|
||||
if got := startupPrefsSync(hub, st, nil); got != "no-hub" || len(hub.pushes) != 0 || st.NotificationPrefsSaved() {
|
||||
t.Fatalf("unreadable hub: %q pushes %v saved %t", got, hub.pushes, st.NotificationPrefsSaved())
|
||||
}
|
||||
}
|
||||
|
||||
// The default list the page shows carries the three events the rehearsal household did not get (kept from R-1199).
|
||||
func TestR1199_TheDefaultListHoldsTheAlarmsTheHouseholdMissed(t *testing.T) {
|
||||
want := map[string]bool{"disk_warning": false, "storage_disconnected": false, "crossdrive_failed": false}
|
||||
for _, e := range settings.DefaultEnabledEvents {
|
||||
if _, ok := want[e]; ok {
|
||||
want[e] = true
|
||||
}
|
||||
}
|
||||
for e, ok := range want {
|
||||
if !ok {
|
||||
t.Fatalf("%s is not in the default list", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1105: restic is pinned OUTSIDE the OS. Until v0.323.0 it came from the runtime's apt list, so the
|
||||
// version was „whatever Debian ships" — a Debian 12 → 13 base move would have swapped restic 0.14.0 for
|
||||
// 0.18.0 under the off-site code with nobody choosing it. The consequences pinned here:
|
||||
// - the version is one explicit ARG, and each architecture's checksum is pinned beside it;
|
||||
// - the fetch checks restic's signature (vendored key, no keyserver) AND both checksums;
|
||||
// - the runtime takes restic ONLY from that stage — no apt package can bring a second one;
|
||||
// - every base image is pinned by digest, and the Docker repo's codename is read from the image.
|
||||
func TestR1105_ResticIsPinnedOutsideTheOS(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(raw)
|
||||
// Stages, split on FROM, with `\`-continued lines joined so an apt list reads as one command.
|
||||
joined := strings.ReplaceAll(src, "\\\n", " ")
|
||||
var stages []string
|
||||
for _, part := range regexp.MustCompile(`(?m)^FROM `).Split(joined, -1)[1:] {
|
||||
stages = append(stages, "FROM "+part)
|
||||
}
|
||||
if len(stages) < 3 {
|
||||
t.Fatalf("expected a builder, a restic and a runtime stage, found %d stages", len(stages))
|
||||
}
|
||||
var fetch string
|
||||
for _, s := range stages {
|
||||
if regexp.MustCompile(`^FROM \S+ AS restic\b`).MatchString(s) {
|
||||
fetch = s
|
||||
}
|
||||
}
|
||||
if fetch == "" {
|
||||
t.Fatal("no `AS restic` stage: restic is no longer fetched as a pinned release binary")
|
||||
}
|
||||
runtime := stages[len(stages)-1]
|
||||
|
||||
if m := regexp.MustCompile(`ARG RESTIC_VERSION=(\d+\.\d+\.\d+)\s`).FindStringSubmatch(fetch); m == nil {
|
||||
t.Error("ARG RESTIC_VERSION=X.Y.Z is missing — the version must be an explicit decision")
|
||||
}
|
||||
for _, arch := range []string{"AMD64", "ARM64"} {
|
||||
if !regexp.MustCompile(`ARG RESTIC_SHA256_` + arch + `=[0-9a-f]{64}\s`).MatchString(fetch) {
|
||||
t.Errorf("no pinned sha256 for %s", arch)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"COPY third_party/restic/restic-release-key.asc",
|
||||
"gpgv --keyring", // SHA256SUMS is restic's, signed
|
||||
`SHA256SUMS | sha256sum -c -`, // the download matches the published sum
|
||||
`echo "${want} ${f}" | sha256sum -c -`, // ...and the sum pinned in this file
|
||||
} {
|
||||
if !strings.Contains(fetch, want) {
|
||||
t.Errorf("the restic stage no longer does %q", want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("../../third_party/restic/restic-release-key.asc"); err != nil {
|
||||
t.Errorf("the vendored release key is missing: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(runtime, "COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic") {
|
||||
t.Error("the runtime does not take restic from the pinned stage")
|
||||
}
|
||||
aptRe := regexp.MustCompile(`apt-get install[^&]*`)
|
||||
for _, inst := range aptRe.FindAllString(runtime, -1) {
|
||||
if regexp.MustCompile(`\srestic(\s|$)`).MatchString(inst) {
|
||||
t.Errorf("the runtime's apt list installs restic — a second, OS-chosen restic: %q", strings.Join(strings.Fields(inst), " "))
|
||||
}
|
||||
}
|
||||
|
||||
for _, from := range regexp.MustCompile(`(?m)^FROM (\S+)`).FindAllStringSubmatch(src, -1) {
|
||||
if !strings.Contains(from[1], "@sha256:") {
|
||||
t.Errorf("base image %s is not pinned by digest", from[1])
|
||||
}
|
||||
}
|
||||
if regexp.MustCompile(`download\.docker\.com/linux/debian (bookworm|trixie|forky)`).MatchString(src) {
|
||||
t.Error("the Docker apt repo codename is hard-coded — it must come from /etc/os-release (VERSION_CODENAME)")
|
||||
}
|
||||
if !strings.Contains(runtime, "${VERSION_CODENAME}") {
|
||||
t.Error("the Docker apt repo line does not read VERSION_CODENAME")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1176 (2026-10-21): the off-site apply-bridge waits only until the FLOOR is known (offsiteapply.AwaitSettle), and
|
||||
// then may mint a repository password unless the box has recorded that the hub holds the household's sealed package
|
||||
// (R-241's mint guard reads that record). Both facts arrive in the same report ACK, so inside OnPushResponse the
|
||||
// escrow must be recorded BEFORE updater.SetFloor makes the floor known — otherwise a bridge poll landing between the
|
||||
// two mints a fresh key on a rebuilt box and the household's history is set aside on its first night.
|
||||
// Walks main.go's AST with comments dropped, so a commented-out call cannot satisfy it.
|
||||
// COMPANION RED-PROOF: move escrowConfirmer.Reconcile below updater.SetFloor → this fails.
|
||||
func TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var escrowPos, floorPos token.Pos
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
as, ok := n.(*ast.AssignStmt)
|
||||
if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
|
||||
return true
|
||||
}
|
||||
sel, ok := as.Lhs[0].(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "OnPushResponse" {
|
||||
return true
|
||||
}
|
||||
ast.Inspect(as.Rhs[0], func(m ast.Node) bool {
|
||||
call, ok := m.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if s, ok := call.Fun.(*ast.SelectorExpr); ok {
|
||||
if x, ok := s.X.(*ast.Ident); ok {
|
||||
switch {
|
||||
case x.Name == "escrowConfirmer" && s.Sel.Name == "Reconcile" && escrowPos == 0:
|
||||
escrowPos = call.Pos()
|
||||
case x.Name == "updater" && s.Sel.Name == "SetFloor" && floorPos == 0:
|
||||
floorPos = call.Pos()
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
if escrowPos == 0 || floorPos == 0 {
|
||||
t.Fatalf("OnPushResponse no longer calls both escrowConfirmer.Reconcile (%v) and updater.SetFloor (%v) — the order is unasserted", escrowPos, floorPos)
|
||||
}
|
||||
if escrowPos > floorPos {
|
||||
t.Fatalf("escrowConfirmer.Reconcile (%s) runs AFTER updater.SetFloor (%s): a rebuilt box can mint a fresh key before it learns the hub holds the household's package",
|
||||
fset.Position(escrowPos), fset.Position(floorPos))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1189 (d): the catch-up in main.go carries the After hook that runs the off-site proof and the due-gated integrity
|
||||
// check (internal/nightchain TestR1189d_* pins that After runs; this pins that production wires it).
|
||||
// RED-PROOF: the After block removed from main.go → this fails.
|
||||
func TestR1189d_TheCatchUpIsWiredToTheChecks(t *testing.T) {
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
i := strings.Index(s, "catchUp := &nightchain.CatchUp{")
|
||||
if i < 0 {
|
||||
t.Fatal("the catch-up literal is gone from main.go")
|
||||
}
|
||||
lit := s[i:]
|
||||
lit = lit[:strings.Index(lit, "\n\t\t\t}\n")]
|
||||
a := strings.Index(lit, "After: func(ctx context.Context) {")
|
||||
if a < 0 || !strings.Contains(lit[a:], "runOffsiteProof(") || !strings.Contains(lit[a:], "runOffsiteIntegrityCheck(ctx, backupMgr, notifier, logger, false)") {
|
||||
t.Fatal("the catch-up must run the off-site proof and the due-gated (force=false) integrity check after its legs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// R-1253 (review round 2, HUNT-1 H3): an export with „stop the app first" during another app's restore replaced the
|
||||
// ONE app-stop marker — the restore's crash record and its Undo were lost, and a power cut then left the restored app
|
||||
// on mixed data with no put-back. The test is the review's mechanism with two Begins on one guard, through the
|
||||
// exporter's real adapter. RED-PROOF (2026-10-24): with exportStopGuard.Begin calling Begin instead of BeginExclusive,
|
||||
// this fails on „the restore's Undo is gone from the marker".
|
||||
func TestR1253_AnExportNeverReplacesARestoresMarker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, "appstop-state.json")
|
||||
g := backup.NewAppStopGuard(marker, log.New(io.Discard, "", 0))
|
||||
if err := g.Begin("unit-restore:bookstack", backup.ReasonUnitRestore, []string{"bookstack"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
undo := filepath.Join(dir, "backups", "restore-undo", "bookstack", "20261024T200000Z")
|
||||
if err := g.SetUndo("bookstack", undo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ex := exportStopGuard{g: g}
|
||||
if busy := ex.BusyWith(); busy != "unit-restore:bookstack" {
|
||||
t.Fatalf("the export must see the restore holding the marker, got %q", busy)
|
||||
}
|
||||
err := ex.Begin("app-export:immich", []string{"immich"})
|
||||
if !errors.Is(err, backup.ErrAppStopBusy) {
|
||||
t.Fatalf("the export's Begin must be refused while the restore holds the marker, got %v", err)
|
||||
}
|
||||
b, _ := os.ReadFile(marker)
|
||||
if !strings.Contains(string(b), undo) || strings.Contains(string(b), "immich") {
|
||||
t.Fatalf("the restore's Undo is gone from the marker (or the export replaced it): %s", b)
|
||||
}
|
||||
// the restore ends → the export may run
|
||||
g.End()
|
||||
if err := ex.Begin("app-export:immich", []string{"immich"}); err != nil {
|
||||
t.Fatalf("after the restore ended the export must start: %v", err)
|
||||
}
|
||||
ex.End()
|
||||
// a marker a CRASH left (a new process over the same file) does not block: nothing in this process will End it
|
||||
if err := g.Begin("volume-dump:paperless", backup.ReasonVolumeDump, []string{"paperless"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := exportStopGuard{g: backup.NewAppStopGuard(marker, log.New(io.Discard, "", 0))}
|
||||
if busy := fresh.BusyWith(); busy != "" {
|
||||
t.Fatalf("a crash-left marker is not a live operation, got %q", busy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-208: `ARG VERSION` / `ARG GIT_COMMIT` declared ABOVE `RUN go mod download` put a per-build value
|
||||
// into that RUN's cache key, so every release re-downloaded the modules (measured: 208 cache records,
|
||||
// every one with usage count 1). The consequence pinned here, in the builder stage: no ARG whose value
|
||||
// changes per build is declared before the module-download step, and both are still declared before
|
||||
// the `go build` that reads them (or the binary would report "dev"/"unknown").
|
||||
func TestR208_DockerfileVersionArgsSitBelowModuleDownload(t *testing.T) {
|
||||
src, err := os.ReadFile("../../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(string(src), "\n")
|
||||
argRe := regexp.MustCompile(`^\s*ARG\s+(VERSION|GIT_COMMIT|BUILD_TIME)\b`)
|
||||
download, build := -1, -1
|
||||
args := map[string]int{}
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "FROM ") && i > 0 && download >= 0 && build >= 0 {
|
||||
break // the runtime stage: only the builder matters
|
||||
}
|
||||
if m := argRe.FindStringSubmatch(l); m != nil {
|
||||
if _, seen := args[m[1]]; !seen {
|
||||
args[m[1]] = i // the FIRST declaration: any one above the download is the defect
|
||||
}
|
||||
}
|
||||
if strings.Contains(l, "go mod download") && download < 0 {
|
||||
download = i
|
||||
}
|
||||
if strings.Contains(l, "go build") && build < 0 {
|
||||
build = i
|
||||
}
|
||||
}
|
||||
if download < 0 || build < 0 {
|
||||
t.Fatalf("the builder stage no longer has a `go mod download` (%d) or a `go build` (%d) — this test reads the wrong file", download, build)
|
||||
}
|
||||
for _, name := range []string{"VERSION", "GIT_COMMIT"} {
|
||||
at, ok := args[name]
|
||||
if !ok {
|
||||
t.Errorf("ARG %s is not declared in the builder stage — the binary would be built without it", name)
|
||||
continue
|
||||
}
|
||||
if at < download {
|
||||
t.Errorf("R-208: ARG %s (line %d) is declared above `go mod download` (line %d), so every build with a new value re-downloads the modules", name, at+1, download+1)
|
||||
}
|
||||
if at > build {
|
||||
t.Errorf("ARG %s (line %d) is declared after the `go build` that reads it (line %d)", name, at+1, build+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// R-359 — the scheduled job must be PROVEN WIRED.
|
||||
@@ -87,16 +89,33 @@ func TestR359_DebugCallbackIsWired(t *testing.T) {
|
||||
|
||||
func TestR359_OutcomeMessagesCarryNoMachineDetail(t *testing.T) {
|
||||
// The customer gets a sentence; restic's words go to the log. R-379: 615 bytes of raw database
|
||||
// text reached a customer once.
|
||||
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
|
||||
if contains(integrityFailedMsg, bad) {
|
||||
t.Errorf("the failure sentence carries machine detail %q: %q", bad, integrityFailedMsg)
|
||||
}
|
||||
// text reached a customer once. R-585: the sentence is a bundle key now, so BOTH languages are
|
||||
// held to it — an English household must be told what not to do just as plainly.
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// It must still tell them what to do — and what NOT to do.
|
||||
for _, want := range []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"} {
|
||||
if !contains(integrityFailedMsg, want) {
|
||||
t.Errorf("the failure sentence is a dead end; missing %q", want)
|
||||
for _, tc := range []struct {
|
||||
lang string
|
||||
wants []string
|
||||
}{
|
||||
{"hu", []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"}},
|
||||
{"en", []string{"Do not delete anything", "contact us"}},
|
||||
} {
|
||||
msg, fellBack, ok := b.Text(tc.lang, "event.backup_integrity_failed")
|
||||
if !ok || fellBack {
|
||||
t.Fatalf("%s: event.backup_integrity_failed is missing (ok=%v fellBack=%v)", tc.lang, ok, fellBack)
|
||||
}
|
||||
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
|
||||
if contains(msg, bad) {
|
||||
t.Errorf("%s: the failure sentence carries machine detail %q: %q", tc.lang, bad, msg)
|
||||
}
|
||||
}
|
||||
// It must still tell them what to do — and what NOT to do.
|
||||
for _, want := range tc.wants {
|
||||
if !contains(msg, want) {
|
||||
t.Errorf("%s: the failure sentence is a dead end; missing %q", tc.lang, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-363 / R-547: the fill watcher ran once a day (plus once after start), so a filesystem that filled
|
||||
// right after the 03:30 run went unannounced for ~24 h, and a disk held at 96 % for ten minutes raised
|
||||
// nothing. The consequence pinned here: main.go registers the SAME fillWatcher.Check on a periodic
|
||||
// job whose interval is no longer than the shortest measured silent window (10 min), so any such
|
||||
// window contains a check. Edge-triggering (no repeat mails at a faster cadence) is pinned in
|
||||
// internal/fillwatch (TestWarnsOnceThenIsSilent, TestEdgeStateSurvivesARestart).
|
||||
func TestFillWatchRunsOnAnInterval(t *testing.T) {
|
||||
const measuredSilentWindow = 10 * time.Minute // R-547, chaos night 2026-09-17
|
||||
if fillWatchInterval <= 0 || fillWatchInterval > measuredSilentWindow {
|
||||
t.Errorf("fillWatchInterval = %s; it must be >0 and <= %s, or a fill window like R-547's can pass unseen", fillWatchInterval, measuredSilentWindow)
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
periodic := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Every" || len(call.Args) != 3 {
|
||||
return true
|
||||
}
|
||||
iv, ok := call.Args[1].(*ast.Ident)
|
||||
if !ok || iv.Name != "fillWatchInterval" {
|
||||
return true
|
||||
}
|
||||
// The job body must call fillWatcher.Check — the same edge-triggered check, not a new one.
|
||||
ast.Inspect(call.Args[2], func(m ast.Node) bool {
|
||||
if c, ok := m.(*ast.CallExpr); ok {
|
||||
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "Check" {
|
||||
if x, ok := s.X.(*ast.Ident); ok && x.Name == "fillWatcher" {
|
||||
periodic++
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
if periodic != 1 {
|
||||
t.Errorf("R-363: main.go registers fillWatcher.Check on a periodic (sched.Every, fillWatchInterval) job %d times, want 1 — without it the fill check is daily only", periodic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-475 — the update reads EVERY backup tier, through the one adapter.
|
||||
|
||||
func TestR475_TierConstantsAgree(t *testing.T) {
|
||||
if stacks.UpdateTierLocal != backup.UpdateTierLocal ||
|
||||
stacks.UpdateTierSecondDrive != backup.UpdateTierSecondDrive ||
|
||||
stacks.UpdateTierOffsite != backup.UpdateTierOffsite {
|
||||
t.Fatal("stacks and backup number the tiers differently — a hold would name the wrong copy")
|
||||
}
|
||||
}
|
||||
|
||||
// adapterMethodSelectors returns the selector names used in updateGuardsAdapter.<name>'s body.
|
||||
func adapterMethodSelectors(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Recv == nil || fn.Name.Name != name || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
star, ok := fn.Recv.List[0].Type.(*ast.StarExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id, ok := star.X.(*ast.Ident); !ok || id.Name != "updateGuardsAdapter" {
|
||||
continue
|
||||
}
|
||||
var names []string
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if sel, ok := n.(*ast.SelectorExpr); ok {
|
||||
names = append(names, sel.Sel.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return " " + strings.Join(names, " ") + " "
|
||||
}
|
||||
t.Fatalf("updateGuardsAdapter.%s not found in main.go", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestR475_AdapterReadsEveryTier(t *testing.T) {
|
||||
rp := adapterMethodSelectors(t, "RestorePoints")
|
||||
if !strings.Contains(rp, " UpdateRestorePoints ") {
|
||||
t.Errorf("the adapter must read every tier through backup.UpdateRestorePoints; selectors:%s", rp)
|
||||
}
|
||||
if strings.Contains(rp, " Tier2UnitRestorePoint ") {
|
||||
t.Error("the Tier-2-only predicate must not come back into the update path (R-475)")
|
||||
}
|
||||
if cb := adapterMethodSelectors(t, "CanBackUp"); !strings.Contains(cb, " CanBackUpApp ") {
|
||||
t.Errorf("CanBackUp must ask backup.CanBackUpApp; selectors:%s", cb)
|
||||
}
|
||||
// R-659 (v0.268.0) CHANGED THIS ON PURPOSE: the hold no longer names the precondition copy the
|
||||
// adapter is handed (round 11: that copy was one the restore refuses). The tier, and R-479's
|
||||
// "what the copy holds", are now chosen inside backup.HoldAfterFailedUpdateWhole from every tier —
|
||||
// pinned there by TestR659_TheHoldNamesOnlyAWholeCopy (which asserts the holds-phrase too).
|
||||
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " HoldAfterFailedUpdateWhole ") {
|
||||
t.Errorf("the hold must be recorded through HoldAfterFailedUpdateWhole (R-659); selectors:%s", h)
|
||||
}
|
||||
}
|
||||
|
||||
// R-490 — /api/system/info reaches the API router; the web layer's /api/system/ prefix used to eat it.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): delete the exact-path mount — this fails.
|
||||
func TestR490_SystemInfoIsMountedAheadOfTheWebPrefix(t *testing.T) {
|
||||
src, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var exact, prefix bool
|
||||
ast.Inspect(src, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) < 2 {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Handle" {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch lit.Value {
|
||||
case `"/api/system/info"`:
|
||||
exact = strings.Contains(fmtNode(call.Args[1]), "apiRouter")
|
||||
case `"/api/system/"`:
|
||||
prefix = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !prefix {
|
||||
t.Fatal("the /api/system/ prefix mount is gone — this test's premise moved")
|
||||
}
|
||||
if !exact {
|
||||
t.Error("/api/system/info must be mounted on the API router ahead of the web layer's /api/system/ prefix (R-490)")
|
||||
}
|
||||
}
|
||||
|
||||
func fmtNode(n ast.Node) string {
|
||||
var names []string
|
||||
ast.Inspect(n, func(x ast.Node) bool {
|
||||
if id, ok := x.(*ast.Ident); ok {
|
||||
names = append(names, id.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return strings.Join(names, " ")
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
type fakeHDD struct {
|
||||
sts []stacks.Stack
|
||||
hdd map[string]string
|
||||
}
|
||||
|
||||
func (f fakeHDD) GetStacks() []stacks.Stack { return f.sts }
|
||||
func (f fakeHDD) LoadAppConfigByName(n string) *stacks.AppConfig {
|
||||
return &stacks.AppConfig{Env: map[string]string{"HDD_PATH": f.hdd[n]}}
|
||||
}
|
||||
|
||||
// R-521: one unplugged drive mailed the operator five times — storage_disconnected plus an
|
||||
// app_start_failed per app on it. The consequence asserted: the NOTIFIER is told the drive's apps are
|
||||
// not down (no per-app mail), while the dashboard's dead list still carries them, and an app on
|
||||
// another drive that is genuinely down still alarms.
|
||||
func TestR521_DriveLostAppsDoNotMailOneByOne(t *testing.T) {
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{"/mnt/felhom-drives/usb", "/mnt/felhom-drives/other"} {
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: p, Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", true, []string{"immich"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sts := []stacks.Stack{
|
||||
stack("immich", stacks.StateExited, true, false), // recorded by the disconnect
|
||||
stack("paperless", stacks.StateExited, true, false), // on the lost drive by HDD_PATH
|
||||
stack("romm", stacks.StateExited, true, false), // another drive, genuinely broken
|
||||
}
|
||||
for i := range sts {
|
||||
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
|
||||
}
|
||||
mgr := fakeHDD{sts: sts, hdd: map[string]string{"immich": "/mnt/felhom-drives/usb", "paperless": "/mnt/felhom-drives/usb", "romm": "/mnt/felhom-drives/other"}}
|
||||
|
||||
dead, states := classifyRunStates(sts, nil, nil, time.Now())
|
||||
notified := downByName(maskDriveLost(states, driveLostApps(sett, mgr)))
|
||||
if notified["immich"] || notified["paperless"] {
|
||||
t.Fatalf("R-521: an app stopped by the lost drive is still reported down to the notifier: %v", notified)
|
||||
}
|
||||
if !notified["romm"] {
|
||||
t.Fatal("a genuinely broken app on another drive stopped alarming — over-correction")
|
||||
}
|
||||
if dn := deadNames(dead); !dn["immich"] || !dn["paperless"] {
|
||||
t.Fatalf("the dashboard's dead list must still carry the drive's apps: %v", dn)
|
||||
}
|
||||
// Reconnected: nothing is masked any more.
|
||||
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := driveLostApps(sett, mgr); len(got) != 0 {
|
||||
t.Fatalf("with no drive disconnected nothing may be masked, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The wiring: the dead-app job hands the notifier the masked states.
|
||||
func TestR521_DeadAppJobMasksDriveLost(t *testing.T) {
|
||||
found := false
|
||||
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "NotifyAppStartFailures" || len(call.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
if inner, ok := call.Args[0].(*ast.CallExpr); ok {
|
||||
if id, ok := inner.Fun.(*ast.Ident); ok && id.Name == "maskDriveLost" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("R-521: main calls NotifyAppStartFailures without maskDriveLost")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/fillwatch"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
type fillPushRecord struct {
|
||||
eventType, severity, message, household string
|
||||
details interface{}
|
||||
}
|
||||
|
||||
type fakeFillPusher struct{ got []fillPushRecord }
|
||||
|
||||
func (f *fakeFillPusher) PushEventHousehold(eventType, severity, message, household string, details interface{}) {
|
||||
f.got = append(f.got, fillPushRecord{eventType, severity, message, household, details})
|
||||
}
|
||||
|
||||
// R-521 F7 (BIGNIGHT 2026-09-14): the guest's own system disk at 95 % produced only `health_degraded`,
|
||||
// whose mail a cooldown swallowed — no disk-specific event reached the hub. The consequence pinned here,
|
||||
// through the REAL target list (fillTargets), the REAL watcher and the REAL push closure (fillEventPush):
|
||||
// the guest's root filesystem at F7's numbers sends the hub's existing `disk_critical` event (severity
|
||||
// critical, a type the hub already allowlists), ONCE (edge-triggered), carrying the Hungarian sentence
|
||||
// AND the household's own (English here).
|
||||
func TestR521F7_RootFilesystemSendsTheDiskEventInBothLanguages(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.SystemDataPath = "/mnt/sys_drive"
|
||||
usage := func(p string) *fillwatch.Usage {
|
||||
if p == system.DockerVolumePath { // the guest's root filesystem as the controller sees it
|
||||
return &fillwatch.Usage{UsedPercent: 95, AvailGB: 3.5, UsedGB: 61.8, TotalGB: 68.7}
|
||||
}
|
||||
return &fillwatch.Usage{UsedPercent: 30, AvailGB: 50, UsedGB: 20, TotalGB: 70}
|
||||
}
|
||||
w := fillwatch.New(filepath.Join(t.TempDir(), "fill.json"), log.New(io.Discard, "", 0),
|
||||
func() []fillwatch.Target { return fillTargets(cfg, nil) }, usage)
|
||||
rec := &fakeFillPusher{}
|
||||
w.SetNotify(fillEventPush(rec))
|
||||
w.SetLang(func() string { return "en" })
|
||||
|
||||
for i := 0; i < 2; i++ { // the second check sees the same band: nothing new (edge-triggered)
|
||||
if err := w.Check(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(rec.got) != 1 {
|
||||
t.Fatalf("pushes = %d (%+v), want exactly 1 disk event for the root filesystem", len(rec.got), rec.got)
|
||||
}
|
||||
p := rec.got[0]
|
||||
if p.eventType != "disk_critical" || p.severity != "critical" {
|
||||
t.Errorf("pushed %s/%s, want disk_critical/critical", p.eventType, p.severity)
|
||||
}
|
||||
if d, _ := p.details.(map[string]any); d == nil || d["path"] != system.DockerVolumePath {
|
||||
t.Errorf("details = %v, want path %q", p.details, system.DockerVolumePath)
|
||||
}
|
||||
if !strings.Contains(p.message, "Alkalmaz") || !strings.Contains(p.message, "kritikusan") {
|
||||
t.Errorf("Hungarian message = %q", p.message)
|
||||
}
|
||||
if !strings.Contains(p.household, "App area") || !strings.Contains(p.household, "critically full") {
|
||||
t.Errorf("household (English) message = %q", p.household)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-659 (v0.268.0) — the pieces are wired: the adapter records the hold through the WHOLE-copy walk
|
||||
// (never the precondition copy again), and the held-event sink sends app_hold_no_whole_copy.
|
||||
// An AST walk, not a substring (a commented-out call satisfies strings.Contains).
|
||||
func TestR659_HoldAndEventAreWired(t *testing.T) {
|
||||
_, f, _ := slice4CallLines(t)
|
||||
var calls []string
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "HoldAfterFailedUpdate" || fn.Recv == nil || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
if st, ok := fn.Recv.List[0].Type.(*ast.StarExpr); !ok || st.X.(*ast.Ident).Name != "updateGuardsAdapter" {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if sel, ok := n.(*ast.SelectorExpr); ok {
|
||||
calls = append(calls, sel.Sel.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
body := strings.Join(calls, " ")
|
||||
if !strings.Contains(body, "HoldAfterFailedUpdateWhole") {
|
||||
t.Fatal("the adapter does not record the hold through HoldAfterFailedUpdateWhole — the precondition copy would be named again (R-659)")
|
||||
}
|
||||
if strings.Contains(body, "HoldAfterFailedUpdateHolding") {
|
||||
t.Fatal("the adapter still calls HoldAfterFailedUpdateHolding with the precondition copy")
|
||||
}
|
||||
sends := false
|
||||
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||
if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "NotifyAppHoldNoWholeCopy" {
|
||||
sends = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !sends {
|
||||
t.Fatal("main never calls NotifyAppHoldNoWholeCopy — support would never be told")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-660 (v0.268.0) — an app HELD after a failed update is stopped by the product and has its own
|
||||
// event. Measured on 9202 2026-09-23 night (chaos rounds 8 and 11): `app_update_held` at 20:56:04 and
|
||||
// 21:42:39, then `app_start_failed` 11 s and 13 s later for the same moment — two alarms, one fact.
|
||||
|
||||
type fakeHeld map[string]bool
|
||||
|
||||
func (f fakeHeld) UpdateHeldStacks() map[string]bool { return f }
|
||||
|
||||
// TestR660_UpdateHeldAppIsNotReportedDown — the consequence, through the suppression the scan builds.
|
||||
// A genuinely exited app beside it must still alarm (the over-correction guard).
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make updateHeldSet return nil. This test then fails at "a held app
|
||||
// was reported DOWN".
|
||||
func TestR660_UpdateHeldAppIsNotReportedDown(t *testing.T) {
|
||||
sts := []stacks.Stack{
|
||||
stack("nextcloud", stacks.StateStopped, true, false), // held after a failed update
|
||||
stack("romm", stacks.StateExited, true, false), // genuinely broken
|
||||
}
|
||||
// A held app's intent is "running" — the household never asked for the stop.
|
||||
for i := range sts {
|
||||
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
|
||||
}
|
||||
dead, states := classifyRunStates(sts, unionSuppressed(nil, updateHeldSet(fakeHeld{"nextcloud": true})), nil, time.Now())
|
||||
down := downByName(states)
|
||||
if down["nextcloud"] || deadNames(dead)["nextcloud"] {
|
||||
t.Fatal("a held app was reported DOWN — the second, false alarm after app_update_held (R-660)")
|
||||
}
|
||||
if !down["romm"] {
|
||||
t.Fatal("a genuinely exited app stopped alarming — the fix silenced a real fault")
|
||||
}
|
||||
// Control: WITHOUT the held set the same app does alarm — so the suppression is what decides.
|
||||
_, states = classifyRunStates(sts, nil, nil, time.Now())
|
||||
if !downByName(states)["nextcloud"] {
|
||||
t.Fatal("control failed: the fixture's app does not alarm even without the suppression")
|
||||
}
|
||||
}
|
||||
|
||||
// The wiring: main passes the backup manager, and the scan unions its held set.
|
||||
func TestR660_ScanIsGivenTheHeldSet(t *testing.T) {
|
||||
_, f, _ := slice4CallLines(t)
|
||||
reads := false
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "scanDeployedAppRunStates" || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if call, ok := n.(*ast.CallExpr); ok {
|
||||
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "updateHeldSet" {
|
||||
reads = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !reads {
|
||||
t.Fatal("scanDeployedAppRunStates does not read the update-held set (R-660)")
|
||||
}
|
||||
withB := false
|
||||
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "scanDeployedAppRunStates" {
|
||||
for _, a := range call.Args {
|
||||
if x, ok := a.(*ast.Ident); ok && x.Name == "backupMgr" {
|
||||
withB = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !withB {
|
||||
t.Fatal("main calls scanDeployedAppRunStates without backupMgr — the held set is built and never read")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/quiesce"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-721 (v0.283.1) — the seam, not the rule. The rule is pinned in internal/backup and internal/quiesce with
|
||||
// fakes that DO answer WantsStopped; production hands the backup manager a *stackAdapter and the quiesce loop
|
||||
// a *stacks.Manager, and a type that does not answer makes the check silently skip. Measured live on 9202
|
||||
// 2026-09-30 with v0.283.0: the adapter lacked the method and the dump restarted a stopped app.
|
||||
// COMPANION RED-PROOF: delete stackAdapter.WantsStopped → this test fails.
|
||||
func TestR721_TheBackupsStackProviderAnswersWantsStopped(t *testing.T) {
|
||||
var prov interface{} = &stackAdapter{}
|
||||
if _, ok := prov.(interface{ WantsStopped(string) bool }); !ok {
|
||||
t.Fatal("the backup manager's production stack provider does not answer WantsStopped — a household Stop is undone by the volume dump (R-721)")
|
||||
}
|
||||
// The quiesce loop is handed the stacks manager itself (main.go quiesce.Options{Stacks: stackMgr}).
|
||||
// The startup crash recovery is handed gatedAppStopStarter{inner: stackMgr} (main.go SetStarter).
|
||||
var gs interface{} = gatedAppStopStarter{inner: (*stacks.Manager)(nil)}
|
||||
if _, ok := gs.(interface{ WantsStopped(string) bool }); !ok {
|
||||
t.Fatal("the crash recovery's starter does not answer WantsStopped (R-721)")
|
||||
}
|
||||
var mgr *stacks.Manager
|
||||
var _ quiesce.Stacks = mgr
|
||||
if _, ok := interface{}(mgr).(interface{ WantsStopped(string) bool }); !ok {
|
||||
t.Fatal("the quiesce loop's production Stacks does not answer WantsStopped (R-721)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1112: main opens the family store at start (webServer.OpenFamilyStore), so an old family.json is converted on every
|
||||
// box — not only on one where someone passes a family gate. Red-proof: remove the call from main.go → this fails.
|
||||
func TestR1112_MainOpensTheFamilyStore(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if call, ok := n.(*ast.CallExpr); ok {
|
||||
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "OpenFamilyStore" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("main.go does not call OpenFamilyStore — an old family.json keeps its session values until a family request")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/ast"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
|
||||
)
|
||||
|
||||
// TestR871_CatchUpWiring — v0.295.0 (`09` decision 109): the catch-up is built, triggered at start and on a host
|
||||
// resume, and holds the whole-guest cycle — the seam-built-but-never-wired class (four earlier instances).
|
||||
// COMPANION RED-PROOF: delete the `catchUp.Evaluate(ctx, "controller start")` line → this fails.
|
||||
func TestR871_CatchUpWiring(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
for _, call := range []string{"ResumeWatch", "SetCatchUpFn", "catchUpLegs", "NotifyBackupCatchUp"} {
|
||||
if len(lines[call]) == 0 {
|
||||
t.Errorf("%s is never called in main.go — the catch-up is built but not wired", call)
|
||||
}
|
||||
}
|
||||
// Both triggers, by their reason argument: the start (a literal) and the resume (inside ResumeWatch's callback).
|
||||
_, f, _ := slice4CallLines(t)
|
||||
start := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if call, ok := n.(*ast.CallExpr); ok {
|
||||
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Evaluate" && len(call.Args) == 2 {
|
||||
if lit, ok := call.Args[1].(*ast.BasicLit); ok && lit.Value == `"controller start"` {
|
||||
start++
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if start != 1 {
|
||||
t.Errorf("the catch-up's START trigger (Evaluate(ctx, \"controller start\")) is called %d times, want 1", start)
|
||||
}
|
||||
}
|
||||
|
||||
// The catch-up's leg table holds the three BACKUP legs and nothing else, and the off-site body it shares with the
|
||||
// night never calls the update leg (that is chained only around the SCHEDULED off-site job).
|
||||
// COMPANION RED-PROOF: put `stackMgr.RunUpdateLeg` inside offsiteOnly's body → "the update leg is inside".
|
||||
func TestR871_CatchUpRunsNoUpdateLeg(t *testing.T) {
|
||||
noop := func(context.Context) error { return nil }
|
||||
legs := catchUpLegs(noop, noop, noop)
|
||||
if len(legs) != 3 || legs[nightchain.LegDBDump] == nil || legs[nightchain.LegTier2] == nil || legs[nightchain.LegOffsite] == nil {
|
||||
t.Fatalf("catchUpLegs = %v, want exactly db-dump, tier2, offsite", legs)
|
||||
}
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
i := strings.Index(s, "offsiteOnly := withLeg(nightchain.LegOffsite,")
|
||||
j := strings.Index(s[i:], "sched.Daily(\"offbox-backup\"")
|
||||
if i < 0 || j < 0 {
|
||||
t.Fatal("the shared off-site leg body was not found in main.go")
|
||||
}
|
||||
if body := s[i : i+j]; strings.Contains(body, "RunUpdateLeg") || strings.Contains(body, "chainUpdateLeg") {
|
||||
t.Fatal("the update leg is inside the off-site body the catch-up runs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
||||
)
|
||||
|
||||
// R-970 (v0.322.0) — the box's own hub API key opens NO route on the box.
|
||||
//
|
||||
// Until v0.322.0 a Bearer equal to the hub key reached /api/config/ (read the whole controller.yaml with every secret,
|
||||
// and REPLACE it), /api/selfupdate/ and /api/geo/ from the internet, unsigned — against `01` §4 („no inbound control
|
||||
// endpoint") and §10. The test drives the REAL route table (buildMux) with real web.Server and api.Router: a password
|
||||
// is set (so auth is on), the request carries the key and no session, and the planted controller.yaml must be neither
|
||||
// served nor changed.
|
||||
//
|
||||
// RED-PROOF (2026-10-16, REPORT): with an /api/geo/ route mounted without the session check, this failed at
|
||||
// „GET /api/geo/countries with the hub key answered 200".
|
||||
func TestR970_HubKeyOpensNoDoor(t *testing.T) {
|
||||
const hubKey = "TESTONLY-hub-key-5d1b"
|
||||
const secret = "TESTONLY-tunnel-token-q9"
|
||||
dir := t.TempDir()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
cfgPath := filepath.Join(dir, "controller.yaml")
|
||||
planted := "customer:\n id: t\n domain: example.hu\ninfrastructure:\n cf_tunnel_token: " + secret + "\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(planted), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Default()
|
||||
cfg.Customer.Domain = "example.hu"
|
||||
cfg.Hub.APIKey = hubKey
|
||||
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetPasswordHash("$2a$10$abcdefghijklmnopqrstuuJ1A9pDq6s9F2x1H3bQb0v8Y3wq6z2Om"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ws := web.NewServer(cfg, nil, nil, nil, nil, sett, nil, nil, nil, lg, "test")
|
||||
ar := api.NewRouter(cfg, cfgPath, sett, nil, nil, nil, nil, nil, nil, nil, lg)
|
||||
mux := buildMux(ar, ws)
|
||||
|
||||
// Control: auth is on — a request with no key and no session is refused, so a refusal below means something.
|
||||
ctl := httptest.NewRequest(http.MethodGet, "/api/stacks", nil)
|
||||
ctl.Host = "felhom.example.hu"
|
||||
cw := httptest.NewRecorder()
|
||||
mux.ServeHTTP(cw, ctl)
|
||||
if cw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("control: an anonymous /api/stacks answered %d, want 401 — auth is not on in this harness", cw.Code)
|
||||
}
|
||||
|
||||
paths := []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash",
|
||||
"/api/selfupdate/check", "/api/selfupdate/update", "/api/geo/settings", "/api/geo/countries", "/api/geo/sync"}
|
||||
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
|
||||
for _, p := range paths {
|
||||
for _, m := range methods {
|
||||
req := httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: evil.example\n"))
|
||||
req.Host = "felhom.example.hu"
|
||||
req.Header.Set("Authorization", "Bearer "+hubKey)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code/100 == 2 {
|
||||
t.Errorf("%s %s with the hub key answered %d — the key opens a door", m, p, w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), secret) {
|
||||
t.Fatalf("%s %s with the hub key served the settings file", m, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got, _ := os.ReadFile(cfgPath); string(got) != planted {
|
||||
t.Fatal("controller.yaml was replaced through a hub-key request")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// R-550 — the restore record survives a restart, and an interruption is raised ONCE.
|
||||
//
|
||||
// Two functions so main() makes two plain calls a test can see (TestMainWiresRestoreRecord): the
|
||||
// record must be loaded BEFORE the web server serves a restore page, and the event can only be pushed
|
||||
// AFTER the notifier exists, which main() constructs later.
|
||||
|
||||
// restoreRecordFileName lives in DataDir, beside settings.json — the SSD state dir that survives a
|
||||
// container recreation.
|
||||
const restoreRecordFileName = "restore-status.json"
|
||||
|
||||
// restoreEventPusher is the notifier method main() hands in (an interface so a test can record it).
|
||||
type restoreEventPusher interface {
|
||||
PushEvent(eventType, severity, message string, details interface{})
|
||||
}
|
||||
|
||||
// loadRestoreRecordAtStartup wires persistence and returns the restore the last stop interrupted.
|
||||
// putBack names the apps the crash recovery put back (AppStopRecovery.PutBack, D22 / R-1195); waiting those whose
|
||||
// put-back waits for their drive (AppStopRecovery.Waiting, R-1251).
|
||||
func loadRestoreRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger, putBack, waiting []string) *backup.RestoreOpResult {
|
||||
if mgr == nil {
|
||||
return nil
|
||||
}
|
||||
path := filepath.Join(dataDir, restoreRecordFileName)
|
||||
mgr.SetRestoreRecordPath(path)
|
||||
res := mgr.LoadRestoreRecordAfterRecovery(putBack, waiting)
|
||||
if logger != nil {
|
||||
logger.Printf("[INFO] [backup] restore record wired: %s (interrupted at startup: %t)", path, res != nil)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// reportInterruptedRestore raises restore_interrupted (warning; for the household — hub v0.117.0).
|
||||
func reportInterruptedRestore(p restoreEventPusher, res *backup.RestoreOpResult) {
|
||||
if p == nil || res == nil {
|
||||
return
|
||||
}
|
||||
if res.PutBack || res.PutBackWaiting {
|
||||
// D22 / R-1195: the data is back as before the restore — the record's own sentence, in the box's language.
|
||||
// R-1251: or it is put back when the drive is there; „start it again" would restore the backup over it.
|
||||
p.PushEvent("restore_interrupted", "warning", res.Stack+": "+res.Message,
|
||||
map[string]any{"stack": res.Stack, "op": res.Op, "put_back": res.PutBack, "put_back_waiting": res.PutBackWaiting})
|
||||
return
|
||||
}
|
||||
p.PushEvent("restore_interrupted", "warning",
|
||||
fmt.Sprintf("A(z) %s visszaállítása megszakadt, mert a doboz újraindult — indítsd el újra.", res.Stack),
|
||||
map[string]any{"stack": res.Stack, "op": res.Op})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
type recordedPush struct{ eventType, severity, message string }
|
||||
|
||||
type pushRecorder struct{ got []recordedPush }
|
||||
|
||||
func (r *pushRecorder) PushEvent(et, sev, msg string, _ interface{}) {
|
||||
r.got = append(r.got, recordedPush{et, sev, msg})
|
||||
}
|
||||
|
||||
// R-550 — the functions main() calls: a restore left in flight is found at startup and raised as
|
||||
// restore_interrupted (warning), exactly once.
|
||||
//
|
||||
// RED-PROOF: make loadRestoreRecordAtStartup skip LoadRestoreRecord → nothing is pushed →
|
||||
// "an interrupted restore raised no restore_interrupted".
|
||||
func TestRestoreRecordAtStartup_RaisesInterruptedOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
|
||||
before := new(backup.Manager)
|
||||
before.SetRestoreRecordPath(dir + "/" + restoreRecordFileName)
|
||||
before.BeginRestoreOp("restore", "gokapi") // the box stops here
|
||||
|
||||
rec := &pushRecorder{}
|
||||
reportInterruptedRestore(rec, loadRestoreRecordAtStartup(new(backup.Manager), dir, lg, nil, nil))
|
||||
if len(rec.got) != 1 || rec.got[0].eventType != "restore_interrupted" || rec.got[0].severity != "warning" {
|
||||
t.Fatalf("an interrupted restore raised no restore_interrupted (warning): %+v", rec.got)
|
||||
}
|
||||
|
||||
again := &pushRecorder{}
|
||||
reportInterruptedRestore(again, loadRestoreRecordAtStartup(new(backup.Manager), dir, lg, nil, nil))
|
||||
if len(again.got) != 0 {
|
||||
t.Fatalf("the same interruption was raised again on the next start: %+v", again.got)
|
||||
}
|
||||
}
|
||||
|
||||
// The seam must be CALLED from main(): both functions, in that order.
|
||||
func TestMainWiresRestoreRecord(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse main.go: %v", err)
|
||||
}
|
||||
pos := map[string]token.Pos{}
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "main" {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if c, ok := n.(*ast.CallExpr); ok {
|
||||
if id, ok := c.Fun.(*ast.Ident); ok {
|
||||
if id.Name == "loadRestoreRecordAtStartup" || id.Name == "reportInterruptedRestore" {
|
||||
if _, seen := pos[id.Name]; !seen {
|
||||
pos[id.Name] = c.Pos()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
load, okL := pos["loadRestoreRecordAtStartup"]
|
||||
report, okR := pos["reportInterruptedRestore"]
|
||||
if !okL || !okR {
|
||||
t.Fatalf("main() does not call both restore-record functions (load=%t report=%t) — the persistence is an inert seam", okL, okR)
|
||||
}
|
||||
if load > report {
|
||||
t.Fatalf("main() reports before it loads — nothing would ever be reported")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// R-519 (v0.296.0) — an app-data backup run cut off by a power cut or a restart is said on the backup pages until
|
||||
// the next complete run (internal/backup/run_record.go). main() calls this right after the restore record, before
|
||||
// any page or any backup run can read the manager. Pinned by TestMainWiresRunRecord.
|
||||
|
||||
// runRecordFileName lives in DataDir, beside settings.json and the restore record.
|
||||
const runRecordFileName = "appdata-run.json"
|
||||
|
||||
func loadRunRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger) time.Time {
|
||||
if mgr == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
path := filepath.Join(dataDir, runRecordFileName)
|
||||
mgr.SetRunRecordPath(path)
|
||||
cut := mgr.LoadRunRecord()
|
||||
if logger != nil {
|
||||
if !cut.IsZero() {
|
||||
logger.Printf("[WARN] [backup] the app-data backup run started %s was cut off by the stop — the backup pages say so until the next complete run (R-519)", cut.UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
logger.Printf("[INFO] [backup] app-data run record wired: %s (an earlier cut still shown: %t)", path, !mgr.InterruptedRunAt().IsZero())
|
||||
}
|
||||
}
|
||||
return cut
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// R-519: the function main() calls finds a cut run.
|
||||
func TestRunRecordAtStartup_FindsACutRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
before := new(backup.Manager)
|
||||
before.SetRunRecordPath(dir + "/" + runRecordFileName)
|
||||
// the record a run leaves when the power goes mid-run (markRunStarted is unexported — write its file shape)
|
||||
writeRunning(t, dir+"/"+runRecordFileName)
|
||||
after := new(backup.Manager)
|
||||
if cut := loadRunRecordAtStartup(after, dir, lg); cut.IsZero() || after.InterruptedRunAt().IsZero() {
|
||||
t.Fatal("a cut run was not found at startup")
|
||||
}
|
||||
}
|
||||
|
||||
// The seam must be CALLED from main() — an AST call, not a string (a comment would match a string).
|
||||
// RED-PROOF: delete the call in main() → this fails.
|
||||
func TestMainWiresRunRecord(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if c, ok := n.(*ast.CallExpr); ok {
|
||||
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "loadRunRecordAtStartup" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("main() never calls loadRunRecordAtStartup — a cut run is never said on the page")
|
||||
}
|
||||
}
|
||||
|
||||
func writeRunning(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
b := []byte(`{"running":true,"started_at":"` + time.Date(2026, 9, 14, 19, 40, 3, 0, time.UTC).Format(time.RFC3339) + `"}`)
|
||||
if err := writeFileForTest(path, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFileForTest(path string, b []byte) error { return os.WriteFile(path, b, 0o600) }
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Update arc slice 4 — the seams are WIRED, in the order that makes them true. A seam built and never
|
||||
// wired has shipped in this project seven times; each assertion here fails if a line moves or goes.
|
||||
|
||||
func slice4CallLines(t *testing.T) (map[string][]int, *ast.File, *token.FileSet) {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := map[string][]int{}
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if call, ok := n.(*ast.CallExpr); ok {
|
||||
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
|
||||
lines[sel.Sel.Name] = append(lines[sel.Sel.Name], fset.Position(call.Pos()).Line)
|
||||
} else if id, ok := call.Fun.(*ast.Ident); ok {
|
||||
lines[id.Name] = append(lines[id.Name], fset.Position(call.Pos()).Line)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return lines, f, fset
|
||||
}
|
||||
|
||||
func TestSlice4_UpdateGuardsAreWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["SetUpdateGuards"]) == 0 {
|
||||
t.Fatal("SetUpdateGuards is never called — every update would be refused as unwired")
|
||||
}
|
||||
if len(lines["RecoverUpdates"]) == 0 || len(lines["runBootReconcile"]) == 0 {
|
||||
t.Fatal("RecoverUpdates or the boot reconciler is missing from main.go")
|
||||
}
|
||||
if lines["RecoverUpdates"][0] > lines["runBootReconcile"][0] {
|
||||
t.Errorf("RecoverUpdates (line %d) must run BEFORE the boot reconciler (line %d), or the sweep can start a half-updated app",
|
||||
lines["RecoverUpdates"][0], lines["runBootReconcile"][0])
|
||||
}
|
||||
if len(lines["ResumeInterruptedUpdates"]) == 0 || lines["ResumeInterruptedUpdates"][0] < lines["SetUpdateGuards"][0] {
|
||||
t.Error("ResumeInterruptedUpdates must run AFTER SetUpdateGuards — a resumed update that fails must be able to HOLD")
|
||||
}
|
||||
}
|
||||
|
||||
func funcBodySource(t *testing.T, f *ast.File, fset *token.FileSet, recv, name string) string {
|
||||
t.Helper()
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != name || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
if recv != "" {
|
||||
if fn.Recv == nil {
|
||||
continue
|
||||
}
|
||||
id, ok := fn.Recv.List[0].Type.(*ast.Ident)
|
||||
if !ok || id.Name != recv {
|
||||
continue
|
||||
}
|
||||
}
|
||||
var names []string
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if sel, ok := n.(*ast.SelectorExpr); ok {
|
||||
names = append(names, sel.Sel.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return strings.Join(names, " ")
|
||||
}
|
||||
t.Fatalf("func %s.%s not found", recv, name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestSlice4_BootSweepAndDeadAppAlarmKnowAboutUpdates(t *testing.T) {
|
||||
_, f, fset := slice4CallLines(t)
|
||||
if !strings.Contains(funcBodySource(t, f, fset, "bootDriveGate", "MayStart"), "IsUpdating") {
|
||||
t.Error("the boot sweep must refuse an app a guarded update is moving")
|
||||
}
|
||||
if !strings.Contains(funcBodySource(t, f, fset, "", "scanDeployedAppRunStates"), "UpdatingStacks") {
|
||||
t.Error("the dead-app alarm must not count an app the update itself is recreating")
|
||||
}
|
||||
}
|
||||
|
||||
// The shared start gate names WHICH hold it is honouring — an operator reading the boot log must not
|
||||
// be told a restore failed when an update did.
|
||||
func TestSlice4_DriveStartGate_NamesAnUpdateHold(t *testing.T) {
|
||||
sett := holdTestSettings(t)
|
||||
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "bookstack", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, why := driveStartGate{sett: sett}.MayStart("bookstack")
|
||||
if ok || !strings.Contains(why, "held after a failed update") {
|
||||
t.Errorf("ok=%v why=%q", ok, why)
|
||||
}
|
||||
}
|
||||
|
||||
// v0.238.1: the backup manager must be told which apps are mid-update, or the nightly legs overwrite a
|
||||
// restore point during an update's health wait (found live, Scenario F).
|
||||
func TestSlice4_UpdatingCheckIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["SetUpdatingCheck"]) == 0 {
|
||||
t.Fatal("backupMgr.SetUpdatingCheck is never called — the nightly legs cannot see an update in progress")
|
||||
}
|
||||
}
|
||||
|
||||
// R-645 (09 §3 decision 142): the backup manager must be told which apps are not running their pinned
|
||||
// version, or the night backup writes a just-failed definition over the good unit after a hand-lifted hold.
|
||||
func TestR645_VersionCheckIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["SetVersionCheck"]) == 0 {
|
||||
t.Fatal("backupMgr.SetVersionCheck is never called — the night backup cannot see an app off its pin")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The stray-copy fix: main.go removes every leftover second-drive copy container BEFORE the scheduler and the web
|
||||
// server start (either could begin a backup or a restore beside a lost copy). RED-PROOF (2026-10-22 night): the call
|
||||
// removed, or moved below sched.Start, → this fails.
|
||||
func TestStrayCopy_ClearIsWiredBeforeTheScheduler(t *testing.T) {
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
c := strings.Index(s, "backup.ClearLeftoverTier2Copies(ctx)")
|
||||
st := strings.Index(s, "sched.Start(ctx)")
|
||||
ws := strings.Index(s, "web.NewServer(")
|
||||
if c < 0 || st < 0 || ws < 0 {
|
||||
t.Fatalf("missing: clear=%d sched.Start=%d web.NewServer=%d", c, st, ws)
|
||||
}
|
||||
if c > st || c > ws {
|
||||
t.Fatal("the leftover-copy removal must run before the scheduler and the web server start")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.264.0. The update-event sink and the R-646 backfill are both CALLED from main.go (an AST walk —
|
||||
// a comment naming them would not count). A sink built and never wired would fail silently: no event,
|
||||
// no mail, no error — this project's commonest defect, four recorded instances.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateEventSink call in main.go — this fails.
|
||||
func TestUpdateEventSinkIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["SetUpdateEventSink"]) == 0 {
|
||||
t.Fatal("SetUpdateEventSink is never called — an undone or held update would tell nobody")
|
||||
}
|
||||
for _, callee := range []string{"NotifyAppUpdateUndone", "NotifyAppUpdateHeld", "RestoreHoldForLang"} {
|
||||
if len(lines[callee]) == 0 {
|
||||
t.Errorf("main.go must call %s inside the sink", callee)
|
||||
}
|
||||
}
|
||||
if len(lines["BackfillAppliedMeta"]) == 0 || len(lines["AdoptPins"]) == 0 || lines["BackfillAppliedMeta"][0] < lines["AdoptPins"][0] {
|
||||
t.Error("BackfillAppliedMeta (R-646) must be called, after AdoptPins")
|
||||
}
|
||||
}
|
||||
|
||||
// R-647 (2) — the held event carries the copy_holds KEY, never the Hungarian phrase: the hub prints the
|
||||
// raw details as the household mail's `Note:` line. COMPANION RED-PROOF (REPORT.md): wire
|
||||
// UpdateCopyHolds back — this fails.
|
||||
func TestR647_HeldEventCarriesTheCopyHoldsKey(t *testing.T) {
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(src), "d.CopyHolds = backupMgr.UpdateCopyHoldsKey(") {
|
||||
t.Fatal("main.go must fill AppUpdateDetails.CopyHolds with UpdateCopyHoldsKey (the key), not the Hungarian phrase")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.271.0 — the automatic update leg (`09` §6.4 part 7).
|
||||
|
||||
// TestChainUpdateLeg_EveryPath — the leg runs after the off-site half on EVERY path it can take:
|
||||
// nothing to do (no target), ran ok, returned an error (incl. every RunOffboxBackup early return, which
|
||||
// returns to here), and panicked. The off-site error reaches the scheduler unchanged; a panic becomes one.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): call the leg after `return offsite(ctx)` instead of in the defer —
|
||||
// the error and panic cases fail at "the leg did not run after the off-site half".
|
||||
func TestChainUpdateLeg_EveryPath(t *testing.T) {
|
||||
boom := errors.New("sftp: connection refused")
|
||||
cases := []struct {
|
||||
name string
|
||||
offsite func(context.Context) error
|
||||
wantErr string
|
||||
}{
|
||||
{"no off-site target (nothing to do)", func(context.Context) error { return nil }, ""},
|
||||
{"off-site ran ok", func(context.Context) error { return nil }, ""},
|
||||
{"off-site failed / an early return", func(context.Context) error { return boom }, "connection refused"},
|
||||
{"off-site panicked", func(context.Context) error { panic("nil map") }, "panicked"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ran := 0
|
||||
err := chainUpdateLeg(context.Background(), c.offsite, func(context.Context) { ran++ })
|
||||
if ran != 1 {
|
||||
t.Errorf("%s: the leg did not run after the off-site half (ran %d times)", c.name, ran)
|
||||
}
|
||||
if c.wantErr == "" && err != nil {
|
||||
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||
}
|
||||
if c.wantErr != "" && (err == nil || !strings.Contains(err.Error(), c.wantErr)) {
|
||||
t.Errorf("%s: error %v, want one containing %q", c.name, err, c.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateLegIsWiredAtStartup — the leg, its chain and the gate's interlock are CALLED from main.go (an
|
||||
// AST walk; a comment naming them would not count). A seam built and never wired is this project's
|
||||
// commonest defect.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateLeg call — this fails.
|
||||
func TestUpdateLegIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
for _, callee := range []string{"SetUpdateLeg", "chainUpdateLeg", "RunUpdateLeg", "FreshWholeCopy"} {
|
||||
if len(lines[callee]) == 0 {
|
||||
t.Errorf("main.go never calls %s — the automatic update leg is not wired", callee)
|
||||
}
|
||||
}
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(src), "Enabled: sett.GetAppUpdateUnattended,") {
|
||||
t.Error("the leg's switch must be the household's app_update.unattended (sett.GetAppUpdateUnattended)")
|
||||
}
|
||||
if !strings.Contains(string(src), "UpdateLegFn: stackMgr.UpdateLegState") {
|
||||
t.Error("the quiesce loop must be given stackMgr.UpdateLegState (decision 20's interlock)")
|
||||
}
|
||||
// the chain must sit INSIDE the offbox-backup job, not beside it
|
||||
i := strings.Index(string(src), `sched.Daily("offbox-backup"`)
|
||||
if i < 0 || !strings.Contains(string(src)[i:i+600], "chainUpdateLeg(") {
|
||||
t.Error("the offbox-backup job must run through chainUpdateLeg")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoCopyRemoverIsWiredAtStartup — R-671's seam is CALLED from main.go (AST walk).
|
||||
// COMPANION RED-PROOF (REPORT.md): drop the SetUndoCopyRemover call — this fails.
|
||||
func TestUndoCopyRemoverIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["SetUndoCopyRemover"]) == 0 {
|
||||
t.Fatal("main.go never calls SetUndoCopyRemover — a lifted hold's undo copies would stay for ever")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/mediawire"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
|
||||
)
|
||||
|
||||
// qbLimitShare is qBittorrent's default share of the measured upload line — CC's pick, open for the operator (decide
|
||||
// row in felhom.eu STATUS): 50 %.
|
||||
const qbLimitShare = 0.5
|
||||
|
||||
// baselineMinMinutes: an app the box cannot ask is judged only once its history holds ~20 hours of minutes.
|
||||
const baselineMinMinutes = 1200
|
||||
|
||||
// newUpwatch builds the upload and CPU watch (internal/upwatch; felhom.eu `08` §9). It does not start it.
|
||||
func newUpwatch(sett *settings.Settings, stackMgr *stacks.Manager, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore,
|
||||
notifier *notify.Notifier, line agentLine, logger *log.Logger) *upwatch.Service {
|
||||
box := mediawire.DockerBox{}
|
||||
qb := &upwatch.QBittorrent{Box: box}
|
||||
tr := &upwatch.Transmission{Box: box}
|
||||
svc := &upwatch.Service{Store: sett, QB: qb, QBRunning: func() bool { return box.Running("qbittorrent") }, QBShare: qbLimitShare,
|
||||
Measured: line.Measured, Log: logger,
|
||||
// R-1250: a box that seeds all day is line-tested by holding its torrent app low for one test on request.
|
||||
Torrents: []upwatch.Holdable{qb, tr}, TorrentRunning: box.Running, LineWaiting: line.Waiting, LineRun: line.Run}
|
||||
w := &upwatch.Watcher{
|
||||
Source: &upwatch.DockerAPI{},
|
||||
Explainers: map[string]upwatch.Explainer{
|
||||
"qbittorrent": qb,
|
||||
"transmission": tr,
|
||||
"plex": &upwatch.Plex{Token: func() string { return mediawire.PlexToken(box) }, URL: mediawire.PlexURL},
|
||||
},
|
||||
Rules: upwatch.DefaultRules(),
|
||||
Line: svc.Line,
|
||||
Busy: func(stack string) bool {
|
||||
if backupMgr != nil && backupMgr.IsRunning() {
|
||||
return true
|
||||
}
|
||||
if st, ok := stackMgr.GetStack(stack); ok && st != nil && (st.Updating || st.Deploying) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
Emit: notifier.NotifyUnexplained,
|
||||
Log: logger,
|
||||
}
|
||||
if metricsStore != nil {
|
||||
w.Baseline = dailyBaseline(metricsStore)
|
||||
}
|
||||
svc.Watcher = w
|
||||
return svc
|
||||
}
|
||||
|
||||
// agentLine is the host's line test as the watch uses it; every field nil without a local API.
|
||||
type agentLine struct {
|
||||
Measured func(context.Context) (float64, time.Time, bool)
|
||||
Waiting func(context.Context) bool // waiting_for_quiet (agent ≥ 0.165.0, R-1250)
|
||||
Run func(context.Context) (upwatch.LineRunResult, error) // POST /host/line-test/run
|
||||
}
|
||||
|
||||
// agentLineTest reads the host's weekly line test through the agent's local API (agent ≥ 0.164.0; an older agent
|
||||
// answers 404 → not measured, and never waiting). Empty without a local API.
|
||||
func agentLineTest(endpoint, token, fingerprint string, logger *log.Logger) agentLine {
|
||||
if endpoint == "" || token == "" {
|
||||
return agentLine{}
|
||||
}
|
||||
ac, err := agentapi.New(endpoint, token, fingerprint)
|
||||
if err != nil {
|
||||
logger.Printf("[WARN] [upwatch] agent client init failed (%v) — no line test; qBittorrent gets no limit", err)
|
||||
return agentLine{}
|
||||
}
|
||||
read := func(ctx context.Context) (agentapi.LineTestState, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return ac.LineTest(cctx)
|
||||
}
|
||||
return agentLine{
|
||||
Measured: func(ctx context.Context) (float64, time.Time, bool) {
|
||||
st, err := read(ctx)
|
||||
if err != nil {
|
||||
logger.Printf("[DEBUG] [upwatch] line test read: %v", err)
|
||||
return 0, time.Time{}, false
|
||||
}
|
||||
return st.Mbps, st.MeasuredAtTime(), st.Present
|
||||
},
|
||||
Waiting: func(ctx context.Context) bool {
|
||||
st, err := read(ctx)
|
||||
return err == nil && st.WaitingForQuiet
|
||||
},
|
||||
Run: func(ctx context.Context) (upwatch.LineRunResult, error) {
|
||||
r, err := ac.LineTestRun(ctx)
|
||||
return upwatch.LineRunResult{OK: r.OK, Mbps: r.Mbps, Refused: r.Refused, Error: r.Error}, err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// dailyBaseline caches each app's p95 for a day (a 7-day query is a few hundred thousand rows).
|
||||
func dailyBaseline(ms *metrics.MetricsStore) func(string, []string) (float64, bool) {
|
||||
type entry struct {
|
||||
at time.Time
|
||||
p95 float64
|
||||
ok bool
|
||||
}
|
||||
var mu sync.Mutex
|
||||
cache := map[string]entry{}
|
||||
return func(stack string, containers []string) (float64, bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if e, ok := cache[stack]; ok && time.Since(e.at) < 24*time.Hour {
|
||||
return e.p95, e.ok
|
||||
}
|
||||
p95, minutes, err := ms.StackTxP95Mbps(containers, time.Now().Add(-7*24*time.Hour))
|
||||
e := entry{at: time.Now(), p95: p95, ok: err == nil && minutes >= baselineMinMinutes}
|
||||
cache[stack] = e
|
||||
return e.p95, e.ok
|
||||
}
|
||||
}
|
||||
|
||||
// startUpwatch runs the minute watcher and the 15-minute qBittorrent limit rule until ctx ends. A torrent limit a
|
||||
// stopped controller left at the line-test value is put back first (R-1250), before any rule reads it.
|
||||
func startUpwatch(ctx context.Context, svc *upwatch.Service, logger *log.Logger) {
|
||||
svc.RecoverHold(ctx)
|
||||
svc.RefreshMeasured(ctx)
|
||||
go svc.Watcher.Run(ctx, time.Minute)
|
||||
go func() {
|
||||
t := time.NewTicker(15 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
svc.SeedLineTestNow(ctx) // R-1250: holds the torrent app only while the agent waits for quiet
|
||||
svc.QBLimitNow(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
l, ok := svc.Line()
|
||||
logger.Printf("[INFO] [upwatch] upload and CPU watch started (line %s, qBittorrent share %.0f %%)", lineText(l, ok), qbLimitShare*100)
|
||||
}
|
||||
|
||||
func lineText(l float64, ok bool) string {
|
||||
if !ok {
|
||||
return "unknown — no weekly line test yet and no counting off-site push"
|
||||
}
|
||||
return upwatch.MbpsText(int64(l*1e6/8)) + " Mbit/s"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
|
||||
)
|
||||
|
||||
// The upload watch is built, started, handed to the off-site push BEFORE the scheduler can start one, and its view
|
||||
// handed to the pages — a seam built and never wired is this project's commonest defect.
|
||||
// RED-PROOF: delete the SetOffboxPushObserver line → fails; move it below the scheduler's start → fails; pass nil
|
||||
// instead of agentLineTest(...) → the qBittorrent limit never gets a tested line → fails.
|
||||
func TestUpwatchIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
for _, fn := range []string{"newUpwatch", "agentLineTest", "SetOffboxPushObserver", "startUpwatch", "SetUpwatchView"} {
|
||||
if len(lines[fn]) == 0 {
|
||||
t.Fatalf("main.go never calls %s", fn)
|
||||
}
|
||||
}
|
||||
if lines["SetOffboxPushObserver"][0] > lines["Daily"][0] {
|
||||
t.Fatalf("SetOffboxPushObserver (line %d) comes after the first scheduled job (line %d)", lines["SetOffboxPushObserver"][0], lines["Daily"][0])
|
||||
}
|
||||
}
|
||||
|
||||
// newUpwatch asks the three apps the spike proved, reports through the notifier, and reads exact counters.
|
||||
// RED-PROOF: drop the transmission entry → fails.
|
||||
func TestNewUpwatch_ExplainersAndEmit(t *testing.T) {
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := newUpwatch(sett, nil, nil, nil, notify.New("", "", "", sett, lg, false), agentLine{}, lg)
|
||||
for _, a := range []string{"qbittorrent", "transmission", "plex"} {
|
||||
if _, ok := svc.Watcher.Explainers[a]; !ok {
|
||||
t.Errorf("no explainer for %s", a)
|
||||
}
|
||||
}
|
||||
if svc.Watcher.Emit == nil || svc.Watcher.Line == nil || svc.QB == nil || svc.QBShare != 0.5 {
|
||||
t.Fatalf("service not wired: %+v", svc)
|
||||
}
|
||||
if _, ok := svc.Watcher.Source.(*upwatch.DockerAPI); !ok {
|
||||
t.Fatal("the watch must read the Engine API's exact counters, not docker stats' text")
|
||||
}
|
||||
// R-1250: both torrent apps can be held, qBittorrent first; without a local API there is no hold at all.
|
||||
if len(svc.Torrents) != 2 || svc.Torrents[0].Stack() != "qbittorrent" || svc.Torrents[1].Stack() != "transmission" ||
|
||||
svc.TorrentRunning == nil || svc.LineWaiting != nil || svc.LineRun != nil {
|
||||
t.Fatalf("line-test hold not wired: %d torrents, waiting=%v run=%v", len(svc.Torrents), svc.LineWaiting != nil, svc.LineRun != nil)
|
||||
}
|
||||
if svc.Watcher.Baseline != nil {
|
||||
t.Fatal("no metrics store, yet a baseline: an app with no explainer would be judged without history")
|
||||
}
|
||||
}
|
||||
|
||||
// R-1250: the start-up check puts a held limit back BEFORE the first rule reads it, and every tick runs the hold
|
||||
// before the 50 % rule. Asserted on startUpwatch's source order.
|
||||
// RED-PROOF: move svc.RecoverHold below the ticker, or drop SeedLineTestNow → fails.
|
||||
func TestStartUpwatchRecoversTheHoldFirst(t *testing.T) {
|
||||
src, err := os.ReadFile("upwatch_wiring.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(src)
|
||||
i := strings.Index(body, "func startUpwatch(")
|
||||
if i < 0 {
|
||||
t.Fatal("no startUpwatch")
|
||||
}
|
||||
body = body[i:]
|
||||
rec, ticker := strings.Index(body, "svc.RecoverHold(ctx)"), strings.Index(body, "time.NewTicker(")
|
||||
seed, rule := strings.Index(body, "svc.SeedLineTestNow(ctx)"), strings.Index(body, "svc.QBLimitNow(ctx)")
|
||||
if rec < 0 || rec > ticker {
|
||||
t.Fatalf("RecoverHold at %d, the ticker at %d: a held limit must go back before anything else runs", rec, ticker)
|
||||
}
|
||||
if seed < 0 || seed > rule {
|
||||
t.Fatalf("SeedLineTestNow at %d, QBLimitNow at %d: the hold must run before the 50 %% rule", seed, rule)
|
||||
}
|
||||
}
|
||||
|
||||
// With a local API, agentLineTest yields all three halves, and newUpwatch hands the hold both agent calls.
|
||||
// RED-PROOF: drop LineRun from newUpwatch's Service, or Run from agentLineTest → fails.
|
||||
func TestUpwatchHoldGetsBothAgentCalls(t *testing.T) {
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
line := agentLineTest("127.0.0.1:1", "token-canary", strings.Repeat("ab", 32), lg)
|
||||
if line.Measured == nil || line.Waiting == nil || line.Run == nil {
|
||||
t.Fatalf("agentLineTest with a local API: measured=%v waiting=%v run=%v", line.Measured != nil, line.Waiting != nil, line.Run != nil)
|
||||
}
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := newUpwatch(sett, nil, nil, nil, notify.New("", "", "", sett, lg, false), line, lg)
|
||||
if svc.Measured == nil || svc.LineWaiting == nil || svc.LineRun == nil {
|
||||
t.Fatalf("newUpwatch dropped an agent call: measured=%v waiting=%v run=%v", svc.Measured != nil, svc.LineWaiting != nil, svc.LineRun != nil)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,6 @@ stacks:
|
||||
- "cloudflared"
|
||||
- "felhom-controller"
|
||||
- "filebrowser"
|
||||
update_window: "03:00-05:00"
|
||||
compose_command: ""
|
||||
|
||||
# --- Backup ---
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
module gitea.dooplex.hu/admin/felhom-controller
|
||||
|
||||
go 1.24.0
|
||||
go 1.27.2
|
||||
|
||||
require (
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.45.0
|
||||
)
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
+6
-6
@@ -18,8 +18,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
@@ -27,10 +27,10 @@ golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
# is a Finder Settings -> Sidebar toggle). Treat sidebar discovery as an OPEN
|
||||
# question, not a shipped feature.
|
||||
# Evidence: felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md.
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
# 1.2.0 (R-972): Alpine 3.24.2, pinned by digest — the same base as the hub. 3.21 carried 48 fixable high findings
|
||||
# (audits/security-review-2026-10-12/scanners/). Same packages; scanner before → after in the controller's v0.323.0 CHANGELOG.
|
||||
FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
# samba = smbd + nmbd + smbpasswd/testparm (meta-package proven installable in the spike);
|
||||
# wsdd = WS-Discovery daemon; tini = a proper PID1 to reap nmbd/wsdd/avahi and forward signals;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package aftercrash runs the apps' after_unclean_boot blocks ONCE per controller start that followed an unclean
|
||||
// host boot (R-1007, `09` §3 decision 236).
|
||||
//
|
||||
// The fact is crashboot's (the host crash guard, read through the agent): the same verdict that holds the app mails
|
||||
// for CrashGrace. Unknown is a normal boot — a repair never runs on a guess. A normal boot never runs it. After a crash
|
||||
// boot it waits CrashGrace from the controller's start (the apps come up slowly after a crash), then runs each app's
|
||||
// block one after the other and reports each app's counts. It never stops an app; a failed run is reported, not
|
||||
// retried. Pinned by aftercrash_test.go.
|
||||
package aftercrash
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// Verdict is crashboot.Grace's Decided (a seam for tests).
|
||||
type Verdict func(now time.Time) (resolved, crash bool, bootAt time.Time)
|
||||
|
||||
// Runner does the one run. Every func field is required; Logger may be nil.
|
||||
type Runner struct {
|
||||
Start time.Time
|
||||
Verdict Verdict
|
||||
Apps func() []string
|
||||
Run func(name string, bootAt time.Time) stacks.AfterUncleanBootResult
|
||||
Report func(name string, bootAt time.Time, res stacks.AfterUncleanBootResult)
|
||||
Logger *log.Logger
|
||||
// Wait is how long after Start a crash-boot run waits (default crashboot.CrashGrace).
|
||||
Wait time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
done bool
|
||||
}
|
||||
|
||||
// New builds the runner on the controller's crash-boot grace.
|
||||
func New(g *crashboot.Grace, apps func() []string,
|
||||
run func(string, time.Time) stacks.AfterUncleanBootResult,
|
||||
report func(string, time.Time, stacks.AfterUncleanBootResult), logger *log.Logger) *Runner {
|
||||
return &Runner{Start: g.Start(), Verdict: g.Decided, Apps: apps, Run: run, Report: report, Logger: logger}
|
||||
}
|
||||
|
||||
// Done reports whether this start's decision has been taken (ran, or decided not to).
|
||||
func (r *Runner) Done() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.done
|
||||
}
|
||||
|
||||
// Tick is called periodically (the scheduler). It returns true once the run has happened or been ruled out.
|
||||
func (r *Runner) Tick(now time.Time) bool {
|
||||
r.mu.Lock()
|
||||
if r.done {
|
||||
r.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
resolved, crash, bootAt := r.Verdict(now)
|
||||
if !resolved {
|
||||
r.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
if !crash {
|
||||
r.done = true
|
||||
r.mu.Unlock()
|
||||
r.logf("[INFO] [aftercrash] a normal (or unknown) boot — no app repair after an unclean boot (R-1007)")
|
||||
return true
|
||||
}
|
||||
wait := r.Wait
|
||||
if wait <= 0 {
|
||||
wait = crashboot.CrashGrace
|
||||
}
|
||||
if now.Sub(r.Start) < wait {
|
||||
r.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
r.done = true // set BEFORE the run: a slow or failing run is never started twice
|
||||
r.mu.Unlock()
|
||||
|
||||
apps := r.Apps()
|
||||
r.logf("[INFO] [aftercrash] the host's boot at %s followed an unclean stop — %d app(s) with an after_unclean_boot block (R-1007)",
|
||||
bootAt.UTC().Format(time.RFC3339), len(apps))
|
||||
for _, name := range apps {
|
||||
res := r.Run(name, bootAt)
|
||||
if res.Skipped != "" {
|
||||
r.logf("[WARN] [aftercrash] %s: skipped — %s", name, res.Skipped)
|
||||
} else {
|
||||
r.logf("[INFO] [aftercrash] %s: found %d empty file(s), repaired %d, still broken %d, lost %d (checked=%t) in %s%s",
|
||||
name, res.Found, res.Repaired, res.StillBroken, res.Lost, res.LostChecked, res.Duration.Round(time.Millisecond), problem(res.Problem))
|
||||
}
|
||||
r.Report(name, bootAt, res)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func problem(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
return " — problem: " + p
|
||||
}
|
||||
|
||||
func (r *Runner) logf(format string, args ...any) {
|
||||
if r.Logger != nil {
|
||||
r.Logger.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package aftercrash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-1007 (decision 236): the repair runs ONLY after an unclean host boot that THIS start followed, only after the
|
||||
// crash grace, and only once. A normal boot and an unknown fact never run it. The verdict is crashboot's own (the
|
||||
// real Grace with a fake probe), so these tests assert the consequence: was the app's block run?
|
||||
|
||||
var start = time.Date(2026, 10, 14, 12, 8, 0, 0, time.UTC)
|
||||
|
||||
func probe(f crashboot.Fact, err error) crashboot.Probe {
|
||||
return func(context.Context) (crashboot.Fact, error) { return f, err }
|
||||
}
|
||||
|
||||
func runner(p crashboot.Probe) (*Runner, *[]string) {
|
||||
var ran []string
|
||||
g := crashboot.New(start, p, nil)
|
||||
r := New(g, func() []string { return []string{"immich"} },
|
||||
func(name string, _ time.Time) stacks.AfterUncleanBootResult {
|
||||
ran = append(ran, name)
|
||||
return stacks.AfterUncleanBootResult{}
|
||||
},
|
||||
func(string, time.Time, stacks.AfterUncleanBootResult) {}, nil)
|
||||
return r, &ran
|
||||
}
|
||||
|
||||
// RED-PROOF: drop the `now.Sub(r.Start) < wait` check → the run happens at +1 min → fails.
|
||||
// RED-PROOF: drop `r.done = true` before the run → a second tick runs it again → fails.
|
||||
func TestAfterCrash_RunsOnceAfterTheGraceOnAnUncleanBootThisStartFollowed(t *testing.T) {
|
||||
r, ran := runner(probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-40 * time.Second)}, nil))
|
||||
for _, at := range []time.Duration{0, time.Minute, 14 * time.Minute} {
|
||||
r.Tick(start.Add(at))
|
||||
}
|
||||
if len(*ran) != 0 {
|
||||
t.Fatalf("ran inside the crash grace: %v", *ran)
|
||||
}
|
||||
r.Tick(start.Add(crashboot.CrashGrace + time.Second))
|
||||
r.Tick(start.Add(crashboot.CrashGrace + time.Minute))
|
||||
if len(*ran) != 1 {
|
||||
t.Fatalf("ran %d time(s), want exactly once after the grace", len(*ran))
|
||||
}
|
||||
}
|
||||
|
||||
// RED-PROOF: in Tick, treat `!crash` like a crash (drop the early return) → a normal boot runs the repair → fails.
|
||||
func TestAfterCrash_NeverOnANormalAnUnknownOrAnOldUncleanBoot(t *testing.T) {
|
||||
cases := map[string]crashboot.Probe{
|
||||
"normal boot": probe(crashboot.Fact{Known: true, Unclean: false, BootAt: start.Add(-time.Minute)}, nil),
|
||||
"unknown (no crash guard)": probe(crashboot.Fact{}, nil),
|
||||
"unknown (agent unreachable)": probe(crashboot.Fact{}, context.DeadlineExceeded),
|
||||
"no agent at all": nil,
|
||||
"an unclean boot days ago": probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-72 * time.Hour)}, nil),
|
||||
"an unclean boot with no time": probe(crashboot.Fact{Known: true, Unclean: true}, nil),
|
||||
}
|
||||
for name, p := range cases {
|
||||
r, ran := runner(p)
|
||||
for at := time.Duration(0); at <= 2*crashboot.CrashGrace; at += 30 * time.Second {
|
||||
r.Tick(start.Add(at))
|
||||
}
|
||||
if len(*ran) != 0 {
|
||||
t.Errorf("%s: the repair ran", name)
|
||||
}
|
||||
if !r.Done() {
|
||||
t.Errorf("%s: the decision was never taken", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ type BackupTierInfo struct {
|
||||
Target string `json:"target"`
|
||||
CadenceSeconds int64 `json:"cadence_seconds"`
|
||||
Primary bool `json:"primary"`
|
||||
// Storage (agent >= v0.131.0, R-518): "present" | "absent" | "unknown"; "" on an older agent.
|
||||
// Only "absent" licenses skipping a tier — unknown and legacy are treated as present.
|
||||
Storage string `json:"storage,omitempty"`
|
||||
}
|
||||
|
||||
// TiersResponse mirrors the agent's GET /backup/tiers payload.
|
||||
@@ -61,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) {
|
||||
@@ -77,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
|
||||
}
|
||||
@@ -102,6 +127,48 @@ 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; HostBusy does (newer agents).
|
||||
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
|
||||
}
|
||||
|
||||
// HostBusy (R-921) reads GET /backup/status's host_busy: whether the agent's host-wide heavy-operation gate is held
|
||||
// right now (another guest's backup, the restore test, a night OS step …) — the agent's second reason to refuse
|
||||
// POST /backup. known=false: the agent does not serve the field (older than the agent release that adds it) — no
|
||||
// information, never „free". An error is returned; the caller fails toward backing up.
|
||||
func (c *Client) HostBusy(ctx context.Context) (busy bool, what string, known bool, err error) {
|
||||
st, err := c.BackupStatusFor(ctx, "")
|
||||
if err != nil {
|
||||
return false, "", false, err
|
||||
}
|
||||
if st.HostBusy == nil {
|
||||
return false, "", false, nil
|
||||
}
|
||||
return st.HostBusy.Busy, st.HostBusy.What, true, nil
|
||||
}
|
||||
|
||||
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
|
||||
type SetBackupTargetResponse struct {
|
||||
Target string `json:"target"`
|
||||
|
||||
@@ -199,6 +199,35 @@ type StatusResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
Error string `json:"error"`
|
||||
Backup *BackupRecord `json:"backup,omitempty"`
|
||||
// Tiers (agent >= v0.131.0, R-517) — per tier: newest success, last attempt, storage presence.
|
||||
// Absent on an older agent; the page then falls back to the single latest record.
|
||||
Tiers []TierBackupState `json:"tiers,omitempty"`
|
||||
// HostBusy (R-921, agent Unreleased 2026-10-19) — whether the agent's host-wide heavy-operation gate is held
|
||||
// right now (then POST /backup answers 409). nil = an older agent that does not serve it.
|
||||
HostBusy *HostBusyState `json:"host_busy,omitempty"`
|
||||
}
|
||||
|
||||
// HostBusyState mirrors the agent's localapi.HostBusyState.
|
||||
type HostBusyState struct {
|
||||
Busy bool `json:"busy"`
|
||||
What string `json:"what,omitempty"`
|
||||
}
|
||||
|
||||
// TierBackupState mirrors the agent's localapi.TierBackupState.
|
||||
type TierBackupState struct {
|
||||
Target string `json:"target"`
|
||||
Primary bool `json:"primary"`
|
||||
Storage string `json:"storage"` // present | absent | unknown
|
||||
LastSuccess *BackupRecord `json:"last_success,omitempty"`
|
||||
LastSuccessSource string `json:"last_success_source,omitempty"` // record | storage
|
||||
LastAttempt *TierAttempt `json:"last_attempt,omitempty"`
|
||||
}
|
||||
|
||||
// TierAttempt is a tier's newest recorded attempt.
|
||||
type TierAttempt struct {
|
||||
StartedAt string `json:"started_at"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The
|
||||
@@ -235,6 +264,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).
|
||||
@@ -252,15 +284,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.
|
||||
@@ -340,8 +364,18 @@ type DiskInfo struct {
|
||||
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
|
||||
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
|
||||
Smart *SmartSummary `json:"smart,omitempty"`
|
||||
// Physical-disk identity (agent v0.157.0, `16` §3): kind, the keys of the physical disks under the place,
|
||||
// proven|unknown + reason. Empty from an older agent or for an absent drive.
|
||||
Kind string `json:"kind,omitempty"` // system | internal | disk | partition | network (felhom.eu `16` §3)
|
||||
PhysicalDisks []string `json:"physical_disks,omitempty"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
IdentityReason string `json:"identity_reason,omitempty"`
|
||||
}
|
||||
|
||||
// PlaceKindInternal is the agent's kind for the one-disk box's internal data place („Belső tárhely", agent ≥ 0.158.0,
|
||||
// felhom.eu architecture/16 §5): a host mount on the system disk, bound at /mnt/felhom-drives/belso.
|
||||
const PlaceKindInternal = "internal"
|
||||
|
||||
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
|
||||
// is not a filesystem UUID (network/lvm targets — not assignable as a host mount).
|
||||
func (d DiskInfo) FSUUID() string {
|
||||
@@ -375,6 +409,10 @@ type FormatResult struct {
|
||||
DurableID string `json:"durable_id,omitempty"`
|
||||
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
|
||||
PendingOp *PendingOp `json:"pending_op,omitempty"`
|
||||
// FSUUID (R-25, agent >= the R-25 agent half) is the UUID of the filesystem the agent just made,
|
||||
// sent only while the format's durable id still names the formatted device. "" = not verified, or
|
||||
// an agent too old to send it. The init flow mounts THIS rather than re-resolving from the /dev path.
|
||||
FSUUID string `json:"fs_uuid,omitempty"`
|
||||
}
|
||||
|
||||
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
|
||||
@@ -400,6 +438,36 @@ var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup de
|
||||
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
|
||||
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
|
||||
|
||||
// ErrNoDurableID is the agent's anti-retarget refusal of a blank format (R-1069): the device has no durable id to
|
||||
// bind the format to — a virtual disk passed on a virtio bus without a serial has no /dev/disk/by-id link. The agent's
|
||||
// rule is a safety rule and stays; this only lets the dashboard say it in plain words. Matched on the agent's own
|
||||
// refusal text (felhom-agent internal/localapi/disks.go „format refused: device has no durable id"); if that text
|
||||
// ever changes, the refusal degrades to the raw message it was before. Pinned by TestFormatDisk_NoDurableIDIsTyped.
|
||||
var ErrNoDurableID = fmt.Errorf("agentapi: format refused — the device has no durable id (path-only formats are not permitted)")
|
||||
|
||||
// SameDiskResult is the agent's answer to POST /disks/same-disk (v0.157.0). Act on Proven first:
|
||||
// Proven+Different = a counted second copy; Proven+!Different = the same disk; !Proven = unknown
|
||||
// (Reason vm|network|usb_bridge|weak_wwn|no_serial|unmapped).
|
||||
type SameDiskResult struct {
|
||||
Different bool `json:"different"`
|
||||
Proven bool `json:"proven"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// SameDisk asks the agent whether two guest paths sit on the same physical disk. Gate it with
|
||||
// Supports(FeatureSameDisk) — an older agent answers 404.
|
||||
func (c *Client) SameDisk(ctx context.Context, a, b string) (SameDiskResult, error) {
|
||||
var out SameDiskResult
|
||||
raw, err := c.post(ctx, "/disks/same-disk", map[string]string{"a": a, "b": b})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /disks/same-disk: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
|
||||
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
|
||||
var out DisksResponse
|
||||
@@ -429,8 +497,37 @@ type DiskCandidate struct {
|
||||
// mountpoint — sending it down the device-attach path would try to mount an in-guest path as if
|
||||
// it were a raw device. See web/attach_sources.go for why the agent's scan cannot supply these.
|
||||
AlreadyMounted bool `json:"already_mounted,omitempty"`
|
||||
// Partitions (agent ≥ 0.158.0, felhom.eu architecture/16 §6): each partition of this disk as its own place —
|
||||
// state empty („format this part"), filesystem („use this part" when mountable; format through the confirmed
|
||||
// path), in_use (already a Felhom drive: shown, never offered). Absent from an older agent.
|
||||
Partitions []CandidatePartition `json:"partitions,omitempty"`
|
||||
// WholeDiskInUse is set by the CONTROLLER (never the agent) when a partition of this disk is a registered drive
|
||||
// (R-542): the disk stays listed for its FREE parts only, and no whole-disk action is offered.
|
||||
WholeDiskInUse bool `json:"whole_disk_in_use,omitempty"`
|
||||
}
|
||||
|
||||
// CandidatePartition mirrors the agent's partition entry (felhom-agent internal/storage/partitions.go).
|
||||
type CandidatePartition struct {
|
||||
Device string `json:"device"`
|
||||
Number int `json:"number,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
FSType string `json:"fstype,omitempty"`
|
||||
State string `json:"state"` // empty | filesystem | in_use
|
||||
Mountable bool `json:"mountable"`
|
||||
DurableID string `json:"durable_id,omitempty"` // uuid:<fs-uuid> — the attach key
|
||||
MountedAt string `json:"mounted_at,omitempty"`
|
||||
// Formattable (agent ≥ 0.158.1): false for every part of a disk with a mounted node — the root mkfs wrapper refuses
|
||||
// those and trusts no „it is Felhom's own" claim. Only formattable parts are offered for formatting.
|
||||
Formattable bool `json:"formattable"`
|
||||
}
|
||||
|
||||
// Partition states (agent ≥ 0.158.0).
|
||||
const (
|
||||
PartEmpty = "empty"
|
||||
PartFilesystem = "filesystem"
|
||||
PartInUse = "in_use"
|
||||
)
|
||||
|
||||
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
|
||||
// unclaimed) and attach (the mountable-FS subset).
|
||||
type CandidatesResult struct {
|
||||
@@ -490,6 +587,10 @@ type GuestMemoryInfo struct {
|
||||
MaxMB int64 `json:"max_mb"`
|
||||
FloorMB int64 `json:"floor_mb"`
|
||||
Running bool `json:"running"`
|
||||
// HostSwapMB / GuestSwapMB (agent ≥ the R-1192 swap release): the host's swap and this guest's swap allowance.
|
||||
// nil = an older agent (not judged).
|
||||
HostSwapMB *int64 `json:"host_swap_mb,omitempty"`
|
||||
GuestSwapMB *int64 `json:"guest_swap_mb,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryResizeResult mirrors the agent's POST /guest/memory success body.
|
||||
@@ -698,6 +799,11 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
|
||||
return out, err
|
||||
}
|
||||
if err := refusalError("/disks/decommission", status, env); err != nil {
|
||||
if status == http.StatusForbidden || status == http.StatusConflict {
|
||||
// R-1063: the agent's two policy refusals (403 a protected role, 409 the whole-guest backup target) are
|
||||
// typed, so the dashboard can say which in plain words. The text is refusalError's, unchanged.
|
||||
return out, &DecommissionRefusedError{Status: status, Reason: refusalReason(env)}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||
@@ -706,6 +812,17 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecommissionRefusedError is the agent saying no to a decommission: Status 403 (the mount is system/backup by its
|
||||
// role) or 409 (the mount holds the whole-guest backup target). Nothing was done on the host.
|
||||
type DecommissionRefusedError struct {
|
||||
Status int
|
||||
Reason string // the agent's own words, truncated (never a secret: the agent's refusal text)
|
||||
}
|
||||
|
||||
func (e *DecommissionRefusedError) Error() string {
|
||||
return fmt.Sprintf("agentapi: POST /disks/decommission: HTTP %d: %s", e.Status, e.Reason)
|
||||
}
|
||||
|
||||
// refusalError converts a non-2xx status or an ok:false envelope into an error that CARRIES the
|
||||
// agent's reason (truncated; never request bodies or secrets). nil on an accepted 200/202+ok=true.
|
||||
func refusalError(path string, status int, env apiResponse) error {
|
||||
@@ -713,16 +830,21 @@ func refusalError(path string, status int, env apiResponse) error {
|
||||
if accepted && env.OK {
|
||||
return nil
|
||||
}
|
||||
reason := truncateErr(env.Error, 300)
|
||||
if reason == "" {
|
||||
reason = "(no reason in agent response)"
|
||||
}
|
||||
reason := refusalReason(env)
|
||||
if accepted { // 2xx but ok:false — business refusal without an HTTP error code
|
||||
return fmt.Errorf("agentapi: POST %s: %s", path, reason)
|
||||
}
|
||||
return fmt.Errorf("agentapi: POST %s: HTTP %d: %s", path, status, reason)
|
||||
}
|
||||
|
||||
// refusalReason is the agent's refusal text as refusalError carries it.
|
||||
func refusalReason(env apiResponse) string {
|
||||
if r := truncateErr(env.Error, 300); r != "" {
|
||||
return r
|
||||
}
|
||||
return "(no reason in agent response)"
|
||||
}
|
||||
|
||||
// truncateErr mirrors stacks.truncateStr for agent refusal reasons.
|
||||
func truncateErr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
@@ -769,6 +891,9 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme
|
||||
// real failure (e.g. the agent's 502 on a mkfs error: "device is mounted"). Returning the zero-value
|
||||
// result with a nil error here made a failed destructive format read as a silent SUCCESS in the web
|
||||
// layer. Surface it as an error so the caller (and the dashboard) report the failure.
|
||||
if status == http.StatusConflict && strings.Contains(env.Error, "has no durable id") {
|
||||
return out, ErrNoDurableID
|
||||
}
|
||||
if status < 200 || status >= 300 || !env.OK {
|
||||
msg := strings.TrimSpace(env.Error)
|
||||
if msg == "" {
|
||||
@@ -788,6 +913,8 @@ type FormatStatusResult struct {
|
||||
Device string `json:"device"`
|
||||
FSType string `json:"fstype"`
|
||||
Error string `json:"error"`
|
||||
// FSUUID (R-25): see FormatResult.FSUUID — "" until the job is done and verified.
|
||||
FSUUID string `json:"fs_uuid,omitempty"`
|
||||
}
|
||||
|
||||
// FormatStatus fetches the agent's most-recent format-job record.
|
||||
@@ -1039,6 +1166,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"`
|
||||
@@ -1157,6 +1291,18 @@ func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error)
|
||||
// post issues an authenticated JSON POST and unwraps the {ok,data,error} envelope. The agent
|
||||
// returns 200 or 202 for accepted requests.
|
||||
func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||
return c.postWith(c.hc, ctx, path, body)
|
||||
}
|
||||
|
||||
// postSlow is post with its own overall timeout, for the few synchronous routes that outlast the client's 15 s
|
||||
// (POST /host/line-test/run, about 55 s). It shares the pinned transport.
|
||||
func (c *Client) postSlow(ctx context.Context, path string, body any, timeout time.Duration) (json.RawMessage, error) {
|
||||
hc := *c.hc
|
||||
hc.Timeout = timeout
|
||||
return c.postWith(&hc, ctx, path, body)
|
||||
}
|
||||
|
||||
func (c *Client) postWith(hc *http.Client, ctx context.Context, path string, body any) (json.RawMessage, error) {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1168,7 +1314,7 @@ func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessa
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
start := time.Now()
|
||||
resp, err := c.hc.Do(req)
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
|
||||
return nil, fmt.Errorf("agentapi: POST %s: %w", path, err)
|
||||
|
||||
@@ -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,87 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1063: the agent's two decommission refusals come back TYPED (403 a protected role, 409 the whole-guest backup
|
||||
// target), with the same text as before; any other failure is not that type.
|
||||
func TestDecommission_PolicyRefusalsAreTyped(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
code int
|
||||
typed bool
|
||||
}{{http.StatusForbidden, true}, {http.StatusConflict, true}, {http.StatusBadGateway, false}} {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(c.code)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"refused for the test"}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).Decommission(context.Background(), "/mnt/x")
|
||||
s.Close()
|
||||
var dr *DecommissionRefusedError
|
||||
if got := errors.As(err, &dr); got != c.typed {
|
||||
t.Fatalf("HTTP %d: typed=%v, want %v (err %v)", c.code, got, c.typed, err)
|
||||
}
|
||||
if c.typed && (dr.Status != c.code || !strings.Contains(err.Error(), "HTTP "+strconv.Itoa(c.code)+": refused for the test")) {
|
||||
t.Fatalf("HTTP %d: %+v / %q", c.code, dr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-1069: the agent's anti-retarget refusal of a blank format (its exact text, a 409) comes back as ErrNoDurableID;
|
||||
// another 409 does not.
|
||||
func TestFormatDisk_NoDurableIDIsTyped(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
body string
|
||||
typed bool
|
||||
}{
|
||||
{`{"ok":false,"error":"format refused: device has no durable id to bind the format to (path-only formats are not permitted)","data":{"formatted":false,"data_bearing":false}}`, true},
|
||||
{`{"ok":false,"error":"format refused (device may have changed since inspection): gone","data":{"formatted":false}}`, false},
|
||||
} {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).FormatDisk(context.Background(), "/dev/vdb", "ext4", false, "")
|
||||
s.Close()
|
||||
if got := errors.Is(err, ErrNoDurableID); got != c.typed || err == nil {
|
||||
t.Fatalf("body %s: ErrNoDurableID=%v want %v (err %v)", c.body[:60], got, c.typed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,20 +57,20 @@ type DiskPrior struct {
|
||||
// DiskVerdictFor maps a SmartSummary plus the previous observation to a verdict. Rules are evaluated
|
||||
// TOP-DOWN and the FIRST match wins (v0.215.0):
|
||||
//
|
||||
// 1. nil / "" / UNKNOWN -> Nincs adat
|
||||
// 2. Health == FAILING -> Hiba (drive self-reports)
|
||||
// 3. temperature_c >= 60 -> Hiba
|
||||
// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba
|
||||
// 5. percentage_used >= 100 -> Hiba
|
||||
// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED)
|
||||
// 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping)
|
||||
// 8. unreadable >= 64 -> Hiba (too large to be a blip)
|
||||
// 9. unreadable > 0 -> Figyelmeztetés (first sighting)
|
||||
// 10. reallocated > 0 -> Figyelmeztetés
|
||||
// 11. media_errors > 0 -> Figyelmeztetés
|
||||
// 12. percentage_used >= 90 -> Figyelmeztetés
|
||||
// 13. temperature_c >= 55 -> Figyelmeztetés
|
||||
// 14. otherwise -> Rendben
|
||||
// 1. nil / "" / UNKNOWN -> Nincs adat
|
||||
// 2. Health == FAILING -> Hiba (drive self-reports)
|
||||
// 3. temperature_c >= 60 (NOT for NVMe — R-333) -> Hiba
|
||||
// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba
|
||||
// 5. percentage_used >= 100 -> Hiba
|
||||
// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED)
|
||||
// 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping)
|
||||
// 8. unreadable >= 64 -> Hiba (too large to be a blip)
|
||||
// 9. unreadable > 0 -> Figyelmeztetés (first sighting)
|
||||
// 10. reallocated > 0 -> Figyelmeztetés
|
||||
// 11. media_errors > 0 -> Figyelmeztetés
|
||||
// 12. percentage_used >= 90 -> Figyelmeztetés
|
||||
// 13. temperature_c >= 55 (NOT for NVMe — R-333) -> Figyelmeztetés
|
||||
// 14. otherwise -> Rendben
|
||||
//
|
||||
// WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187,
|
||||
// 197 and 198 all carry thresh 0, and a normalized SMART value floors at 1, so it can never drop to
|
||||
@@ -82,6 +82,12 @@ type DiskPrior struct {
|
||||
// drive sustain fires a full day earlier (12 Aug) than the count threshold (13 Aug). Row 8 exists for
|
||||
// a box that was powered off or restarted across the sustain window and so has no prior.
|
||||
//
|
||||
// WHY rows 3 and 13 skip an NVMe drive (R-333, `09` §3 decision 206, v0.308.0): the 55/60 °C bands are
|
||||
// SPINNING-DISK bands. A healthy NVMe idles at 53 °C (measured on demo-hp) and routinely passes 60 °C
|
||||
// under load with no fault, so the bands would tell a household "Hiba" about a working drive. An NVMe
|
||||
// drive judges its own heat: critical_warning bit 1 is the device's temperature alarm, and row 4 still
|
||||
// turns any critical_warning bit into Hiba. Pinned by TestDiskVerdict_NVMeHasNoHeatBand.
|
||||
//
|
||||
// Pure: no clock, no I/O, no logging. Everything it needs arrives as an argument.
|
||||
func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
|
||||
// 1 — no data. Never alarms.
|
||||
@@ -94,8 +100,9 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
|
||||
}
|
||||
// Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters,
|
||||
// because the overall verdict is structurally unable to report this class of fault.
|
||||
nvme := IsNVMe(s)
|
||||
switch {
|
||||
case atLeast(s.TemperatureC, TemperatureFailC): // 3
|
||||
case !nvme && atLeast(s.TemperatureC, TemperatureFailC): // 3
|
||||
return DiskVerdictFail
|
||||
case positive(s.CriticalWarning): // 4
|
||||
return DiskVerdictFail
|
||||
@@ -118,7 +125,7 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
|
||||
return DiskVerdictWarn
|
||||
case atLeast(s.PercentageUsed, percentageUsedWarn): // 12
|
||||
return DiskVerdictWarn
|
||||
case atLeast(s.TemperatureC, temperatureWarnC): // 13
|
||||
case !nvme && atLeast(s.TemperatureC, temperatureWarnC): // 13
|
||||
return DiskVerdictWarn
|
||||
}
|
||||
return DiskVerdictOK // 14
|
||||
@@ -190,12 +197,21 @@ func DegradedAttributes(s *SmartSummary) []string {
|
||||
if atLeast(s.PercentageUsed, percentageUsedWarn) {
|
||||
out = append(out, "elhasználódás")
|
||||
}
|
||||
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable.
|
||||
if atLeast(s.TemperatureC, temperatureWarnC) {
|
||||
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. Not for an
|
||||
// NVMe drive (R-333): its heat never triggers a verdict, so naming it would give a wrong reason.
|
||||
if !IsNVMe(s) && atLeast(s.TemperatureC, temperatureWarnC) {
|
||||
out = append(out, "hőmérséklet")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsNVMe reports whether the summary came from an NVMe drive. The agent fills critical_warning,
|
||||
// percentage_used and media_errors ONLY from smartctl's nvme_smart_health_information_log
|
||||
// (felhom-agent internal/storage/smart.go), so any of them present means NVMe; a SATA or USB drive
|
||||
// carries none of them.
|
||||
func IsNVMe(s *SmartSummary) bool {
|
||||
return s != nil && (s.CriticalWarning != nil || s.PercentageUsed != nil || s.MediaErrors != nil)
|
||||
}
|
||||
|
||||
func positive(p *int) bool { return p != nil && *p > 0 }
|
||||
func atLeast(p *int, n int) bool { return p != nil && *p >= n }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package agentapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// R-333 (`09` §3 decision 206): no heat band for an NVMe drive. Both directions, because "fixed" means
|
||||
// one kind of drive stopped alarming on heat and the other did not, and the drive's own alarm stays.
|
||||
func TestDiskVerdict_NVMeHasNoHeatBand(t *testing.T) {
|
||||
noPrior := DiskPrior{}
|
||||
cases := []struct {
|
||||
name string
|
||||
in *SmartSummary
|
||||
want DiskVerdict
|
||||
}{
|
||||
// A healthy NVMe under load: 72 °C, the device raises no flag. No heat alarm.
|
||||
{"hot NVMe, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(0), PercentageUsed: ip(3), MediaErrors: ip(0)}, DiskVerdictOK},
|
||||
{"warm NVMe 56 °C, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), CriticalWarning: ip(0)}, DiskVerdictOK},
|
||||
// The same drive with its own temperature bit set (critical_warning bit 1 = 0x02): Hiba.
|
||||
{"hot NVMe with critical_warning temperature bit → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}, DiskVerdictFail},
|
||||
{"cool NVMe with critical_warning → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(40), CriticalWarning: ip(1)}, DiskVerdictFail},
|
||||
// Spinning/SATA drives keep their 55/60 °C bands, unchanged.
|
||||
{"SATA 56 °C → warn", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), ReallocatedSectors: ip(0)}, DiskVerdictWarn},
|
||||
{"SATA 60 °C → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(60), ReallocatedSectors: ip(0)}, DiskVerdictFail},
|
||||
{"SATA 54 °C → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(54)}, DiskVerdictOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DiskVerdictFor(c.in, noPrior); got != c.want {
|
||||
t.Errorf("%s: DiskVerdictFor = %s, want %s", c.name, got.Label(), c.want.Label())
|
||||
}
|
||||
}
|
||||
// The alert body must not name heat as the reason for an NVMe drive.
|
||||
for _, a := range DegradedAttributes(&SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}) {
|
||||
if a == "hőmérséklet" {
|
||||
t.Error("an NVMe alert names temperature as an attribute; the verdict fired on the drive's own warning")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,31 +92,63 @@ func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatus
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EscrowClaim is a claimed recovery code. ConfirmRequired (agent ≥ 0.160.0, R-1151): the code's package reaches the
|
||||
// hub only when EscrowCeremonyConfirm brings back the words at Positions (1-based); an older agent answers without
|
||||
// it — its package is already on the hub, and no confirm is needed.
|
||||
type EscrowClaim struct {
|
||||
Code string
|
||||
ConfirmRequired bool
|
||||
Positions []int
|
||||
}
|
||||
|
||||
// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's
|
||||
// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never
|
||||
// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries
|
||||
// the agent's reason text, never the code.
|
||||
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) {
|
||||
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", struct{}{})
|
||||
//
|
||||
// R-1151: the claim asks the agent to keep the new package until the household typed the code back
|
||||
// (`defer_upload`). An agent older than 0.160.0 ignores the body and uploads as before.
|
||||
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, error) {
|
||||
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true})
|
||||
if err != nil {
|
||||
return "", status, err
|
||||
return EscrowClaim{}, status, err
|
||||
}
|
||||
if status == http.StatusGone {
|
||||
return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
|
||||
return EscrowClaim{}, status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
|
||||
}
|
||||
if err := refusalError("/escrow/ceremony/claim", status, env); err != nil {
|
||||
return "", status, err
|
||||
return EscrowClaim{}, status, err
|
||||
}
|
||||
var out struct {
|
||||
RecoveryCode string `json:"recovery_code"`
|
||||
RecoveryCode string `json:"recovery_code"`
|
||||
ConfirmRequired bool `json:"confirm_required"`
|
||||
VerifyPositions []int `json:"verify_positions"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||
return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
|
||||
return EscrowClaim{}, status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
|
||||
}
|
||||
if out.RecoveryCode == "" {
|
||||
return "", status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
|
||||
return EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
|
||||
}
|
||||
return out.RecoveryCode, status, nil
|
||||
if out.ConfirmRequired && len(out.VerifyPositions) != 2 {
|
||||
return EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim asks for a confirmation without two positions")
|
||||
}
|
||||
return EscrowClaim{Code: out.RecoveryCode, ConfirmRequired: out.ConfirmRequired, Positions: out.VerifyPositions}, status, nil
|
||||
}
|
||||
|
||||
// EscrowCeremonyConfirm sends the two words the household typed back (R-1151, agent ≥ 0.160.0); on success the agent
|
||||
// has stored the new package on the hub. The status is returned for the caller to classify: 422 the words do not
|
||||
// match (still waiting), 410 the window ran out, 404 nothing waits (superseded, restarted), 502 the hub failed (still
|
||||
// waiting). The words are part of the recovery code — never logged.
|
||||
func (c *Client) EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error) {
|
||||
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words})
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
if err := refusalError("/escrow/ceremony/confirm", status, env); err != nil {
|
||||
return status, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the
|
||||
@@ -210,6 +242,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 +287,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 +318,8 @@ func (f RecoveryFailure) String() string {
|
||||
return "bundle-too-old"
|
||||
case RecoveryCodeOpensRetained:
|
||||
return "code-opens-retained"
|
||||
case RecoveryOlderUnchecked:
|
||||
return "older-unchecked"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1151 — the claim asks the agent to keep the package until the words come back, and reads the agent's answer;
|
||||
// the confirm carries the two words. An agent older than 0.160.0 answers without confirm_required.
|
||||
func TestEscrowClaimAndConfirm_R1151(t *testing.T) {
|
||||
var claimBody map[string]any
|
||||
var confirmBody map[string][]string
|
||||
newAgent := true
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /escrow/ceremony/claim", func(w http.ResponseWriter, r *http.Request) {
|
||||
claimBody = nil
|
||||
_ = json.NewDecoder(r.Body).Decode(&claimBody)
|
||||
if newAgent {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c","confirm_required":true,"verify_positions":[1,3]}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c"}}`))
|
||||
})
|
||||
mux.HandleFunc("POST /escrow/ceremony/confirm", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&confirmBody)
|
||||
if confirmBody["words"][0] != "a" {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"the words do not match the recovery code"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"uploaded":true}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
claim, status, err := c.EscrowCeremonyClaim(context.Background())
|
||||
if err != nil || status != http.StatusOK || claim.Code != "a-b-c" || !claim.ConfirmRequired || len(claim.Positions) != 2 || claim.Positions[1] != 3 {
|
||||
t.Fatalf("claim: %+v %d %v", claim, status, err)
|
||||
}
|
||||
if claimBody["defer_upload"] != true {
|
||||
t.Fatalf("the claim did not ask the agent to wait for the words: body %v", claimBody)
|
||||
}
|
||||
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"a", "c"}); err != nil || st != http.StatusOK {
|
||||
t.Fatalf("confirm: %d %v", st, err)
|
||||
}
|
||||
if strings.Join(confirmBody["words"], ",") != "a,c" {
|
||||
t.Fatalf("confirm sent %v", confirmBody)
|
||||
}
|
||||
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"x", "c"}); err == nil || st != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("a wrong word must come back as a 422 error: %d %v", st, err)
|
||||
}
|
||||
|
||||
newAgent = false // agent 0.159.2: no confirm request
|
||||
claim, _, err = c.EscrowCeremonyClaim(context.Background())
|
||||
if err != nil || claim.ConfirmRequired || claim.Code != "a-b-c" {
|
||||
t.Fatalf("claim on an old agent: %+v %v", claim, err)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,16 @@ const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
|
||||
// for two months, and it heals itself when the agent updates.
|
||||
const FeatureRetainedRecoveryClass Feature = "retained_recovery_class"
|
||||
|
||||
// FeatureHostPowerOff is the household power-off (R-998, agent v0.155.0): POST /host/poweroff. A POST that switches
|
||||
// the machine off cannot be probed, so the VERSION decides. The settings card hides only on a definite SupportNo; on
|
||||
// Unknown it stays, and an old agent's 404 is answered with the update sentence.
|
||||
const FeatureHostPowerOff Feature = "host_poweroff"
|
||||
|
||||
// FeatureSameDisk — POST /disks/same-disk (agent v0.157.0, felhom.eu architecture/16-storage-physical-identity.md
|
||||
// §4): the agent's physical-disk verdict for two guest paths. Below it the Tier-2 runner keeps the old st_dev
|
||||
// rule (backup.Manager.diskRelation), logged once.
|
||||
const FeatureSameDisk Feature = "same_disk"
|
||||
|
||||
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
|
||||
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
||||
type SupportState int
|
||||
@@ -159,6 +169,13 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
|
||||
FeatureRetainedRecoveryClass: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe
|
||||
},
|
||||
// R-998: probing would switch the machine off. The row must exist (table-gap rule above).
|
||||
FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe
|
||||
},
|
||||
FeatureSameDisk: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe // not probeable: decided by the version header alone
|
||||
},
|
||||
}
|
||||
|
||||
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
|
||||
@@ -192,6 +209,11 @@ var featureMinAgent = map[Feature]string{
|
||||
// screen must keep saying it cannot tell the two apart — which was true, and is what this gate
|
||||
// preserves for boxes that have not updated yet.
|
||||
FeatureRetainedRecoveryClass: "0.129.0",
|
||||
|
||||
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
|
||||
FeatureHostPowerOff: "0.155.0",
|
||||
|
||||
FeatureSameDisk: "0.157.0",
|
||||
}
|
||||
|
||||
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
|
||||
)
|
||||
|
||||
// ── POST /host/poweroff (R-998, agent >= 0.155.0, felhom.eu documentation/architecture/14-host-power-off.md) ──
|
||||
//
|
||||
// The household's „A gép kikapcsolása". The agent answers 202 (it shuts this guest down, then the host) or 409
|
||||
// with a machine-readable reason. Unlike post(), the 409 BODY matters here: the dashboard tells the household
|
||||
// WHY (a backup runs, too soon after start, already asked…), so this call reads it.
|
||||
|
||||
// Agent refusal reasons (stable wire values; mirror felhom-agent internal/localapi/hostpoweroff.go).
|
||||
const (
|
||||
PowerOffTooSoon = "too_soon_after_boot"
|
||||
PowerOffAlreadyAsked = "already_requested_this_boot"
|
||||
PowerOffBackup = "backup_running"
|
||||
PowerOffRestoreTest = "restore_test_running"
|
||||
PowerOffOSUpdate = "os_update_running"
|
||||
PowerOffCtrlUpdate = "controller_update_running"
|
||||
PowerOffDrive = "drive_operation_running"
|
||||
PowerOffEscrow = "escrow_ceremony_running"
|
||||
PowerOffBusy = "host_busy"
|
||||
PowerOffOtherGuests = "other_guests_running"
|
||||
PowerOffUnknown = "state_unknown"
|
||||
)
|
||||
|
||||
// PowerOffRefused is the agent's 409: Reason is one of the constants above (or a newer one), Detail is for the log.
|
||||
type PowerOffRefused struct {
|
||||
Reason string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (e *PowerOffRefused) Error() string {
|
||||
return "agent refused the power-off: " + e.Reason + " (" + e.Detail + ")"
|
||||
}
|
||||
|
||||
// HostPowerOff asks the agent to shut this guest down and power the host off. nil = accepted (the box goes down
|
||||
// within minutes). A *PowerOffRefused = the agent said no, with its reason. A *StatusError 404 = an agent that
|
||||
// predates the route.
|
||||
func (c *Client) HostPowerOff(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/host/poweroff", bytes.NewReader([]byte("{}")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
start := time.Now()
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentapi: POST /host/poweroff: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.noteAgentVersion(resp)
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
logx.Debugf(c.logger, "[agentapi] POST /host/poweroff -> %d (%dms)", resp.StatusCode, time.Since(start).Milliseconds())
|
||||
switch resp.StatusCode {
|
||||
case http.StatusAccepted, http.StatusOK:
|
||||
return nil
|
||||
case http.StatusConflict:
|
||||
var env struct {
|
||||
Data struct {
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(raw, &env) != nil || env.Data.Reason == "" {
|
||||
return &PowerOffRefused{Reason: PowerOffUnknown, Detail: "unreadable 409 body"}
|
||||
}
|
||||
return &PowerOffRefused{Reason: env.Data.Reason, Detail: env.Data.Detail}
|
||||
default:
|
||||
return &StatusError{Method: http.MethodPost, Path: "/host/poweroff", Code: resp.StatusCode}
|
||||
}
|
||||
}
|
||||
|
||||
// IsPowerOffRefused returns the refusal, if err is one.
|
||||
func IsPowerOffRefused(err error) (*PowerOffRefused, bool) {
|
||||
var r *PowerOffRefused
|
||||
ok := errors.As(err, &r)
|
||||
return r, ok
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LineTestState mirrors the agent's GET /host/line-test (agent ≥ 0.164.0; felhom.eu R-1247): the host's weekly upload
|
||||
// line test — a 10-second iperf3 upload to ep0 inside the offsite tunnel, best of three runs, as ep0's server counted
|
||||
// it. Present=false: never measured (no tunnel, not yet due, or disabled). An agent before the route answers 404 (a
|
||||
// *StatusError); all three mean UNKNOWN, and the controller then sets no qBittorrent limit.
|
||||
type LineTestState struct {
|
||||
Present bool `json:"present"`
|
||||
Mbps float64 `json:"mbps,omitempty"`
|
||||
MeasuredAt string `json:"measured_at,omitempty"` // RFC3339 UTC
|
||||
Method string `json:"method,omitempty"`
|
||||
// WaitingForQuiet (agent ≥ 0.165.0, R-1250): a test is due and could start now, but the host's uplink has not been
|
||||
// quiet for 10 minutes. With a seeding torrent app the controller then holds the app's limit and calls LineTestRun.
|
||||
WaitingForQuiet bool `json:"waiting_for_quiet,omitempty"`
|
||||
}
|
||||
|
||||
// LineTestRunResult mirrors POST /host/line-test/run. OK=false with Refused: nothing ran (not-due, outside-window,
|
||||
// tries-used, no-tunnel, heavy-op, not-quiet, server-busy); OK=false without it: the runs failed.
|
||||
type LineTestRunResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Mbps float64 `json:"mbps,omitempty"`
|
||||
RunsMbps []float64 `json:"runs_mbps,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LineTestRunTimeout bounds the synchronous request: about 55 s on the agent (5 s settle, 10 s quiet check, three
|
||||
// 10-second runs 5 s apart), each run itself bounded at 25 s.
|
||||
const LineTestRunTimeout = 2 * time.Minute
|
||||
|
||||
// LineTestRun calls POST /host/line-test/run (R-1250): a test on request while the caller holds its torrent app's
|
||||
// upload down. An agent before the route answers 404 (*StatusError).
|
||||
func (c *Client) LineTestRun(ctx context.Context) (LineTestRunResult, error) {
|
||||
var out LineTestRunResult
|
||||
body, err := c.postSlow(ctx, "/host/line-test/run", struct{}{}, LineTestRunTimeout)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /host/line-test/run: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LineTest calls GET /host/line-test.
|
||||
func (c *Client) LineTest(ctx context.Context) (LineTestState, error) {
|
||||
var out LineTestState
|
||||
body, err := c.get(ctx, "/host/line-test")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /host/line-test: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MeasuredAtTime parses MeasuredAt (zero time when absent or garbled).
|
||||
func (s LineTestState) MeasuredAtTime() time.Time {
|
||||
t, _ := time.Parse(time.RFC3339, s.MeasuredAt)
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-1247: GET /host/line-test decodes the agent's weekly line test, and an agent before the route is a typed 404 (the
|
||||
// controller reads it as „not measured" → no qBittorrent limit).
|
||||
func TestLineTestDecodesAndAnOlderAgentIs404(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /host/line-test", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":true,"mbps":90.7,"measured_at":"2026-10-24T06:31:00Z","method":"iperf3-tunnel"}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
st, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).LineTest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !st.Present || st.Mbps != 90.7 || !st.MeasuredAtTime().Equal(time.Date(2026, 10, 24, 6, 31, 0, 0, time.UTC)) {
|
||||
t.Fatalf("state = %+v", st)
|
||||
}
|
||||
old := httptest.NewTLSServer(http.NewServeMux())
|
||||
defer old.Close()
|
||||
_, err = clientFor(t, old, strings.TrimPrefix(old.URL, "https://")).LineTest(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)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1250: the request decodes its outcome, waiting_for_quiet decodes, and the request outlasts the client's own
|
||||
// timeout (the agent answers after about 55 s; the client's default is 15 s).
|
||||
func TestLineTestRunOutlastsTheClientTimeout(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /host/line-test", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":false,"waiting_for_quiet":true}}`))
|
||||
})
|
||||
mux.HandleFunc("POST /host/line-test/run", func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"ok":true,"mbps":93.5,"runs_mbps":[92,93.5,93]}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
st, err := c.LineTest(context.Background())
|
||||
if err != nil || st.Present || !st.WaitingForQuiet {
|
||||
t.Fatalf("GET = %+v %v, want present:false waiting_for_quiet:true", st, err)
|
||||
}
|
||||
c.hc.Timeout = 100 * time.Millisecond // stands in for the 15 s default against a 55 s answer
|
||||
if _, err := c.post(context.Background(), "/host/line-test/run", struct{}{}); err == nil {
|
||||
t.Fatal("the plain POST should time out here — the seam does not test anything")
|
||||
}
|
||||
res, err := c.LineTestRun(context.Background())
|
||||
if err != nil || !res.OK || res.Mbps != 93.5 || len(res.RunsMbps) != 3 {
|
||||
t.Fatalf("LineTestRun = %+v %v", res, err)
|
||||
}
|
||||
}
|
||||
@@ -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,107 @@
|
||||
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'")
|
||||
}
|
||||
}
|
||||
|
||||
// R-921 part 2: HostBusy reads the agent's real wire shape (untargeted GET /backup/status, `host_busy`); an agent
|
||||
// without the field is „not known" — never „free".
|
||||
func TestHostBusy_ReadsTheAgentsGate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
busy, known bool
|
||||
what string
|
||||
}{
|
||||
{"held", `{"ok":true,"data":{"vmid":9201,"phase":"idle","host_busy":{"busy":true,"what":"os-pve-step"}}}`, true, true, "os-pve-step"},
|
||||
{"free", `{"ok":true,"data":{"vmid":9201,"phase":"idle","host_busy":{"busy":false}}}`, false, true, ""},
|
||||
{"older agent", `{"ok":true,"data":{"vmid":9201,"phase":"idle"}}`, false, false, ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.RawQuery != "" {
|
||||
http.Error(w, "want the untargeted status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
busy, what, known, err := c.HostBusy(context.Background())
|
||||
if err != nil || busy != tc.busy || known != tc.known || what != tc.what {
|
||||
t.Fatalf("HostBusy = %v %q known=%v err=%v, want %v %q known=%v", busy, what, known, err, tc.busy, tc.what, tc.known)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWriteConfig0600 asserts F8: controller.yaml is persisted 0600 (it holds infra secrets), even when
|
||||
// the target file already existed with looser (0644) permissions. POSIX modes only — skipped on Windows.
|
||||
func TestWriteConfig0600(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX file modes not represented on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "controller.yaml")
|
||||
|
||||
// Pre-create with world-readable 0644 to prove the helper tightens an existing file.
|
||||
if err := os.WriteFile(path, []byte("old: true\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeConfig0600(path, []byte("hub:\n api_key: redacted\n")); err != nil {
|
||||
t.Fatalf("writeConfig0600: %v", err)
|
||||
}
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode := fi.Mode().Perm(); mode != 0o600 {
|
||||
t.Fatalf("config mode = %o, want 0600", mode)
|
||||
}
|
||||
// No leftover temp file.
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("temp file not cleaned up")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// D4 (`16` §5): the no-drive refusal is a 400 the household can act on — not a 500.
|
||||
func TestD4_NeedsDriveIs400(t *testing.T) {
|
||||
err := util.MsgErrorf(stacks.ErrNeedsDrive, "err.stacks.needs_drive")
|
||||
if got := deployStatusFor(err); got != http.StatusBadRequest {
|
||||
t.Fatalf("ErrNeedsDrive → %d, want 400", got)
|
||||
}
|
||||
}
|
||||
@@ -57,11 +57,15 @@ func TestRefuseNetworkStubDeploy_Table(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := newDeployGateRouter(t, c.class)
|
||||
msg := r.refuseNetworkStubDeploy(c.hdd)
|
||||
// v0.252.0 (R-557): the gate returns a bundle KEY, so the customer reads it in their own
|
||||
// language. Rendered here rather than compared as a key, because what this test is about is
|
||||
// the SENTENCE the customer is refused with -- a key renamed with its message intact is not
|
||||
// a regression, and a message reworded is.
|
||||
msg := r.msgLang("hu", r.refuseNetworkStubDeploy(c.hdd))
|
||||
if c.refuse && !strings.Contains(msg, "a telepítés nem indítható") {
|
||||
t.Errorf("%s: want the refusal message, got %q", c.name, msg)
|
||||
}
|
||||
if !c.refuse && msg != "" {
|
||||
if !c.refuse && r.refuseNetworkStubDeploy(c.hdd) != "" {
|
||||
t.Errorf("%s: must proceed, got refusal %q", c.name, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
@@ -67,7 +68,7 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
if err := r.sett.SetGeoRestriction(geo); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Failed to save geo settings: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,12 +87,12 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
|
||||
// away instead of after the next ~15-min cycle.
|
||||
r.reportPushNow()
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Geo-korlátozás beállítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.set")})
|
||||
}
|
||||
|
||||
func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) geoTriggerSync(w http.ResponseWriter, req *http.Request) {
|
||||
if r.geoSync == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Cloudflare API nincs konfigurálva"})
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: r.msg(req, "api.geo.no_cloudflare")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,11 +106,23 @@ func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
r.reportPushNow()
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Szinkronizálás elindítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.sync_started")})
|
||||
}
|
||||
|
||||
func (r *Router) geoCountries(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: cf.AllCountries()})
|
||||
// geoCountries serves the picker's country list in the request's language.
|
||||
//
|
||||
// The TABLE in internal/cloudflare is untouched and stays Hungarian: it is also the validator, and
|
||||
// internal/report puts country CODES on the wire. Only the NAME shown to the customer is translated,
|
||||
// and the list is re-sorted afterwards -- alphabetical order is per language, and a list sorted by
|
||||
// the Hungarian names would read as shuffled in English.
|
||||
func (r *Router) geoCountries(w http.ResponseWriter, req *http.Request) {
|
||||
lang := r.langFor(req)
|
||||
list := cf.AllCountries()
|
||||
for i := range list {
|
||||
list[i].Name = r.countryName(lang, list[i].Code, list[i].Name)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list})
|
||||
}
|
||||
|
||||
func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
|
||||
@@ -144,7 +157,7 @@ func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, app
|
||||
override := &settings.AppGeoOverride{AllowedCountries: body.AllowedCountries}
|
||||
if err := r.sett.SetGeoAppOverride(appName, override); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Failed to save geo override for %s: %v", appName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -159,10 +172,10 @@ func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, app
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás beállítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_set")})
|
||||
}
|
||||
|
||||
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, appName string) {
|
||||
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
|
||||
if appName == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
|
||||
return
|
||||
@@ -170,7 +183,7 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, ap
|
||||
|
||||
if err := r.sett.RemoveGeoAppOverride(appName); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Failed to remove geo override for %s: %v", appName, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,5 +198,5 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, ap
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás eltávolítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_removed")})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// ── The JSON answers follow the language too (v0.252.0, slice 2 — R-557) ───────────────────────
|
||||
//
|
||||
// Design: felhom.eu/documentation/architecture/10-localisation.md. The dashboard's page markup has
|
||||
// followed the household's language since slice 1, but a large part of what a customer READS on
|
||||
// those pages never passes through a template: the page's own script fetches /api/… and shows the
|
||||
// `message` or `error` the answer carries. Until now those were Hungarian literals, so an English
|
||||
// dashboard answered in Hungarian the moment anything was clicked.
|
||||
//
|
||||
// These helpers mirror internal/web's (i18n_web.go) exactly, and for the same reason: the Hungarian
|
||||
// text is the SAME BYTES the literal carried, pinned by scripts/i18n_go_parity.py against a frozen
|
||||
// capture of the base commit.
|
||||
//
|
||||
// NOT everything here is display. `internal/report` and `internal/notify` put Hungarian on the wire,
|
||||
// where it is read by the hub and mailed to the household — those stay Hungarian in every language
|
||||
// until slice 3 (R-558) teaches the hub which language to compose in, and their bytes are pinned by
|
||||
// the wire goldens in those packages.
|
||||
|
||||
// langFor decides the language of one API request: `?lang=hu|en` (a testing override, never
|
||||
// persisted), else the household's saved setting, else Hungarian. Identical in meaning to
|
||||
// web.Server.langFor, deliberately — the page and the calls its script makes must never disagree.
|
||||
func (r *Router) langFor(req *http.Request) string {
|
||||
if req != nil {
|
||||
if q := req.URL.Query().Get("lang"); i18n.IsSupported(q) {
|
||||
return q
|
||||
}
|
||||
}
|
||||
if r.sett != nil {
|
||||
return r.sett.GetLanguage()
|
||||
}
|
||||
return i18n.Default
|
||||
}
|
||||
|
||||
// bundle returns the process-wide bundle, or nil if it somehow failed to load. It is embedded in the
|
||||
// binary, so a running box cannot reach the nil case; the callers still degrade to the key rather
|
||||
// than panicking.
|
||||
func bundle() *i18n.Bundle {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// msg returns a message in the request's language, with the message's own printf verbs filled in.
|
||||
func (r *Router) msg(req *http.Request, key string, a ...interface{}) string {
|
||||
return r.msgLang(r.langFor(req), key, a...)
|
||||
}
|
||||
|
||||
// msgLang is msg for a language that is already known.
|
||||
func (r *Router) msgLang(lang, key string, a ...interface{}) string {
|
||||
b := bundle()
|
||||
if b == nil {
|
||||
return key
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return b.Msg(lang, key)
|
||||
}
|
||||
return b.Msgf(lang, key, a...)
|
||||
}
|
||||
|
||||
// errText renders an error in the request's language: its bundle message when it carries one, its own
|
||||
// text otherwise (restic, docker, ssh and the Go stdlib print verbatim — that text is not ours).
|
||||
func (r *Router) errText(req *http.Request, err error) string {
|
||||
return util.ErrText(r.langFor(req), err)
|
||||
}
|
||||
|
||||
// countryName returns a country's name in lang, falling back to the name the cloudflare table
|
||||
// carries when the bundle has no entry for the code.
|
||||
//
|
||||
// The TABLE is not translated and must not be: internal/report puts country CODES on the wire, and
|
||||
// cloudflare/countries.go is also what validates them. Only the NAME shown in the geo picker follows
|
||||
// the language, which is why this lives here and not in that package.
|
||||
func (r *Router) countryName(lang, code, fallback string) string {
|
||||
b := bundle()
|
||||
if b == nil {
|
||||
return fallback
|
||||
}
|
||||
if v, _, ok := b.Text(lang, "country."+code); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Localisation slice 2 (R-557), scenario S2 — the JSON the page's own script reads follows the
|
||||
// household's language, and the Hungarian answer is byte-identical to what it was.
|
||||
//
|
||||
// This is the half of the dashboard a template test cannot see. Since slice 1 the PAGE has been
|
||||
// English, but every sentence that arrives by fetch — a refused deploy, a start blocked for memory,
|
||||
// "the backup has started" — was a Hungarian literal in internal/api. An English household clicked an
|
||||
// English button and was answered in Hungarian.
|
||||
//
|
||||
// The Hungarian side is a parity measurement, not a translation check: the `want` strings below are
|
||||
// the literals that stood at the call sites at 736f54b49610.
|
||||
|
||||
func langRouter(t *testing.T, lang string) *Router {
|
||||
t.Helper()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lang != "" {
|
||||
if err := sett.SetLanguage(lang); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &Router{sett: sett, logger: lg}
|
||||
}
|
||||
|
||||
func TestAPIMessagesFollowTheHouseholdLanguage(t *testing.T) {
|
||||
cases := []struct {
|
||||
key, hu, en string
|
||||
args []interface{}
|
||||
}{
|
||||
{key: "api.deploy.started",
|
||||
hu: "Telepítés elindítva – az állapot a kártyán követhető",
|
||||
en: "The install has started – the card shows how it is going"},
|
||||
{key: "api.backup.already_running",
|
||||
hu: "Mentés már folyamatban", en: "A backup is already running"},
|
||||
{key: "api.start.drive_missing", args: []interface{}{"Kulso HDD"},
|
||||
hu: "A(z) Kulso HDD tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.",
|
||||
en: "Storage Kulso HDD cannot be reached right now — the app cannot start until the drive is back."},
|
||||
{key: "api.start.not_enough_memory", args: []interface{}{512, 128, 1400, 1528},
|
||||
hu: "Nincs elég memória az indításhoz. Szükséges: 512 MB, elérhető: 128 MB (használt: 1400 MB / használható: 1528 MB)",
|
||||
en: "There is not enough memory to start. Needs 512 MB, 128 MB is free (1400 MB used of 1528 MB usable)"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.key, func(t *testing.T) {
|
||||
// hu: the saved setting, no query override — the ordinary household.
|
||||
r := langRouter(t, "hu")
|
||||
req := httptest.NewRequest("POST", "/api/stacks/x/start", nil)
|
||||
if got := r.msg(req, c.key, c.args...); got != c.hu {
|
||||
t.Errorf("Hungarian answer moved (parity)\n got %q\n want %q", got, c.hu)
|
||||
}
|
||||
// en: the same request shape on a box switched to English.
|
||||
r = langRouter(t, "en")
|
||||
if got := r.msg(req, c.key, c.args...); got != c.en {
|
||||
t.Errorf("English answer\n got %q\n want %q", got, c.en)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPILangForPrecedence — `?lang=` overrides the saved setting (the testing door slice 0 built),
|
||||
// an unsupported value is ignored rather than honoured, and a box with no setting answers Hungarian.
|
||||
func TestAPILangForPrecedence(t *testing.T) {
|
||||
cases := []struct{ saved, query, want string }{
|
||||
{"hu", "", "hu"},
|
||||
{"en", "", "en"},
|
||||
{"hu", "en", "en"},
|
||||
{"en", "hu", "hu"},
|
||||
{"en", "de", "en"}, // unsupported: ignored, the saved setting stands
|
||||
{"", "", i18n.Default},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := langRouter(t, c.saved)
|
||||
url := "/api/stacks"
|
||||
if c.query != "" {
|
||||
url += "?lang=" + c.query
|
||||
}
|
||||
if got := r.langFor(httptest.NewRequest("GET", url, nil)); got != c.want {
|
||||
t.Errorf("saved=%q query=%q: got %q, want %q", c.saved, c.query, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeoCountryNamesFollowTheLanguage — the picker's 237 country names. The cloudflare TABLE is not
|
||||
// translated (it validates codes, and internal/report puts codes on the wire); only the displayed
|
||||
// name is, and the list is re-sorted afterwards because alphabetical order is per language.
|
||||
func TestGeoCountryNamesFollowTheLanguage(t *testing.T) {
|
||||
r := langRouter(t, "hu")
|
||||
for _, c := range []struct{ lang, code, want string }{
|
||||
{"hu", "DE", "Németország"},
|
||||
{"en", "DE", "Germany"},
|
||||
{"hu", "US", "Egyesült Államok"},
|
||||
{"en", "US", "United States"},
|
||||
{"hu", "AO", "Angola"}, // identical in both languages — must still resolve, not fall through
|
||||
{"en", "AO", "Angola"},
|
||||
} {
|
||||
if got := r.countryName(c.lang, c.code, "FALLBACK"); got != c.want {
|
||||
t.Errorf("%s/%s: got %q, want %q", c.lang, c.code, got, c.want)
|
||||
}
|
||||
}
|
||||
// A code the bundle does not carry keeps the table's own name rather than showing a key.
|
||||
if got := r.countryName("en", "ZZ", "Ismeretlen"); got != "Ismeretlen" {
|
||||
t.Errorf("an unknown code must keep the table name, got %q", got)
|
||||
}
|
||||
// And the English list really is sorted by the English names: „Németország" sorts under N in
|
||||
// Hungarian and Germany under G, so the two orders must differ.
|
||||
if strings.EqualFold(r.countryName("hu", "DE", ""), r.countryName("en", "DE", "")) {
|
||||
t.Error("the country names did not change with the language at all")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// keptChoiceData is what the install page needs to ask the household (`09` §3 decision 36). The
|
||||
// sentences come rendered in the request's language; the page shows them as they are.
|
||||
type keptChoiceData struct {
|
||||
Code string `json:"code"` // "kept_data_choice"
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
UseLabel string `json:"use_label"`
|
||||
UseDesc string `json:"use_desc"` // "" when use is off
|
||||
UseOff string `json:"use_off"` // "" when use is offered
|
||||
UseOffered bool `json:"use_offered"` // a database copy exists for these files
|
||||
FreshLabel string `json:"fresh_label"`
|
||||
FreshDesc string `json:"fresh_desc"`
|
||||
NotBacked string `json:"not_backed_up"`
|
||||
}
|
||||
|
||||
// keptDataAtInstall answers the install when the app's private drive folder already holds data:
|
||||
//
|
||||
// - no choice → 409 kept_data_choice with the sentences (nothing moved, nothing installed);
|
||||
// - "use" → a LOAD from the newest usable copy (backup.KeptBestCopy: own unit, second drive, or —
|
||||
// R-691 (2) — the off-site copy, downloaded unit-only at load time), then the app's after_load; 202.
|
||||
// The page names the copy and its date. Refused 409 with use_off when no copy exists;
|
||||
// - "fresh" → not handled here: DeployStack moves the old data aside and installs.
|
||||
//
|
||||
// Returns true when it wrote the response.
|
||||
func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, name, hdd, choice string) bool {
|
||||
old := r.stackMgr.OldAppData(name, hdd)
|
||||
if len(old) == 0 || choice == stacks.KeptChoiceFresh {
|
||||
return false
|
||||
}
|
||||
display := name
|
||||
if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" {
|
||||
display = st.Meta.DisplayName
|
||||
}
|
||||
var size int64
|
||||
var newest time.Time
|
||||
for _, p := range old {
|
||||
size += stacks.DirSizeBytes(p)
|
||||
if t := stacks.DirModTime(p); t.After(newest) {
|
||||
newest = t
|
||||
}
|
||||
}
|
||||
var cp struct {
|
||||
ok bool
|
||||
c backup.KeptCopy
|
||||
}
|
||||
if r.backupMgr != nil {
|
||||
cp.c, cp.ok = r.backupMgr.KeptBestCopy(req.Context(), name, hdd)
|
||||
}
|
||||
lang := r.langFor(req)
|
||||
data := keptChoiceData{
|
||||
Code: "kept_data_choice",
|
||||
Title: r.msgLang(lang, "kept.choice.title"),
|
||||
Body: r.msgLang(lang, "kept.choice.body", display, appbackup.HumanizeBytes(size), localDay(newest)),
|
||||
UseLabel: r.msgLang(lang, "kept.choice.use.label"),
|
||||
UseOffered: cp.ok,
|
||||
FreshLabel: r.msgLang(lang, "kept.choice.fresh.label"),
|
||||
FreshDesc: r.msgLang(lang, "kept.choice.fresh.desc"),
|
||||
NotBacked: r.msgLang(lang, "kept.not_backed_up"),
|
||||
}
|
||||
if cp.ok {
|
||||
data.UseDesc = r.msgLang(lang, "kept.choice.use.desc_from", r.keptCopyName(lang, cp.c))
|
||||
} else {
|
||||
data.UseOff = r.msgLang(lang, "kept.choice.use_off")
|
||||
}
|
||||
|
||||
switch choice {
|
||||
case "":
|
||||
r.logger.Printf("[INFO] [api] Deploy %s: the drive already holds its old data %v (%d bytes) — asking the household (use offered: %v)", name, old, size, cp.ok)
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.Title, Data: data})
|
||||
return true
|
||||
case stacks.KeptChoiceUse:
|
||||
if !cp.ok {
|
||||
r.logger.Printf("[WARN] [api] Deploy %s: use my kept data REFUSED — no database copy for %s", name, hdd)
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.UseOff, Data: data})
|
||||
return true
|
||||
}
|
||||
if st := r.backupMgr.RestoreStatus(); st.Running || r.backupMgr.IsRunning() {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msgLang(lang, "api.kept.busy")})
|
||||
return true
|
||||
}
|
||||
src := cp.c.UnitDir
|
||||
if cp.c.Tier == backup.KeptTierOffsite {
|
||||
src = "off-site snapshot " + cp.c.SnapshotID
|
||||
}
|
||||
r.logger.Printf("[INFO] [api] Deploy %s: USE MY KEPT DATA — loading from %s (tier %d, %s) under the kept files %v", name, src, cp.c.Tier, cp.c.Time.UTC().Format(time.RFC3339), old)
|
||||
r.dropLeftoverHold(name, "use-kept-data") // R-704: this is a new install of an app that is not installed
|
||||
r.startKeptLoad(name, hdd, cp.c, lang, nil)
|
||||
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msgLang(lang, "kept.load.started", display)})
|
||||
return true
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("kept_data %q is not a choice", choice)})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// keptCopyName names a copy for the household: which copy and its date („saját mentés, 2026-09-28 04:15").
|
||||
func (r *Router) keptCopyName(lang string, c backup.KeptCopy) string {
|
||||
return r.msgLang(lang, backup.KeptCopyKey(c.Tier), localMinute(c.Time))
|
||||
}
|
||||
|
||||
// startKeptLoad runs the load and, when it succeeded, the app's after_load. then(ok) runs last. The
|
||||
// off-site copy goes through LoadKeptOffsite (download the unit alone, judge it, then the same restore).
|
||||
func (r *Router) startKeptLoad(name, drive string, c backup.KeptCopy, lang string, then func(ok bool)) {
|
||||
display := name
|
||||
if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" {
|
||||
display = st.Meta.DisplayName
|
||||
}
|
||||
okMsg := func(error) string { return r.msgLang(lang, "kept.load.done", display) }
|
||||
failMsg := func(err error) string { return r.msgLang(lang, "kept.load.failed", display, err) }
|
||||
if c.Tier == backup.KeptTierOffsite {
|
||||
r.backupMgr.LoadKeptOffsite(name, drive, c, nil, okMsg, failMsg, r.keptAfterLoad(name, then))
|
||||
return
|
||||
}
|
||||
r.backupMgr.LoadKeptApp(name, c.UnitDir, okMsg, failMsg, r.keptAfterLoad(name, then))
|
||||
}
|
||||
|
||||
// keptAfterLoad runs the app's after_load when the load succeeded, then then(ok).
|
||||
func (r *Router) keptAfterLoad(name string, then func(ok bool)) func(ok bool) {
|
||||
return func(ok bool) {
|
||||
if ok {
|
||||
if ran, err := r.stackMgr.RunAfterLoad(name, 10*time.Minute); ran && err != nil {
|
||||
r.logger.Printf("[WARN] [api] kept load %s: after_load failed: %v", name, err)
|
||||
}
|
||||
}
|
||||
if then != nil {
|
||||
then(ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func localDay(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "?"
|
||||
}
|
||||
return t.In(budapest()).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// localMinute is localDay with the time of day — the kept list's format, so both pages print one date.
|
||||
func localMinute(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "?"
|
||||
}
|
||||
return t.In(budapest()).Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func budapest() *time.Location {
|
||||
loc, err := time.LoadLocation("Europe/Budapest")
|
||||
if err != nil {
|
||||
return time.UTC
|
||||
}
|
||||
return loc
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// `09` §3 decision 36 — the install API answers an install over old data with the CHOICE (409,
|
||||
// code kept_data_choice, both sentences, "use" OFF when no database copy exists) and installs nothing;
|
||||
// "use" without a copy is refused; "fresh" is left to DeployStack. Nothing here reaches Docker.
|
||||
// COMPANION RED-PROOF: make keptDataAtInstall return false at once → the no-choice case writes nothing
|
||||
// and the first assertion fails (the install would go on to DeployStack).
|
||||
func TestKept_InstallAPIAsksAndInstallsNothing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
drive := filepath.Join(dir, "drive")
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
|
||||
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
|
||||
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &Router{stackMgr: m, logger: log.New(io.Discard, "", 0)}
|
||||
|
||||
call := func(choice string) (bool, int, map[string]interface{}) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang=en", nil)
|
||||
handled := r.keptDataAtInstall(w, req, "cloudapp", drive, choice)
|
||||
var body map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
return handled, w.Code, body
|
||||
}
|
||||
handled, code, body := call("")
|
||||
data, _ := body["data"].(map[string]interface{})
|
||||
if !handled || code != http.StatusConflict || data["code"] != "kept_data_choice" {
|
||||
t.Fatalf("no choice: handled=%v code=%d body=%v", handled, code, body)
|
||||
}
|
||||
if data["title"] != "This app's old data is still here" || data["use_offered"] != false ||
|
||||
data["use_off"] != "There is no backup of the database, so the app cannot load the old files. You can look at the files under Kept data." ||
|
||||
data["fresh_label"] != "Start fresh" || data["not_backed_up"] != "This is not backed up." {
|
||||
t.Fatalf("the choice's sentences: %v", data)
|
||||
}
|
||||
if handled, code, _ := call("use"); !handled || code != http.StatusConflict {
|
||||
t.Fatalf("use with no copy: handled=%v code=%d", handled, code)
|
||||
}
|
||||
if handled, _, _ := call("fresh"); handled {
|
||||
t.Fatal("fresh must be left to DeployStack (which moves the old data aside)")
|
||||
}
|
||||
if st, _ := m.GetStack("cloudapp"); st.Deployed || st.Deploying {
|
||||
t.Fatal("something was installed")
|
||||
}
|
||||
// No old data → the API does not interfere.
|
||||
_ = os.RemoveAll(filepath.Join(drive, "appdata/cloudapp"))
|
||||
if handled, _, _ := call(""); handled {
|
||||
t.Fatal("an install with no old data was intercepted")
|
||||
}
|
||||
}
|
||||
|
||||
// R-691 (2) — when the only database copy of kept files is OFF-SITE, the install choice offers „use" and
|
||||
// names that copy and its date; before v0.277.0 it said "no backup". The repository is the restic runner
|
||||
// seam; nothing reaches restic, ssh or Docker.
|
||||
// COMPANION RED-PROOF: keptDataAtInstall back on KeptDBCopy (0.276.0) → use_offered=false and this fails.
|
||||
func TestR691_InstallChoiceNamesTheOffsiteCopy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
drive := filepath.Join(dir, "drive")
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
|
||||
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp"), cfg.Paths.DataDir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
|
||||
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{Enabled: true, Host: "nas.local", Port: 22, User: "felhom",
|
||||
RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
|
||||
if err := bm.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapAt := time.Date(2026, 9, 28, 2, 15, 0, 0, time.UTC) // 04:15 in Budapest
|
||||
bm.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
for _, a := range args {
|
||||
if a == "snapshots" {
|
||||
return json.Marshal([]map[string]interface{}{{"short_id": "ab12cd34", "time": snapAt,
|
||||
"tags": []string{"cloudapp"}, "paths": []string{drive + "/backups/primary/cloudapp", drive + "/appdata/cloudapp"}}})
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
r := &Router{stackMgr: m, backupMgr: bm, logger: log.New(io.Discard, "", 0)}
|
||||
for lang, want := range map[string]string{
|
||||
"en": "We load the database from the off-site copy, 2026-09-28 04:15 and start the app with the old files. Changes made after that time can be missing.",
|
||||
"hu": "Az adatbázist innen töltjük vissza: távoli mentés, 2026-09-28 04:15. A régi fájlokkal indítjuk az alkalmazást. Ami ezután változott, hiányozhat.",
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang="+lang, nil)
|
||||
if !r.keptDataAtInstall(w, req, "cloudapp", drive, "") || w.Code != http.StatusConflict {
|
||||
t.Fatalf("%s: the choice was not asked (code %d)", lang, w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
data, _ := body["data"].(map[string]interface{})
|
||||
if data["use_offered"] != true || data["use_desc"] != want || data["use_off"] != "" {
|
||||
t.Fatalf("%s: use_offered=%v use_desc=%q use_off=%q\nwant use_desc=%q", lang, data["use_offered"], data["use_desc"], data["use_off"], want)
|
||||
}
|
||||
}
|
||||
if st, _ := m.GetStack("cloudapp"); st.Deployed || st.Deploying {
|
||||
t.Fatal("something was installed")
|
||||
}
|
||||
}
|
||||
|
||||
// R-704 (v0.278.0) — a hold an EARLIER install left under the app's name is dropped when a new install
|
||||
// begins; the consequence measured on demo-hp was a fresh nextcloud whose backups skipped its volumes and
|
||||
// unit because of a 2026-09-13 update hold. Driven through the real „use my kept data" path (the off-site
|
||||
// copy, restic and the unit restore behind their seams — no Docker).
|
||||
// COMPANION RED-PROOF: remove the dropLeftoverHold call from keptDataAtInstall → the hold is still there
|
||||
// after the 202 and the first assertion fails; remove it from deployStack → the AST check fails.
|
||||
func TestR704_AFreshInstallDropsALeftoverHold(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
drive := filepath.Join(dir, "drive")
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
|
||||
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp"), cfg.Paths.DataDir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox:1\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
|
||||
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = sett.SetOffboxTarget(&settings.OffboxTarget{Enabled: true, Host: "nas.local", Port: 22, User: "felhom",
|
||||
RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed"})
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
|
||||
if err := bm.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bm.SetOffboxFreeFn(func(string) int64 { return 100 << 30 })
|
||||
unitPath := drive + "/backups/primary/cloudapp"
|
||||
bm.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
var target string
|
||||
isRestore := false
|
||||
for i, a := range args {
|
||||
if a == "snapshots" {
|
||||
return json.Marshal([]map[string]interface{}{{"short_id": "ab12cd34", "time": time.Now().Add(-time.Hour),
|
||||
"tags": []string{"cloudapp"}, "paths": []string{unitPath}}})
|
||||
}
|
||||
if a == "restore" {
|
||||
isRestore = true
|
||||
}
|
||||
if a == "--target" && i+1 < len(args) {
|
||||
target = args[i+1]
|
||||
}
|
||||
}
|
||||
if isRestore && target != "" {
|
||||
u := filepath.Join(target, strings.TrimPrefix(unitPath, "/"))
|
||||
_ = os.MkdirAll(filepath.Join(u, "compose"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(u, "db-dumps"), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(u, "db-dumps", "cloudapp.sql"), []byte("x"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(u, "compose", "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox:1\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(u, "compose", "app.yaml"), []byte("env:\n HDD_PATH: "+drive+"\n"), 0o600)
|
||||
man, _ := json.Marshal(map[string]interface{}{"schema_version": 2, "app_name": "cloudapp", "db_dumps": []string{"cloudapp.sql"},
|
||||
"data": map[string]interface{}{"at": time.Now().UTC().Format(time.RFC3339), "image_pins": []string{"busybox:1"}}})
|
||||
_ = os.WriteFile(filepath.Join(u, "manifest.json"), man, 0o644)
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
done := make(chan struct{}, 1)
|
||||
bm.SetKeptUnitRestoreFn(func(app, unitDir string) (backup.UnitRestoreResult, error) {
|
||||
done <- struct{}{}
|
||||
return backup.UnitRestoreResult{}, nil
|
||||
})
|
||||
r := &Router{stackMgr: m, backupMgr: bm, sett: sett, logger: log.New(io.Discard, "", 0)}
|
||||
|
||||
// A leftover update hold from an earlier install (the demo-hp shape): the new install drops it.
|
||||
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "cloudapp", At: "2026-09-13T19:56:32Z", Reason: settings.HoldReasonUpdateFailed})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang=en", nil)
|
||||
if !r.keptDataAtInstall(w, req, "cloudapp", drive, "use") || w.Code != http.StatusAccepted {
|
||||
t.Fatalf("use: code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if h, held := sett.GetRestoreHold("cloudapp"); held {
|
||||
t.Fatalf("the earlier install's hold survived the new install: %+v", h)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the load never reached the unit restore")
|
||||
}
|
||||
|
||||
// A restore hold (R-379) is NEVER dropped by an install.
|
||||
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "cloudapp", At: "2026-09-13T19:56:32Z", Reason: settings.HoldReasonRestoreFailed})
|
||||
r.dropLeftoverHold("cloudapp", "install")
|
||||
if _, held := sett.GetRestoreHold("cloudapp"); !held {
|
||||
t.Fatal("an install dropped an R-379 restore hold")
|
||||
}
|
||||
_, _ = sett.ClearRestoreHold("cloudapp")
|
||||
|
||||
// The plain install path drops it too — before DeployStack (AST: the call precedes it in deployStack).
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var dropAt, deployAt token.Pos
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "deployStack" || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if s, ok := n.(*ast.SelectorExpr); ok {
|
||||
switch s.Sel.Name {
|
||||
case "dropLeftoverHold":
|
||||
dropAt = s.Pos()
|
||||
case "DeployStack":
|
||||
deployAt = s.Pos()
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if dropAt == token.NoPos || deployAt == token.NoPos || dropAt > deployAt {
|
||||
t.Fatalf("deployStack must call dropLeftoverHold before DeployStack (drop=%v deploy=%v)", dropAt, deployAt)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
@@ -77,21 +78,38 @@ func TestDeployStackWiresTheLifecycleGate(t *testing.T) {
|
||||
|
||||
// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via
|
||||
// showAlert(), so it must be the sentence the spec ruled, not a Go error string.
|
||||
//
|
||||
// v0.252.0 (R-557) moved the sentence into the message bundle, so router.go now names a KEY. The
|
||||
// ruling is unchanged and still checkable in one step more: router.go must name that key, and the
|
||||
// key must still carry the ruled sentence, byte for byte. Both halves matter — a key with no handler
|
||||
// is dead copy, and a handler naming a key whose text was reworded is the drift this test exists to
|
||||
// catch. scripts/i18n_go_parity.py proves the same equality against the base commit independently.
|
||||
func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "api.deploy.not_installable"
|
||||
const want = "Ez az alkalmazás jelenleg nem telepíthető."
|
||||
found := false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, want) {
|
||||
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, key) {
|
||||
found = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatalf("the ruled refusal message %q is not present in router.go", want)
|
||||
t.Fatalf("router.go no longer names %q -- the ruled refusal has no handler", key)
|
||||
}
|
||||
b, err := i18n.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := b.Msg(i18n.Default, key); got != want {
|
||||
t.Fatalf("the ruled refusal was reworded\n hu.json %s = %q\n ruled %q", key, got, want)
|
||||
}
|
||||
if en := b.Msg("en", key); en == "" || en == want {
|
||||
t.Errorf("%s has no English of its own: %q", key, en)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-474 — the remove handler really deletes the whole unit, the Tier-2 mirror and the prefs.
|
||||
func TestR474_RemoveHandlerDeletesUnitMirrorAndPrefs(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sel []string
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "removeStack" || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if s, ok := n.(*ast.SelectorExpr); ok {
|
||||
sel = append(sel, s.Sel.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
joined := " " + strings.Join(sel, " ") + " "
|
||||
for _, want := range []string{"RecoveryUnitPath", "Tier2MirrorDirsForApp", "RemoveTier2Mirrors", "DeleteAppBackupPrefs"} {
|
||||
if !strings.Contains(joined, " "+want+" ") {
|
||||
t.Errorf("removeStack must call %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-486 — removing an app with its backups KEPT keeps its Tier-2 record. The only call that may
|
||||
// forget the record is DeleteAppBackupPrefs, which the R-474 branch guards on remove_backups.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): put `r.sett.SetCrossDriveConfig(name, nil)` back into
|
||||
// removeStack unconditionally — this fails.
|
||||
func TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "removeStack" || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "SetCrossDriveConfig" {
|
||||
t.Errorf("removeStack calls SetCrossDriveConfig at line %d — a removal with backups kept would forget the mirror it kept (R-486)", fset.Position(s.Pos()).Line)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// R-491 — a removal clears the app's UPDATE hold (and only that kind).
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): remove the ClearUpdateHold call from removeStack — this fails.
|
||||
func TestR491_RemovalClearsTheUpdateHold(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "removeStack" || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "ClearUpdateHold" {
|
||||
found = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !found {
|
||||
t.Error("removeStack must clear the app's update hold (R-491), or a reinstall starts held")
|
||||
}
|
||||
}
|
||||
|
||||
// R-490 — with a default storage path registered, system-info reports the drive (the fallback every
|
||||
// other reader already had).
|
||||
func TestR490_SystemInfoFallsBackToTheDefaultStoragePath(t *testing.T) {
|
||||
r, sett, _, _ := newSlice4Router(t)
|
||||
r.sett = sett // the fixture router carries no settings; system-info reads the default path from them
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: t.TempDir(), Label: "HDD", Schedulable: true, IsDefault: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/info", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
System struct {
|
||||
HDDConfigured bool `json:"hdd_configured"`
|
||||
} `json:"system"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resp.Data.System.HDDConfigured {
|
||||
t.Errorf("hdd_configured must be true with a default storage path registered; body %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-536 — the ACCEPT-time path must not claim the app is installed.
|
||||
//
|
||||
// The router holds a concrete *notify.Notifier, so there is no seam to fake here; the claim is
|
||||
// therefore asserted where it actually lives — in the source of the deploy handler. This is the same
|
||||
// shape as the stacks package's AST test, and it exists for the same reason: the defect was not a
|
||||
// wrong value, it was a call in the wrong place.
|
||||
//
|
||||
// What it pins: the deploy handler (which writes 202 „Telepítés elindítva" and returns, while the
|
||||
// compose up runs afterwards) may announce that the install STARTED, and may not announce that it
|
||||
// finished. The finishing half is stacks.deployDoneHook → main.go → NotifyAppDeployed, pinned by
|
||||
// TestDeployDoneHook_FiresAtTheEndAndSaysWhichEndItWas.
|
||||
//
|
||||
// Red-proof: put `r.notifier.NotifyAppDeployed(name, displayName)` back beside the 202 → this fails.
|
||||
func TestDeployAcceptance_DoesNotClaimTheAppIsInstalled(t *testing.T) {
|
||||
src, err := os.ReadFile("router.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(src)
|
||||
|
||||
if strings.Contains(body, "NotifyAppDeployed(") {
|
||||
t.Fatal("the deploy handler announces an INSTALLED app at accept time — the install has not run yet; " +
|
||||
"an interrupted deploy then stands on the timeline as a completed one (measured 2026-09-16, mealie)")
|
||||
}
|
||||
if !strings.Contains(body, "NotifyAppDeployStarted(") {
|
||||
t.Fatal("the acceptance is no longer recorded at all — the timeline loses the fact that the customer asked for this app")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-552: an interrupted-restore notice was cleared only by a NEW restore of the same app, so a
|
||||
// household that answered it by REMOVING the app kept a „Megszakadt visszaállítás" card about an app
|
||||
// that no longer exists — for ever, because the notice is persisted. The consequence asserted: after
|
||||
// the removal path's clear, the notice is gone from the page's list AND from the persisted record (a
|
||||
// fresh Manager loading the file sees none), while another app's notice stays.
|
||||
// COMPANION RED-PROOF: drop the clearInterruptedRestoreNotice call from removeStack → the AST check
|
||||
// fails; make ClearInterruptedRestore a no-op → the notice is still listed.
|
||||
func TestR552_RemoveClearsTheInterruptedRestoreNotice(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dir
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recPath := filepath.Join(dir, "restore-status.json")
|
||||
// The record two stops left behind: "otherapp" already carries a notice, "homebox" was running.
|
||||
seed := map[string]any{
|
||||
"running": true, "op": "restore", "stack": "homebox",
|
||||
"interrupted": map[string]any{"otherapp": map[string]any{"op": "restore", "stack": "otherapp", "interrupted": true}},
|
||||
}
|
||||
b, _ := json.Marshal(seed)
|
||||
if err := os.WriteFile(recPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
load := func() *backup.Manager {
|
||||
bm := backup.NewManager(cfg, sett, lg)
|
||||
bm.SetRestoreRecordPath(recPath)
|
||||
bm.LoadRestoreRecord()
|
||||
return bm
|
||||
}
|
||||
bm := load()
|
||||
if _, ok := bm.InterruptedRestore("homebox"); !ok {
|
||||
t.Fatal("setup: no interrupted notice for homebox")
|
||||
}
|
||||
|
||||
r := &Router{backupMgr: bm, logger: lg}
|
||||
r.clearInterruptedRestoreNotice("homebox")
|
||||
|
||||
if _, ok := bm.InterruptedRestore("homebox"); ok {
|
||||
t.Errorf("R-552: the removed app's interrupted-restore notice is still listed")
|
||||
}
|
||||
if _, ok := bm.InterruptedRestore("otherapp"); !ok {
|
||||
t.Errorf("another app's notice was cleared by this app's removal")
|
||||
}
|
||||
again := load()
|
||||
if _, ok := again.InterruptedRestore("homebox"); ok {
|
||||
t.Errorf("R-552: the notice comes back after a restart — the clear was not persisted")
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
for _, d := range f.Decls {
|
||||
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil {
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "clearInterruptedRestoreNotice" {
|
||||
called = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
if !called {
|
||||
t.Errorf("R-552: removeStack does not call clearInterruptedRestoreNotice")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-553 — the deploy endpoint's status code must survive the day its Hungarian words are translated.
|
||||
//
|
||||
// Until v0.251.0 the handler read the refusal's TEXT: „kötelező", „memória", "does not exist",
|
||||
// "already deployed". Localisation slice 2 translates exactly those words, and every empty required
|
||||
// field would then answer 500 instead of 400 — a server error for something the customer can fix.
|
||||
//
|
||||
// The table below therefore passes messages that are ALREADY translated: the words are gone, only the
|
||||
// kind remains. RED-PROOF (REPORT): restore the strings.Contains chain in deployStack and this fails
|
||||
// on every translated row.
|
||||
func TestR553_Deploy_DecisionSurvivesWordingChange(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"required field, Hungarian as shipped", util.KindErrorf(stacks.ErrRequiredField,
|
||||
"a(z) %q (%s) mező kitöltése kötelező", "Jelszó", "PASSWORD"), http.StatusBadRequest},
|
||||
{"required field, TRANSLATED", util.KindErrorf(stacks.ErrRequiredField,
|
||||
"the %q (%s) field is required", "Password", "PASSWORD"), http.StatusBadRequest},
|
||||
{"password field, TRANSLATED", util.KindErrorf(stacks.ErrRequiredField,
|
||||
"fill in the %q field — use the Generate button", "Password"), http.StatusBadRequest},
|
||||
{"path missing, TRANSLATED", util.KindErrorf(stacks.ErrPathMissing,
|
||||
"a mappa %q nem létezik (%q mező)", "/mnt/nope", "Adatok"), http.StatusBadRequest},
|
||||
{"not enough memory, Hungarian as shipped", util.KindError(stacks.ErrNotEnoughMemory,
|
||||
"Nincs elég memória az alkalmazás telepítéséhez. Szükséges: 512 MB, Elérhető: 100 MB"), http.StatusBadRequest},
|
||||
{"not enough memory, TRANSLATED", util.KindError(stacks.ErrNotEnoughMemory,
|
||||
"Not enough memory to install this app. Needed: 512 MB, available: 100 MB"), http.StatusBadRequest},
|
||||
{"already deployed, TRANSLATED", util.KindErrorf(stacks.ErrAlreadyDeployed,
|
||||
"a(z) %q alkalmazás már telepítve van", "privatebin"), http.StatusConflict},
|
||||
{"anything else is a server error", errors.New("compose exploded"), http.StatusInternalServerError},
|
||||
{"a message that merely CONTAINS the old words is not a refusal kind",
|
||||
errors.New("docker: kötelező memória does not exist"), http.StatusInternalServerError},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := deployStatusFor(c.err); got != c.want {
|
||||
t.Errorf("%s: deployStatusFor(%q) = %d, want %d", c.name, c.err.Error(), got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The seam has to be WIRED, not merely present: the handler must call deployStatusFor, and no
|
||||
// decision in the deploy handler may read the error's text again (seam-built-but-never-wired, four
|
||||
// instances in this repo).
|
||||
func TestR553_DeployHandlerUsesTheKind(t *testing.T) {
|
||||
src, err := os.ReadFile("router.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(src)
|
||||
i := strings.Index(body, "func (r *Router) deployStack(")
|
||||
if i < 0 {
|
||||
t.Fatal("deployStack handler not found — this test no longer reads what it thinks it reads")
|
||||
}
|
||||
handler := body[i:]
|
||||
if j := strings.Index(handler[10:], "\nfunc "); j > 0 {
|
||||
handler = handler[:j+10]
|
||||
}
|
||||
if !strings.Contains(handler, "deployStatusFor(err)") {
|
||||
t.Error("the deploy handler no longer asks deployStatusFor for the status code")
|
||||
}
|
||||
// Comments may still NAME those words (this one does); a DECISION may not read them.
|
||||
for _, line := range strings.Split(handler, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "//") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "strings.Contains(err.Error()") {
|
||||
t.Errorf("the deploy handler decides by reading the error text again (R-553): %s", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-569 — the stop/start/restart/update, remove and delete handlers pick their status by the
|
||||
// refusal's KIND. Every row passes a REWORDED message: the words the handlers used to match
|
||||
// ("protected", "not found", "not deployed", "still running", "not orphaned") are gone, only the
|
||||
// kind remains. RED-PROOF (REPORT): restore any strings.Contains chain and its family's reworded rows
|
||||
// answer 500.
|
||||
func TestR569_StackOpStatus_SurvivesRewording(t *testing.T) {
|
||||
cases := []struct {
|
||||
family, name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
// action family (start/stop/restart/update)
|
||||
{"action", "unknown app, reworded", util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x"), http.StatusNotFound},
|
||||
{"action", "unknown app, wrapped by the caller", fmt.Errorf("restarting: %w",
|
||||
util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x")), http.StatusNotFound},
|
||||
{"action", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "%q is infrastructure", "traefik"), http.StatusForbidden},
|
||||
{"action", "update refusal", &stacks.UpdateRefusal{Reason: "busy", Message: "foglalt"}, http.StatusConflict},
|
||||
{"action", "update refusal: unknown app", &stacks.UpdateRefusal{Reason: "not_found", Message: "nincs ilyen"}, http.StatusNotFound},
|
||||
// remove family
|
||||
{"remove", "not deployed, reworded", util.KindErrorf(stacks.ErrNotDeployed, "%q has nothing installed", "x"), http.StatusConflict},
|
||||
{"remove", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up — stop it first", "x"), http.StatusConflict},
|
||||
{"remove", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "cannot take %q away", "traefik"), http.StatusForbidden},
|
||||
// delete family
|
||||
{"delete", "not orphaned, reworded", util.KindErrorf(stacks.ErrNotOrphaned, "%q still belongs to the catalog", "x"), http.StatusConflict},
|
||||
{"delete", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up", "x"), http.StatusConflict},
|
||||
{"delete", "unknown, reworded", util.KindErrorf(stacks.ErrStackNotFound, "nothing named %q", "x"), http.StatusNotFound},
|
||||
// the negative half: the old WORDS without a kind are not a refusal
|
||||
{"any", "text that merely contains the old words", errors.New("docker: image not found; protected; still running"), http.StatusInternalServerError},
|
||||
{"any", "anything else", errors.New("compose exploded"), http.StatusInternalServerError},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stackOpStatusFor(c.err); got != c.want {
|
||||
t.Errorf("%s / %s: stackOpStatusFor(%q) = %d, want %d", c.family, c.name, c.err.Error(), got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The seam must be WIRED in all three handlers, and none may decide by reading the error text again.
|
||||
func TestR569_HandlersUseTheKind(t *testing.T) {
|
||||
src, err := os.ReadFile("router.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(src)
|
||||
for _, fn := range []string{"func (r *Router) actionStack(", "func (r *Router) removeStack(", "func (r *Router) deleteStack("} {
|
||||
i := strings.Index(body, fn)
|
||||
if i < 0 {
|
||||
t.Fatalf("%s not found — this test no longer reads what it thinks it reads", fn)
|
||||
}
|
||||
h := body[i:]
|
||||
if j := strings.Index(h[10:], "\nfunc "); j > 0 {
|
||||
h = h[:j+10]
|
||||
}
|
||||
if !strings.Contains(h, "stackOpStatusFor(err)") {
|
||||
t.Errorf("%s no longer asks stackOpStatusFor for the status code", fn)
|
||||
}
|
||||
for _, line := range strings.Split(h, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "//") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "strings.Contains(err.Error()") {
|
||||
t.Errorf("%s decides by reading the error text again (R-569): %s", fn, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-575: the deploy answer renders the overcommit warning in the reader's language — English for an
|
||||
// English request (it used to be Hungarian on every page), Hungarian by default.
|
||||
func TestR575_OvercommitWarningFollowsTheLanguage(t *testing.T) {
|
||||
r := &Router{}
|
||||
en := r.deployWarningData(httptest.NewRequest("POST", "/api/stacks/x/deploy?lang=en", nil), stacks.KeyMemoryOvercommitWarning)
|
||||
hu := r.deployWarningData(httptest.NewRequest("POST", "/api/stacks/x/deploy?lang=hu", nil), stacks.KeyMemoryOvercommitWarning)
|
||||
if en == nil || hu == nil {
|
||||
t.Fatal("a warning key must produce a warning")
|
||||
}
|
||||
if !strings.Contains(en["warning"], "memory") || strings.Contains(en["warning"], "memóri") {
|
||||
t.Errorf("English page got %q", en["warning"])
|
||||
}
|
||||
if !strings.Contains(hu["warning"], "memóri") {
|
||||
t.Errorf("Hungarian page got %q", hu["warning"])
|
||||
}
|
||||
if strings.Contains(en["warning"], "err.stacks.") || strings.Contains(hu["warning"], "err.stacks.") {
|
||||
t.Error("a raw key reached the page")
|
||||
}
|
||||
if d := r.deployWarningData(httptest.NewRequest("POST", "/", nil), ""); d != nil {
|
||||
t.Errorf("no warning -> no data, got %v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-619: GET /api/stacks/<n>/deploy-fields served a `type: password` field as `required:false` (as the
|
||||
// template declares), while the deploy refuses 400 without it — so a caller that trusted the contract
|
||||
// was refused (measured on grafana, 2026-09-21). The consequence asserted, on the wire through the real
|
||||
// handler: the password field arrives `required:true`; a `secret` field (which the box DOES generate)
|
||||
// keeps its declared `required:false`; and the stack's own metadata is not changed for the next reader.
|
||||
func TestR619_PasswordFieldIsServedAsRequired(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
app := filepath.Join(cfg.Paths.StacksDir, "grafana")
|
||||
if err := os.MkdirAll(app, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n grafana:\n image: busybox\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(`display_name: Grafana
|
||||
deploy_fields:
|
||||
- env_var: GF_SECURITY_ADMIN_PASSWORD
|
||||
label: Admin jelszo
|
||||
type: password
|
||||
generate: "password:16"
|
||||
required: false
|
||||
- env_var: GF_SECRET_KEY
|
||||
label: Titkos kulcs
|
||||
type: secret
|
||||
generate: "hex:32"
|
||||
required: false
|
||||
`), 0o644)
|
||||
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &Router{stackMgr: m, cfg: cfg, logger: log.New(io.Discard, "", 0)}
|
||||
|
||||
read := func() map[string]bool {
|
||||
w := httptest.NewRecorder()
|
||||
r.getDeployFields(w, httptest.NewRequest(http.MethodGet, "/api/stacks/grafana/deploy-fields", nil), "grafana")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
Metadata struct {
|
||||
DeployFields []struct {
|
||||
EnvVar string `json:"env_var"`
|
||||
Required bool `json:"required"`
|
||||
} `json:"deploy_fields"`
|
||||
} `json:"metadata"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, f := range body.Data.Metadata.DeployFields {
|
||||
out[f.EnvVar] = f.Required
|
||||
}
|
||||
return out
|
||||
}
|
||||
got := read()
|
||||
if !got["GF_SECURITY_ADMIN_PASSWORD"] {
|
||||
t.Errorf("R-619: the password field reaches the wire as required:false, but the deploy refuses without it")
|
||||
}
|
||||
if req, ok := got["GF_SECRET_KEY"]; !ok || req {
|
||||
t.Errorf("a secret field (the box generates it) must keep its declared required:false; got present=%v required=%v", ok, req)
|
||||
}
|
||||
if meta, _, _ := m.GetDeployFields("grafana"); meta.DeployFields[0].Required {
|
||||
t.Errorf("the derivation leaked into the stack's metadata — it must be applied to the answer only")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-642 (v0.263.0): a start or restart is never answered "completed" — measured 2026-09-23 on docmost
|
||||
// and romm, both 200 "start completed" while `Restarting (1)` behind a 404 front door.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): put `Message: "Stack " + name + " " + action + " completed"` back in
|
||||
// startAnswer — the first assertion fails.
|
||||
func TestR642_StartIsNeverReportedCompleted(t *testing.T) {
|
||||
for _, action := range []string{"start", "restart"} {
|
||||
a := startAnswer("docmost", action, "restarting")
|
||||
if strings.Contains(a.Message, "completed") {
|
||||
t.Errorf("%s answered %q — a start cannot know it completed", action, a.Message)
|
||||
}
|
||||
if !strings.Contains(a.Message, "restarting") {
|
||||
t.Errorf("%s must say the state it sees, got %q", action, a.Message)
|
||||
}
|
||||
if d, _ := a.Data.(map[string]interface{}); d["state"] != "restarting" {
|
||||
t.Errorf("%s data = %v", action, a.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestR642_TheHandlerUsesStartAnswer walks the router's AST: the stack-action handler must CALL
|
||||
// startAnswer (a comment naming it would not count — the v0.154.0 / R-106 inert-seam class).
|
||||
func TestR642_TheHandlerUsesStartAnswer(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if c, ok := n.(*ast.CallExpr); ok {
|
||||
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "startAnswer" {
|
||||
calls++
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if calls != 1 {
|
||||
t.Errorf("startAnswer must be called exactly once from router.go, found %d", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
)
|
||||
|
||||
// TestMain puts a silent docker stub on PATH for every test in this package: R-650, the fixtures
|
||||
// here build a real stacks.Manager, and on the build host (DooPlex) that reached production Docker.
|
||||
func TestMain(m *testing.M) { os.Exit(dockerexec.RunWithStub(m)) }
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// v0.269.0 (`09` §3 decision 27, R-666) — while a held app's page says support is informed, the remove
|
||||
// API allows only "remove the app, keep my data". Through the production wiring (NewManager + ScanStacks,
|
||||
// the hold read through UpdateGuards as main.go wires it).
|
||||
type heldGuards struct{ noWhole bool }
|
||||
|
||||
func (g heldGuards) HoldFor(string) (bool, string) { return true, "HELD" }
|
||||
func (g heldGuards) HoldNoWholeCopy(string) bool { return g.noWhole }
|
||||
func (g heldGuards) Busy(string) (bool, string) { return false, "" }
|
||||
func (g heldGuards) CanBackUp(string) (bool, string) { return true, "" }
|
||||
func (g heldGuards) BackupNow(context.Context, string) error { return nil }
|
||||
func (g heldGuards) SafetyDump(context.Context, string) ([]string, error) { return nil, nil }
|
||||
func (g heldGuards) RestorePoints(context.Context, string, func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) {
|
||||
return stacks.UpdateRestorePoint{}, false, nil
|
||||
}
|
||||
func (g heldGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// COMPANION RED-PROOF (REPORT): delete the decision-27 block in removeStack — the data-deleting remove
|
||||
// then reaches RemoveStack and this test fails at "was not refused".
|
||||
func TestR666_HeldWithNoWholeCopyRemovesOnlyKeepingData(t *testing.T) {
|
||||
for _, body := range []string{`{"remove_hdd_data":true,"remove_backups":false}`, `{"remove_hdd_data":false,"remove_backups":true}`} {
|
||||
r, dir := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
|
||||
r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true})
|
||||
code, resp := postRemove(t, r, body)
|
||||
if code != http.StatusConflict || resp.OK {
|
||||
t.Fatalf("%s: HTTP %d ok=%v — a data-deleting remove of a stranded app was not refused", body, code, resp.OK)
|
||||
}
|
||||
if !strings.Contains(resp.Error, "ügyfélszolgálat") {
|
||||
t.Fatalf("error = %q, want the support sentence", resp.Error)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil {
|
||||
t.Fatal("app.yaml gone — something was removed by a refused request")
|
||||
}
|
||||
}
|
||||
// The dialog is told to offer keep-data only …
|
||||
r, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
|
||||
r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true})
|
||||
w := httptest.NewRecorder()
|
||||
r.getStackHDDData(w, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app")
|
||||
if !strings.Contains(w.Body.String(), `"keep_data_only":true`) {
|
||||
t.Fatalf("hdd-data does not tell the dialog: %s", w.Body.String())
|
||||
}
|
||||
// … and a hold that NAMES a whole copy keeps the full dialog (positive control).
|
||||
r2, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
|
||||
r2.stackMgr.SetUpdateGuards(heldGuards{noWhole: false})
|
||||
w2 := httptest.NewRecorder()
|
||||
r2.getStackHDDData(w2, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app")
|
||||
if strings.Contains(w2.Body.String(), "keep_data_only") {
|
||||
t.Fatalf("a hold with a whole copy must keep the full dialog: %s", w2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-706 (v0.279.0) — a removal "with its backups" also deletes the app's off-site verification copy, and
|
||||
// ONLY that app's. COMPANION RED-PROOF: remove the removeVerificationCopy call from removeStack → the AST
|
||||
// check fails; make the helper skip the delete → the copy is still on disk.
|
||||
func TestR706_RemovalWithBackupsDeletesTheVerificationCopy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
drive := filepath.Join(dir, "drive")
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||
_ = os.MkdirAll(cfg.Paths.DataDir, 0o755)
|
||||
_ = os.MkdirAll(drive, 0o755)
|
||||
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
|
||||
mine, other := bm.OffsiteRestoreScratchPath("cloudapp"), bm.OffsiteRestoreScratchPath("otherapp")
|
||||
if mine == "" || other == "" {
|
||||
t.Fatalf("no verification-copy location resolved (%q, %q)", mine, other)
|
||||
}
|
||||
for _, p := range []string{mine, other} {
|
||||
_ = os.MkdirAll(p, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(p, "restored.bin"), []byte("x"), 0o644)
|
||||
}
|
||||
r := &Router{backupMgr: bm, logger: log.New(io.Discard, "", 0)}
|
||||
got := r.removeVerificationCopy("cloudapp")
|
||||
if len(got) != 1 || !strings.HasPrefix(got[0], mine) {
|
||||
t.Fatalf("reported %v, want the copy at %s", got, mine)
|
||||
}
|
||||
if _, err := os.Stat(mine); !os.IsNotExist(err) {
|
||||
t.Fatalf("the verification copy is still on disk: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(other, "restored.bin")); err != nil {
|
||||
t.Fatalf("another app's verification copy was touched: %v", err)
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
for _, d := range f.Decls {
|
||||
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil {
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "removeVerificationCopy" {
|
||||
called = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("removeStack does not call removeVerificationCopy (R-706)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-839: a deploy whose HDD_PATH names a folder INSIDE a registered drive is refused at the API, so
|
||||
// the boot sweep can never again hold an app for asking a folder whether it is a mountpoint. Through
|
||||
// the real deployStack handler. PATH is emptied so a regression that lets the deploy proceed can
|
||||
// never reach a real docker (tests that reach real docker act on DooPlex).
|
||||
func TestR839_DeployRefusesASubPathOfADrive(t *testing.T) {
|
||||
t.Setenv("PATH", "")
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
root := t.TempDir()
|
||||
sett, err := settings.Load(filepath.Join(root, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const drive = "/mnt/felhom-drives/scratch_hdd"
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
if err := os.MkdirAll(cfg.Paths.StacksDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := stacks.NewManager(cfg, lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &Router{stackMgr: m, cfg: cfg, sett: sett, logger: lg}
|
||||
r.classifyFSPath = func(string) string { return "" }
|
||||
|
||||
deploy := func(hdd string) (int, string) {
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"values":{"HDD_PATH":"` + hdd + `"}}`
|
||||
r.deployStack(w, httptest.NewRequest(http.MethodPost, "/api/stacks/paperless-ngx/deploy", strings.NewReader(body)), "paperless-ngx")
|
||||
return w.Code, w.Body.String()
|
||||
}
|
||||
|
||||
code, body := deploy(drive + "/userdata/paperless-ngx")
|
||||
if code != http.StatusConflict || !strings.Contains(body, "nem egy mappa") || !strings.Contains(body, drive) {
|
||||
t.Fatalf("R-839: a folder inside a drive must be refused naming the drive; got %d %s", code, body)
|
||||
}
|
||||
// Controls: the drive itself and an unregistered path are not this gate's business.
|
||||
for _, ok := range []string{drive, drive + "/", "/srv/elsewhere"} {
|
||||
if _, b := deploy(ok); strings.Contains(b, "nem egy mappa") {
|
||||
t.Errorf("R-839: %q must not be refused as a folder inside a drive: %s", ok, b)
|
||||
}
|
||||
}
|
||||
if d, in := sett.DriveOfSubPath(drive + "x/userdata"); in {
|
||||
t.Errorf("a sibling path sharing the prefix (%s) is not inside the drive", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-970 (v0.322.0): the router has no /config routes at all — even a SIGNED-IN request gets the router's 404 for every
|
||||
// method, and the settings file is neither served nor written.
|
||||
// RED-PROOF (2026-10-16, REPORT): with the `/config` GET case put back, this failed at „GET /api/config answered 200".
|
||||
func TestR970_ConfigRoutesAreGone(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "controller.yaml")
|
||||
planted := "customer:\n id: t\n domain: example.hu\nhub:\n api_key: TESTONLY-key-r970\n"
|
||||
if err := os.WriteFile(path, []byte(planted), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &Router{configPath: path, logger: log.New(io.Discard, "", 0)}
|
||||
r.restart = func() { t.Fatal("a /config request restarted the controller") }
|
||||
for _, p := range []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash"} {
|
||||
for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: e.example\n")))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s answered %d, want the router's 404", m, p, w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "TESTONLY-key-r970") {
|
||||
t.Fatalf("%s %s served the settings file", m, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got, _ := os.ReadFile(path); string(got) != planted {
|
||||
t.Fatal("the settings file was written")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-442 through the PRODUCTION WIRING: the POST /api/stacks/{name}/remove handler → the real
|
||||
// stacks.Manager (constructor + ScanStacks, no hand-set state) → RemoveStack. Two refusals, both
|
||||
// non-2xx with the exact customer sentence, and in both cases app.yaml is still on disk afterwards.
|
||||
// Neither reaches the compose boundary (the refusal precedes it), so no docker is touched.
|
||||
func newR442Router(t *testing.T, appYAML string) (*Router, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "app")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compose := "services:\n app:\n image: nginx:1.27\n volumes:\n - ${HDD_PATH}/appdata/app:/data\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = root
|
||||
cfg.Paths.SystemDataPath = "/mnt/sys_drive"
|
||||
cfg.Stacks.ComposeCommand = "docker compose" // skips detection; never invoked on the refusal path
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
m, err := stacks.NewManager(cfg, lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Router{cfg: cfg, stackMgr: m, logger: lg}, dir
|
||||
}
|
||||
|
||||
func postRemove(t *testing.T, r *Router, body string) (int, apiResponse) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/stacks/app/remove", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.removeStack(w, req, "app")
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
func TestRemoveHandler_R442_UnresolvedDriveIsRefusedNon2xx(t *testing.T) {
|
||||
r, dir := newR442Router(t, "deployed: true\nenv: {}\n")
|
||||
code, resp := postRemove(t, r, `{"remove_hdd_data":true,"remove_backups":false}`)
|
||||
if code/100 == 2 || resp.OK {
|
||||
t.Fatalf("HTTP %d ok=%v — a 2xx over a removal that could not resolve the data location is the R-442 defect (body error=%q)", code, resp.OK, resp.Error)
|
||||
}
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", code)
|
||||
}
|
||||
const want = "Az alkalmazás adatainak helye nem állapítható meg, ezért semmit nem töröltünk. Az alkalmazás nem lett eltávolítva."
|
||||
if resp.Error != want {
|
||||
t.Fatalf("error = %q, want the exact customer sentence", resp.Error)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil {
|
||||
t.Fatal("app.yaml gone — the app was removed with its data left behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveHandler_R442_DriveAbsentIsRefusedNon2xx(t *testing.T) {
|
||||
drive := t.TempDir() // a plain directory is not a mountpoint → the drive is "absent"
|
||||
r, dir := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: "+drive+"\n")
|
||||
code, resp := postRemove(t, r, `{"remove_hdd_data":true}`)
|
||||
if code != http.StatusConflict || resp.OK {
|
||||
t.Fatalf("HTTP %d ok=%v, want 409 refusal (error=%q)", code, resp.OK, resp.Error)
|
||||
}
|
||||
if !strings.Contains(resp.Error, drive) || !strings.Contains(resp.Error, "vissza nem csatlakozik") {
|
||||
t.Fatalf("error = %q, want the drive-absent sentence naming %s", resp.Error, drive)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil {
|
||||
t.Fatal("app.yaml gone on a drive-absent refusal")
|
||||
}
|
||||
}
|
||||
+402
-202
@@ -1,14 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -108,8 +106,8 @@ func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, s
|
||||
func (r *Router) SetRestarter(fn func()) { r.restart = fn }
|
||||
|
||||
// refuseNetworkStubDeploy is the deploy-time stub gate (RCA fix 2). Non-empty return = the
|
||||
// Hungarian refusal for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
|
||||
// local stub. Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
|
||||
// refusal KEY for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
|
||||
// local stub; the caller renders it in the request's language (v0.252.0, R-557). Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
|
||||
// classification timeout/unknown fails OPEN (a wedged share is the unreachable badge's business);
|
||||
// local (non-network) and empty paths keep today's behavior exactly.
|
||||
func (r *Router) refuseNetworkStubDeploy(hdd string) string {
|
||||
@@ -119,7 +117,7 @@ func (r *Router) refuseNetworkStubDeploy(hdd string) string {
|
||||
if r.classifyFSPath(hdd) != system.FSClassStub {
|
||||
return ""
|
||||
}
|
||||
return "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek."
|
||||
return "api.deploy.netstorage_unreachable"
|
||||
}
|
||||
|
||||
// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
|
||||
@@ -179,17 +177,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case path == "/selfupdate/update" && req.Method == http.MethodPost:
|
||||
r.selfupdateTrigger(w, req)
|
||||
|
||||
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
|
||||
case path == "/config/apply" && req.Method == http.MethodPost:
|
||||
r.configApply(w, req)
|
||||
|
||||
// GET /api/config/hash — return current config file hash
|
||||
case path == "/config/hash" && req.Method == http.MethodGet:
|
||||
r.configHash(w, req)
|
||||
|
||||
// GET /api/config — return raw controller.yaml content
|
||||
case path == "/config" && req.Method == http.MethodGet:
|
||||
r.configContent(w, req)
|
||||
// v0.322.0 (R-970): /api/config, /api/config/hash and /api/config/apply are GONE. They read the box's
|
||||
// controller.yaml with every secret in it and replaced it — reachable with the box's own hub key, unsigned,
|
||||
// which contradicted `01-topology-and-trust.md` §4/§10. Nothing called them (the hub never connects to a box;
|
||||
// the agent injects config at provision; the signed config bundle writes root files). Pinned by
|
||||
// TestR970_ConfigRoutesAreGone and cmd/controller TestR970_HubKeyOpensNoDoor.
|
||||
|
||||
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
|
||||
case path == "/selfrestart" && req.Method == http.MethodPost:
|
||||
@@ -222,19 +214,19 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// POST /api/stacks/{name}/start
|
||||
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "start", extractName(path, "/start"))
|
||||
r.actionStack(w, req, "start", extractName(path, "/start"))
|
||||
|
||||
// POST /api/stacks/{name}/stop
|
||||
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "stop", extractName(path, "/stop"))
|
||||
r.actionStack(w, req, "stop", extractName(path, "/stop"))
|
||||
|
||||
// POST /api/stacks/{name}/restart
|
||||
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "restart", extractName(path, "/restart"))
|
||||
r.actionStack(w, req, "restart", extractName(path, "/restart"))
|
||||
|
||||
// POST /api/stacks/{name}/update
|
||||
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "update", extractName(path, "/update"))
|
||||
r.actionStack(w, req, "update", extractName(path, "/update"))
|
||||
|
||||
// POST /api/stacks/{name}/optional-config
|
||||
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
|
||||
@@ -357,11 +349,11 @@ func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
|
||||
}
|
||||
|
||||
func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) rescanStacks(w http.ResponseWriter, req *http.Request) {
|
||||
r.logger.Printf("[INFO] [api] Manual stack rescan requested")
|
||||
if err := r.stackMgr.ScanStacks(); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Stack rescan failed: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
stackCount := len(r.stackMgr.GetStacks())
|
||||
@@ -372,22 +364,49 @@ func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
func (r *Router) getStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
stack, ok := r.stackMgr.GetStack(name)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
|
||||
return
|
||||
}
|
||||
// v0.264.0 (R-606): the page polls this for the Update button's label and the outcome. Rendered in
|
||||
// the reader's language; a Hungarian reader gets exactly the bytes it always got. GetStack returns a
|
||||
// COPY, so nothing here touches the manager's own state.
|
||||
// v0.265.0 (R-647): EVERY reader, Hungarian included — a Hungarian reader of an English box read
|
||||
// the hold in English before. On a Hungarian box a Hungarian reader still gets the same bytes.
|
||||
lang := r.langFor(req)
|
||||
stack.UpdatePhaseLabel = stacks.UpdatePhaseLabelIn(lang, stack.UpdatePhase)
|
||||
stack.UpdateError = r.stackMgr.UpdateErrorFor(*stack, lang)
|
||||
stack.HoldReason = r.stackMgr.HoldReasonFor(*stack, lang)
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
|
||||
}
|
||||
|
||||
func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
// markPasswordFieldsRequired (R-619) serves the deploy refusal's real rule on the wire: a
|
||||
// `type: password` field is MANDATORY however the template's `required` reads, because the deploy
|
||||
// never generates a password the household has not seen (stacks/deploy.go, the "password" case).
|
||||
// Served `required:false`, a caller that trusts the contract was refused 400. Derived here, not stored,
|
||||
// so templates need no edit. meta is the handler's own fresh LoadMetadata copy, so changing it touches
|
||||
// no shared state. Pinned by TestR619_PasswordFieldIsServedAsRequired.
|
||||
func markPasswordFieldsRequired(meta *stacks.Metadata) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
for i := range meta.DeployFields {
|
||||
if meta.DeployFields[i].Type == "password" {
|
||||
meta.DeployFields[i].Required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name string) {
|
||||
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
markPasswordFieldsRequired(meta)
|
||||
data := map[string]interface{}{
|
||||
"metadata": meta,
|
||||
"app_config": appCfg,
|
||||
@@ -397,13 +416,35 @@ func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name st
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
||||
}
|
||||
|
||||
// deployStatusFor maps a deploy refusal to its HTTP status by the refusal's KIND (R-553).
|
||||
//
|
||||
// It used to read the customer's Hungarian sentence — `strings.Contains(err.Error(), "kötelező")`,
|
||||
// „memória", plus the English "does not exist" / "already deployed". Localisation slice 2 translates
|
||||
// exactly those words, and on that day every validation refusal would have become a 500: the customer
|
||||
// sees a server error for a field they simply left empty, and the API contract changes silently.
|
||||
// The sentences are untouched; the kinds come from internal/stacks (deploy_errors.go).
|
||||
// Pinned by TestR553_Deploy_DecisionSurvivesWordingChange.
|
||||
func deployStatusFor(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, stacks.ErrProtectedStack):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing), errors.Is(err, stacks.ErrNeedsDrive),
|
||||
errors.Is(err, stacks.ErrNotEnoughMemory):
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
limitBody(w, req)
|
||||
r.logger.Printf("[INFO] [api] Deploy requested for stack: %s", name)
|
||||
r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)
|
||||
|
||||
var body struct {
|
||||
Values map[string]string `json:"values"`
|
||||
Values map[string]string `json:"values"`
|
||||
KeptData string `json:"kept_data"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
||||
@@ -421,7 +462,15 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)",
|
||||
name, st.Meta.EffectiveLifecycle())
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false,
|
||||
Error: "Ez az alkalmazás jelenleg nem telepíthető."})
|
||||
Error: r.msg(req, "api.deploy.not_installable")})
|
||||
return
|
||||
}
|
||||
|
||||
// v0.320.0: a protected infra stack is part of the box, never an app to install. Refused here, before the
|
||||
// kept-data check or anything else that reads the request, and again inside DeployStack for every other caller.
|
||||
if st, ok := r.stackMgr.GetStack(name); ok && (st.Protected || r.cfg.IsProtectedStack(name)) {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: a protected infra stack is part of the box", name)
|
||||
writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: r.msg(req, "api.deploy.part_of_box")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -432,9 +481,8 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)",
|
||||
name, hr.AvailGB, hr.ReserveGB, hr.TotalGB)
|
||||
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false, Error: fmt.Sprintf(
|
||||
"Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
|
||||
hr.AvailGB, hr.ReserveGB)})
|
||||
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false,
|
||||
Error: r.msg(req, "api.deploy.no_space", hr.AvailGB, hr.ReserveGB)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -442,9 +490,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
// must see a network filesystem (or its healthy idle autofs trigger) in THIS namespace — the one
|
||||
// the app will consume the path in. A stub (plain local dir after a guest reboot) would silently
|
||||
// send the app's data to the system drive.
|
||||
if msg := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); msg != "" {
|
||||
if key := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); key != "" {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: network HDD_PATH %s is a stub in the controller namespace", name, body.Values["HDD_PATH"])
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: msg})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, key)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -462,22 +510,38 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
return
|
||||
}
|
||||
|
||||
// R-839: HDD_PATH must name the drive itself, never a folder inside it — see
|
||||
// settings.DriveOfSubPath. The deploy page only offers drive roots; this guards every other caller.
|
||||
if drive, inside := r.sett.DriveOfSubPath(body.Values["HDD_PATH"]); inside {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: HDD_PATH %s is a folder inside the drive %s, not the drive (R-839)", name, body.Values["HDD_PATH"], drive)
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.deploy.hdd_path_inside_drive", drive)})
|
||||
return
|
||||
}
|
||||
|
||||
// `09` §3 decision 36: the app's drive folder already holds its old data → the household chooses.
|
||||
if handled := r.keptDataAtInstall(w, req, name, body.Values["HDD_PATH"], body.KeptData); handled {
|
||||
return
|
||||
}
|
||||
|
||||
// R-704 (v0.278.0): a fresh install drops a hold an EARLIER install left under this name.
|
||||
r.dropLeftoverHold(name, "install")
|
||||
|
||||
deployReq := stacks.DeployRequest{
|
||||
StackName: name,
|
||||
Values: body.Values,
|
||||
KeptData: body.KeptData,
|
||||
}
|
||||
|
||||
warning, err := r.stackMgr.DeployStack(deployReq)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err)
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "already deployed") {
|
||||
status = http.StatusConflict
|
||||
// R-553 — the status code comes from the refusal's KIND (deployStatusFor), never from its words.
|
||||
resp := apiResponse{OK: false, Error: r.errText(req, err)}
|
||||
if errors.Is(err, stacks.ErrNeedsDrive) {
|
||||
// D4 (`16` §5): the wizard renders the sentence with a link to the drives page.
|
||||
resp.Data = map[string]string{"code": "needs_drive", "link": "/storage"}
|
||||
}
|
||||
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, deployStatusFor(err), resp)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -485,9 +549,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
// polls GET /api/stacks/{name}). The old "Stack X deployed" message asserted completion before it
|
||||
// was true — misleading for API/script consumers. Report that the deploy STARTED, not that it
|
||||
// finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
|
||||
resp := apiResponse{OK: true, Message: "Telepítés elindítva – az állapot a kártyán követhető"}
|
||||
if warning != "" {
|
||||
resp.Data = map[string]string{"warning": warning}
|
||||
resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")}
|
||||
if d := r.deployWarningData(req, warning); d != nil {
|
||||
resp.Data = d
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, resp)
|
||||
|
||||
@@ -497,7 +561,10 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
if s, ok := r.stackMgr.GetStack(name); ok && s.Meta.DisplayName != "" {
|
||||
displayName = s.Meta.DisplayName
|
||||
}
|
||||
r.notifier.NotifyAppDeployed(name, displayName)
|
||||
// R-536: this is the ACCEPTANCE, not the installation. `app_deployed` moved to the async
|
||||
// path's own end (stacks.deployDoneHook → Router.OnDeployDone), because the deploy runs after
|
||||
// this returns and an interrupted one used to leave a completed-install record behind.
|
||||
r.notifier.NotifyAppDeployStarted(name, displayName)
|
||||
}
|
||||
|
||||
// Re-sync geo rules (new hostname may need to be added)
|
||||
@@ -562,7 +629,37 @@ func desiredStateForAction(action string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
// stackOpStatusFor maps a stack-lifecycle refusal (stop/start/restart/update, remove, delete) to its
|
||||
// HTTP status by the refusal's KIND (R-569) — the R-553 rule one handler family over.
|
||||
//
|
||||
// Until R-569 these three handlers matched "protected", "not found", "not deployed", "still running"
|
||||
// and "not orphaned" in err.Error(). Those strings are internal English, so localisation never moved
|
||||
// them; a reworded internal message would have, silently turning a 404/403/409 into a 500. The kinds
|
||||
// come from internal/stacks (stack_errors.go); the messages are unchanged.
|
||||
//
|
||||
// One function serves the three families because their kinds are disjoint: an action never returns
|
||||
// ErrNotOrphaned, a remove never returns an UpdateRefusal. Pinned by
|
||||
// TestR569_StackOpStatus_SurvivesRewording and TestR569_HandlersUseTheKind.
|
||||
func stackOpStatusFor(err error) int {
|
||||
var ref *stacks.UpdateRefusal
|
||||
switch {
|
||||
case errors.Is(err, stacks.ErrStackNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.As(err, &ref):
|
||||
if ref.Reason == "not_found" {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, stacks.ErrProtectedStack):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, stacks.ErrNotDeployed), errors.Is(err, stacks.ErrStillRunning),
|
||||
errors.Is(err, stacks.ErrNotOrphaned):
|
||||
return http.StatusConflict
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, name string) {
|
||||
r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
|
||||
r.dbg("actionStack: action=%s name=%s", action, name)
|
||||
|
||||
@@ -578,9 +675,27 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
// R-379/R-380: an app held after a failed restore + failed rollback must not start from the
|
||||
// customer's button either. Checked BEFORE the drive gate because it applies to driveless apps,
|
||||
// which is the class the hold exists for.
|
||||
if action == "start" || action == "restart" {
|
||||
//
|
||||
// R-439 (slice 4): `update` is in this list. It was not until v0.237.0, so a held app could be
|
||||
// updated — the one action most likely to make a held app's data worse. Pinned by
|
||||
// TestR439_UpdateOfAHeldAppIsRefused.
|
||||
// v0.269.0 (`09` §3 decision 28): an app the BOX stopped for a crash loop / OOM storm is started
|
||||
// again by the household's Start — the hold is lifted and the app gets one more try. Restart and
|
||||
// Update stay refused while it holds; any other hold kind is untouched.
|
||||
if action == "start" && r.backupMgr != nil && r.backupMgr.HoldKind(name) == settings.HoldReasonUnhealthyStop {
|
||||
if r.backupMgr.LiftUnhealthyStop(name) {
|
||||
r.logger.Printf("[INFO] [api] %s: the unhealthy stop is LIFTED by Start — one more try (decision 28)", name)
|
||||
}
|
||||
}
|
||||
if action == "start" || action == "restart" || action == "update" {
|
||||
if held, why := r.restoreHoldFor(name); held {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why})
|
||||
// `reason` (v0.261.0) — THIS LINE FIRES BEFORE UpdatePreflight, so without it a held app
|
||||
// answers 409 with no machine-readable reason at all, and an unattended caller cannot
|
||||
// tell it from a passing backup window: it would press a terminally-refused button for
|
||||
// ever. Found while writing the reason test, not by reading. `held` is TERMINAL until a
|
||||
// person acts, and it is the one reason that matters most to get right.
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why,
|
||||
Data: map[string]string{"reason": "held"}})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -589,7 +704,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
if gated, hdd := r.startGatedByMissingDrive(name); gated {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{
|
||||
OK: false,
|
||||
Error: fmt.Sprintf("A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.", hdd),
|
||||
Error: r.msg(req, "api.start.drive_missing", hdd),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -609,7 +724,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
if afterMB > usableMB {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{
|
||||
OK: false,
|
||||
Error: fmt.Sprintf("Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)", stackMemMB, usableMB-usedMB, usedMB, usableMB),
|
||||
Error: r.msg(req, "api.start.not_enough_memory", stackMemMB, usableMB-usedMB, usedMB, usableMB),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -617,6 +732,34 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Slice 4: every cheap refusal of an update — busy, already updating, deploying, memory, disk, and
|
||||
// "no backup to return to" — BEFORE the intent below is recorded, so an update that was never going
|
||||
// to happen records nothing (§8.2). Each is a 409 with the Hungarian sentence.
|
||||
if action == "update" {
|
||||
if ref := r.stackMgr.UpdatePreflight(name); ref != nil {
|
||||
status := http.StatusConflict
|
||||
if ref.Reason == "not_found" {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
// errText, not ref.Message (v0.260.0): a refusal built with refuseUpdateErr carries its
|
||||
// bundle key, so the household reads the refusal in its own language. A refusal still
|
||||
// built from a Hungarian literal has no key and errText returns ref.Message byte for
|
||||
// byte — which is why this line is safe to change for all of them at once, and why the
|
||||
// R-524 downgrade refusal below is not a key nobody reads (the "seam built but never
|
||||
// wired" class).
|
||||
//
|
||||
// `data.reason` (v0.261.0) is ADDITIVE and is for a caller that is not a person. The
|
||||
// sentence says WHAT happened; only the reason says whether trying again can ever work —
|
||||
// `busy`/`updating`/`deploying`/`migrating`/`self_updating` are TRANSIENT, `held` and
|
||||
// `downgrade` are TERMINAL until a human acts. An unattended caller that cannot tell
|
||||
// those apart either gives up on a passing backup window or presses a refused button for
|
||||
// ever. The sentence is unchanged, so no page moves.
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, ref),
|
||||
Data: map[string]string{"reason": ref.Reason}})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// R-166: THE CUSTOMER-INTENT POINT. This switch is where a human's decision about whether their
|
||||
// app should be running enters the system, and until v0.189.0 that decision was recorded nowhere
|
||||
// — so the box had to infer it from container counts, and inferred wrong for a power cut and for
|
||||
@@ -631,7 +774,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
r.logger.Printf("[ERROR] [api] %s for %s refused: could not record desired state: %v", action, name, derr)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{
|
||||
OK: false,
|
||||
Error: "A művelet nem hajtható végre: az alkalmazás beállításai nem menthetők.",
|
||||
Error: r.msg(req, "api.action.state_not_saved"),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -646,23 +789,42 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
case "restart":
|
||||
err = r.stackMgr.RestartStack(name)
|
||||
case "update":
|
||||
err = r.stackMgr.UpdateStack(name)
|
||||
// Slice 4: the GUARDED update. It returns as soon as the job has started; the result is only
|
||||
// ever known from GET /api/stacks/{name} (updating / update_phase / update_error).
|
||||
err = r.stackMgr.StartGuardedUpdate(name)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err)
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
|
||||
// R-443 (slice 4): an update is NEVER reported completed here. The spike measured this line saying
|
||||
// "update completed" over an app that was already crash-looping. It answers 202 — accepted, not
|
||||
// finished — and "completed" exists only as update_phase=done on GET /api/stacks/{name}, which is
|
||||
// written after the app's health is known. Pinned by TestR443_UpdateIsNeverReportedCompleteSynchronously.
|
||||
if action == "update" {
|
||||
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msg(req, "api.update.started"),
|
||||
Data: map[string]interface{}{"accepted": true, "completed": false}})
|
||||
return
|
||||
}
|
||||
// R-642 (v0.263.0): a start or restart is NEVER reported "completed". `compose up -d` returns 0
|
||||
// over an app that is about to crash-loop — measured 2026-09-23 on docmost and romm, each answering
|
||||
// 200 "start completed" while `Restarting (1)` behind a 404 front door. What this knows at return is
|
||||
// the state the containers are in right now, so that is what it says; whether the APP works is
|
||||
// the health probe's to answer, not this line's. Pinned by TestR642_StartIsNeverReportedCompleted.
|
||||
if action == "start" || action == "restart" {
|
||||
state := ""
|
||||
_ = r.stackMgr.RefreshStatus()
|
||||
if st, ok := r.stackMgr.GetStack(name); ok {
|
||||
state = string(st.State)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, startAnswer(name, action, state))
|
||||
} else {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
|
||||
}
|
||||
|
||||
// Trigger integration lifecycle hooks after successful action
|
||||
if r.integrationMgr != nil {
|
||||
@@ -689,11 +851,11 @@ func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request,
|
||||
|
||||
if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Optional config update failed for %s: %v", name, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.settings.updated")})
|
||||
}
|
||||
|
||||
// --- Integration API handlers ---
|
||||
@@ -734,13 +896,13 @@ func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, pro
|
||||
state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Integration toggle failed for %s:%s: %v", provider, target, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
msg := "Integráció engedélyezve"
|
||||
msg := r.msg(req, "api.integration.enabled")
|
||||
if !body.Enabled {
|
||||
msg = "Integráció kikapcsolva"
|
||||
msg = r.msg(req, "api.integration.disabled")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
|
||||
}
|
||||
@@ -758,22 +920,23 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str
|
||||
|
||||
output, err := r.stackMgr.GetLogs(name, lines)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
|
||||
}
|
||||
|
||||
func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
func (r *Router) getStackHDDData(w http.ResponseWriter, req *http.Request, name string) {
|
||||
resp, err := r.stackMgr.GetStackHDDData(name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
resp.KeepDataOnly = r.stackMgr.RemoveKeepsDataOnly(name) // decision 27: the dialog offers keep-data only
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
||||
}
|
||||
|
||||
func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
func (r *Router) getStackBackupData(w http.ResponseWriter, req *http.Request, name string) {
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
||||
return
|
||||
@@ -783,18 +946,76 @@ func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name
|
||||
// mount IS the namespace; SSD-only: <systemDataPath>/felhom-data). Passing the namespace root
|
||||
// (not the raw drive) keeps GetStackBackupData's paths single-nested under Model A.
|
||||
var nsRoot string
|
||||
var mirrors []string
|
||||
if r.backupMgr != nil {
|
||||
nsRoot = r.backupMgr.AppNamespaceRoot(name)
|
||||
mirrors = r.backupMgr.Tier2MirrorDirsForApp(name) // R-485: the real second-drive copy, not a dead `rsync` path
|
||||
}
|
||||
|
||||
resp, err := r.stackMgr.GetStackBackupData(name, nsRoot)
|
||||
resp, err := r.stackMgr.GetStackBackupData(name, nsRoot, mirrors)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
||||
}
|
||||
|
||||
// dropLeftoverHold (R-704, v0.278.0) clears the update or crash-loop hold of an app that is NOT installed,
|
||||
// before a new install of it begins (a plain install, or „use my kept data"). Such a hold can only have
|
||||
// been left by an earlier install: before v0.242.0 a removal kept update holds, and before v0.278.0 it
|
||||
// kept crash-loop stops — measured 2026-09-28: a nextcloud update hold from 2026-09-13 on demo-hp made
|
||||
// the backup skip the FRESH install's volumes and unit, so its off-site copy held no data; on 9202 a
|
||||
// calcom crash-loop stop refused the fresh install's update. A restore hold (R-379) is never touched.
|
||||
// Pinned by TestR704_AFreshInstallDropsALeftoverHold.
|
||||
func (r *Router) dropLeftoverHold(name, why string) {
|
||||
if r.sett == nil {
|
||||
return
|
||||
}
|
||||
if st, ok := r.stackMgr.GetStack(name); ok && (st.Deployed || st.Deploying) {
|
||||
return
|
||||
}
|
||||
if cleared, err := r.sett.ClearUpdateHold(name); err != nil {
|
||||
r.logger.Printf("[WARN] [api] %s %s: the hold an earlier install left could not be cleared: %v", why, name, err)
|
||||
} else if cleared {
|
||||
r.logger.Printf("[INFO] [api] %s %s: a hold left by an EARLIER install of this app is dropped (R-704)", why, name)
|
||||
}
|
||||
}
|
||||
|
||||
// clearInterruptedRestoreNotice (R-552) drops the removed app's „Megszakadt visszaállítás" notice. The
|
||||
// notice tells the household to run the restore again; once the app is removed that advice has no
|
||||
// subject, and nothing else would ever clear it. Pinned by TestR552_RemoveClearsTheInterruptedRestoreNotice.
|
||||
func (r *Router) clearInterruptedRestoreNotice(name string) {
|
||||
if r.backupMgr == nil {
|
||||
return
|
||||
}
|
||||
if r.backupMgr.ClearInterruptedRestore(name) {
|
||||
r.logger.Printf("[INFO] [api] remove %s: its interrupted-restore notice is cleared with it (R-552)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// removeVerificationCopy (R-706, v0.279.0) deletes the app's off-site VERIFICATION copy
|
||||
// (`backups/offsite-restore/<app>`, left by a full off-site restore for the household to inspect) when the
|
||||
// app is removed "with its backups". Measured 2026-09-28 on demo-hp: ~1 GB stayed after such a removal, and
|
||||
// the app list no longer showed anything it belonged to. Returns the removed path as the removal reports it.
|
||||
// Pinned by TestR706_RemovalWithBackupsDeletesTheVerificationCopy.
|
||||
func (r *Router) removeVerificationCopy(name string) []string {
|
||||
if r.backupMgr == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, c := range r.backupMgr.ListOffsiteRestoreCopies() {
|
||||
if c.Stack != name {
|
||||
continue
|
||||
}
|
||||
if err := r.backupMgr.DeleteOffsiteRestoreCopy(name); err != nil {
|
||||
r.logger.Printf("[WARN] [api] remove %s: its off-site verification copy %s could not be deleted: %v", name, c.Path, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s (%s)", c.Path, c.SizeHuman))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
||||
@@ -814,39 +1035,84 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
}
|
||||
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
|
||||
|
||||
// v0.269.0 (`09` §3 decision 27, R-666): while a held app's page says support is informed, only
|
||||
// "remove the app, keep my data" is allowed. Refused BEFORE anything is removed (live-probes rule).
|
||||
if (body.RemoveHDDData || body.RemoveBackups) && r.stackMgr.RemoveKeepsDataOnly(name) {
|
||||
r.logger.Printf("[WARN] [api] Remove of %s with data REFUSED: the app is held with no whole copy (support is informed) — only keep-data is allowed (decision 27)", name)
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, stacks.ErrRemoveKeepDataWhileSupport)})
|
||||
return
|
||||
}
|
||||
|
||||
// Compute backup paths to remove if requested. Disk-tier (cross-drive rsync)
|
||||
// backup has moved to the host agent; only the app-data DB-dump path is removed here.
|
||||
var backupPaths []string
|
||||
//
|
||||
// R-474 (v0.240.0): "delete backups" deletes the app's WHOLE recovery unit (definition, db-dumps,
|
||||
// volume tars) and its Tier-2 mirror, not only db-dumps. The mirror is on another drive, outside
|
||||
// RemoveStack's per-app base, so the backup manager removes it — and its location is read NOW,
|
||||
// before the cross-drive record is cleared below. Off-site snapshots are not touched by this path.
|
||||
var backupPaths, mirrorDirs []string
|
||||
if body.RemoveBackups && r.backupMgr != nil {
|
||||
nsRoot := r.backupMgr.AppNamespaceRoot(name)
|
||||
if nsRoot != "" {
|
||||
backupPaths = append(backupPaths, backup.AppDBDumpPath(nsRoot, name))
|
||||
backupPaths = append(backupPaths, backup.RecoveryUnitPath(nsRoot, name), backup.AppDBDumpPath(nsRoot, name))
|
||||
}
|
||||
mirrorDirs = r.backupMgr.Tier2MirrorDirsForApp(name)
|
||||
}
|
||||
|
||||
resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Remove failed for %s: %v", name, err)
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
// R-442: a refused data removal is a first-class, non-2xx answer with the exact customer
|
||||
// sentence — never a 200 with empty lists. The app was not removed either.
|
||||
var refused *stacks.RemoveRefusedError
|
||||
if errors.As(err, &refused) {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
// R-633: "the backup side owns this app right now" is a CONFLICT, not a server error. It is
|
||||
// matched by TYPE rather than by prose — the status mapping below greps the error text, and
|
||||
// the busy sentence contains none of the words it looks for, so the first live run of this
|
||||
// guard answered 500 while doing exactly the right thing.
|
||||
var busy *stacks.RemoveBusyError
|
||||
if errors.As(err, &busy) {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up cross-drive backup config for this stack
|
||||
if r.sett != nil {
|
||||
if err := r.sett.SetCrossDriveConfig(name, nil); err != nil {
|
||||
r.logger.Printf("[WARN] [api] Failed to clean cross-drive config for %s: %v", name, err)
|
||||
if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 {
|
||||
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...)
|
||||
}
|
||||
if body.RemoveBackups {
|
||||
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.removeVerificationCopy(name)...)
|
||||
}
|
||||
|
||||
// R-486 (v0.240.0): the app's backup preferences — and with them the Tier-2 RECORD that
|
||||
// tier2RecordedCopyDir needs — are forgotten ONLY when the customer asked for the backups to be
|
||||
// deleted. Until v0.239.0 the cross-drive record was cleared on every removal, so an app removed
|
||||
// with its backups kept could not be restored from its intact second-drive mirror (measured on
|
||||
// demo-hp 2026-09-13: „nincs másodlagos fájlmásolat" over a 236 MB mirror). Pinned by
|
||||
// TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo.
|
||||
if r.sett != nil && body.RemoveBackups {
|
||||
if err := r.sett.DeleteAppBackupPrefs(name); err != nil {
|
||||
r.logger.Printf("[WARN] [api] Failed to forget the backup preferences of %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// R-491 (v0.242.0): an app held after a failed update that the customer then REMOVES is not held
|
||||
// any more — the hold named a copy for a deploy that no longer exists, and every start gate reads
|
||||
// the store, so a reinstall under the same name would begin refused. Measured on demo-hp
|
||||
// 2026-09-13 (nextcloud). An R-379 restore hold is deliberately NOT cleared here.
|
||||
if r.sett != nil {
|
||||
if cleared, err := r.sett.ClearUpdateHold(name); err != nil {
|
||||
r.logger.Printf("[WARN] [api] Failed to clear the update hold of %s: %v", name, err)
|
||||
} else if cleared {
|
||||
r.logger.Printf("[INFO] [api] remove %s: its update hold is cleared with it (R-491)", name)
|
||||
}
|
||||
}
|
||||
r.clearInterruptedRestoreNotice(name)
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
|
||||
|
||||
@@ -883,17 +1149,13 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Delete failed for %s: %v", name, err)
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
var refused *stacks.RemoveRefusedError // R-442, same as removeStack
|
||||
if errors.As(err, &refused) {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -915,7 +1177,15 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector)
|
||||
// R-490 / R-465 / R-492: the default storage path is the only source (the always-empty global
|
||||
// paths.hdd_path was deleted in R-492); without it hdd_configured was false on every box.
|
||||
hddPath := ""
|
||||
if r.sett != nil {
|
||||
if p := r.sett.GetDefaultStoragePath(); p != "" {
|
||||
hddPath = p
|
||||
}
|
||||
}
|
||||
info := system.GetInfo(hddPath, r.cpuCollector)
|
||||
// F1: GetInfo now reports the guest RAM cap (from the Docker daemon) as TotalMemMB, but the guest-wide
|
||||
// "used" is not observable from the container. Report the controller's accurate committed-app memory
|
||||
// (sum of running apps' mem requests) as used — a meaningful "allocated of cap" figure for the UI.
|
||||
@@ -930,7 +1200,10 @@ func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
info.MemPercent = float64(used) / float64(info.TotalMemMB) * 100
|
||||
}
|
||||
}
|
||||
syncStatus := r.syncer.Status()
|
||||
var syncStatus interface{}
|
||||
if r.syncer != nil { // nil in tests; production always wires the syncer
|
||||
syncStatus = r.syncer.Status()
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"system": info,
|
||||
"sync_status": syncStatus,
|
||||
@@ -1016,7 +1289,7 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
|
||||
// moved to the host agent (slice 8C).
|
||||
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) triggerBackup(w http.ResponseWriter, req *http.Request) {
|
||||
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
|
||||
if r.backupMgr == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
||||
@@ -1025,7 +1298,7 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
if r.backupMgr.IsRunning() {
|
||||
r.dbg("triggerBackup: backup already running, rejecting")
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1033,23 +1306,23 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
r.backupMgr.MarkManualRun() // R-182: operator-triggered — its digest must not be collapsed into the nightly one
|
||||
go r.backupMgr.RunDBDumps(context.Background())
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.started")})
|
||||
}
|
||||
|
||||
// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a
|
||||
// different physical disk). Auto-targets and applies the rootfs-headroom guard internally.
|
||||
func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) triggerTier2(w http.ResponseWriter, req *http.Request) {
|
||||
if r.backupMgr == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
||||
return
|
||||
}
|
||||
if r.backupMgr.IsRunning() {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
|
||||
return
|
||||
}
|
||||
r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered")
|
||||
go r.backupMgr.RunAllTier2()
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.tier2_started")})
|
||||
}
|
||||
|
||||
// --- Metrics handlers ---
|
||||
@@ -1066,7 +1339,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
||||
samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Failed to query system metrics: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1094,7 +1367,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
||||
}})
|
||||
}
|
||||
|
||||
func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) metricsContainerSummary(w http.ResponseWriter, req *http.Request) {
|
||||
if r.metricsStore == nil {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
|
||||
return
|
||||
@@ -1103,7 +1376,7 @@ func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request)
|
||||
summary, err := r.metricsStore.QueryContainerSummary()
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Failed to query container summary: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1122,7 +1395,7 @@ func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name
|
||||
samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Failed to query container metrics for %s: %v", name, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1236,108 +1509,17 @@ func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
|
||||
}
|
||||
|
||||
func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) {
|
||||
if r.updater == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
|
||||
return
|
||||
}
|
||||
if err := r.updater.TriggerUpdate("manual"); err != nil {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
r.logger.Println("[INFO] [api] Manual self-update triggered")
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
|
||||
}
|
||||
|
||||
// --- Config apply handler ---
|
||||
|
||||
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
||||
r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
|
||||
// Read YAML body (limit to 1MB)
|
||||
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate it's parseable YAML by attempting to load it
|
||||
if _, err := config.LoadFromBytes(body); err != nil {
|
||||
r.logger.Printf("[WARN] [api] Config apply rejected: invalid YAML: %v", err)
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// No-op guard: if the pushed config is byte-identical to what is already on disk, do
|
||||
// nothing — don't rewrite, don't restart. The hub may re-push the same config
|
||||
// idempotently, and a self-restart on every push would be a needless flap.
|
||||
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
|
||||
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."})
|
||||
return
|
||||
}
|
||||
|
||||
// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
|
||||
// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
|
||||
// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
|
||||
if err := writeConfig0600(r.configPath, body); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
|
||||
return
|
||||
}
|
||||
|
||||
// Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
|
||||
// effect on restart — singletons such as the Cloudflare client are built once at
|
||||
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
|
||||
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
|
||||
flushResponse(w)
|
||||
if r.restart != nil {
|
||||
r.restart()
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename),
|
||||
// falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS
|
||||
// enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml
|
||||
// holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file.
|
||||
func writeConfig0600(path string, body []byte) error {
|
||||
tmpPath := path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, body, 0600); err != nil {
|
||||
return fmt.Errorf("writing temp config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(path, 0600); err != nil {
|
||||
return fmt.Errorf("chmod config 0600: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
|
||||
hash, err := config.FileHash(r.configPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
|
||||
}
|
||||
|
||||
func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
|
||||
data, err := os.ReadFile(r.configPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
|
||||
w.Write(data)
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.update.kicked_off")})
|
||||
}
|
||||
|
||||
// --- Asset sync handlers ---
|
||||
@@ -1376,3 +1558,21 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
func limitBody(w http.ResponseWriter, req *http.Request) {
|
||||
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
|
||||
}
|
||||
|
||||
// startAnswer is R-642's answer for a start or restart: what was requested and the state the
|
||||
// containers are in right now — never "completed", which nothing at this point can know.
|
||||
func startAnswer(name, action, state string) apiResponse {
|
||||
return apiResponse{OK: true,
|
||||
Message: "Stack " + name + " " + action + " requested — state now: " + state,
|
||||
Data: map[string]interface{}{"state": state}}
|
||||
}
|
||||
|
||||
// deployWarningData renders the deploy's soft warning for the answer. R-575: DeployStack hands back the
|
||||
// warning's bundle KEY, never a sentence, so it is rendered here in the reader's language. nil = none.
|
||||
// Pinned by TestR575_OvercommitWarningFollowsTheLanguage.
|
||||
func (r *Router) deployWarningData(req *http.Request, warningKey string) map[string]string {
|
||||
if warningKey == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"warning": r.msg(req, warningKey)}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -28,61 +27,6 @@ func newRestartTestRouter(t *testing.T, prior []byte) (*Router, *int, string) {
|
||||
return r, &calls, path
|
||||
}
|
||||
|
||||
// a minimal config that passes config.LoadFromBytes (customer.id + customer.domain required).
|
||||
var validConfig = []byte("customer:\n id: test\n domain: test.example\n")
|
||||
|
||||
func TestConfigApply_ChangedConfig_Restarts(t *testing.T) {
|
||||
prior := []byte("customer:\n id: old\n domain: old.example\n")
|
||||
r, calls, path := newRestartTestRouter(t, prior)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
|
||||
rec := httptest.NewRecorder()
|
||||
r.configApply(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("restart called %d times, want 1 (config changed)", *calls)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(got, validConfig) {
|
||||
t.Fatalf("config not written: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// COMPANION: an identical re-push must NOT restart (the old handler always restarted /
|
||||
// always ran its post-apply hook regardless of whether anything changed).
|
||||
func TestConfigApply_IdenticalConfig_NoRestart(t *testing.T) {
|
||||
r, calls, _ := newRestartTestRouter(t, validConfig) // prior == body
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
|
||||
rec := httptest.NewRecorder()
|
||||
r.configApply(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if *calls != 0 {
|
||||
t.Fatalf("restart called %d times, want 0 (config byte-identical)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigApply_InvalidYAML_NoRestart(t *testing.T) {
|
||||
r, calls, _ := newRestartTestRouter(t, validConfig)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader([]byte("customer:\n id: only-id-no-domain\n")))
|
||||
rec := httptest.NewRecorder()
|
||||
r.configApply(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (missing customer.domain)", rec.Code)
|
||||
}
|
||||
if *calls != 0 {
|
||||
t.Fatalf("restart called %d times, want 0 (validation failed)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfRestart_InvokesRestarter(t *testing.T) {
|
||||
r, calls, _ := newRestartTestRouter(t, nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"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/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// Update arc slice 4 through the PRODUCTION handler: actionStack → the real stacks.Manager
|
||||
// (NewManager + ScanStacks) and the real backup.Manager over real settings. No docker is reached:
|
||||
// every path here refuses, or fails at the pin (the app's catalog template is absent on purpose).
|
||||
|
||||
type apiFakeGuards struct {
|
||||
b *backup.Manager
|
||||
points []stacks.UpdateRestorePoint
|
||||
cannotBackUp bool
|
||||
// blindToHolds makes the manager-side preflight NOT see holds, so a test can prove the ROUTER's
|
||||
// own hold check refuses — the two layers are each pinned separately (the preflight's by
|
||||
// TestSlice4_D_CheapRefusals/held). Without it, removing either layer passes inertly, because the
|
||||
// other refuses with the same sentence (observed on the first run of red-proof 4, 2026-09-13).
|
||||
blindToHolds bool
|
||||
}
|
||||
|
||||
func (g *apiFakeGuards) HoldFor(n string) (bool, string) {
|
||||
if g.blindToHolds {
|
||||
return false, ""
|
||||
}
|
||||
return g.b.RestoreHoldFor(n)
|
||||
}
|
||||
func (g *apiFakeGuards) Busy(string) (bool, string) { return false, "" }
|
||||
func (g *apiFakeGuards) RestorePoints(_ context.Context, _ string, accept func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) {
|
||||
for _, p := range g.points {
|
||||
if accept == nil || accept(p) {
|
||||
return p, true, g.points
|
||||
}
|
||||
}
|
||||
return stacks.UpdateRestorePoint{}, false, g.points
|
||||
}
|
||||
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
|
||||
func (g *apiFakeGuards) BackupNow(context.Context, string) error { return nil }
|
||||
func (g *apiFakeGuards) SafetyDump(context.Context, string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const slice4AppYAML = "deployed: true\nenv: {}\npinned_images:\n app: nginx:1.27\n"
|
||||
|
||||
func newSlice4Router(t *testing.T) (*Router, *settings.Settings, *apiFakeGuards, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "stacks", "app")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n app:\n image: nginx:1.27\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(slice4AppYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
|
||||
cfg.Paths.DataDir = filepath.Join(root, "data")
|
||||
cfg.Paths.SystemDataPath = filepath.Join(root, "sys")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
m, err := stacks.NewManager(cfg, lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sett, err := settings.Load(filepath.Join(root, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := backup.NewManager(cfg, sett, lg)
|
||||
g := &apiFakeGuards{b: b, points: []stacks.UpdateRestorePoint{{Tier: stacks.UpdateTierSecondDrive, ProvenAt: time.Now().Add(-time.Hour)}}}
|
||||
m.SetUpdateGuards(g)
|
||||
return &Router{cfg: cfg, stackMgr: m, backupMgr: b, logger: lg}, sett, g, dir
|
||||
}
|
||||
|
||||
func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", "app")
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
// TestR439_UpdateOfAHeldAppIsRefused — R-439 closed.
|
||||
//
|
||||
// COMPANION RED-PROOF 4 (REPORT.md): remove `|| action == "update"` from actionStack's hold check. The
|
||||
// preflight then refuses on its own grounds with a DIFFERENT sentence, and this test fails on the
|
||||
// message — which is what proves the router line is the one doing it.
|
||||
func TestR439_UpdateOfAHeldAppIsRefused(t *testing.T) {
|
||||
r, sett, g, dir := newSlice4Router(t)
|
||||
g.points, g.cannotBackUp = nil, true // the preflight's own refusal would say "no backup" — not the hold
|
||||
g.blindToHolds = true // only the router's line can produce the hold's sentence
|
||||
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "app", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-13T01:30:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
||||
code, resp := postUpdate(t, r)
|
||||
_, holdText := r.backupMgr.RestoreHoldFor("app")
|
||||
if code != http.StatusConflict || resp.OK || resp.Error != holdText {
|
||||
t.Fatalf("a HELD app's update must be refused with the hold's own sentence: code=%d ok=%v error=%q", code, resp.OK, resp.Error)
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
||||
if string(before) != string(after) {
|
||||
t.Error("a refused update must record no intent — app.yaml changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlice4_Router_NoBackupIs409AndRecordsNothing(t *testing.T) {
|
||||
r, _, g, dir := newSlice4Router(t)
|
||||
g.points, g.cannotBackUp = nil, true
|
||||
before, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
||||
code, resp := postUpdate(t, r)
|
||||
if code != http.StatusConflict || resp.Error != fmt.Sprintf(stacks.MsgUpdateNoBackupFmt, "app") {
|
||||
t.Fatalf("code=%d error=%q", code, resp.Error)
|
||||
}
|
||||
if after, _ := os.ReadFile(filepath.Join(dir, "app.yaml")); string(before) != string(after) {
|
||||
t.Error("the preflight refusal must come BEFORE the intent write")
|
||||
}
|
||||
}
|
||||
|
||||
// TestR443_UpdateIsNeverReportedCompleteSynchronously — R-443 closed. The handler answers 202 with
|
||||
// completed:false; the job then runs (and here fails at the pin, the catalog being absent), and the
|
||||
// outcome exists ONLY on GET /api/stacks/{name}.
|
||||
func TestR443_UpdateIsNeverReportedCompleteSynchronously(t *testing.T) {
|
||||
r, _, _, _ := newSlice4Router(t)
|
||||
code, resp := postUpdate(t, r)
|
||||
if code != http.StatusAccepted {
|
||||
t.Fatalf("an accepted update must answer 202, got %d (%+v)", code, resp)
|
||||
}
|
||||
data, _ := resp.Data.(map[string]interface{})
|
||||
if data["completed"] != false || data["accepted"] != true {
|
||||
t.Errorf("the body must say accepted and NOT completed, got %v", resp.Data)
|
||||
}
|
||||
if resp.Message == "Stack app update completed" {
|
||||
t.Error("the synchronous response claimed completion — R-443")
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if st, ok := r.stackMgr.GetStack("app"); ok && !st.Updating {
|
||||
if st.UpdatePhase != stacks.UpdatePhaseFailed || st.UpdateError != stacks.MsgUpdatePinFailed {
|
||||
t.Errorf("the job's truth must be on the stack: phase=%q err=%q", st.UpdatePhase, st.UpdateError)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the job never finished")
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-609 (v0.261.0) — the 409 carries a machine-readable REASON, additively.
|
||||
//
|
||||
// WHY. `UpdateRefusal.Reason` has existed since v0.237.0 and never left the process: the body carried
|
||||
// only the translated sentence. A caller that is not a person cannot parse a Hungarian sentence, and
|
||||
// the distinction it needs is not decorative:
|
||||
//
|
||||
// TRANSIENT — busy, updating, deploying, migrating, self_updating → try again later
|
||||
// TERMINAL — held, downgrade → never press this again
|
||||
//
|
||||
// An unattended caller that cannot tell them apart either gives up on a passing backup window, or
|
||||
// presses a terminally-refused button on every pass for ever. `09` §6.2's caller reads exactly this.
|
||||
//
|
||||
// The SENTENCE is unchanged and stays where it was, so no page moves — this is purely additive.
|
||||
|
||||
// reasonOf pulls `data.reason` out of a refusal body, or "" when absent.
|
||||
func reasonOf(t *testing.T, resp apiResponse) string {
|
||||
t.Helper()
|
||||
if resp.Data == nil {
|
||||
return ""
|
||||
}
|
||||
m, ok := resp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data is not an object: %#v", resp.Data)
|
||||
}
|
||||
s, _ := m["reason"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// TestR609_EveryRefusalCarriesItsReason is table-driven over the refusal paths reachable without
|
||||
// docker, and it INCLUDES the router's own hold line — which fires before UpdatePreflight and was
|
||||
// the one that had no reason at all until this test was written.
|
||||
//
|
||||
// COMPANION RED-PROOF (run 2026-09-21): drop the `Data:` field from either writeJSON in actionStack's
|
||||
// update paths. The corresponding row fails with `reason = ""`. Reverted.
|
||||
func TestR609_EveryRefusalCarriesItsReason(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
arrange func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string)
|
||||
wantReason string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "held — the ROUTER's own line, before the preflight",
|
||||
arrange: func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string) {
|
||||
if err := sett.SetRestoreHold(settings.RestoreHold{
|
||||
Stack: "app", At: "2026-09-21T08:00:00Z",
|
||||
Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-21T01:30:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantReason: "held", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "no_backup — nothing on any tier and none can be taken",
|
||||
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, g *apiFakeGuards, _ string) {
|
||||
g.points, g.cannotBackUp = nil, true
|
||||
},
|
||||
wantReason: "no_backup", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "self_updating — the controller is swapping itself (v0.261.0)",
|
||||
arrange: func(_ *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {
|
||||
r.stackMgr.SetSelfUpdatingCheck(func() bool { return true })
|
||||
},
|
||||
wantReason: "self_updating", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "downgrade — the box runs something newer than the catalog (R-524)",
|
||||
arrange: func(t *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, dir string) {
|
||||
setInstalledAndCatalog(t, r, dir, "nginx:1.28", "nginx:1.27")
|
||||
},
|
||||
wantReason: "downgrade", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "not_found — an app that exists nowhere",
|
||||
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {},
|
||||
wantReason: "not_found", wantCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r, sett, g, dir := newSlice4Router(t)
|
||||
c.arrange(t, r, sett, g, dir)
|
||||
|
||||
name := "app"
|
||||
if c.wantReason == "not_found" {
|
||||
name = "no-such-app"
|
||||
}
|
||||
code, resp := postUpdateNamed(t, r, name)
|
||||
|
||||
if code != c.wantCode {
|
||||
t.Errorf("code = %d, want %d (error %q)", code, c.wantCode, resp.Error)
|
||||
}
|
||||
if resp.OK {
|
||||
t.Fatal("a refusal must not answer ok")
|
||||
}
|
||||
if got := reasonOf(t, resp); got != c.wantReason {
|
||||
t.Errorf("reason = %q, want %q", got, c.wantReason)
|
||||
}
|
||||
// The sentence is the household's and must still be there, in words — never a bare key.
|
||||
if resp.Error == "" {
|
||||
t.Error("the reason is ADDITIVE: the sentence must still be present")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestR609_ASuccessfulActionCarriesNoReason — the field is for refusals only, so a caller cannot
|
||||
// mistake a normal answer for one.
|
||||
func TestR609_ASuccessfulActionCarriesNoReason(t *testing.T) {
|
||||
r, _, _, _ := newSlice4Router(t)
|
||||
_, resp := postStopNamed(t, r, "app")
|
||||
if got := reasonOf(t, resp); got != "" {
|
||||
t.Errorf("a non-refusal must carry no reason, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// postUpdateNamed is postUpdate for an arbitrary app name (the not_found row needs one that is absent).
|
||||
func postUpdateNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", name)
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
// postStopNamed exercises a NON-update action, as the control that `reason` is refusal-only.
|
||||
func postStopNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "stop", name)
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
// setInstalledAndCatalog puts the app AHEAD of the catalog, which is R-524's Ahead arm.
|
||||
//
|
||||
// It writes the REAL files the real code reads — `installed_images` into the app's app.yaml and a
|
||||
// catalog template under <DataDir>/catalog-cache/templates/<app>/ — and then rescans. **No test-only
|
||||
// seam is added to production code for this**: the whole point of the api tests here is that they go
|
||||
// through the production handler over a real stacks.Manager, and a setter that only tests call would
|
||||
// be a second way to reach the state, which is how the two drift apart.
|
||||
func setInstalledAndCatalog(t *testing.T, r *Router, dir, installed, catalog string) {
|
||||
t.Helper()
|
||||
appYAML := slice4AppYAML + "installed_images:\n app:\n ref: " + installed +
|
||||
"\n digest: sha256:x\n at: \"2026-09-01T00:00:00Z\"\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catDir := filepath.Join(r.cfg.Paths.DataDir, "catalog-cache", "templates", "app")
|
||||
if err := os.MkdirAll(catDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(catDir, "docker-compose.yml"),
|
||||
[]byte("services:\n app:\n image: "+catalog+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.stackMgr.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Positive control: the state this helper claims to create must actually exist, or the row it
|
||||
// serves would pass for the wrong reason (a different refusal firing first).
|
||||
if got := stacks.CatalogOrder(mustStack(t, r, "app")); got != stacks.UpdateOrderAhead {
|
||||
t.Fatalf("fixture did not produce the AHEAD state; CatalogOrder = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStack(t *testing.T, r *Router, name string) stacks.Stack {
|
||||
t.Helper()
|
||||
st, ok := r.stackMgr.GetStack(name)
|
||||
if !ok {
|
||||
t.Fatalf("stack %q missing", name)
|
||||
}
|
||||
return *st
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user