Compare commits
275 Commits
be2efe5624
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+10
-10
@@ -6,17 +6,17 @@ paths: ["controller/**/*.go", "controller/**/*.html", "controller/**/*.css", "co
|
||||
|
||||
## The ONE entry point
|
||||
|
||||
**Run `python3 controller/scripts/controller_gates.py` (from `controller/`) after ANY change in this
|
||||
repo.** It runs the local gates — `template_id_gate`, `emoji_gate`, `native_confirm_gate`,
|
||||
`offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`, `docker_run_volume_path_gate`,
|
||||
`secret_in_markup_gate`, `retrieval_promise_gate`, `debug_route_gate` — plus `reuse_refs_check`,
|
||||
`instructions_gate` and `observations_gate` on the repo root, streaming each gate's own output and
|
||||
exiting non-zero if any fails. **The runner's `GATES` table is the list; this sentence is a pointer to
|
||||
it, not a second copy** — it has already drifted once (it said "seven" while nine were registered).
|
||||
**Run `python3 scripts/controller_gates.py` from `controller/` (or `python3
|
||||
controller/scripts/controller_gates.py` from the repo root) after ANY change in this repo.** It runs
|
||||
every gate in the runner's `GATES` table, the shared `reuse_refs_check`, `instructions_gate` and
|
||||
`observations_gate` among them, streaming each gate's own output and exiting non-zero if any fails;
|
||||
one, `golden-notice`, is ADVISORY and never refuses. **The `GATES` table is the list; this sentence is a
|
||||
pointer to it, not a second copy** — it drifted twice (it said "seven" while nine were registered, then
|
||||
named ten local gates while fourteen were).
|
||||
|
||||
- `--fast` selects the gates that touch no network and no container runtime; today that is all of them.
|
||||
- **A missing gate script is a FAILURE, never a skip.**
|
||||
- **The shared `reuse_refs_check.py` and `instructions_gate.py` live in `felhom.eu/scripts/` and are
|
||||
- **The shared `reuse_refs_check.py`, `instructions_gate.py` and `observations_gate.py` live in `felhom.eu/scripts/` and are
|
||||
never copied here** — a copy would recreate the drift they detect; an absent sibling clone FAILS.
|
||||
- **The pre-push hook** (`.githooks/pre-push`) runs it with `--fast` and refuses a failing push. It is
|
||||
per-clone — switch it on once with `git config core.hooksPath .githooks`, and a manual run WARNS
|
||||
@@ -59,7 +59,7 @@ fast, and wrong.
|
||||
|
||||
**A decoy is the LABEL without the FACT** — a directory with the right name and no bake log, a
|
||||
handler case that exists only in a comment, a note whose prose mentions the marker it lacks. Write
|
||||
one for every new gate, run it, and watch it convict. `scripts/decoy_coverage_gate.py` refuses a gate
|
||||
one for every new gate, run it, and watch it convict. `felhom.eu/scripts/decoy_coverage_gate.py` (run by felhom.eu's `repo_gates.py`, for all four repos) refuses a gate
|
||||
registered without one, or without a named exemption carrying its row.
|
||||
|
||||
**Earned by five instances, every one found by accident:** R-410, R-400, R-378, R-419, R-94. The
|
||||
@@ -75,4 +75,4 @@ green and correct today, blind the moment anyone adds a subdirectory. Prefer `os
|
||||
glob over a hand-maintained list of files.
|
||||
|
||||
**A decoy nobody would write proves nothing** — say the gate is sound and move on. Five of mine were
|
||||
withdrawn as illegitimate and are named in `documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
|
||||
withdrawn as illegitimate and are named in `felhom.eu/documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
|
||||
|
||||
@@ -6,22 +6,25 @@ unconditional: true
|
||||
> Goal sessions, nightly sessions, "work the register" sessions. **A session that starts from
|
||||
> `/goal` or a standing brief inherits these rules exactly as it inherits the gates.** They are the
|
||||
> part of `PROMPT-TEMPLATE.md` that a task file used to carry and a goal does not. Same wording lives
|
||||
> in all three repos' `.claude/rules/`; change it in all three or in none.
|
||||
> in `felhom.eu`, `felhom-controller`, `felhom-agent` and `app-catalog-felhom.eu` `.claude/rules/`, and in the workspace
|
||||
> root's unversioned `.claude/rules/`; change all five or none.
|
||||
|
||||
## 1. What you may pick up on your own
|
||||
|
||||
- A register row **you or another CC session filed**, with owner CC, at P3 or a bounded P2, that
|
||||
needs **no operator decision**, touches **no customer data by design**, and introduces **no
|
||||
mechanism nobody has measured**. Smallest first.
|
||||
- A defect you find while exercising the product, filed as a row **before** you fix it.
|
||||
- A defect you find while exercising the product, filed as a row **before** you fix it — **unless it is small**:
|
||||
a small finding is fixed in the session and never filed (the size rule, `OPEN-ITEMS.md` „How a row is filed").
|
||||
- Hygiene: register compression, stale citations, rows with no owner, documents that contradict
|
||||
live source.
|
||||
|
||||
**Not yours, ever, without a task file or an operator word:** money; anything that changes risk to
|
||||
customer data; anything that changes a promise the product makes to a customer; anything that
|
||||
reverses a documented design decision (`documentation/architecture/` — a design decision is not a
|
||||
defect, R-370); anything on DooPlex, Peti's box or ep0; baking or vouching a golden; promoting a
|
||||
catalog version; a new external dependency.
|
||||
defect, R-370); anything on DooPlex or ep0; baking or vouching a golden; promoting a
|
||||
catalog version; a new external dependency; **a hub image build or hub deploy in a session the operator does not
|
||||
attend** (operator ruling 2026-10-07, `09` §3 decision 162).
|
||||
|
||||
## 2. When you may decide instead of ask (operator grant, 2026-09-14)
|
||||
|
||||
@@ -41,12 +44,28 @@ it **first** in the morning note. A decision you cannot write in that shape is o
|
||||
Throwaway apps only; the standing apps and `bentopdf` stay.
|
||||
5. **Evidence off the machine at the end of each phase**, before any revert (R-320).
|
||||
6. **One release per repo per session**, with a CHANGELOG entry (controller: with its `MinAgent`
|
||||
line), REPORT overwritten, floor raised to deliver it. **No golden unless a drill or fresh install
|
||||
needs one** (the waiver, R-468). **No `--no-verify`.**
|
||||
7. **An enumerated gap becomes a row in the same session.** Prose is not a record.
|
||||
line) and REPORT overwritten. **A controller release needs no golden** (operator ruling 2026-10-15
|
||||
11:26, `09` §3 decision 250; decision 7 stands): push the image only when it is ready for EVERY box (the
|
||||
household's update button offers the highest tag); raise the floor, MinAgent declared, only after it ran
|
||||
on Tester 1, demo-hp and demo-felhom through the normal update path and the box walk's checks are green
|
||||
on all three — not at night unless the night brief says so. **Agent releases still stop for the
|
||||
operator's vouch.** The golden is baked weekly and vouched by the operator; CC never vouches. **No `--no-verify`.**
|
||||
**A version number is pushed once** — `felhom-controller/controller/build.sh` now refuses a second
|
||||
push of a tag that exists, and a registry it cannot reach is a refusal too. This is a FACT about
|
||||
what the build does, not a new rule: a fix for something already pushed is the next patch version,
|
||||
and a box on a bad image is moved by the normal update path, never by re-tagging. Earned on
|
||||
2026-10-15, when 0.315.0 was pushed three times from three different commits and a cache kept
|
||||
serving the first one (`09-update-architecture.md` §0).
|
||||
7. **An enumerated gap becomes a row in the same session — or, if it is small, is fixed in it** (the size rule).
|
||||
Prose is not a record.
|
||||
8. **Hungarian text is searched with ASCII fragments**, with a positive and a negative control.
|
||||
9. **Never leave a half-state.** If time runs out, revert to clean and say what was reverted.
|
||||
10. **Teardown, three layers, stated** — machine, host, hub — or "provisioned nothing".
|
||||
11. **Every helper prompt carries the brief's fences in full** (operator ruling 2026-10-07). A helper session (a
|
||||
subagent, a fork, a workflow agent) gets the brief's fence list word for word — every protected machine, every
|
||||
„no", every delivery and Docker limit — not a summary and not „the usual fences". Earned on 2026-10-06 night: two
|
||||
helpers whose prompts carried only part of the fences ran `docker volume prune` on the bench and a Docker-using
|
||||
gate on DooPlex.
|
||||
|
||||
## 4. The morning note
|
||||
|
||||
@@ -54,3 +73,29 @@ One screen, plain language, in this order: **decisions you took** (§2) first; w
|
||||
what broke and whether you fixed it; rows opened and closed with the register size before and after;
|
||||
what needs the operator, each with what happens if they do nothing. No file paths, no function
|
||||
names, no row numbers as the subject of a sentence.
|
||||
|
||||
## 5. Instruction files
|
||||
|
||||
**Instruction files (`CLAUDE.md`, `.claude/rules/*`) are kept true by the session that finds them wrong**
|
||||
(operator ruling 2026-10-06, `09` §3 decision 150). A session MAY, without asking: correct a stale fact (a command, a
|
||||
count, a version, a path, a description of what a gate does), add a fact it proved, and remove a reference to something
|
||||
that no longer exists. Each edit is named in the report (file, line, before, after, why). A session MAY NOT, without the
|
||||
operator's word: loosen a safety rule, a fence, a „never", a protected machine, a secret rule, or a review step; or
|
||||
remove a rule. When in doubt, it is a rule change, and it goes to the operator. If Claude Code's own permission check
|
||||
asks before such an edit, wait for the operator's click; if it refuses, record that and file the exact line.
|
||||
|
||||
## 6. The system poster stays true
|
||||
|
||||
**A session that changes a fact listed in `architecture/felhom-system-poster.facts.md` (a machine, a
|
||||
role, a traffic path, a backup tier, a time, a retention, a key, a known gap) updates that file in
|
||||
the same commit.** If the change is text only, it also edits the matching text in
|
||||
`felhom-system-poster.html`. If it needs a new drawing, it adds the line
|
||||
**"System poster needs a refresh: <what changed>"** to `STATUS.md`'s "waiting on the operator" list.
|
||||
**The session report names which of the three it did.**
|
||||
|
||||
Why the three-way split: the poster is a drawing made in Claude Design, and **nothing in this
|
||||
repository renders it** — unlike `where-felhom-stands.html`, which has `render_stands.py`. So the
|
||||
facts file is the source of truth and the drawing trails it. `scripts/poster_facts_gate.py` warns
|
||||
when the facts file has a newer commit than the poster; it **never fails a push**, because a refresh
|
||||
needs the operator and another tool, and a gate nobody can clear is a gate people learn to route
|
||||
around.
|
||||
|
||||
+2606
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 |
|
||||
|
||||
|
||||
+340
-1
@@ -7,7 +7,346 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-09-17 (v0.247.0 — i18n spike: the dashboard can speak English, Hungarian byte-identical)
|
||||
Last updated: 2026-10-16 (v0.323.0 — Debian 13, restic 0.18.1 pinned outside the OS and re-proven, felhom-samba 1.2.0)
|
||||
|
||||
> **2026-10-16 (evening) — v0.323.0 RELEASED to the three test boxes ONLY (per-box floors); the GLOBAL floor stays
|
||||
> 0.322.0 until each test box has made one real night's off-site backup on 0.323.0** (the brief's rule; the check and
|
||||
> the floor command are in felhom.eu `STATUS.md`). R-1105: runtime `debian:13.7-slim` (digest-pinned), Docker repo
|
||||
> codename from `/etc/os-release`; restic is the official 0.18.1 release binary from a `restic` build stage (signed
|
||||
> `SHA256SUMS` + vendored key in `third_party/restic/`, published sum, pinned per-arch sum). Every restic assumption
|
||||
> re-proven with REAL outputs of 0.14.0 and 0.18.1 (`internal/backup/testdata/restic/`, `TestR1105_*`); the one that
|
||||
> moved is the forget plan — 0.17+ keeps each group's oldest snapshot while monthly slots are unused (only ever keeps
|
||||
> more). Removed packages the binary never runs (postgres/mysql clients, sqlite3, fdisk, e2fsprogs, parted, sshpass).
|
||||
> felhom-samba 1.2.0 (Alpine 3.24.2), and `build-samba-image.sh` refuses a reused tag. Live on demo-hp 9201: both
|
||||
> restore directions byte-identical on the real store; the drill's data came back; the full restore after a reinstall
|
||||
> leaves a DB app unusable (old volume, new password — R-1106, not restic). Open: R-1107 (restic 0.18.1's binary adds
|
||||
> 50 fixable scanner findings; 0.19.1 has 19 — the operator's choice), R-1108 (a share added before the first password
|
||||
> is not exported). **Decision (CC, inside the brief, reversible):** the packages above were removed though the brief
|
||||
> named only the two database clients — none is run by the binary (every exec call read), and the image carries fewer
|
||||
> known holes. Full report: felhom.eu `REPORT-debian13-restic-2026-10-16.md`.
|
||||
|
||||
> **2026-10-16 — v0.322.0 RELEASED by CC; the global floor 0.322.0 / MinAgent 0.158.1.** R-970: `/api/config`,
|
||||
> `/api/config/apply`, `/api/config/hash` are gone; the self-update and geo routes no longer take the box's hub key
|
||||
> (`selfUpdateAuthMiddleware` removed), and the CSRF Bearer exemption is gone. The route table is `buildMux` in `main.go`
|
||||
> so `TestR970_HubKeyOpensNoDoor` drives the real table. R-972: Go 1.27.2, x/crypto v0.58.0, runtime
|
||||
> `debian:12.13-slim` pinned by digest + `apt-get upgrade`. **Decision (CC, inside the brief, reversible): the runtime
|
||||
> stays on Debian 12** — Debian 13 brings restic 0.18.0 and the off-site code relies on measured 0.14.0 behaviour; the
|
||||
> move is R-1105. Data tab: the export field is `fab_encryption_key`, `autocomplete="new-password"`; the 2nd-copy
|
||||
> status rows are gone. R-1103: the installed-only branches of `app_fields.html` removed; their rules live in the
|
||||
> app-page and settings-tab tests. Full report: felhom.eu `REPORT-burndown2-2026-10-16.md`.
|
||||
|
||||
> **2026-10-16 — v0.321.0 RELEASED by CC; the global floor 0.321.0 / MinAgent 0.158.1.** R-1101: in `generated` the page asks
|
||||
> FileBrowser whether the stored password still logs in (`stacks.FileBrowserStoredPasswordRefused`, only a 401/403 counts)
|
||||
> and, refused, offers the reset; no „change it" line for FileBrowser; its config switches off its update banner and
|
||||
> „Install app" prompt. The Data tab's backup part is one section in plain words. R-1095: `/apps/{slug}/install` of an
|
||||
> installed app → 302 settings; the installed-only `app_fields.html` branches stay (R-1103). Catalog the same day: RomM's
|
||||
> saves in a named volume (R-1097), `default_creds` off eight apps (D10 = A). Next: R-1103; R-1067 waits on Tester-2 (0.292.0).
|
||||
|
||||
> **2026-10-16 — v0.320.0 RELEASED by CC; the global floor 0.320.0 / MinAgent 0.158.1.** A protected infra stack is
|
||||
> installed by definition on the app page (`Deployed || Protected`; v0.315.0 had shown FileBrowser „not installed" with
|
||||
> Install and no login); the deploy API and `DeployStack` refuse a protected stack. FileBrowser `operator` state →
|
||||
> „Új jelszó beállítása" (`stacks.ResetFileBrowserAdminPassword`: its CLI with the server stopped, verify, encrypt,
|
||||
> `generated`) — pressed on demo-hp; **the N100 waits for the operator's yes**. Every installed app has ONE login answer
|
||||
> (`web/app_login.go` `loginAnswerFor`; catalog `app_info.login_kind`). „Fájlok ↗" + per-drive media folders; a folder with
|
||||
> no FileBrowser source is text. Decisions inside the brief (felhom.eu `REPORT-logins-folders-2026-10-16.md` §0): no backup
|
||||
> sentence on media rows; no data tab for infra; „none" behind the family gate says the family sign-in. Next: R-1100
|
||||
> (one sign-in), R-1101 (stale FileBrowser password), R-1102 (eight „known password" lines, decision D10).
|
||||
|
||||
> **2026-10-16 — v0.319.0 RELEASED by CC; the global floor 0.319.0 / MinAgent 0.158.1.** App page style A (operator
|
||||
> 2026-10-16): the hero, the tab bar and every section are panels on `--bg-1`; the install page's form groups use the same
|
||||
> rule. „Belépés az alkalmazásba" pairs a `type: password` field with its `*_USER/_USERNAME/_EMAIL` name
|
||||
> (`web/app_login.go`); a field the box holds no value for is a sentence, never an eye — page and reveal endpoint share
|
||||
> `storedValuePresent`. Measured cause on demo-hp: Calibre-Web installed 2026-08-21, `ADMIN_PASSWORD` entered its template
|
||||
> 2026-09-29, and `InjectMissingFields` never fills a `type: password` field; its stored user name is real (renamed in the
|
||||
> app on 2026-10-01). The settings tab no longer renders `app_deploy_fields`; `/apps/{slug}/install` for an installed app
|
||||
> still does (R-1095). Evidence: felhom.eu `audits/app-page-tuning-2026-10-16/`.
|
||||
|
||||
> **2026-10-16 — v0.318.0 RELEASED; the global floor 0.318.0 / MinAgent 0.158.1 (set by the operator).** The internal data
|
||||
> place („Belső tárhely", agent ≥ 0.158.1 reports kind `internal`): bound on one drive-gate tick, registered on the next as
|
||||
> the default drive (web/internal_place.go); eject/decommission/wipe of it refused. Partitions in the format/attach wizards
|
||||
> (agent `formattable`: only while nothing on the disk is mounted); the whole disk under a registered part refused
|
||||
> server-side (`deviceBacksRegistered`). R-1084: the whole-box backup offer asks the same-disk question (`offerRelationFor`).
|
||||
> R-1082: `/mnt/sys_drive` never offered (`withoutSystemVolume`, by device). Live on a fresh one-disk scratch box: felhom.eu
|
||||
> `audits/storage-identity-buildB-2026-10-15/`. Next: R-1094 (the whole-disk refusal sentence), with the next release.
|
||||
|
||||
> **2026-10-15 (late evening) — v0.316.0/.1/.2 built and on demo-hp; .3 committed, NOT built.**
|
||||
> **Measured before fixing:** the tabs rendered unstyled through the public address because of a
|
||||
> CACHE, not the box — the stylesheet URL was `?v={{.Version}}`, identical across three different
|
||||
> 0.315.0 builds; the origin sends NO ETag and NO Last-Modified, so a copy can only expire; and
|
||||
> Cloudflare caches it while rewriting the browser TTL to four hours. Evidence and its caveat:
|
||||
> `audits/app-page-2026-10-15b/P0/VERDICT.md`.
|
||||
> **Two mechanisms now:** `build.sh` refuses a second push of a version (a registry it cannot reach
|
||||
> is a refusal too), and every shell's stylesheet address is a per-build tag.
|
||||
> **The app page is the approved one-column mockup**: one width, hairline sections, underlined tabs,
|
||||
> and a box only for „the app is waiting for you" (blue) or „this is risky" (amber). The
|
||||
> auto-update SUCCESS banner is now meta text.
|
||||
> **THE LESSON OF THE EVENING, and it cost a bad push:** I put the guard in `controller/build.sh`,
|
||||
> but the release runbook runs `build/felhom-controller/build.sh` — a SEPARATE COPY from
|
||||
> 2026-07-18. Proving the refusal against a real tag therefore re-pushed 0.315.0 a fourth time
|
||||
> (R-1081). The build directory's script is a symlink now. A guard in a script nobody runs is not a
|
||||
> guard. The same shape bit twice more in one evening: the asset tag shipped dead because only one
|
||||
> of TWO render paths set it, and both times a BROWSER found what the suite could not.
|
||||
> Not yet validated: the operator's own look; 0.316.3's two cosmetic fixes.
|
||||
|
||||
> **2026-10-15 (evening) — v0.315.0, ONE APP PAGE WITH FOUR TABS.** An app was six pages and every „Vissza" went to the
|
||||
> app LIST; it is now `/apps/{slug}/{settings,data,guide,logs}` plus `/apps/{slug}/install`, one shared header and one
|
||||
> set of banners (`internal/web/app_page.go`, `templates/app_shell.html`). Operator decisions recorded for this release:
|
||||
> **D1** — Export lives on „Adatok és mentés", not under Beállítások. **D2** — an after-start field's CARD is rendered
|
||||
> generically from the catalog's `after_start: true`, and its ACTION is Go code in a registry keyed by slug + env var
|
||||
> (`internal/web/after_start.go`), so customer text can never reach a catalog-defined command. **The FAQ is a section of
|
||||
> the guide, not a tab**, and **the guide tab replaces the details page**. **302, never 301**, for every old
|
||||
> `/stacks/{name}/…` GET — a cached 301 cannot be withdrawn; every POST on those paths is untouched.
|
||||
> New catalog block `app_info.guide` (sections + faq + `checked_version`), rendered only when `checked_version` names the
|
||||
> image tag its author checked it against; no template carries one yet (the content is the companion task). The Pi and
|
||||
> „csak x86" badges are gone; `PiCompatible` and the catalog key STAY this release, or a box on 0.314.0 would read
|
||||
> „csak x86" on every app.
|
||||
> **Found by a red-proof, and worth remembering:** the after-start handler's „the catalog must have DECLARED this field"
|
||||
> check could be deleted with the whole suite green — and a POST for an undeclared field then reached Plex and opened the
|
||||
> setup gate. Two guards that look like one are two tests.
|
||||
> **Two defects the whole test suite could not see, both found in Chrome on the workstation** (this is
|
||||
> the case for CC running on Windows): the tab bar had no CSS at all and the hero logo was unsized,
|
||||
> because the shell invented class names beside ones the design system already had rules for; and the
|
||||
> log tab's `<script>` was never closed, so the page rendered, every marker matched, and the live log
|
||||
> poller was dead ("SyntaxError: Unexpected token '<'"). A marker proves a string is present, not that
|
||||
> the browser will run it. `TestAppPage_ScriptTagsAreBalanced` now pins the second on all seven URLs.
|
||||
> Live on demo-hp 9201; the floor stays 0.314.0 until all three test boxes have taken it.
|
||||
> Not yet validated: the operator's own look at the page, and a real Plex claim through the new card.
|
||||
|
||||
> **2026-10-15 (evening) — v0.314.0, the floor since 13:11 UTC (raised by CC's token after the three test boxes; no golden).**
|
||||
> A restore puts the setup gate back as it was (`internal/stacks/restore_gate.go`, felhom.eu `09` §3 decision 253, R-1057);
|
||||
> `util.ParseVersionPre` puts `X.Y.Z-rc.N` below `X.Y.Z` for the floor ONLY — never for the tags offered (R-1053);
|
||||
> Prowlarr's start runs the media wiring (`linkOnlyApps`, R-1058); every Plex call goes to the container's address
|
||||
> (`mediawire.PlexURL`, R-1060 — Plex refuses the name `plex` as non-local). Still open: the real Plex claim on demo-hp
|
||||
> 9201 (the operator's code), R-1052's new-box proof (a throwaway customer is session only).
|
||||
|
||||
> **2026-10-15 (afternoon) — release rule `09` §3 decision 250:** a release needs no golden; the floor carries it after
|
||||
> the three test boxes; the golden is weekly. Test images use `-rc.N` tags — the household button skips them, but the
|
||||
> floor never moves a box running one (`maybeAutoUpdate: not parseable — skip`, R-1053): write the release tag back.
|
||||
|
||||
> **2026-10-15 — v0.313.0, the media build (R-1023 and the rows under it; `09` §3 decisions 245, 247, 248).** Media apps bind
|
||||
> every PRESENT drive through an owned `docker-compose.override.yml` (`media_library:` marker in the template); a real
|
||||
> restart (force-recreate + start times); the drive wiring (`internal/mediawire`, keys read at use — Plex's own token,
|
||||
> decision 248; Jellyfin's key named „felhom"); drive events with the notice, „restart now/later" and a stream check; safe
|
||||
> removal keeps the drive; Plex's code asked after the start (`after_start`). Floor 0.313.0 and golden 0.313.0 vouched by the operator 2026-10-15 11:29. Traps found on Tester 1 and fixed: qBittorrent 5 logs in with 204; the agent's disk
|
||||
> `state` stays „attached" for a pulled registered disk (presence = backing device); the drive gate raced a safe removal.
|
||||
> A Plex status probe (`/library/sections`, 401) OPENED an unclaimed Plex while Plex started — removed within the hour;
|
||||
> never gate on a status a starting app also returns.
|
||||
|
||||
> **2026-10-14 (night) — v0.312.0, https only (R-1033, P1; `09` §3 decision 244).** DECIDED BY CC UNATTENDED — operator
|
||||
> may reverse: the redirect lives in traefik on the box (`felhom-https-only` in the `felhom-forwarded` chain), not in
|
||||
> Cloudflare, because every box's `cf_api_token` lacks „Zone Settings" (403, measured on all three zones). Cloudflare's
|
||||
> „Always Use HTTPS" stays the operator's defence-in-depth option once the keys carry the permission. HSTS one day.
|
||||
|
||||
> **2026-10-08 (day) — UNRELEASED on main, ships with tomorrow's release (no release today: kernel night 8→9, `09` §3
|
||||
> decision 178).** R-899 (decision 177): a daytime press never cancels the night's whole-guest backup —
|
||||
> `internal/quiesce/nightowed.go` ledger, owed tiers never fire the valve; a press reaches the agent as `trigger=manual`.
|
||||
> R-304: the agent's new 424 `older_unchecked` → `RecoveryOlderUnchecked`, bundle key `recovery.older_unchecked` (hu+en).
|
||||
> MinAgent unchanged (0.131.0): both changes degrade safely against an older agent. Afternoon (`a40729a`): the
|
||||
> operator-only `recovery_older_package` event (decision 183; needs the hub of the same day), no eject/format on a
|
||||
> backup-target drive (R-298 side fix), R-717 comments.
|
||||
|
||||
> **2026-10-04 night — v0.293.0 (R-860; floor 0.293.0 on the demo customers only, golden 0.293.0 vouched):**
|
||||
> `internal/sockheal` — the controller exits after 60 s of socket REFUSALS (never timeouts, only after Docker answered
|
||||
> once) so Docker restarts it on the current socket, then restarts any other socket user on an older inode (traefik).
|
||||
> Measured: only a `docker.socket` restart re-creates the file. Live: 9202 104 s, demo-hp 9201 120 s, ids unchanged.
|
||||
> The global floor stays 0.292.0 until Tester 2 may move (operator ruling 97 limited Tester 2 to three acts).
|
||||
|
||||
> **2026-10-04 — v0.290.0 (floored, golden 0.290.0 vouched, needs hub v0.128.0):** `offsiteGuard` EXCLUDES a young
|
||||
> snapshot superseded the same day in its group (R-824) and REFUSES above the hub's `max_remove` (half); a due
|
||||
> abandonment on the pinned tier is handed to the hub (`OffsiteAbandonClient`), which deletes after 7 days unless
|
||||
> cancelled (decision 74); a recovery cancels at the hub. Weekly windows ON fleet-wide since 2026-10-04.
|
||||
|
||||
> **2026-10-03 (evening) — v0.289.0 + v0.289.1 (floored 0.289.1, golden 0.289.1 vouched, needs hub v0.127.0):** the
|
||||
> hub-provisioned off-site tier is reached through an APPEND-ONLY key the hub's registrar pins
|
||||
> (`offsiteapply.HubRegistrar`, `PinnedProber`); the box never receives the Storage Box password; transport `rclone:`
|
||||
> over ssh 23 (`OffboxTarget.Transport = "rclone-pinned"`; the household NAS stays sftp). Retention only inside a hub
|
||||
> window behind the fake-snapshot guard (`backup/offbox_window.go`) — weekly windows are OFF; the guard is too strict
|
||||
> after manual runs (R-824, next). Move-aside is the hub's; abandonment deferred to the operator (R-823). v0.289.1: the
|
||||
> provider rclone NOTICE line is stripped from restic output, and an unreadable snapshot count is never a measured 0
|
||||
> (it caused one false `offsite_snapshots_dropped` mail, R-825). Both demo boxes migrated live with history kept.
|
||||
> Evidence `felhom.eu/documentation/audits/offsite-lock-build-2026-10-03/`.
|
||||
|
||||
> **2026-10-02 (afternoon) — v0.288.0 (floored, golden 0.288.0 vouched):** `userdata_kept` in hdd-data and both remove
|
||||
> results; the dialog says the household's files stay and names them (decision 67). Rule for later work: a remove NEVER
|
||||
> deletes a `${USERDATA_PATH}` folder — only the app's `${HDD_PATH}` binds, volumes and backups.
|
||||
|
||||
> **2026-10-02 — v0.287.0 (floored, golden 0.287.0 vouched): the family gate.** `internal/family` (members, sessions asked
|
||||
> on every request), `stacks/family_gate.go` (door before the first start; anchored exceptions; record at install and at a
|
||||
> removed app's restore; `min_controller`), `web/family_gate.go` (forwardAuth, `/__family/*`, the Család card). The family
|
||||
> cookie is never accepted by RequireAuth. Rules for later work: a family door is never removed while the app is
|
||||
> installed; an exception is a literal prefix, anchored; never an exception on an app with no login. Live proof
|
||||
> `felhom.eu/documentation/audits/family-gate-2026-10-02/A/items.txt`. Catalog: Grimmory + MeTube published behind it.
|
||||
|
||||
> **2026-10-01 night — v0.286.0 (9202 only) → v0.286.1 (floored, golden 0.286.1 vouched).** Operator ruling `09` §3
|
||||
> decision 63: extend the box's own gate; first the box tells visitors apart (R-753). `internal/infra`: `felhom-tunnel`
|
||||
> network `172.16.253.0/29` (ip-range `.4/30`), cloudflared ALONE at `.2`, traefik at `.3`; traefik's websecure trusts
|
||||
> forwarded headers from `172.16.253.2/32` only and passes `felhom-forwarded@file` (removes client-writable
|
||||
> host/path/address headers, fixes `X-Forwarded-Port: 443`). `EnsureBaseStack` now RECONCILES a running traefik/cloudflared
|
||||
> whose rendered files changed (recreate; refuses dropping a certificate resolver). `internal/web/clientaddr.go`: `clientIP`
|
||||
> = the hop traefik saw (rightmost XFF, only when the peer is traefik), the tunnel hop → `CF-Connecting-IP`; `rateKey`
|
||||
> IPv6 per /64. **Readers take the visitor from the RIGHT, never the leftmost** — catalog apps that read the leftmost have
|
||||
> `<router>-xff` (19, catalog). R-772: `HealthProbeResult.NotChecked`, seen on the next tick (0.286.1). R-773: a removed
|
||||
> app restored gets its sign-up lock back (`SetupGateByRestore`). Open: R-776..R-779 (felhom.eu register). The permanent
|
||||
> family gate (R-780) was SPIKED and passed — not built; the operator decides.
|
||||
|
||||
> **2026-10-01 — v0.285.0.** Operator ruling `09` §3 decision 56 (R-745): `stacks/controller_image_retention.go` keeps
|
||||
> the running controller image + the previous (swap record `update-state.json` via `UpdateState.RecordedPrevious`; version
|
||||
> order as fallback) + anything above the running version + non-version tags; deletes older/untagged by exact name/ID,
|
||||
> 3 min after every start (main.go, after the one-time app sweep). Measured: the agent's swap rolls back to the RUNNING
|
||||
> image (what `/etc/felhom-controller-image` named), never to a controller-supplied previous. R-751: nil-stack panic in
|
||||
> `RetainImagesAfterUpdate` fixed; both retention seams are no-ops in the stacks tests (`TestMain`).
|
||||
|
||||
> **2026-09-30 late — v0.284.0.** Operator rulings: `09` §3 decision 53 (R-736 A: keep running + previous image per
|
||||
> service, delete older, never an image a container/compose/record names) → `stacks/image_retention.go`, with a
|
||||
> one-time sweep at start; R-741 → `stacks/install_hold.go` (after_install apps held behind the gate's door until the
|
||||
> login is replaced). Decision 52 (same-tag re-tests) needed NO controller change (measured by a unit walk).
|
||||
|
||||
> **2026-09-30 — v0.283.0 / v0.283.1.** Decision 50: `settings.DefaultOffboxOnForNewApp` from the deploy-done hook
|
||||
> (an earlier per-app choice wins); one press for older apps; `backup/offbox_fit.go` size card (estimate at each run
|
||||
> + background from the page, never `du` in a request). R-721: `stacks.Manager.WantsStopped` asked by the quiesce
|
||||
> resume, the volume dump (through `stackAdapter` — the v0.283.0 miss), the update leg and `AppStopGuard.Recover`.
|
||||
> R-724/R-725 page slips. Floor 0.283.1, MinAgent 0.131.0. No golden (the operator's choice before Tester-2).
|
||||
|
||||
> **2026-09-28 evening — v0.279.0.** `stacks/after_install.go` (decision 45: a fresh install replaces a known default
|
||||
> login; the deploy-done hook now set on every box), `web/known_login.go` (the page's default-login rule),
|
||||
> `backup/hollow_watch.go` (Part D: a running app's empty copy → digest + page), `web/night_chain.go` + `RunUpdateLegNow`
|
||||
> (R-705), `Router.removeVerificationCopy` (R-706). Also: v0.278.0 (R-704) earlier the same day.
|
||||
|
||||
> **2026-09-28 — v0.277.0 (MinAgent 0.131.0).** `backup/kept_load.go`: `KeptBestCopy` (local newest vs off-site —
|
||||
> off-site only when NEWER or the only copy), `KeptOffsiteCopies` (ONE `snapshots --json`, 20 s bound; `offsiteNewest`
|
||||
> now carries `paths`), `LoadKeptOffsite` (unit-only download via `restoreUnitReadOnly` into the PROOF scratch → judge:
|
||||
> `KeptCopyAt` + `data` block required + `unitVersionCheck` → `prepare` → `keptUnitRestore` (seam) → scratch removed
|
||||
> before the outcome). `KeptCopyKey` names a copy on both pages. Golden 0.276.0 baked + vouched with agent 0.137.0 the
|
||||
> same day.
|
||||
|
||||
> **2026-09-27 — v0.276.0, floor 0.276.0 (MinAgent 0.131.0).** `stacks/life_records.go` `carryLifeRecords` — the restore's fresh `app.yaml` keeps `desired_state`, `conversion_copy`, `earlier_conversion_copies`, `failed_update_step`, `last_update_undone`, `last_auto_update` (NOT the pin, NOT `installed_images`); `recordConversionCopy` moves a superseded copy to `earlier_conversion_copies`; `ReleaseConversionCopies` → `releaseOneConversionCopy` per kept copy. Drive move: `persistDriveFlip` (load-then-save, `HDD_PATH` only) + `upFromAppConfig`; `flipEnv` removed. **A new `app.yaml` writer that is not a new install must load-then-save** (R-697, R-700). Tests `r700_records_carried_test.go`.
|
||||
>
|
||||
> **2026-09-27 — v0.275.0, floor 0.275.0 (MinAgent 0.131.0).** `backup/data_versions.go`: `stampDataFile` (called by the
|
||||
> DB leg, the volume leg and `RunAppBackupNow` — a new writer of a unit data file MUST call it), `foldUnitData`,
|
||||
> `RecoveryManifest.Data`; `captureRecoveryUnit(stack, dataRun)` keeps `compose/` when the pins moved since the data
|
||||
> (frozen); `unitVersionCheck`/`ErrUnitVersionMismatch` in the unit restore and the off-site reconstitute (which now
|
||||
> writes the snapshot's definition when versions differ); `unitDataTime` = THE unit data time (+ proven flag, R-699);
|
||||
> Tier 2 `DataDate`/`DataUnproven`; Tier 3 `offsiteDataTime`; `stacks.DumpStampSource` (optional guards interface) +
|
||||
> `ConversionCopy.Service`; `stacks.RestoredVersionPosition` + the restore pages' first sentence; R-695 single-flight
|
||||
> FB sync; R-691 `keptReadGroups`/`group_add` (CC-unattended decision, `07` §6.5); R-694 `restored_logins`.
|
||||
> D4 (open) option A is the built behaviour — `07` §6.6.
|
||||
|
||||
> **2026-09-25 midday — v0.272.0, floor 0.272.0.** `web.noSpaceLine` (agent prefix `skipped: not enough space: `,
|
||||
> key `backup.tier.no_space`); `backup.SetUndoCopyRemover` → `stacks.RemoveUndoCopies` from `clearUpdateHoldAfterRestore`
|
||||
> (R-671); `stacks.LoadProbeMetadata` (R-670); `stacks.BehindSinceAge` in both badge producers (R-677). Rulings: Peti's
|
||||
> box retired; `09` decision 34 — no resume of the update leg after a restart.
|
||||
|
||||
> **2026-09-25 night — v0.271.0, floor 0.271.0 (MinAgent 0.131.0).** `stacks/unattended.go`: `RunUpdateLeg` — one
|
||||
> app at a time, ONE step per app per night (decision 33), only `proven` entries, never `needs_person`,
|
||||
> `files_may_change` only with `backup.FreshWholeCopy` (the hold's truth table), never the step in `app.yaml`
|
||||
> `failed_update_step` while the ladder print is unchanged (R-680), transient refusals retried once, no step at or
|
||||
> after W+5h (`backupwindow.UpdateLegStopOffsetMin`). Chained by `chainUpdateLeg` inside the `offbox-backup` job
|
||||
> (every path incl. panic). `quiesce.Options.UpdateLegFn` = decision 20 + 31 (the gate waits until W+5h, then only
|
||||
> for a step in flight, cap W+5h30m). Self-update waits for the whole leg (decision 32). Switch `settings.json`
|
||||
> `app_update.unattended`, absent = ON, card on /settings (`POST /settings/app-update`). Page line
|
||||
> `last_auto_update` („Automatikus frissítés %s-kor — sikeres."), report `update_leg`. R-678: `ScanStacks` before
|
||||
> `finishUpdate` on done/undone. `stacks.update_window` REMOVED. Decisions 31–33 taken unattended (operator may
|
||||
> reverse). Open: R-686 (no resume after a restart), R-687 (live-proof gaps), R-685 controller half (the backup
|
||||
> page line for a space skip). Record: `felhom.eu/documentation/audits/DRILL-night-2026-09-25.md`.
|
||||
|
||||
> **2026-09-24 evening — v0.270.0, floor 0.270.0.** `UpdatePreflight` refuses `already_current` (R-679).
|
||||
> `stacks/install_interrupted.go`: `.felhom-install-pending` marker + `RecoverInterruptedInstalls` at start (after the
|
||||
> deploy-done hook) → compose down, stale pins cleared, `app_deploy_failed`, `.felhom-install-interrupted` → the
|
||||
> apps page sentence (R-681). The recovery unit captures `stacks.AppliedMetaFile`; a restore calls
|
||||
> `RecordRestoredAppliedMeta` (R-669). Next: part 7 (the update leg, `09` §6.4.2), R-678, R-680, R-682.
|
||||
|
||||
|
||||
> **2026-09-24 night — v0.269.0 + v0.269.1. Fleet floor 0.269.1 (MinAgent 0.131.0; needs hub v0.123.0).**
|
||||
> `backup.RestoreTier2Whole` (`tier2_whole.go`, decision 26): `mergeRestoreFiles` by the four file rules
|
||||
> (never delete; never an older file over a newer one; bring back missing; an older differing live file kept
|
||||
> beside as `.felhom-<UTC>`), then `RestoreFromRecoveryUnitAtWith(AcceptMissingFiles)` from the MIRROR; refuses
|
||||
> before anything moves (not a file app / no proven openable mirror / < 2 GB). `sameDevice` fails CLOSED (R-668).
|
||||
> Decision 27: `RemoveKeepsDataOnly` → 409 `err.stacks.remove_keep_data_while_support`; `keep_data_only` on
|
||||
> hdd-data. Decision 28/29: `stacks.ObserveUnhealthy` (RestartCount delta ≥ 6 in 10 min, OOM ≥ 20 in 30 min)
|
||||
> → `stopUnhealthyApps` → `HoldUnhealthy` (`unhealthy_stop`, trip 2 within 24 h) → `app_stopped_unhealthy`;
|
||||
> Start lifts it. R-664/R-665: `entry.NewMeta` = the step's own `.felhom.yml`. §6.4 part 6 box half:
|
||||
> `stacks/digest.go` — `RenderWithLadderDigests` for update + fresh install; **`CarryDigests` for an installed
|
||||
> app's sync (v0.269.1, decision 30)**. Open next: R-669 (applied-meta after hold + restore), R-679/R-680 +
|
||||
> part 7 (the update leg, `09` §6.4.2), R-681/R-682 (interrupted install/remove). Record:
|
||||
> `felhom.eu/documentation/audits/DRILL-night-2026-09-24.md`.
|
||||
|
||||
> **2026-09-24 — v0.268.0 (R-658, R-659, R-660, R-651; `09` §6.4 part 5). Fleet floor 0.268.0.** The undo
|
||||
> selects volumes with `DeclaredVolumeNames` (the compose definition), never the project label; the restore
|
||||
> labels volumes (project/volume/version, no config-hash). The hold names the newest WHOLE copy
|
||||
> (`backup.WholeOnTier` = the restores' refusal predicate `DeclaredDriveFileLegs`; off-site only for file apps)
|
||||
> or, with none, `hold.update.no_whole_copy` + no Mentések button + `app_hold_no_whole_copy` (needs hub
|
||||
> v0.122.0). `UpdateHeldStacks` is the fourth app-down suppression. Remove deletes `applied-compose.yml` +
|
||||
> `applied-meta/`. The ladder: `stacks/ladder.go` — `nextLadderStep` picks the NEWEST entry whose `from` is
|
||||
> the pin; a non-head step pins `catalog-cache/templates/<app>/steps/<StepKey(to)>.yml`; missing/wrong →
|
||||
> `update.error.pin_failed` before anything moves; no match → template, logged. `LadderStepsLeft` on the
|
||||
> page. Open next to it: R-661 (Tier-2 no whole action), R-664 (a step has no .felhom.yml), R-665, R-666,
|
||||
> R-667 (crash-loop clock resets on a flapping container).
|
||||
|
||||
> **2026-09-23 (night) — v0.267.0 (R-650, R-640, R-499, R-518).** Every docker exec goes through
|
||||
> `internal/dockerexec`; under `go test` a real docker is refused (opt-in `FELHOM_TEST_REAL_DOCKER=1`; a
|
||||
> stub under `os.TempDir()` passes). `api`/`stacks`/`web` tests run under `RunWithStub` (TestMain). Restore:
|
||||
> `appbackup.CheckDumpComplete` — a dump without its engine's end marker is refused before the first
|
||||
> mutation (unit + off-site) and again before any load. Tier-2 page: `systemBackupFact` → four sentences.
|
||||
> Backup button: the measured ~8 min. R-626 not reproduced (closed by measurement).
|
||||
|
||||
> **2026-09-23 (late evening) — v0.265.0 (R-634, R-625, R-636, R-647).** A whole-box backup no longer
|
||||
> stops/restarts a DEPLOYING app (`ListDeployedStacks` skips `Deploying`; the volume leg re-asks via the
|
||||
> optional `deployingReporter`; `StopStack`/`StartStack` → `ErrStackDeploying`). Held apps: badge
|
||||
> `badge.update.held`, no Update button. OOM: the kernel `oom_kill` counter (the `OOMKilled` flag is sticky);
|
||||
> ≥20 kills / 30 min per container run → one `app_oom_storm`. Web `readerFuncs` render hold/error per reader
|
||||
> in EVERY language. Needs hub v0.121.0. Floor 0.265.0. Open: R-649 (operator), R-650 (tests on real docker).
|
||||
|
||||
> **2026-09-23 (evening) — v0.264.0 (`09` §6.4 parts 2–3; R-606, R-620, R-646).** Two events,
|
||||
> `app_update_undone` / `app_update_held`, one per app per outcome, via `Manager.SetUpdateEventSink`
|
||||
> (main.go) → `NotifyAppUpdateUndone` / `NotifyAppUpdateHeld`; on by default and seeded once on existing
|
||||
> boxes (`app_update_events_seeded`), with two toggles on the notifications page (the page pushes the
|
||||
> list to the hub on save). Update sentences are stored as key + args (`UpdateErrorKey/Args`,
|
||||
> `update.error.*`, `update.refusal.*`, `update.phase.*`, `hold.*`) and rendered per reader
|
||||
> (`updatePhaseText` / `updateErrorText` / `holdText`, `RestoreHoldForLang`, `UpdateErrorIn`); stored
|
||||
> Hungarian unchanged. Startup `BackfillAppliedMeta` (R-646). Disabled notifier WARNs once per type
|
||||
> (R-620). Needs hub v0.120.0. Floor 0.264.0. Leftovers: R-647. Evidence:
|
||||
> `felhom.eu/documentation/audits/undo-fleet-2026-09-23/`.
|
||||
|
||||
> **2026-09-23 — v0.263.0 → v0.263.2 (R-637, `09` §3 decisions 15 + 19).** A failed health check after
|
||||
> an update is now UNDONE: phase `copying` (after the pull, the app stopped anyway) copies every NAMED
|
||||
> volume `cp -a` into `<vol>.pre-update-<stamp>` (label `felhom.undo-copy-of`, finished-marker last);
|
||||
> on failure `undoing` validates every copy first, refills the volumes, puts back definition + pin +
|
||||
> the pinned version's `.felhom.yml` from the job's OWN copies, and checks the old version with ITS
|
||||
> probe → `undone` (`app.yaml` `last_update_undone`, one page line) or a HOLD prefixed with the undo's
|
||||
> failure and the data state. Bind folders are never touched. Journal recovery: `copying` → old version
|
||||
> back; `undoing` → resumed. **Two live-only lessons, both now in code:** (1) the periodic probe with
|
||||
> the CURRENT `.felhom.yml` flips the app `unhealthy`, so the undo's health wait must probe an
|
||||
> `unhealthy` app with its own override (`waitUpdateHealthyMeta`, v0.263.1); (2) `.felhom.yml` flows
|
||||
> into the stack dir on every catalog SYNC, so the pinned version's file must be recorded AT PIN TIME
|
||||
> — `<stack>/applied-meta/.felhom.yml`, written at deploy, adoption and pin advance (v0.263.2). Apps
|
||||
> pinned earlier have no record until their next pin (R-646). R-642: start/restart never say
|
||||
> "completed". **The fleet floor is still 0.262.1** — raising it is the operator's decision. Evidence:
|
||||
> `felhom.eu/documentation/audits/undo-bakeoff-2026-09-23/`, `…/undo-live-2026-09-23/`.
|
||||
|
||||
> **2026-09-21 (evening) — v0.261.0 (R-608 + R-609).** The controller self-updates daily at **04:30** by default and after ANY hub report once a floor sits above the box; that swap restarts the controller, which is the supervisor of a running app update. The window `09` §3b Q1 proposes for automatic app updates is 02:30-05:00. **It contains 04:30.** **A two-way lock, wired in `main.go` — `stacks` never imports `selfupdate`:** `Manager.AnyUpdating()` -> `Updater.SetAppUpdatingCheck` (a sibling of the existing `SetBackupRunningCheck`, consulted in the SAME three places), and `Updater.IsUpdateRunning` -> `Manager.SetSelfUpdatingCheck` with `UpdatePreflight` refusing `self_updating`. **MEASURED: the gap was NARROWER than assumed** — the update's `backing-up` phase already took the backup single-flight, so only the other six phases were exposed. The live probe landed in `safety-dump`, i.e. in the real gap. **THE PROPERTY THAT MATTERS: the lock must NOT latch** — `Stack.Updating` clears on done, failed AND held, so a held app does not block the controller's own updates for ever. **R-609:** the 409 now carries `data.reason` — transient (`busy`,`updating`,`deploying`,`migrating`,`self_updating`) vs terminal (`held`,`downgrade`). **Found while writing the test: the router refuses a HELD app on its OWN line BEFORE `UpdatePreflight`**, so `held` — the reason an unattended caller needs most — would have been the one missing. Five red-proofs, each seen to fail. **Proven live on guest 9202 with a negative control:** with no app update the manual swap gives the AGENT refusal; with one in flight it gives OURS. The sentence changing IS the proof. Deployed to **9202 only**; the fleet floor is 0.260.0 and 0.261.0 is a separate operator ask. Measurements: `felhom.eu/documentation/audits/update-arc-gaps-2026-09-21/`.
|
||||
|
||||
> **2026-09-21 — v0.260.0 (R-524, update arc).** A box that updated before the catalog was reverted under it read „Frissítés elérhető" over an Update that would have moved its pin BACKWARDS (measured BIGNIGHT Phase 6, privatebin 2.0.6 vs catalog 2.0.5). **The comparison gains a fourth verdict and MOVES OUT OF `web`:** `stacks.CatalogOrder` — Unknown/Current/Behind/**Ahead** — is read by BOTH the badge and `UpdatePreflight`, because a comparison implemented twice drifts. Ahead reads „Naprakész"/"Up to date" (`tag-ok`, same word and class as level — nothing for the household to do) with a title saying why; the Update is refused `downgrade` 409. **The API now renders update refusals through `errText`** — otherwise the new key would be a seam built and never wired. **Ahead is NARROW:** every differing service must be orderable AND newer, else Behind — this gate can BLOCK an update, so it errs towards letting one run. **THE TRAP, and the fixture caught it, not the design:** the first tag rule accepted only bare `X.Y.Z`, so every REAL catalog tag was unorderable and the refusal test failed with `got nil`. The rule now takes the version at the FRONT and requires the trailing suffix to be IDENTICAL on both sides — `31.0.14-apache → 31.0.15-apache` orders; `26.05.2-ls310 → -ls311`, `postgres:16-alpine`, `apache-2.57.0`, a date stamp and a digest pin do not. Ordering is `util.Version.Compare` and nothing else (one comparator, house rule). Three red-proofs, each seen to fail. **R-589 was NOT open** — it shipped in v0.258.0 and only its row was stale; a reviewer who reads ONE producer cannot see a SECOND that overrides it. **Floor NOT raised — the operator's step; and CONTEXT's own 0.257.0 was STALE — the hub says 0.259.0 (read live from `/configs`, never from a doc).** Deployed on demo-felhom 9201, demo-hp 9201 and demo-hp 9202 (scratch, upgraded from 0.245.0 for the live proof). Seven questions for Slices 6 and 7 are in `09` §3b; the state of the whole arc with drift numbers is `audits/UPDATE-ARC-STATE-2026-09-21.md`.
|
||||
|
||||
> **2026-09-21 — v0.259.0 (R-596 P1 + R-598, the drill's blockers).** The claim page's answers and the Backup page's protection warnings follow the reader's language. **Fourteen** live sites carrying **nine** messages, not the sixteen literals R-596 counted — and the sixteenth, `data["Title"]`, was **DEAD** (`claim.html` is standalone; `.Title` belongs to `layout.html`) and was DELETED rather than translated. `backup_handlers.go` had **nine** code literals, not twelve; three were inside comments. `degradedMessageFor` now returns a **KEY**, so the decision stays language-free in one place while the words are chosen by whoever knows the reader; `buildTierViews`/`backupTargetLabel`/`loadGuestBackup` take `lang` the `buildDataPathCards` way. **The anonymous cookie-less claim page's language chain** (`langFor` → `settings.GetLanguage` → `configLanguage` ← `cfg.Customer.Language`) **was an unpinned assumption and is now a test.** Six existing copy-contract tests were kept rather than weakened — each resolves its key through the real bundle, so they still convict on a reworded Hungarian sentence. **Two things the tests caught and review did not:** an apostrophe in an English value is escaped to `'` 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).
|
||||
|
||||
|
||||
@@ -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,109 +1,39 @@
|
||||
# REPORT — controller v0.247.0: the i18n spike — English on three pages, Hungarian byte-identical
|
||||
# REPORT — v0.323.0: Debian 13, restic 0.18.1 pinned outside the OS and re-proven, felhom-samba 1.2.0 (2026-10-16)
|
||||
|
||||
**Date:** 2026-09-17 · **Task:** localisation starter (spike + design), Phase 1 · **Baseline:** `main`
|
||||
`89dd3e94b1de` (v0.246.0, MinAgent 0.131.0) · **Released:** `612c417` → image
|
||||
`gitea.dooplex.hu/admin/felhom-controller:0.247.0` · **MinAgent:** 0.131.0 (unchanged).
|
||||
**Architecture read before any claim:** `felhom.eu/documentation/architecture/02-controller-module-map.md`
|
||||
(templates, setup wizard obsolete) and `05-hub-architecture.md` (report ingestion, config push).
|
||||
**Design written after the spike ran:** `felhom.eu/documentation/architecture/10-localisation.md`.
|
||||
**Inventory:** `felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
|
||||
Brief: „the controller on Debian 13 with a pinned restic, re-proven; the Samba image rebuilt". Full report, in plain
|
||||
words: felhom.eu `REPORT-debian13-restic-2026-10-16.md`. Evidence: felhom.eu `documentation/audits/debian13-restic-2026-10-16/`.
|
||||
|
||||
## Claims in the brief that turned out wrong — named first
|
||||
## 1. What changed
|
||||
|
||||
The brief named four already. Further ones (details and methods in the inventory §0):
|
||||
- `controller/Dockerfile`: a `restic` stage fetches the official restic 0.18.1 binary and checks the signed `SHA256SUMS`
|
||||
(vendored key, `third_party/restic/`), the published sum and the per-arch sum pinned in the file. Runtime
|
||||
`debian:13.7-slim` (digest); Docker repo codename from `/etc/os-release`. Removed packages the binary never runs:
|
||||
postgres and mysql clients, sqlite3, fdisk, e2fsprogs, parted, sshpass.
|
||||
- `infra-images/samba/Dockerfile`: Alpine 3.24.2 (digest). `SambaImage` → `felhom-samba:1.2.0` (pushed once).
|
||||
`scripts/build-samba-image.sh` refuses a reused tag, an unanswerable registry and a dirty/unpushed tree.
|
||||
- Comments in `internal/backup/offbox_{capture,progress,proof,window}.go` corrected to what was measured on both versions.
|
||||
|
||||
1. **„`fmtTime`, `fmtBytes`, `timeAgo` do not exist; `fmtMB` is the one size helper"** — all three
|
||||
exist (`internal/web/funcmap.go` L265/L318/L403), plus nine more copy- or locale-producing helpers
|
||||
and four size helpers. `timeAgo` builds „%d perce" — the plural problem lives there.
|
||||
2. **„`fmtMB` used 10× in templates"** — 8×.
|
||||
3. **„~13 `.Format(` layout calls in `internal/web`"** — 10 at the baseline (plus 8 `time.RFC3339`).
|
||||
4. **Template lines 1 613 / 1 838** — 1 553, by two methods; neither figure reproduced by four variants.
|
||||
5. **Go literals 1 234 in 99 files** — 1 141 in 84 (a lexer, comments skipped); comments explain most of
|
||||
the difference.
|
||||
6. **Banner 33 lines** — 34 once ASCII-only Hungarian words count (method difference).
|
||||
## 2. Tests (each red-proved)
|
||||
|
||||
## What shipped
|
||||
- `TestR1105_ResticIsPinnedOutsideTheOS` (`cmd/controller`): fails on the old Dockerfile and on four single mutations.
|
||||
- `TestR1105_EachResticFailureKeepsItsClass`, `_InitRaceIsStillSuccess`, `_TheJSONTheCodeReadsStillParses`,
|
||||
`_TheForgetPlanOnlyEverKeepsMore` (`internal/backup`): REAL outputs of 0.14.0 and 0.18.1 in `testdata/restic/`, through
|
||||
the code's own parsers and classifiers. Six mutations, each convicted.
|
||||
- `scripts/test_samba_build_push_once.sh`: 6 cases; 4 fail on the old script.
|
||||
- Full suite green; `controller_gates.py --fast` green.
|
||||
|
||||
- **Mechanism (b), expanded at template LOAD:** `internal/i18n` (`Bundle`, `Expand`, `Msg`, `Plural`,
|
||||
embedded `locales/hu.json` + `en.json`). `loadTemplates` parses one template set per language via
|
||||
`parseTemplateSet` (= `ParseFS` + `Expand`); `s.tmpl` stays Hungarian. Why not a runtime `T` func: the
|
||||
escaper changes `+ ' "` bytes and 618 of 1 867 template strings are inside `<script>`. Why not
|
||||
`x/text/message`: no gender, only count plurals — a dependency for nothing.
|
||||
- **Converted:** `launcher.html`, `backups.html`, `app_info.html`, `layout.html`, `backups_shared.html`
|
||||
(`backups_empty`) — 252 markers, 235 hu keys, all translated (238 en keys incl. plural forms).
|
||||
Page titles via `TitleKey`; English overrides for `stateLabel`, `timeAgo`, `timeAgoStr`,
|
||||
`nextRunLabel`, `statusText`; `<html lang>` follows.
|
||||
- **Setting / switch / report:** `settings.json` `language`; `POST /settings/language`; `?lang=`;
|
||||
switch in the sidebar footer only when not Hungarian or `?lang=` is present; `"language"` in the hub
|
||||
report at all four build sites in `cmd/controller/main.go`.
|
||||
- **Gates:** new `i18n_missing_gate.py` (registered in `controller_gates.py`). `emoji`,
|
||||
`native_confirm`, `secret_in_markup`, `retrieval_promise` now read templates expanded
|
||||
(`scripts/i18n_bundle.py`); `mojibake` scans `.json`. **Found while converting:** retrieval-promise
|
||||
went STALE (red) the moment copy moved; the other three went silently BLIND — the HEAD emoji gate
|
||||
passed a planted emoji in `en.json`, and the HEAD retrieval gate never saw a planted promise in
|
||||
`hu.json`.
|
||||
- **Tool:** `scripts/i18n_extract.py`. First pass pulled layout `<span>`s and a button-link pair into
|
||||
messages and mis-split a JS string that starts inside a tag — fixed and re-run from clean templates.
|
||||
It also **missed eight ASCII-only Hungarian strings** (Megszakadt, Elavult, Csak x86, Pi kompatibilis,
|
||||
Rendszermonitor, Most nem, sikertelen, ismeretlen) and one JS fragment („adatait ide:"), found by a
|
||||
by-eye review of what it left; the word list was extended and the fragment converted by hand.
|
||||
- Two existing tests (`TestTemplatesParse`, `TestTemplatesParseWithFuncmap`) parsed raw templates with
|
||||
`ParseFS`; they now parse through `parseTemplateSet` for every language.
|
||||
## 3. Live
|
||||
|
||||
## Red-proofs — each seen failing, then passing (`felhom.eu/documentation/audits/i18n-2026-09-17/redproofs/`)
|
||||
demo-hp 9201 on rc.1 (code identical to the release): both restore directions byte-identical on the real store; the
|
||||
off-site run (dumps + push, live progress) ok; the drill's row and file came back from the off-site copy; Samba 1.1.0 →
|
||||
1.2.0 with the same household password, every discovery daemon answering. Released by per-box floors to Tester 1,
|
||||
demo-hp, demo-felhom (19:25 UTC), box walk green. **The global floor waits for each box's first night on 0.323.0.**
|
||||
|
||||
| test | mutation | failure seen |
|
||||
|---|---|---|
|
||||
| `TestI18nParity` | fixture file absent | `no parity fixture` (red before the fixture existed) |
|
||||
| `TestI18nParity` | „Megosztási link" → „…linK" in hu.json | `launcher_full: Hungarian page CHANGED at line 261` (+ launcher_nopassword) |
|
||||
| `TestI18nEnglishPages` | `layout.inditopult` deleted from en.json | `Hungarian template copy on the English page, line 111 … Indítópult` (5 cases) |
|
||||
| `TestLanguageSwitch_EndToEnd` | `s.addLanguageData` removed from `executeTemplate` | `?lang=en must render English and show the switch`; title and switch missing |
|
||||
| `TestI18nJSContextValuesAreSafe` | en `launcher.masolva` = `Copied'` | `contains '\'', which breaks its context` |
|
||||
| `TestBundleParametersMatchAcrossLanguages` | en banner drops `{{.RecoveryAbandonDate}}` | `template fields ".RecoveryAbandonDays", Hungarian has ".RecoveryAbandonDate,.RecoveryAbandonDays"` |
|
||||
| `TestLocaleFuncsHungarianBundleMatchesFuncMap` | hu `func.state.running` = „Futás" | `hu.json "Futás", funcmap "Fut"` |
|
||||
| gate decoys (`test_gate_decoys.py`) | undefined key, „Please", gap grew, new formal form; emoji / confirm( / secret / retrieval promise planted in a bundle | all 18 decoys rejected; HEAD emoji gate passed the bundle emoji, HEAD retrieval gate did not see the bundle promise |
|
||||
## 4. Observations
|
||||
|
||||
Fixtures: captured by running the parity test with `-update-i18n-golden` in a clean `git worktree` of
|
||||
`origin/main` 89dd3e94b1de (the test file adapted to `s.tmpl`), then copied in; the current tree passed
|
||||
them before AND after conversion. All files restored after each mutation (`cmp`).
|
||||
|
||||
## Gates
|
||||
|
||||
- Green gate: `go build ./... && go vet ./... && go test ./...` → rc 0, 29 packages ok (first run found
|
||||
the two raw-`ParseFS` tests; fixed; re-run green).
|
||||
- `controller_gates.py --fast` → all OK incl. `i18n` (English missing 0 / ceiling 0; formal forms 6 /
|
||||
ceiling 6). Pre-push hook ran them on the push.
|
||||
|
||||
## Live validation (endpoint-level; no browser on DooPlex) — demo-hp guest 9201
|
||||
|
||||
Deployed by hand (`/etc/felhom-controller-image`, bootstrap restart); **fleet floor NOT raised; no
|
||||
golden.** Startup: `loadTemplates: lang=hu … 252 markers expanded` and `lang=en … 252`.
|
||||
|
||||
- **Hungarian unchanged:** launcher, `/backups`, `/apps/privatebin` fetched on 0.246.0 and on 0.247.0 —
|
||||
identical after replacing version string and CSRF token; raw sizes equal (44 462 / 46 250 / 42 081).
|
||||
A fetch 7 s after restart differed only in app state tags („Nem egészséges" while health warmed up).
|
||||
- **English:** `?lang=en` and, after `POST /settings/language lang=en` (302 → `/launcher`, settings
|
||||
`"language": "en"`, INFO `language switch: household language set to en`), identical renders. No
|
||||
accented Hungarian from template copy. Remaining Hungarian, all Go view models or catalog: backup-target
|
||||
banner/offer and whole-guest tier labels (`/backups`); the „Naprakész" update badge and the catalog
|
||||
tagline / use cases / first steps (`/apps/privatebin`). Launcher: none.
|
||||
- **Hub report:** stored reports read no field (0.246.0), `hu`, `en` (12:54:43Z), `hu` (12:55:22Z).
|
||||
Negative control: demo-felhom (0.246.0) no field.
|
||||
- **Refusals:** no `_csrf` → 403; no session → 302 to login; setting unchanged.
|
||||
- Evidence (CSRF redacted): `felhom.eu/documentation/audits/i18n-2026-09-17/live/`.
|
||||
|
||||
## Teardown
|
||||
|
||||
Machine: guest temp scripts, password file, session file shredded/removed; box left on `hu`, 0.247.0.
|
||||
Host: the tarball removed from demo-hp. Hub: nothing written; the DB copy read for reports shredded on
|
||||
DooPlex. Nothing provisioned. The scratch worktree of `origin/main` is removed.
|
||||
|
||||
## Observations
|
||||
|
||||
1. The wire-contract gate passes an unread field whose name appears in a hub Go comment (`language`). **FILED: R-555**
|
||||
2. Four places decide behaviour by matching Hungarian wording (HTTP status, quota class, alert placement, a persisted warning marker). **FILED: R-553**
|
||||
3. The extractor and the inventory detect ASCII-only Hungarian only through a word list; eight strings slipped past on three pages. **FILED: R-556**
|
||||
4. `TestI18nEnglishPages` subtracts fixture data strings, so a template word equal to a data string (the launcher title „Indítópult") is masked on the launcher cases. **FILED: R-556**
|
||||
5. Six formal „ön" forms in the converted Hungarian copy, now counted by a ratchet. **FILED: R-516**
|
||||
6. Dates and sizes are not locale-formatted; the two template date layouts disagree; sizes use a decimal point. **FILED: R-562**
|
||||
7. `storage_handlers_test.go` imported `text/template` for its template-parse check. **NOT-A-FINDING: a test-only parse check with no escaping assertion; it now parses through the production `parseTemplateSet`, which uses `html/template`.**
|
||||
1. The forget plan of restic 0.17+ keeps each group's oldest snapshot while monthly slots are unused. NOT-A-FINDING: it only ever keeps more; pinned by `TestR1105_TheForgetPlanOnlyEverKeepsMore`, written into felhom.eu `07` §6.8.
|
||||
2. A reinstalled database app restored from the off-site copy is unusable (old volume, new password). FILED: R-1106
|
||||
3. The official restic 0.18.1 binary adds 50 fixable scanner findings. FILED: R-1107
|
||||
4. A share added before the first household password is not exported. FILED: R-1108
|
||||
5. A plain `restic restore` writes a lock on both versions; a comment said it did not. NOT-A-FINDING: comment corrected; the nightly proof uses `--no-lock`.
|
||||
6. `build.sh --push` also pushes `:latest` for an rc tag. NOT-A-FINDING: nothing pulls `:latest`; boxes run a named tag and the update button reads X.Y.Z tags.
|
||||
|
||||
@@ -23,7 +23,23 @@
|
||||
| `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) |
|
||||
| `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/<n>` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert |
|
||||
| `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) |
|
||||
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, legacy felhom-data) | Consult before ANY recursive delete under a drive |
|
||||
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, kept, legacy felhom-data) | Consult before ANY recursive delete under a drive |
|
||||
| `stacks.OldAppDataPaths` / `Manager.ListKept` / `KeepAside` / `DeleteKept` / `FindKept` | controller/internal/stacks/kept.go | `(composePath, hdd)` / `(drives)` / … | Kept data (`09` §3 decision 36): what counts as an app's old data (ONLY `<hdd>/appdata/…` binds), the list, start-fresh, the household's delete | **An action names a kept item by path only through `FindKept`** — `DeleteKept` refuses anything not listed. `KeepAside` is a rename on one drive; never copy, never `RemoveAll` in a rollback (`removeEmptyDirs`) |
|
||||
| `stacks.RunAfterInstall` / `expandAfterInstall` / `web.defaultLoginInEffect` (v0.279.0, decision 45) | controller/internal/stacks/after_install.go · controller/internal/web/known_login.go | `(name, wait)` / `(cmd, allowed, env)` / `(meta, cfg, installed)` | A fresh install replaces a known default login; the page says when a default is still in effect | **Only from the deploy-done hook** — never after a restore/kept load (R-694). A `success:` marker is required (exit 0 lies). Never log the expanded command |
|
||||
| `stacks.OpenSetupGate` / `SetupGateTick` / `SetupGateHost` · `web.ServeGateAuth` / `ServeGateStart` (v0.280.0, decision 46) | controller/internal/stacks/setup_gate.go · controller/internal/web/setup_gate.go | `(name, by)` / `()` / `(host)` · handlers | The setup gate: a `setup_gate: true` install is closed to everyone but the household until its probe or the household's press opens it | **Write the gate BEFORE the first start** (spike F2). Open = record first, then remove the file. Never widen the dashboard cookie — the handshake mints a host-bound one-use token |
|
||||
| `stacks.OpenSignupWindow` / `SignupBlocked` / `SetupGateProbe` · `web.ServeSignupClosed` (v0.281.0, decision 47) | controller/internal/stacks/signup_block.go · controller/internal/web/setup_gate.go | `(name)` | Sign-up closed at the app's own address once the gate opens; the household's 15-minute window; the press asks the probe | **The block goes up BEFORE the gate comes down** (a failed write keeps the gate closed). Never on an app this box did not gate |
|
||||
| `family.Store` · `stacks.FamilyGateHost` / `familyGateTick` / `FamilyExceptRegexp` · `web.ServeFamilyGateAuth` / `ServeFamilyStart` / `ServeFamilyLogin` / `ServeFamilyLogout` (v0.287.0, decisions 63/64) | controller/internal/family/family.go · controller/internal/stacks/family_gate.go · controller/internal/web/family_gate.go | store `Add/Reset/Remove/Verify/NewSession/Valid/EndSession` · `(host)` / `()` / `(prefix)` · handlers | The PERMANENT family gate: family members with their own logins in front of a `family_gate: true` app | **A family cookie never opens the dashboard** (RequireAuth reads only `felhom_session`); the app cookie names a STORE session, so the store is asked on every request (reset/remove/logout end access at once); **every exception goes through `FamilyExceptRegexp`** (anchored — never a hand-written PathPrefix); door written before the first start, like the setup gate. `familyStoreOverride` is the test seam. Fifth atomic-write helper (family.json, fsync) — see §6 |
|
||||
| `stacks.OpenInstallHold` / `installHoldTick` (v0.284.0, R-741) | controller/internal/stacks/install_hold.go | `(name, by)` / `()` | An `after_install` app held behind the setup gate's door until its known login is replaced | **Written before the first start**, like the gate; opens on `after_install` success or the household's "I changed it"; the door (`SetupGateHost`) reads holds first |
|
||||
| `stacks.RetainImagesAfterUpdate` / `RetainImagesAfterRemove` / `RunImageRetentionOnce` (→ `(deleted, done)` since v0.294.0) / `RunImageRetentionOnceUntilDone` · seam `imageDocker` (v0.284.0, decision 53) | controller/internal/stacks/image_retention.go | `(name, previous)` / `(name, repos)` / `()` | Deletes an app's images older than its running + previous one | **The keep set is box-wide and read at delete time** (containers, installed composes, installed/previous records); exact id, never forced or pruned; skipped while any update runs AND while any image-pulling compose command runs (R-863, `dockerexec.TryImageCleanup`); the one-time pass writes its marker only after a pass that RAN; tests use the `imageDocker` seam, never Docker |
|
||||
| `dockerexec.BeginImageWork(args)` / `TryImageCleanup()` / `ImagePulling(args)` (v0.294.0, R-863) | controller/internal/dockerexec/imagework.go | `defer dockerexec.BeginImageWork(args)()` | **Every NEW place that runs `compose up/pull/create/run` must hold it** (the stacks compose helpers, appexport's restore and the FileBrowser sync already do) | An image pulled by compose is named by no container until compose creates one; a clean-up pass in that window deleted it (measured, R-863). The pass takes the lock exclusively without waiting; pulling commands wait for a running pass (seconds) |
|
||||
| `nightchain.Ledger` / `CatchUp` / `ResumeWatch` / `ComputeBanner` (v0.295.0, R-871) | controller/internal/nightchain/ | `Open(path, now)`, `MarkEnded(leg, t)`, `Missed(now, W, loc)`, `Evaluate(ctx, why)` | "was a night missed?" and making it up; the missed-backup banner's rule | The ledger is an ATTEMPT record (ran to its end) — never evidence a backup exists; a NEW nightly backup leg must be wrapped by `withLeg` in main.go and listed in `nightchain.Order`, or the catch-up never makes it up; never add the update leg to `catchUpLegs` |
|
||||
| `scheduler.DailyLateLimit` (v0.295.0) | controller/internal/scheduler/scheduler.go | — | a daily timer firing > 60 min late (host suspend) is skipped | do not "fix" a late fire by running it: the app-update leg would run at noon |
|
||||
| `stacks.RetainControllerImages(ControllerImageRecord)` · `selfupdate.UpdateState.RecordedPrevious` (v0.285.0, decision 56) | controller/internal/stacks/controller_image_retention.go | `({Repo, Running, Previous})` | Deletes controller images older than the running + previous one | The previous comes from the SWAP RECORD (success onto the running version), version order only as the fallback; versions above the running one and non-version tags are kept; skipped while the controller swaps itself; same `imageDocker` seam |
|
||||
| `stacks.CloseSignupNow` / `CloseSignupOffered` / `applyNativeLock` (v0.282.0, decisions 47/49) | controller/internal/stacks/after_setup.go | `(name)` | The app's own sign-up switch after the setup; "close sign-up now" for an app installed before the rule | **Check the installed compose reads the variable** (an old install carries the old compose until its next update) — never record a lock that is not there. One run per app at a time (`nativeLockBusy`) |
|
||||
| `backup.judgeCopy` / `HollowCopies` / `SetHollowCopyNotify` (Part D, v0.279.0) | controller/internal/backup/hollow_watch.go | `(app, tier, unitDir)` | A RUNNING app whose newest copy holds no data → operator digest once/day + page sentence | Uses `unitCarriesData` (the manifest, never size); a stopped held app is never flagged |
|
||||
| `web.nightChain` (R-705, v0.279.0) | controller/internal/web/night_chain.go | `POST /api/debug/backup/night-chain` | The night's four legs now, in order | Refuses while any op/update/chain runs; the leg uses `RunUpdateLegNow` |
|
||||
| `Router.dropLeftoverHold` + `settings.ClearUpdateHold` (R-704, v0.278.0) | controller/internal/api/router.go · controller/internal/settings/settings.go | `(name, why)` / `(stack) (bool, error)` | A new install (plain or "use my kept data") and a removal clear the update / crash-loop hold of the app's install | **A hold belongs to an INSTALL; the name is all the next install shares with it.** Never clears an R-379 restore hold (operator-only) |
|
||||
| `backup.KeptBestCopy` / `KeptDBCopy` / `KeptOffsiteCopies` / `KeptCopyAt` / `LoadKeptApp` / `LoadKeptOffsite` / `KeptCopyKey` | controller/internal/backup/kept_load.go | `(ctx, app, drive)` / `(app, drive)` / `(ctx, apps)` / `(unitDir, drive, tier)` / … | Which copy can load kept files (local tiers + since v0.277.0 the off-site copy, R-691 (2)), the load as a restore op, the copy's name for the page | A copy counts only with data (DB dump or volume tar) AND its app.yaml `HDD_PATH` = this drive. Installed apps are never offered. **The off-site copy is judged only after its unit is downloaded** — `LoadKeptOffsite` refuses an unversioned unit (`07` §6.6) BEFORE `prepare` (a dated folder's move-back); ask the repository once per page (`KeptOffsiteCopies`), never per row. Both pages name a copy through `KeptCopyKey` |
|
||||
|
||||
### Subprocess + timeout + exit-code discipline
|
||||
|
||||
@@ -37,6 +53,19 @@
|
||||
| `parseWWWAuthenticate` + `fetchAnonymousToken` | controller/internal/selfupdate/updater.go | Bearer-challenge parse + anonymous Docker v2 token | Any credential-free registry API access | realm comes FROM THE HEADER (never hardcode a token URL); denial = errAnonymousDenied, never "credentials missing" |
|
||||
| `Syncer.runGit` / `runGitInDir` | controller/internal/sync/sync.go | `(args...) error` | git CLI ops | Credentials masked in logs via `maskRepoURL` |
|
||||
|
||||
### Error kinds — never branch on a customer-facing sentence (R-553)
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `util.KindErrorf` / `util.KindError` | controller/internal/util/errkind.go | `(kind error, format string, a ...interface{}) error` | ANY refusal a caller must tell apart: build the message exactly as `fmt.Errorf` would AND carry a sentinel for `errors.Is` | The message bytes are unchanged (pinned by tests); never `fmt.Errorf("%w: …")`, which would prepend the sentinel's own text to the customer's sentence |
|
||||
| `stacks.ErrAlreadyDeployed` / `ErrRequiredField` / `ErrPathMissing` / `ErrNotEnoughMemory` | controller/internal/stacks/deploy_errors.go | sentinels | the API's deploy status code (`api.deployStatusFor`) | 409 / 400 / 400 / 400. Do NOT add a text signature beside them |
|
||||
| `stacks.ErrStackNotFound` / `ErrProtectedStack` / `ErrNotDeployed` / `ErrStillRunning` / `ErrNotOrphaned` | controller/internal/stacks/stack_errors.go | sentinels | the API's stop/start/restart/update, remove and delete status code (`api.stackOpStatusFor`, R-569) | 404 / 403 / 409 / 409 / 409. Only the producers in manager.go and delete.go carry them; a new refusal on those paths must carry one or it answers 500 |
|
||||
| `backup.ErrOffsiteQuota` | controller/internal/backup/offbox.go | sentinel | `ClassifyOffsiteFailure` telling a quota over-run apart | The other arms of that switch stay TEXT matches on purpose — they are restic's and ssh's own English output, which we neither write nor translate |
|
||||
| `monitor.WarnKind*` + `HealthReport.addWarning` / `WarningKindAt` | controller/internal/monitor/healthcheck.go | `(text, kind string)` | a health warning whose PLACEMENT the dashboard decides | Internal only: `internal/report/builder.go` copies Status/Issues/Warnings, so kinds never reach the hub (pinned) |
|
||||
| `monitor.MsgRef` + `HealthReport.addWarningMsg` / `addIssue` / `WarningMsgAt` / `IssueMsgAt` | controller/internal/monitor/healthcheck.go | `MsgRef{Key, Args}` beside the wire text | a health warning/issue whose BANNER must follow the household's language (R-516 item 10) | The wire text (report health.*) stays byte-identical; `AlertManager.Refresh` renders the key and falls back to the text when Key is empty. Every issue goes through `addIssue` so the parallel slices cannot drift (pinned) |
|
||||
| `AlertManager.GetBannerAlerts(page, lang)` + `bannerShows` (R-906, 2026-10-08) | controller/internal/web/alerts.go | the layout banner's list for ONE page, capped AFTER the page filter | any page that fills `data["Alerts"]` (`baseData` does it for you) | `GetAlerts` keeps Inline and other pages' alerts (debug dump, placement tests) and must not feed a banner: its cap counted alerts the layout then dropped, so a page showed „+ 5 további figyelmeztetés" and nothing above it. `bannerShows` mirrors the layout's own filter (pinned: `TestR906_*`) |
|
||||
| `settings.OffboxTarget.LastWarningKind` + `backup.OffboxWarnNoAppsSelected` | controller/internal/settings/settings.go | persisted string | the Távoli mentés page's stale-note substitution | Written and cleared with `LastWarning`; the page decides on the kind ONLY — the legacy text fallback was removed when R-570 closed (2026-10-10) |
|
||||
|
||||
### HTTP/JSON envelopes + flash messages
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
@@ -64,6 +93,8 @@
|
||||
| `Manager.SetOffboxLatestSnapshotFn` (R-357, v0.226.0) | controller/internal/backup/offbox_restore.go | `(fn func(ctx, stack) (id string, paths []string, err error))` INIT/TEST-ONLY | Overriding the restic snapshot lookup in tests | Exists because R-357's gate could not otherwise be tested at the level that matters: reaching it requires getting past `offboxLatestSnapshot`, which shells to restic. **The assertion the seam enables is `StopStack` call count == 0** — a gate placed after the stop returns the right sentence and still takes the outage. Nil in production |
|
||||
| `CheckPlacement` + `PlacementMismatchMessage` | controller/internal/backup/offbox_placement.go | `(*RecoveryManifest, liveDrive, liveNS) PlacementCheck` / `(stack, PlacementCheck) string` | Comparing where a backup SAYS the data lived against where a restore is about to write | Pure and total — nil/empty/blank manifest all give the same honest "not known, no mismatch". **An UNKNOWN is never a mismatch** (refusing on an absence strands every pre-field unit). Compares the DRIVE only (the namespace root is derived from it), Cleaned, so a trailing slash is not a difference. The message names BOTH values on purpose |
|
||||
| `RecordedUnitForStack` + `RecordedAddress` | controller/internal/backup/offbox_placement.go | `(stack) (RecordedPlacement, RecordedAddress, bool)` | Reading back the address + data folder a backup recorded, for a reinstall prefill | Local file reads over every readable namespace root — **no network, no restic, no restore**; it exists for the NOT-INSTALLED case where `GetStackHDDPath` is `""`. **`RecordedAddress.Known()` requires BOTH halves:** an absent `SUBDOMAIN` makes the live deploy path fall back to the CATALOG default (`stacks/deploy.go:88-90`), and offering that back as "what your backup says" is a fabricated fact |
|
||||
| **`Metadata.For(lang)` + `LocalizeStacks` / `LocalizeStackPtr` / `Stack.MetaFor` (R-560, v0.257.0)** | controller/internal/stacks/metadata_i18n.go | `(lang string) Metadata`; `([]Stack, lang) []Stack`; `(*Stack, lang) *Stack` | THE only way a page may reach catalog COPY — the app description, tagline, use cases, first steps, prerequisites, deploy-field labels and descriptions, optional-config text, integration labels, data-path labels and the initial-credentials note | Reading `stack.Meta.<copy>` off the manager renders HUNGARIAN to an English household, with no error and nothing wrong-looking on the page — `TestNoDirectMetaCopyReadOnPages` keeps the named, reasoned allow-list of every direct read in `internal/web`. **`For("hu")` is the parsed struct with `I18n` cleared** and nothing else (pinned over all 53 real catalog files), so a translation cannot change the Hungarian product. **Fallback is FIELD BY FIELD** — absent or blank English shows Hungarian, which is what makes a half-translated app shippable. **Lists replace WHOLE; every other list is matched by its own key** (`env_var`, option `value`, `match_group`, `target`, `path`) — position matching mistranslates silently the first time a field is inserted. **It never writes through:** the metadata is shared by concurrent requests, so an in-place merge leaks one household's language into another's page |
|
||||
| `AppInfo.WithAddress` (R-498) | controller/internal/stacks/appinfo_address.go | `(subdomain, domain string) AppInfo` | Filling the catalog copy's `<label>.DOMAIN` / bare `DOMAIN` placeholder with the household's real address (effective subdomain + customer domain) | Returns a COPY (the metadata is shared); `\bDOMAIN\b` leaves `SUBDOMAIN`/`MAIL_DOMAIN` alone; no domain → unchanged. Apply AFTER `LocalizeStackPtr`. The deploy page still fills its post-deploy steps in JS (`deploy.html`), `knownLoginLine` its own line |
|
||||
| `Metadata.HasDeployField` | controller/internal/stacks/metadata.go | `(envVar string) bool` | "Does this app have somewhere to PUT a recorded value?" | **13 of 53 templates declare `HDD_PATH`; 40 do not** (measured 2026-08-21). For the 40 a recorded placement is a FACT TO STATE, never a value to write into a field that does not exist |
|
||||
| `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention |
|
||||
| `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
|
||||
@@ -82,7 +113,6 @@
|
||||
| `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback |
|
||||
| `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) |
|
||||
| `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) |
|
||||
| `atomicWriteFile` | controller/internal/setup/handlers.go | `(path, data, perm) error` | setup-wizard writes | Same bind-mount fallback caveat |
|
||||
| `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand |
|
||||
| `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops |
|
||||
| `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
|
||||
@@ -94,7 +124,9 @@
|
||||
| `infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount` | controller/internal/infra/samba.go | consts | single source of truth for the samba container identity | the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (`felhom-samba`) is NOT the stack name (`samba`) — `EffectiveProtected` needs the container one |
|
||||
| `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 |
|
||||
| `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot |
|
||||
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE R-82 multi-tier backup schedule — several whole-guest tiers (local daily + PBS weekly) reconciled into ONE quiesce window | **Both tiers due ⇒ ONE stop/start pair**, never two (two = two app outages for one night). Tiers run SEQUENTIALLY (vzdump holds a guest lock) and the app stays down until the LAST tier snapshots — resuming earlier loses app-consistency on the DR tier. Order is fast-first (agent advertises primary first) or downtime blows up. `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
|
||||
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE multi-tier whole-guest backup schedule (local daily + PBS weekly) | **One stop per tier** (`09` §3 decision 156, v0.301.0 — REVERSES R-82's one window for both tiers): a window backs up only the FIRST due tier and resumes the apps at its `snapshotted`; another due tier waits for a later cycle. A tier that refuses to start (BUSY or an error) lets the next tier try in the same window. Order is agent order (primary first). `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
|
||||
| `quiesce.InFlightProber` + `Loop.anotherTierInFlight` · `agentapi.Client.BackupJobsInFlight` (R-921) | controller/internal/quiesce/precheck.go · controller/internal/agentapi/backup_tiers.go | `BackupJobsInFlight(ctx) ([]string, error)` | Check first, stop second: no app is stopped for a tier the agent will refuse because ANOTHER tier's job is in flight | OPTIONAL interface (fakes without it keep the old path). Fails toward backing up on any read error. **It cannot see the agent's host-wide heavy-op gate** (OS step, restore-test, fstrim — no endpoint serves it): that refusal is met by the BUSY path's immediate resume (`TestR921_BusyRefusalResumesAtOnce`). The window's OWN tier in flight is not skipped (the agent answers with the running job) |
|
||||
| `quiesce` whole-guest ledger — `Loop.pressOwesNight` / `recordWholeGuestSuccess`, `WithManualTrigger` / `IsManualTrigger` (R-899) | controller/internal/quiesce/nightowed.go | `pressOwesNight(led, target) bool`; `WithManualTrigger(ctx) ctx` | **A household press never cancels the night's whole-guest backup** (operator ruling 2026-10-08) | The ledger (`whole-guest-ledger.json` beside the marker) records SUCCESSES only, per tier, press vs scheduled — it decides due-ness and is never evidence that a copy exists (the agent's storage answers that). An „owed" tier never fires the window gate's safety valve (`withoutOwed`/`gateAge`). The press mark rides the context to the agent adapter (`agentapi.StartBackupForTrigger` → `trigger=manual`), so the agent runs no OS leg after a press. |
|
||||
| `quiesce.failureBreaker` + `Loop.dropBackedOffTiers` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `blocked/recordFailure/recordSuccess(target, now)`; `backoffFor(n) time.Duration` | **R-88** — a tier whose backups keep failing stops re-quiescing. Backoff 15m→30m→1h→2h→4h (cap), reset on success | **It gates the QUIESCE, not the backup** — the harm was never the failing backup, it was the app outage taken to attempt it, so backed-off tiers are dropped from the due set BEFORE any stack is stopped. **Per TARGET** — a broken offsite tier must never suppress a healthy local one (`TestBreaker_OneFailingTierDoesNotSuppressAHealthyOne`). **Never permanent** — the cap bounds the retry INTERVAL, it never stops retrying; a latched breaker is a silent backup outage, worse than the loop it replaces. **`TriggerNow` is never gated** (it already bypasses due-ness and the window gate), though a manual run still RECORDS its outcome. **`stillRunning` is NOT a failure** — a first full offsite snapshot legitimately runs for hours. State is **in-memory on purpose**: a restart forgets the backoff and re-attempts, which is the cheap direction to fail. Log the deferral ONCE when armed, never per tick. |
|
||||
| `quiesce.TierNotifier` + `Loop.SetTierNotifier` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `BackupFailed(tier,msg,err)` / `BackupRecovered(tier,msg)`; `SetTierNotifier(n)` INIT-ONLY | **R-97a** — the whole-guest backup tier reports its outcome to the hub | A **seam, not an import** — quiesce keeps no dependency on `internal/notify` (same reason `windowStartFn` is injected). Wired by a setter because main.go builds the notifier AFTER the loop; `nil` = unprovisioned guest, not an error. **Edge-triggered:** failure fires only when the breaker ARMS (`n == 1`), never per retry — the cadence is 15m/30m/1h/2h/4h and an event per attempt is an inbox nobody reads. Recovery rides `recordSuccess`'s existing bool. **Event types are OPERATOR-ONLY** (`whole_guest_backup_failed`/`_recovered`, hub >= v0.78.0) — NOT `backup_failed`, which has a customerMessages entry AND sits in live `enabled_events`, so it would email the CUSTOMER about a backup they cannot act on. `WholeGuestBackupDetails.Tier` is load-bearing: the hub keys its per-tier cooldown on it. |
|
||||
| `quiesce.Loop.SuppressedStacks` + `markQuiesced` / `markUnquiesced` | controller/internal/quiesce/suppress.go | `() map[string]bool` (nil-safe on a nil *Loop) | **R-97b** — an app THIS controller stopped for a backup is not a fault | Consumed at the SINGLE derivation point `classifyRunStates` (which computes both the banner dead-list and the notifier Down-set — keep it one place). **Cycle-keyed, not state-based:** v0.164.0's `!= StateStopped` filter cannot see an app caught MID-RESTART (`starting`/`unhealthy`), which is how BookStack alarmed on 2026-07-27. The window (`quiesceAlarmGrace` = 180 s, derived from the deploy flow's 120 s health timeout and Mealie's 60 s start_period) **EXPIRES** — permanent suppression turns a loud false alarm into a silent real one. Open-ended while the cycle runs (a first offsite snapshot legitimately takes hours). **This set alone is NOT the whole answer** — see `AppStopGuard.SuppressedStacks` (R-330) for the per-app operations; `classifyRunStates` consumes the union of both. |
|
||||
@@ -105,6 +137,10 @@
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure |
|
||||
| `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `<project>_<key>`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only |
|
||||
| `renderDigests` / `RenderWithLadderDigests` / `CarryDigests` / `StripDigest` (v0.269.0/.1) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update; the sync for an app NOT yet installed) — an INSTALLED app's sync uses `CarryDigests` (v0.269.1), which keeps the digest the app runs | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin |
|
||||
| `mergeRestoreFiles` / `RestoreTier2Whole` (v0.269.0) | controller/internal/backup/tier2_whole.go | mirror subtree → live subtree, four rules, counts | ANY restore that brings files back over live ones | Never `rsyncMirror` (--delete) in the restore direction; never overwrite a newer live file; a replaced file's old copy stays beside |
|
||||
| `nextLadderStep` / `StepKey` / `StepFile` (v0.268.0) | controller/internal/stacks/ladder.go | `(templateDir, pinned) (LadderStep, error)` | which definition ONE guarded-update press pins (`09` §3 decision 14) | `StepKey` must equal the catalog's `ladder.step_key` (TestLadder_StepKeyMatchesTheCatalog). A missing/wrong step file is an ERROR, never a jump |
|
||||
| `Manager.RedeployFromEnv` | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | Re-up with changed env (migration flip, config edits) | `compose up -d`, never `restart` (restart won't pick up images/env) |
|
||||
| `Manager.PersistUnitRedeployConfig` (R-47, v0.153.0) | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | the PERSIST half of `RedeployFromEnv` — app.yaml + locked fields + in-memory flags, **starts nothing** | **TRAP: the restore paths must use THIS, never `RedeployFromEnv`.** RedeployFromEnv ends in a full `up -d`, which before the replay IS the H4 race. RedeployFromEnv is now literally this + the unchanged up-and-report tail |
|
||||
| `Manager.StartStackServices` (R-47, v0.153.0) | controller/internal/stacks/manager.go | `(name string, services []string) error` | scoped `compose up -d <svc>...` — the DB-only window a dump is replayed in | **REFUSES an empty list** (argument-less `up -d` is a FULL start — the one silent fall-through that would reintroduce the race). No `logPostStartStatus`: the app containers are absent on purpose. Never `RestartStack` here — it is a full up in disguise |
|
||||
@@ -117,7 +153,7 @@
|
||||
| `backup.AppStopGuard` (`Begin`/`End`/`Recover`) (R-166, v0.189.0) | controller/internal/backup/appstop_marker.go | `(opID, reason, stacks) error` / `()` / `() *AppStopRecovery` | THE crash marker for stop→work→start windows (volume dump, offbox reconstitute, `.fab` export) | Its **own** file (`appstop-state.json`), never quiesce's — one file, one writer. **A `defer` is NOT the mechanism** (Campaign 8 fault 10: SIGKILL runs no defer); the marker is. Written BEFORE the stop, cleared ONLY after a restart that succeeded; a FAILED restart deliberately KEEPS it. `Recover` RETURNS its outcome rather than notifying, because it must complete before the boot reconciler while the notifier does not exist yet |
|
||||
| `backup.AppStopGuard.SuppressedStacks` + `markStopped` / `releaseStarted` / `ReleaseFailed` (R-330, v0.224.0) | controller/internal/backup/appstop_suppress.go | `() map[string]bool` (nil-safe on a nil *AppStopGuard); `ReleaseFailed(stacks ...string)` | **R-330** — an app a PER-APP operation is holding stopped (nightly volume dump, offbox reconstitute, `.fab` export) is not a fault | The **twin** of `quiesce.Loop.SuppressedStacks` above, and the two are unioned by `unionSuppressed` in main.go before `classifyRunStates` — **consult BOTH or the bug comes back**: R-330 shipped because the alarm read only the quiesce set while the per-app legs stopped apps through a different path. Rides `Begin`/`End`, so all three call sites got it with no call-site change. Grace is `appStopAlarmGrace` = 180 s, deliberately the SAME constant and derivation as quiesce's — two windows over one alarm that disagreed would be a bug on whichever path used the shorter one. **It must never latch**, and unlike quiesce's loop `End()` runs ONLY on a restart that succeeded: (1) every failure path calls `ReleaseFailed`, which drops the entry IMMEDIATELY so the app alarms on the next scan; (2) `Begin` REPLACES the set (one marker file = one operation); (3) `appStopMaxHold` (6 h) caps an open-ended hold and logs at WARN. **Deliberately NOT persisted** — after a crash the guard holds nothing and a down app must alarm. `ReleaseFailed` drops the suppression and KEEPS the durable marker; the two are independent and a test pins that |
|
||||
| `backup.ErrStartRefused` + `AppStopRecovery.Refused`/`Alarming()` (R-174, v0.191.0) | controller/internal/backup/appstop_marker.go | `errors.Is(err, ErrStartRefused)` / `() bool` | THE refusal-vs-failure split in the app-stop crash recovery | **A gated starter's refusal is NOT a restart failure.** `Recover`'s starter MUST be the gated `gatedAppStopStarter` (cmd/controller/main.go), never the raw `stacks.Manager` — that was the v0.189.0 defect, which started apps onto ABSENT drives at boot (R-171 one path over). A refusal goes to `Refused` (marker KEPT, silent), a real error to `Failed` (marker kept, ALARMS). Collapsing them routes a deliberate hold into `NotifyBackupFailed`, a customer-enabled type — the R-171 false alarm again. `main.go` must guard the notify with `Alarming()`, not `!= nil` |
|
||||
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never `cfg.Paths.HDDPath`; a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** |
|
||||
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never a global (`cfg.Paths.HDDPath` was deleted in R-492); a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** |
|
||||
| `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix |
|
||||
| `Manager.recordInstalledImages` (v0.233.0) | controller/internal/stacks/installed.go | `(name, stackDir string, env []string)` | writing down what each compose SERVICE is ACTUALLY running, into `app.yaml.installed_images` | Called after a successful compose up from `StartStack`/`RestartStack`/`UpdateStack`/`runComposeDeploy`. **Reads the CONTAINER, never `docker-compose.yml`** — that file is the value the syncer has already moved (spike §3: 25 minutes of disagreement). **A failed write NEVER refuses the action** — the deliberate OPPOSITE of `SetDesiredState`: intent refused, observation logged at ERROR. **NOT from `StartStackServices`** (the R-47 DB-only window would overwrite a complete record with a partial one). Skips the write when ref+digest are unchanged, and carries `at` forward so it means "running since". Its OWN seam (`installedExecFn`) with a **context + 30 s timeout** — the two existing exec helpers have neither |
|
||||
| `Manager.SetPin` / `AdoptPins` / `RenderPlanFor` / `AppliedComposePath` (v0.235.0) | controller/internal/stacks/pin.go | `SetPin(name, stackDir, pin, composeSrc) error` | THE version freeze — `app.yaml.pinned_images` + the stored `applied-compose.yml` | **`PinnedImages` is INTENT, `InstalledImages` is an OBSERVATION — never feed one from the other** (the R-166 category error, one field over). Four writers only: deploy, the guarded update (via `advancePinToCatalog`, which advances the pin and re-renders BEFORE the pull, and REFUSES the update if the pin cannot be written; a failed pull puts the pin BACK via `SetPin`), the restore adapter (this is what closes R-441), and `AdoptPins`. `AdoptPins` reuses `observationCoversTemplate` — do NOT write a second completeness rule — and skips loudly rather than inventing a pin. Absent pin = pre-v0.235.0 behaviour |
|
||||
@@ -127,13 +163,15 @@
|
||||
| `backup.Manager.UpdateRestorePoints` + `CanBackUpApp` (v0.239.0, R-475) | controller/internal/backup/update_guard.go | `(ctx, stack, accept func(UpdateTierPoint) bool) (UpdateTierPoint, bool, []UpdateTierPoint)` | "which backup can this update lean on" — walks Tier 2, 1, 3 and returns the first copy `accept` admits | **The age rule lives in the caller's `accept`** (stacks' `freshRestorePoint`), so it is ONE rule for every tier. **The ORDER is `UpdateTierOrderFor` (v0.241.0, R-479): 2 → 3 → 1 for an app with classified binds (`DataOutsideUnit`), 2 → 1 → 3 otherwise; `UpdateCopyHolds` is the matching phrase for the hold.** Stops at the first accepted copy, so Tier 3 (restic, 15 s bound, unreachable = absent + WARN) is reached only when needed. Seams: `updateTier2PointFn`, `updateTier1PointsFn`, `updateOffsiteTimesFn` (v0.240.0: Tier 3 reads `OffsiteSnapshotTimes` — snapshots only, never the inventory's per-app `stats`). stacks adds R-478's rule: a copy older than `deployed_at` does not count (`usableRestorePoint`). Tier numbers are pinned equal across stacks/backup by `TestR475_TierConstantsAgree` |
|
||||
| `backup.Manager.Tier2MirrorDirsForApp` / `RemoveTier2Mirrors` + `settings.DeleteAppBackupPrefs` (v0.240.0, R-474 / R-486) | controller/internal/backup/r474_remove_mirrors.go | `(stack) []string`; `(stack, dirs) []string` | deleting an app's backups on removal — the Tier-2 mirror lives on ANOTHER drive, outside RemoveStack's per-app base; the same dirs size the backup card (R-485) | Read the mirror dirs BEFORE the prefs are forgotten. Deletes only `<root>/backups/secondary/<stack>` for a known root; never `_shares`, never a path that merely cleans to it. **The Tier-2 RECORD goes only with `remove_backups`** (R-486) — a removal that keeps the backups must keep the record, or the mirror is unrestorable. Pinned by `TestR474_RemoveHandlerDeletesUnitMirrorAndPrefs` + `TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo` |
|
||||
| `appbackup.dbTypeForImage` (R-484, v0.240.0) | controller/internal/appbackup/dbservices.go | `(image) (DBType, bool)` | the ONE place an image is judged a database — nightly dumps, pre-update safety dump, DB-only replay | Derived Postgres images (`postgis`, `pgvector`, `timescaledb`) are Postgres. A new engine image goes HERE and in `dbservices_test.go`'s table, never in a second matcher |
|
||||
| `backup.Manager.HoldAfterFailedUpdate` / `RunAppBackupNow` / `WriteUpdateSafetyDump` / `UpdateBusy` (v0.237.0) | controller/internal/backup/update_guard.go | see file | the update's hold, per-app backup-now, safety dump, busy check | The hold is `settings.RestoreHold` with `Reason: update_failed` — SAME store and gate as R-379, never a second map. `RunAppBackupNow` composes the nightly legs for ONE app (admission, DB dump, volume dump, capture, Tier-2) — do not write a second backup orchestration. A successful unit restore lifts an UPDATE hold only. **`isHeld` is ALSO true while a guarded update is moving the app (`SetUpdatingCheck`, v0.238.1)** — found live: the periodic capture overwrote a primary unit during a health wait |
|
||||
| `backup.Manager.HoldAfterFailedUpdate` / `RunAppBackupNow` / `WriteUpdateSafetyDump` / `UpdateBusy` (v0.237.0) | controller/internal/backup/update_guard.go | see file | the update's hold, per-app backup-now, safety dump, busy check | The hold is `settings.RestoreHold` with `Reason: update_failed` — SAME store and gate as R-379, never a second map. `RunAppBackupNow` composes the nightly legs for ONE app (admission, DB dump, volume dump, capture, Tier-2) — do not write a second backup orchestration. A successful unit restore lifts an UPDATE hold only. **`isHeld` is ALSO true while a guarded update is moving the app (`SetUpdatingCheck`, v0.238.1)** — found live: the periodic capture overwrote a primary unit during a health wait. **R-645: every night leg (DB dump, volume dump, capture, Tier-2) also skips an app whose pin is not what it runs (`versionSkip`, wired by `SetVersionCheck` → `stacks.Manager.PinNotRunning`); unknown never skips. The page line is `VersionSkipFor`** |
|
||||
| `stacks.Manager.memoryVerdict` (v0.237.0) | controller/internal/stacks/deploy.go | `(newReq, newLimit, releasedReq, releasedLimit int) (refusal, warning string)` | the deploy's memory check, shared with the update | An update RELEASES the app's current request first. Deploy passes `0, 0` and is byte-identical in wording and log line |
|
||||
| `Syncer.SetRenderPlanFn` + `renderSource` (v0.235.0) | controller/internal/sync/sync.go | `func(appName string) stacks.RenderPlan` | the catalog render table | **NIL-SAFE: no seam = copy verbatim = the old product.** Catalog images == pin → verbatim (fixes flow + self-healing, both deliberately kept); differ → the WHOLE stored definition, **never a ref substitution into a newer template** (`wger 2.6`). `.felhom.yml` always verbatim (R-458). The syncer must NEVER read app.yaml. Re-reads the applied file before writing it — a test caught it writing an empty compose over a live app |
|
||||
| `Stack.CatalogImages` vs `Stack.TemplateImages` (v0.235.0) | controller/internal/stacks/manager.go | both `map[string]string` | badge input vs "what the next `up -d` gives this app" | **THE TRAP: same type, same shape, opposite meaning after the freeze.** `TemplateImages` reads the LIVE (possibly frozen) compose file; `CatalogImages` reads the syncer's clone. `web.compareInstalledToTemplate` MUST use `CatalogImages` or it answers „Naprakész" on exactly the apps that are behind, with every test green. Red-proved |
|
||||
| `Manager.BackfillInstalledImages` (v0.234.0) | controller/internal/stacks/installed.go | `() int` | seeding `installed_images` for apps that have NO record — call ONCE at startup | Beside `BackfillDesiredState` in `cmd/controller/main.go`, after it and BEFORE the boot reconciler (pinned by an AST-walking test that asserts the ORDER). **READS only** — starts nothing, writes no compose file. **Never overwrites an existing record** (an app that has one is not even observed). **REFUSES a partial observation** (`observationCoversTemplate`): `web.compareInstalledToTemplate` reads a service-count mismatch as BEHIND, so seeding a degraded app from what is visible renders „Frissítés elérhető" over an app that is current. The bring-up paths may write a partial because they follow a SUCCESSFUL `up -d` where a gap is real news; a backfill meets any state and must be stricter |
|
||||
| `stacks.ParseComposeImages` (v0.233.0) | controller/internal/stacks/installed.go | `(composePath string) (map[string]string, error)` | compose SERVICE name -> the image the FILE pins; feeds `Stack.TemplateImages` and the update badge | yaml.v3 `services:` MAP parse, never a line scan (same reason as `DBServiceNames`). An error means CANNOT-TELL — `ScanStacks` leaves `TemplateImages` nil and the badge renders NOTHING, never "current" |
|
||||
| `web.updateBadge` / `updateBadgeAt` / `Metadata.CatalogSince` + `CatalogSinceAge` (v0.233.0) | controller/internal/web/updatebadge.go, controller/internal/stacks/metadata.go | `(stacks.Stack) *MetaBadge` | THE "is this app current?" label — „Naprakész" / „Frissítés elérhető — N napja" | The SECOND `*MetaBadge` user the type was built for: existing `meta_badge` partial, **no new markup or CSS**. **NO RECORD RENDERS NOTHING — absent means UNKNOWN, never current** (R-166 applied to an observation; red-proved). **No version number reaches the customer** and **no registry is queried**. `catalog_since` is tolerant in the `lifecycle` style — absent/empty/malformed/**future** all degrade to a badge with no age + one WARN. LIMITATION: for the 23 floating pins the ref can match while the image has moved, so those read „Naprakész" when they may not be |
|
||||
| `stacks.Manager.AnyUpdating` / `SetSelfUpdatingCheck` + `selfupdate.Updater.SetAppUpdatingCheck` (v0.261.0, R-608) | controller/internal/stacks/update.go, internal/selfupdate/updater.go, wired in cmd/controller/main.go | `() bool` callbacks, both directions | THE two-way lock between the CONTROLLER's own swap and a guarded APP update | **Wire BOTH halves or neither** — they are wired together in `main.go`, the only place holding both objects; **`stacks` must never import `selfupdate`**. The app-update side is a sibling of `SetBackupRunningCheck` and is consulted in the SAME three places (dry run, `TriggerUpdate`, `maybeAutoUpdate`) — do not add a fourth pattern. **`AnyUpdating` MUST answer false for a HELD app** (`Stack.Updating` is cleared on done/failed/held): a latching gate would block the controller's own updates for ever, including the one that fixes the hold. Nil callbacks are SAFE and mean pre-v0.261.0 behaviour — never fail closed on an unwired gate |
|
||||
| `stacks.CatalogOrder` / `CompareImageRefs` (v0.260.0, R-524) | controller/internal/stacks/updateorder.go | `(Stack) UpdateOrder` — Unknown/Current/Behind/**Ahead** | THE one "how does this app stand against the catalog?" verdict | **Both the badge AND `Manager.UpdatePreflight`'s `downgrade` refusal read it — never re-implement the comparison.** `web.compareInstalledToTemplate` is a thin wrapper. Ahead is NARROW: every differing service must be orderable AND newer, else Behind. Ordering is `util.Version.Compare` behind a tag normaliser (`X.Y`/`X.Y.Z`, optional `v`, suffix must be IDENTICAL on both sides) — **never add a second comparator**. Queries NO registry; absent record = Unknown, never „Naprakész" |
|
||||
| `web.updateBadge` / `updateBadgeAt` / `Metadata.CatalogSince` + `CatalogSinceAge` (v0.233.0) | controller/internal/web/updatebadge.go, controller/internal/stacks/metadata.go | `(stacks.Stack) *MetaBadge` | THE "is this app current?" label — „Naprakész" / „Frissítés elérhető — N napja" | The SECOND `*MetaBadge` user the type was built for: existing `meta_badge` partial, **no new markup or CSS**. **NO RECORD RENDERS NOTHING — absent means UNKNOWN, never current** (R-166 applied to an observation; red-proved). **No version number reaches the customer** and **no registry is queried**. `catalog_since` is tolerant in the `lifecycle` style — absent/empty/malformed/**future** all degrade to a badge with no age + one WARN. LIMITATION: for the **10** floating pins (recounted 2026-09-21; the old "23" matched no definition the catalog supports) the ref can match while the image has moved — 6 of the 7 measurable ones HAVE moved — so those read „Naprakész" when they may not be |
|
||||
| `Manager.logPostStartStatus` | controller/internal/stacks/manager.go | `(name, stackDir, env)` | Async post-start verification | compose up exits 0 on crash-loops; this is the detection. Goroutine + 3s, never blocks |
|
||||
| `Manager.EnsureBaseStack` | controller/internal/stacks/infra.go | `() error` | Traefik/cloudflared/FileBrowser infra convergence | Renders from `internal/infra` templates |
|
||||
| `appbackup.ClassifyBinds` / `ValidateBackupSpec` | controller/internal/appbackup/classify.go | `(spec, binds) ([]ClassifiedBind, bool)` / `(spec, binds) error` | Backup-classification (Task 2, referential coupling) — pure | Two-level default: explicit wins over `:ro`; unlisted writable→mandatory, unlisted `:ro`→excluded; nil spec→legacy/false. Validate REJECTS the WHOLE block on any defect (whole-block semantics). INERT — no tier consumes it yet |
|
||||
@@ -142,9 +180,13 @@
|
||||
| `Manager.ClassifiedBinds` + `StackDataProvider.GetStackClassifiedBinds` | controller/internal/stacks/metadata.go, appbackup/appdata.go | `(name) ([]appbackup.ClassifiedBind, bool)` | Per-stack classification through the REAL LoadMetadata validate path | The wired seam Task 3 consumes; LoadMetadata is the SINGLE validation choke point (bad block → nil + one ERROR → legacy) |
|
||||
| `backup.Manager.DumpAppVolumesSafe` | controller/internal/backup/backup.go | `(stackName) error` | Volume tar of a live app | Stops → dumps → restarts; surfaces BOTH errors (app may be left stopped). Check `GetDockerVolumes()!=0` + `IsProtectedStack` BEFORE calling — it stops the stack before its own volume check (see `runVolumeDumps`) |
|
||||
| `backup.Manager.ListRestorePoints` | controller/internal/backup/restore_points.go | `(stackName) ([]RestorePoint, bool)` | Restorable keep-side backups (the /api/backup/snapshots payload) | ONE point per app (the current unit); tier always 1 — never list Tier-2 (not restorable via /backup/restore) |
|
||||
| `backup.Manager.stampDataFile` / `foldUnitData` / `unitNewestArtifact` / `RecoveryManifest.Data` (v0.275.0, R-696) | controller/internal/backup/data_versions.go, removed_units.go | `(stack, unitDir, rel)` / … / `(unitDir) (time.Time, bool)` | A backup's data and its version travel together: the leg stamps each file it writes, the capture folds the stamps into `data` and keeps the definition the data belongs to; `unitNewestArtifact` is THE unit data time for every tier and list | **A new writer of a unit's data file MUST call `stampDataFile` right after the file reaches its final name** — an unstamped file makes the whole unit's versions unknown (restores as before, WARN). Never date a unit by its manifest; never count `pre-restore-*` as data. `definitionPins` is the ONE source of "current pins" (the stack's compose parse) |
|
||||
| `backup.unitVersionCheck` / `ErrUnitVersionMismatch` (v0.275.0) | controller/internal/backup/restore_unit.go | `(stack, *RecoveryManifest, composeDir) ([]string, error)` | A restore never starts data with a definition it does not belong to — the check every unit restore and the off-site restore make before the first mutation | Branch with `errors.Is`; the sentence is the bundle's (`err.backup.unit_version_mismatch` / `_versions_mixed`). Unknown (`nil, nil`) restores as before |
|
||||
| `backup.Manager.RestoreTier2Files` | controller/internal/backup/tier2_restore.go | `(stackName) (filesRestored int, err error)` | In-place ADDITIVE-ONLY class-C file restore from the recorded Tier-2 copy (`POST /backup/tier2/restore`) | Never overwrites/deletes live files; refusals (Hungarian) before any stop; source = recorded `DestinationPath`, never re-selected. **C9-F1 (v0.183.0): reads `hdd/` + `userdata/` ONLY — never `recovery-unit/`.** For 43 of 53 catalog apps that is a guaranteed no-op, so it now refuses with `ErrTier2NoRestorableData` BEFORE stopping the app. Ask `Tier2RestoreCoverage` first |
|
||||
| `backup.Manager.Tier2RestoreCoverage` | controller/internal/backup/tier2_restore.go | `(stackName) (Tier2Coverage{Legs, HasUnit}, error)` | Answers what a Tier-2 restore CAN and CANNOT return for an app, from the RECORDED copy on disk | **C9-F1.** `Legs` = subtrees the restore reads; `HasUnit` = the copy also holds DB dumps + volume tarballs it will NEVER read. Use it to refuse up front and to decide whether the success message must disclose uncovered data. Judged from the copy, not the catalog, so a retemplated app is judged by what it actually has |
|
||||
| `Manager.acquireRunning`/`releaseRunning`, `acquireMigrating` | controller/internal/backup/backup.go, controller/internal/stacks/migrate.go | `() error` | Single-flight for long ops | Copy this mutex-flag pattern for any new long-running manager op |
|
||||
| `stacks.Manager.WantsStopped` (v0.283.0, R-721) | controller/internal/stacks/desiredstate.go | `(name) bool` | The household's recorded intent is „stopped" | ASK IT before any machine restarts an app it stopped for its own purpose (quiesce, volume dump, update leg) — a Stop pressed meanwhile must hold. Never gate the household's own Start on it |
|
||||
| `settings.DefaultOffboxOnForNewApp` / `AppsWithoutOffbox` / `EnableOffboxFor` (v0.283.0, decision 50) | controller/internal/settings/offbox_default.go | `(stack) (bool, why)` | A fresh install joins the off-site copy when the customer has off-site | Call only from the deploy-done hook; it never overrides an existing per-app choice |
|
||||
|
||||
### Secrets hygiene
|
||||
|
||||
@@ -162,6 +204,12 @@
|
||||
| `maskRepoURL` | controller/internal/sync/sync.go | `(url) string` | Logging git URLs | Strips embedded credentials |
|
||||
| `metrics.RedactLine` | controller/internal/metrics/redact.go | `(s string) string` | ANY log line shipped off-box (issue context, log tails) | Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative |
|
||||
| `settingsRetrievalPasswordRevealHandler` | controller/internal/web/handlers.go | `POST /settings/retrieval-password/reveal` | **THE PATTERN for showing a secret in the UI** — an XHR that returns only the value | **Never template a secret into a page and hide it with CSS.** `display:none` / `hidden` / `type="password"` stop a browser DRAWING the value; the plaintext is still in the response body, so a `curl` of the page returns it, and it reaches caches, history and any screen-share of the source. R-249 shipped exactly that for two months and was found by it landing in a transcript. The page carries a **boolean** (`HasRetrievalPassword`); the value comes from a POST (CSRF-covered, uncacheable) and the reveal is **logged as an act**. `escrow_handlers.go` states the same rule for R. **Test on the RESPONSE BODY** — a test asserting what the customer *sees* cannot see this class at all. **Both R-254 sites are now FIXED the same way** — `POST /apps/<slug>/initial-credentials/reveal` (re-reads the container, never a cached copy) and `POST /stacks/<name>/auto-field/reveal` (authorised on the field being a `type: secret` auto-field of that stack). **Per-secret, never one generic reveal-any-named-secret endpoint.** The PRE-DEPLOY hidden input is deliberate and untouched — a form must carry what it submits (README §318). Enforced by `scripts/secret_in_markup_gate.py`, whose measured blind spot (a secret under a neutral page-data key) is in its docstring; runtime body-assertion covers 4 of 27 pages — R-255. |
|
||||
| `storedValuePresent` / `appLoginFields` | controller/internal/web/app_login.go | the settings tab's login panel and „Cím és kulcsok" rows (v0.319.0) | **A field the box holds no value for is a sentence, never an eye.** `storedValuePresent` is the ONE definition of „stored", read by the page AND by `appAutoFieldRevealHandler`'s 404 branch — so the page cannot offer a reveal the endpoint refuses. The page gets a boolean per login field, never the value (R-254/R-709). Pinned by `TestAppPage_NoStoredValueShowsSentence`. |
|
||||
| `loginAnswerFor` | controller/internal/web/app_login.go | the settings tab's ONE login answer (v0.320.0) | `fields` · `initial` · the catalog's `app_info.login_kind` via `AppInfo.EffectiveLoginKind` (`first_visit` default; an unknown value reads as it). An infra stack with no login gets no panel. Pinned by `TestAppPage_EveryInstalledAppHasOneLoginAnswer`. |
|
||||
| `filesButtonLink` / `mediaFolderCards` / `drivePresent` | controller/internal/web/datapath_card.go | the „Fájlok ↗" target and the media folder rows (v0.320.0) | **A folder whose drive is not bound into FileBrowser now is TEXT, never a link** — `drivePresent` reads `stacks.PresentMediaDrives` (the same gates `buildFileBrowserPaths` applies); tests seam it with `Server.presentDrivesFn`. Links only through `fileBrowserLink`. Pinned by `TestAppPage_FilesButtonTarget`, `_UnknownSourceIsTextNotLink`. |
|
||||
| `infraMetaIn` | controller/internal/web/inframeta.go | an infra stack's curated name/description in a language | The ONE place for both the `infraMeta` template func and the app page. |
|
||||
| `Manager.FileBrowserStoredPasswordRefused` | controller/internal/stacks/filebrowser_password.go | is the stored FileBrowser password still valid (v0.321.0, R-1101) | TRUE only on FileBrowser's own 401/403 in the `generated` state; FileBrowser not answering is FALSE (an unproven refusal must not tell the household its password changed). 3 s bound. Web seam: `Server.fbStoredRefused`. |
|
||||
| `Manager.ResetFileBrowserAdminPassword` | controller/internal/stacks/filebrowser_password.go | FileBrowser's admin password without the old one (v0.320.0) | Only in `operator`; holds `infraMu` (the base tick must not start the stopped server), and the web handler holds `fileBrowserMu` (the mount sync must not recreate it). Stop → `docker run --rm --volumes-from filebrowser … set -u admin,<pw>` → start (detached from the request's cancel) → verify → store encrypted. TRAP: the CLI opens the DB with no lock timeout — never run it against a running server. Seam: `fbSetPassword`. |
|
||||
|
||||
### Storage registry + mount detection
|
||||
|
||||
@@ -172,6 +220,10 @@
|
||||
| `registerStoragePath` | controller/internal/web/storage_handlers.go | `(where, label, setDefault) error` | Post-enroll registration | The single funnel used by init/attach/manual-add |
|
||||
| `system.IsMountPoint` / `IsWritable` / `PathsOverlap` | controller/internal/system/mounts_linux.go | `(path) bool` | Mount checks | `_other.go` stubs return permissive values — Linux behavior is the real one |
|
||||
| `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) |
|
||||
| `backup.Manager.diskRelation` | controller/internal/backup/backup.go | `(a, b) (DiskRelation, reason)` — DiskDifferent / DiskSame / DiskUnknown | EVERY Tier-2 or one-copy "same disk?" decision (v0.317.0, felhom.eu `16-storage-physical-identity.md` §4) | Asks the host agent (`SetDiskJudge`, FeatureSameDisk ≥ agent 0.157.0); an older agent or an `unmapped` path → the old `sameDevice`/st_dev rule. Never call `sameDevice` directly for a Tier-2 decision — st_dev calls two partitions of one disk "different". Pinned by diskrelation_test.go |
|
||||
| `web.Server.ensureInternalPlace` / `refuseInternalLifecycle` | controller/internal/web/internal_place.go | drive-gate tick hook; `(w, r, where) bool` | the internal data place (v0.318.0, `16` §5): bind on one tick, register (kind internal, default when none) on the next; refuse eject/decommission/wipe | Only an agent row of kind `internal` is enrolled without a press — a household drive never is. Pinned by internal_place_test.go + stacks d4_needs_drive_test.go (a media app lands on it) |
|
||||
| `web.Server.offerRelationFor` | controller/internal/web/backup_target_offer.go | `(ctx, drive) offerRelation` (sameDiskFn seam) | the whole-box backup offer's disk question (R-1084) | Older agent → today's offer; an error → no promise. Pinned by r1084_offer_identity_test.go |
|
||||
| `web.withoutSystemVolume` / `deviceBacksRegistered` / `freePartitions` | controller/internal/web/attach_sources.go, agent_disk_handlers.go | pure filters | R-1082 (sys_drive never offered, by device) and partitions (`16` §6: free parts of an in-use disk stay, the whole disk never; server-side refusal) | Pinned by r1082_sysdrive_hidden_test.go + partitions_test.go |
|
||||
| `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — |
|
||||
| `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var |
|
||||
| `stacks.ExportDataMounts` | controller/internal/stacks/delete.go | `(composePath, hddPath) []string` | THE .fab-export mount discovery (v0.130.0 C6B-F1) | Unions `${HDD_PATH}` binds + the `${USERDATA_PATH}` ROOT (single `userdata` entry — basename must round-trip the import's `<HDD_PATH>/<subdir>` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it |
|
||||
@@ -205,26 +257,33 @@
|
||||
|---|---|---|---|---|
|
||||
| `Notifier.PushEvent` | controller/internal/notify/notifier.go | `(eventType, severity, message, details)` | Hub events | Async goroutine, 3 attempts/3s backoff. NEW event types MUST be added to hub `allowedEventTypes` or POST /event 400s. **SEVERITY IS AN EXACT WIRE CONTRACT: `{"info","warning","error","critical"}` and nothing else.** The hub silently COERCES any other string to `"info"` (`hub/internal/api/handler.go`, the ingest severity switch) and `severityNotifies` (`hub/internal/notify/dispatcher.go`) emails only warning/error/critical — so a typo'd severity is stored and delivered to NOBODY, with no error anywhere. **`"warn"` is not a severity.** It shipped on `disk_health_degraded` (fixed v0.215.0, R-328) and is STILL live on `app_start_failed` (R-329) |
|
||||
| `notify.DiskAlert` / `DiskAlertKind` / `DiskAlertKind.Severity()` | controller/internal/notify/notifier.go | `NotifyDiskHealthDegraded(DiskAlert)` | The disk-health alert payload + its five Hungarian message shapes | The notifier owns customer copy — pass a `DiskAlert`, never a pre-formatted string, or Hungarian scatters across packages. `Severity()` is the ONE mapping kind→hub severity and is exported so any package can assert the contract instead of duplicating the literal |
|
||||
| `Notifier.PushEventHousehold` (v0.309.0, R-521 F7) | controller/internal/notify/notifier.go | `(eventType, severity, message, household, details)` | A producer that renders BOTH languages itself because its sentence carries figures it computed (fillwatch) | Prefer `pushEventMsg` (one key, both renderings) inside notify; this is for a sentence built outside. `household` "" for a Hungarian household. Registered in the R-329 severity contract (`severityArg`) |
|
||||
| `Notifier.Notify*` convenience methods | controller/internal/notify/notifier.go | typed wrappers (backup/DB/storage/channel/DR…) | Standard events | Add a typed wrapper rather than raw PushEvent calls |
|
||||
| `report.BuildReport` / `Pusher.Push` | controller/internal/report/builder.go + pusher.go | periodic hub report | Box→hub reporting | ACK carries `config_version` → `ConfigRefresher.Reconcile` |
|
||||
| `report.Trigger` (`NewTrigger`/`Fire`/`Run`) | controller/internal/report/trigger.go | `Fire()` after a hub-relevant user action | THE out-of-cycle report push (v0.139.0) — fire via `api.Router.reportPushNow` / `web.Server.reportTriggerNow`, both nil-safe | Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the `internal/sync` REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit |
|
||||
| `report.SetPendingLogTails` + `buildLogTailsSection` | controller/internal/report/logtail.go | ACK `log_tail_requests` → next report `log_tails` | THE pull-based ACK-flag pattern (hub asks, controller pushes next cycle) — copy for any new hub→box request | Consume-once drain at BuildReport; failed push re-arms from the hub's still-pending request; NEVER add a hub→controller push channel |
|
||||
| `metrics.FetchContainerLogTail` | controller/internal/metrics/logscanner.go | `(name, tailLines) (string, error)` | Raw per-container `docker logs --tail=N` | 15s timeout; caller caps/redacts (capTailLines) |
|
||||
| `ConfigRefresher.Reconcile` | controller/internal/report/config_refresh.go | `(ackVersion int)` | Pull-based config refresh | Re-pulls controller.yaml (re-merging local_api), then graceful self-restart; first-run = baseline, no restart |
|
||||
| `offsiteapply.HubRegistrar` / `HubWindowClient` / `PinnedProber` (v0.289.0, decisions 68–69) | controller/internal/offsiteapply/seams.go | `Register(ctx,pub)(fp,err)` · `Confirm` · `MoveAside` · `Open/Close` window · `Probe(ctx,host,user,port,kh,privPEM) bool` | EVERY off-site key install, the hub move-aside, the clean-up window | **The box never handles the sub-account password** — there is no consume path any more. `PinnedProber` is a POSITIVE observable (exit 0 + rclone output); "authenticates" is NOT enough — an unpinned key authenticates and can delete. |
|
||||
| `Manager.offsiteWindowRetention` + `offsiteGuard` (v0.289.0, R-822) | controller/internal/backup/offbox_window.go | `(ctx, base, env, why)` · pure `(all, plan, now, newestAllowed, max) (ids, refuse)` | THE retention step for both callers (after a run, over quota) | Pinned tier: no window → nothing deleted; the guard runs BEFORE any forget; "young" = fewer than `keepDaily` CALENDAR days old (v0.294.0, R-867 — the line is built from the same constants as `retentionPolicy`; never an hour count); a young snapshot superseded the same day is EXCLUDED, any other young removal / future date / plan above `max_remove` REFUSES; tests run restic 0.14.0's policy itself (`restic0140Plan`, proven identical to the binary); forget is by explicit ids, oldest first. A due abandonment goes to `OffsiteAbandonClient` (hub, 7-day wait). NAS tier: the old SP-2 policy, unchanged. |
|
||||
| `offsiteapply.SettleProvider` / `SettleFunc` / `Bridge.AwaitSettle` / `ReconcileWhenSettled` (R-71a, v0.162.0) | controller/internal/offsiteapply/offsiteapply.go + seams.go | `SettleState() (version, floor string, updateRunning, floorKnown bool)` | THE settle-gate: defers the offsite one-time-password consume past a managed day-0 floor-update (the F10 race). Wire the `SettleFunc` adapter over `updater.GetFloor()`/`IsUpdateRunning()` — **the updater's knowledge is the ONE floor source; never fetch the floor a second way**. Gate ONLY the bridge goroutine, and only when an updater exists (nil `Settle` = reconcile immediately). Bounds `settlePoll`/`settleFloorSubBound`/`settleOverallBound`; the floor is in-memory (report-ACK-derived, ~5–10 s), NOT persisted → unknown until the first ACK on any restart. Inject `Now`/`Sleep` in tests (no real sleeps). B′: at/above-floor GOes on the first poll, zero wait. Do NOT touch the consume/persist order or the 404 contract — ordering only |
|
||||
| `bootstrap.MaybeIngest` / `RefreshConfig` | controller/internal/bootstrap/bootstrap.go | bootstrap.json → controller.yaml | Day-0 + refresh | Overwrites controller.yaml, NEVER settings.json |
|
||||
| `api.GracefulSelfRestart` | controller/internal/api/selfrestart.go | `(logger)` | Controller self-restart | Detached exit; bootstrap unit re-runs the image |
|
||||
| `settings.NotificationGroups` + `NotificationGroupState` / `NotificationPrefs.GroupState` (R-388 slice 1, v0.309.0) | controller/internal/settings/notif_groups.go | `(enabled []string, key) "on"\|"off"\|"custom"` | THE `08` §8.1 group membership of every customer-switchable detector — the notification page's three switches | A group is a VIEW over `enabled_events`, never a stored field. A new detector with a page switch MUST join exactly one group (`TestR388_EveryPageSwitchIsInExactlyOneGroup` fails otherwise). The save rule lives in `Server.applyNotificationGroupSwitches`: only a MOVED group switch overrides the per-detector switches — an untouched „egyedi" group is saved back unchanged |
|
||||
| `Settings.AddPendingEvent/DrainPendingEvents` | controller/internal/settings/settings.go | offline event queue | Events while hub unreachable | — |
|
||||
| `Manager.SetUnitNotify` + `UnitSpace` (R-158/R-167, v0.191.0) | controller/internal/backup/recovery_unit.go | `(func(stack string, err error, *UnitSpace))` | THE per-app Tier-1 recovery-unit capture failure alert — fires PER APP from `captureAllRecoveryUnits`, loop continues | **OPERATOR-TIER** (`recovery_unit_capture_failed`, in the hub's `operatorOnlyEvents`). **NEVER route it to `backup_failed`** — that type is in `DefaultEnabledEvents` and carries Hungarian copy, so it emails the CUSTOMER about a failure they cannot act on (D-c; R-158's own proposal said `backup_failed` and D-c overrides it). `UnitSpace` is **nil when the target filesystem is unreadable** and renders as *"unavailable"*, never as zeros — "0 GB free" and "we could not look" are opposite diagnoses. No controller-side cooldown: the hub owns it |
|
||||
| `Manager.beginRunSummary` / `noteFailure` / `noteAttempted` / `emitRunSummary` / `SetRunSummaryNotify` (R-182, v0.194.0) | controller/internal/backup/runsummary.go | `(kind, runID) func()` / `(app, leg, reason)` / `(RunSummary)` | **THE per-run operator digest.** One `backup_run_failures` event at the end of a run listing every failed app, its leg and its reason — emitted ONLY when something failed | **The RECORD and the NOTIFICATION are different things and must stay so.** The per-app `recovery_unit_capture_failed` event is the record (hub routes it *record-only*, stored + logged every time); this digest is the notification. Before R-182 one event was both, and did neither: nine arrived, two were mailed, seven vanished before `LogNotification`. **Lifetime is `admissionSet`'s exactly** — absent collector means "no run in flight", never a stale answer. **A refusal is noted ONCE, inside `admitApp` where the verdict is taken**, not at the three legs that consult it: R-181's one-verdict-covers-all-three contract makes per-leg noting produce "2 of 1 apps failed". **Deliberate skips (disconnected / decommissioned) must NEVER be noted** — they have their own alert and a nightly digest about an unplugged drive is an ignored digest. **A clean run emits NOTHING**; silence is safe only because the hub's deadline check (`monitor/deadline.go:396,417`) raises a missed backup from report freshness independently — if that is ever weakened this design loses its footing. **`run_id` is unique per real run** (so the hub's 1-h cooldown cannot collapse a manual run into the nightly one) and **deliberately EMPTY on the periodic refresh sweep**, which must stay under that cooldown or a polled status page becomes a mail flood |
|
||||
| `Manager.admitApp` / `beginAdmissionRun` / `decideAdmission` / `estimatedWriteBytes` (R-181, v0.193.0) | controller/internal/backup/admission.go | `(stackName) bool` / `() func()` | **THE reserve gate. Call it before ANY per-app backup write** — one verdict per app per run, covering the DB dump, the volume dump and the unit capture (all three write under one per-app root) | **Decided LAZILY at the app's first write, never once at run start** — app A's dump can put app B under the reserve, so a run-start verdict reads a disk that no longer exists. **Never re-decided between an app's own legs**: that is exactly the split R-181 closed (bulk written, capture refused). **Reset per run** via the closer `beginAdmissionRun` returns. **Must sit ahead of `DumpAppVolumesSafe`**, which stops the stack as its first act — a refusal decided inside it has already bounced the app. Fires **exactly one** `unitNotify` per refused app per run. Nil admission set (periodic status refresh) → decides fresh, which is still once per app per sweep. Wiring pinned by an **AST walk** in `TestAdmission_IsWiredIntoEveryProductionWriteLeg`, not `strings.Contains` |
|
||||
| `Manager.floorVerdict` + `FloorUsedPercent`/`FloorFreeGiB` / `ErrCaptureFloor` / `floorReason` (R-165 B2 v0.192.0, size term R-181 v0.193.0) | controller/internal/backup/recovery_unit.go | `(*UnitSpace, estGiB float64) (*UnitSpace, floorReason)` | The pure two-question predicate behind `admitApp`: is the filesystem already below the reserve (`floorHeadroom`), and would THIS app's write take it below (`floorSize`)? | **Headroom is about the FILESYSTEM, never a per-unit cap** — a size cap is R-163 rebuilt inside one volume; the size term bounds the *delta*, not the unit. **REFUSES, never deletes:** nothing here is generational (a unit is one fixed path per app, a DB dump one fixed name), so pruning could only destroy a DIFFERENT app's only local copy — **never repurpose `pruneStalePrimaryDirs`**, which removes ORPHANED dirs from an app that moved drives and has no notion of age. Two terms (97% / 1 GiB) in `fillwatch`'s shape, deliberately BEYOND its critical band (95% / 2 GiB) so the customer is always warned first — pinned by `TestFloorSitsBelowTheCriticalWarningBand`. **`estGiB == 0` degrades to headroom-only on purpose** — refusing an app with no history makes the FIRST backup the one that can never happen. A nil reading neither refuses nor warns (§8.4). Inject `unitSpaceFn` in tests rather than manufacturing occupancy on a real disk |
|
||||
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space |
|
||||
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space. **Both languages (v0.309.0, R-521 F7):** the sentence is the bundle's `event.fill.*`, the household's rendering rides as `message_customer` via `Notifier.PushEventHousehold` (`SetLang`; `SetExtra` takes the language); a built-in area carries a `LabelKey`, a household's own drive label is used as written. Hungarian pinned byte-for-byte by `TestMessageHungarianWireText` (v0.309.0 dropped the formal „Kérjük," — R-516) |
|
||||
|
||||
### Scheduler / time / UI
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `Scheduler.RunNow` / `scheduler.OnDemand` | controller/internal/scheduler/scheduler.go | `RunNow(name) error`; `OnDemand(ctx) bool` | Run a registered job once now (operator action `run_job`, decision 185) | Refuses unknown and already-running jobs; a job that checks due-ness reads `OnDemand(ctx)` to skip that check on demand |
|
||||
| `report.OperatorActions` | controller/internal/report/opactions.go | `Reconcile(reply)` → results in the next report | Hub-requested actions in the report reply — the `selftail.go` pattern plus results | The action table is CLOSED and pinned by `TestOpActions_ClosedList`; once per id in memory; every entry must be safe to repeat |
|
||||
| `web.sessionFingerprint` / `loadSessions` / `saveSessionsLocked` / `saveSessionsRevokingLocked` | controller/internal/web/session_store.go | dashboard sessions on disk (R-35) | Any session lookup | The map is keyed by sha256(cookie), NEVER the cookie — hash with `sessionFingerprint` first; a path that ENDS sessions uses the revoking save |
|
||||
| `Scheduler.Every` / `Daily` | controller/internal/scheduler/scheduler.go | `(name, interval/"HH:MM", fn)` | ALL background jobs | Daily is Europe/Budapest, DST-safe (`nextDailyRun` avoids Add(24h)); register in main.go block (§5) |
|
||||
| `Scheduler.UpdateDaily` | controller/internal/scheduler/scheduler.go | `(name, "HH:MM") bool` | Retime a daily job at runtime (no restart) | Per-job buffered `resched` chan + select case in `runDailyJob`; false (WARN) on invalid time / unknown-or-non-daily name; read `Schedule` under the mutex in the loop |
|
||||
| `backupwindow.*` (LegTimes / GateWindow / EffectiveWindow / ParseHHMM / FmtHHMM / Valid) | controller/internal/backupwindow/backupwindow.go | pure `string`↔`int` | Backup-window arithmetic (v0.168.0) | Offsets (W+60m/W+105m, gate W+2h..W+6h) are CONSTANTS — derived, never stored; wrap-safe modulo 1440; `EffectiveWindow(settings, yaml)` = settings>yaml>"02:30" |
|
||||
@@ -233,9 +292,11 @@
|
||||
| `i18n.Bundle` (`Shared` / `Msg` / `Plural` / `Expand`) | controller/internal/i18n/i18n.go | `Msg(lang, key) string` | ALL customer copy that has been converted (v0.247.0) | hu.json is authoritative; a key missing in en shows Hungarian (counted), never blank; `Expand` runs BEFORE template parse — never add a runtime `T` func (escaping would change Hungarian bytes). A template edit to converted copy goes in hu.json + en.json, then `TestI18nParity` must still pass |
|
||||
| `huTemplateSource` (test) | controller/internal/web/i18n_wiring_test.go | `(name) ([]byte, error)` | A test that asserts a template's Hungarian WORDING | Reads the expansion, not the raw file — since slice 1 the wording lives in hu.json. Never use `templateFS.ReadFile` for a wording assertion |
|
||||
| `Server.langFor` / `templatesFor` / `addLanguageData` | controller/internal/web/server.go + i18n_web.go | request → lang → template set | Rendering a page in the household's language | Only `executeTemplate` is language-aware; `s.tmpl` is the Hungarian set; Go-side titles via `data["TitleKey"]` |
|
||||
| `bootstrap.Unconfigured` / `WaitForConfig` / `WaitingHandler` (v0.308.0, R-554) | controller/internal/bootstrap/waiting.go | `WaitForConfig(ctx, ln, configPath, cfg, logger, pull, every, version) *config.Config` | The ONLY unconfigured-box path: the bilingual waiting page + a retry of `MaybeIngest` every minute; returns the configured config (nil on stop) | Replaces the deleted setup wizard. `/api/health` stays 200 while waiting; a nil listener still retries |
|
||||
| `i18n.FormatDateTime` / `FormatDate` / `FormatMonthDayTime` / `FormatDecimal` / `LocalizeDecimals` | controller/internal/i18n/format.go | `(lang, …) string` | EVERY date or decimal number shown on the dashboard (R-562, v0.308.0): hu „2026. 10. 10. 15:04" + decimal comma, en „2026-10-10 15:04" + point | Pass the time already in Budapest; `LocalizeDecimals` is only for a size another package already formatted („1.5 GB") — new code calls `FormatDecimal`. Templates use the bound names in `formatFuncs(lang)` (funcmap.go: `fmtTime`, `fmtTimeStr`, `fmtGB`, `fmtBytes`, `numText`, …); Go page data uses `fmtLocalTime(lang, t)` / `fmtRFC3339Local(lang, s)` / `fmtSizeBytes(lang, n)`; page scripts use `fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` (layout.html). Never a `"2006-01-02 15:04"` literal for display. CSS widths keep `printf "%.1f"` |
|
||||
| `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) |
|
||||
| `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` |
|
||||
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input; setup wizard has its OWN csrf (§6) |
|
||||
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input |
|
||||
| `LogBuffer` + `Lines(maxBytes)` | controller/internal/web/logbuffer.go | ring buffer io.Writer | In-memory log capture for the debug UI + the report `controller_log_tail` source | v0.116.0: ALWAYS constructed (any logging.level) — the logger is `MultiWriter(LevelFilterWriter(stdout, level), ring)`; `Lines` drops OLDEST to honor the byte budget |
|
||||
| `logx.Debugf/Infof/Warnf/Errorf` | controller/internal/logx/logx.go | `(l *log.Logger, format, args…)` | ALL NEW leveled log lines (the v0.116.0 sweep standard) | routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3) |
|
||||
| `web.LevelFilterWriter` | controller/internal/web/levelfilter.go | `NewLevelFilterWriter(w, minLevel)` | stdout leveling under the always-on ring | untagged lines parse INFO; always reports full length written |
|
||||
@@ -259,6 +320,7 @@
|
||||
| Streaming subprocess progress | controller/internal/backup/offbox_progress.go | `offboxStreamRunner` seam (stdout scanned line-by-line, stderr buffered, output tail-bounded) + a PURE line parser + a mutex-guarded published snapshot. Traps it encodes: a source reporting nothing is **normal** (restic sends 0 bytes for a whole incremental run) and the progress source may only update on unit completion — degrade bytes → files → current item + elapsed, never fake a percentage |
|
||||
| Post-start async verification | controller/internal/stacks/manager.go `logPostStartStatus` | goroutine + sleep, INFO log, never blocks/fails the operation |
|
||||
| Startup wiring order | controller/cmd/controller/main.go | init-only setters (`SetStackProvider` M2 contract: exactly once, before scheduler/HTTP), scheduler registration block |
|
||||
| Tabbed page (one subject, several URLs) | controller/internal/web/app_page.go + templates/app_shell.html | ONE data builder for what every tab shows (`appCommonData`), one shell partial (header + page-level banners + tab bar), one page template per tab calling `{{template "app_shell_start" .}}`. Tabs are `<a>` links with `aria-current`, never hidden panels — a link in an alert must land ON a tab and a poller must run on its tab only. The default tab is RENDERED at the bare URL, never redirected to. A key the tab owns (`Page`, `Title`, `TitleKey`, `TitleArgs`, `ActiveTab`, `AppTabs`) is listed in `appPageDataKeysOwnedByTab` so a merged data builder cannot overwrite it |
|
||||
|
||||
## 3. Dangerous lookalikes — do NOT reuse
|
||||
|
||||
@@ -270,9 +332,11 @@
|
||||
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` |
|
||||
| `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) |
|
||||
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
|
||||
| `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else |
|
||||
| `config.LoadPermissive` | Skips validation — first boot only (customer.id/domain may be unset until `bootstrap.WaitForConfig` gets the configuration, R-554) | `config.Load` everywhere else |
|
||||
| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
|
||||
| any settings getter/setter or helper that ends in one (`m.note`, `boxLang`) INSIDE a `settings.UpdateOffboxStatus`/`UpdateCrossDriveStatus` callback (R-578) | The callback runs under the settings WRITE lock and `sync.RWMutex` is not reentrant — it DEADLOCKS holding the lock and wedges settings.json for the box | Resolve the value BEFORE the callback (`lang := m.boxLang()`); `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` (controller/internal/settings/r578_callback_lock_gate_test.go) convicts it in every package |
|
||||
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
|
||||
| `backup.WholeOnTier` vs `UpdateCopyHolds` (R-659) | `UpdateCopyHolds` says what a copy HOLDS; it does not say the restore will ACCEPT it — a file app's second-drive copy holds the files and its unit restore still refuses | `WholeOnTier` (asks `DeclaredDriveFileLegs`, the refusal's own predicate) before a sentence names a copy as a way back |
|
||||
|
||||
## 4. Seams & interfaces (testing + cross-repo)
|
||||
|
||||
@@ -280,10 +344,14 @@
|
||||
|---|---|---|---|
|
||||
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
|
||||
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
|
||||
| `healthSettle` / `healthInterval` / `healthTimeout` (package vars, R-488) | controller/internal/backup/restore.go | production 3 s / 5 s / 90 s — `waitForHealthy`'s clock, used by every restore path | shortened ONCE in controller/internal/backup/main_test.go `TestMain` (0 / 2 ms / 50 ms; the package went 444 s → ~2 s); `TestR488_HealthWaitDefaultsAreProduction` pins the production values. A new real-clock wait in this package goes behind the same kind of var, not a per-test sleep. Also: `newOffboxManager` installs a failing `SetOffboxSSH` fake, so no backup test spawns a real `ssh` |
|
||||
| `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) |
|
||||
| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated |
|
||||
| `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) |
|
||||
| `Manager.sambaUpFn` / `sambaPasswdFn` / `sambaRunFn` / `sambaAddrFn` (func seams) | controller/internal/stacks/manager.go (fields) + samba.go | nil → `composeUp` / `docker exec smbpasswd` (STDIN) / `containerRunning("felhom-samba")` / `docker exec felhom-samba ip -4 -o addr show eth0` | injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called **zero** times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker. **`sambaRunFn` has an EXPORTED setter (`SetSambaRunProbe`)** — internal/web's status-contract tests need a live-container world from another package. `sambaAddrFn` backs `SambaLANAddress()` (v0.151.0); its parse is separately pinned in samba_lanaddr_test.go and it returns "" on any failure — the page omits a line rather than printing a wrong address |
|
||||
| `volumeCopier` + `Manager.undoCopier` / `updateUndoHealthFn` (v0.263.0) | controller/internal/stacks/undo.go | nil → `dockerVolumeCopier` (alpine helper: `cp -a` named volume → `<vol>.pre-update-<stamp>`, finished-marker LAST; restore re-checks the marker in the same shell) / nil → `waitUpdateHealthyMeta` with the OLD `.felhom.yml` | `fakeCopier` in controller/internal/stacks/undo_test.go — volume CONTENT as strings, so "the data came back" is a compare; a cut-off copy is a copy without its marker. **Judge a copy by the helper's own exit + the marker, never by the client** (killing `docker run` leaves the container copying — measured) |
|
||||
| `pgConverter` + `Manager.pgConv` / `convertFreeFn` (v0.273.0) | controller/internal/stacks/pgconvert.go | nil → `dockerPGConverter` (docker exec psql/pg_dumpall over 127.0.0.1 — the entrypoint's temporary init server listens on the socket only; `Empty` re-checks the undo copy's marker in the same helper) / nil → statfs of the stack dir | `fakePG` in controller/internal/stacks/pgconvert_test.go — works on `fakeCopier`'s volume strings. **Never convert without the ladder's `engine_conversion` mark** (`planEngineConversion` refuses a PostgreSQL major move without it); `isPostgresImage` must match `appbackup.dbTypeForImage` (pinned by a source-reading test) |
|
||||
| `Manager.composeExecFn` / `stacks.setupGateProbeGet` / `Server.gateClock` (v0.280.0) | controller/internal/stacks/manager.go + setup_gate.go · controller/internal/web/server.go | nil → the real compose call / an HTTP GET (5 s) / `time.Now` | controller/internal/stacks/setup_gate_test.go (with a docker STUB on PATH — R-650) · controller/internal/web/setup_gate_test.go |
|
||||
| `Manager.SambaLANAddress()` | controller/internal/stacks/samba.go | `() string` — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) | Read from the SAMBA container's netns (`network_mode: host`), never `net.InterfaceAddrs()` — the controller is on a docker BRIDGE and would answer 172.x (the same trap `setup.DetectLocalIPs` needs `HOST_IP` for). **NEVER cache/persist it** — the guest holds it by DHCP (S-5); callers re-derive per render. `""` = omit the line |
|
||||
| `Server.sambaAddrFn` (func seam) | controller/internal/web/server.go (field) + sharing_handlers.go `sambaLANAddress()` | nil → `stackMgr.SambaLANAddress()` | The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease |
|
||||
| `Manager.guestNetExecFn` (func seam) + `GuestGateway()` / `GuestNetSnapshot()` | controller/internal/stacks/manager.go (field) + guestnet.go | nil → `docker exec felhom-samba <args>` — ONE seam for all R-66 guest-netns reads (route/link/addr/resolv.conf); tests script canned outputs per argv | guestnet_test.go. **The netns door rule:** the controller's OWN netns is the docker bridge, so any in-process read (`net.Interfaces`, `/proc/net/route`, its own `/etc/resolv.conf` = 127.0.0.11) is the S-2 wrong answer — guest-net reads MUST go through the samba (`network_mode: host`) exec door. Megosztás off ⇒ door closed ⇒ "" / per-item error strings; NEVER substitute an in-process value. Same S-5 law as SambaLANAddress: live per render, never cached/persisted. Parsers (`parseDefaultRoute`, `parseGuestInterfaces`, `parseResolvConf`) are pure + separately pinned |
|
||||
@@ -305,9 +373,13 @@
|
||||
| `Manager.execFn` (func seam) + `restartPolicyLookup` / `inspectRestartPolicyFn` (R-51, v0.156.0) | controller/internal/stacks/manager.go | nil → real `exec.Command` / `docker inspect -f {{.HostConfig.RestartPolicy.Name}}` | `scriptedDocker` in controller/internal/stacks/degraded_test.go drives the WHOLE production path (docker ps → aggregateState → docker inspect) — an aggregateState-only test proves the function, not the caller. Policy answers are cached per container+state and pruned to the live `docker ps` set; a FAILED inspect is deliberately never cached (a hiccup must not pin a container to "unknown") and reads as SUPERVISED, i.e. fail-closed — the opposite of `IsDownState`'s fail-open, because there the state is ambiguous while here a member is known dead |
|
||||
| `bootrecon.StackProvider` (R-52, v0.156.0) | controller/internal/bootrecon/bootrecon.go | `*stacks.Manager` (GetStacks/StartStack/RefreshStatus) | `fakeStacks` counts StartStack per app; the load-bearing assertion is the NEGATIVE — a zero-container stack (a UI Stop = `compose down` = containers removed) must record **0** starts, while a boot orphan (containers present, Exited) records exactly 1. `Reconciler.sleep` is injected so the 30 s gap costs nothing |
|
||||
| `bootReconcileFn` + `runBootReconcile` (package-main seam, v0.156.0) | controller/cmd/controller/main.go | `bootrecon.New(mgr, logger).Run` | controller/cmd/controller/bootrecon_wiring_test.go. **The wiring itself is asserted by an AST walk** over `func main()`, not a `strings.Contains` — the substring version passed its own red-proof because a commented-out call still contains the string. Comments are not callers |
|
||||
| `crashboot.Probe` / `crashboot.Grace` (R-856) | controller/internal/crashboot/crashboot.go | `crashboot.AgentProbe(agentClient.CrashGuard)` (GET /host/crash-guard) | `fixed(Fact, err)` in crashboot_test.go. The dead-app check gates on `bootGrace.Within(now)` — 90 s normally, `CrashGrace` (15 min) when the HOST's last boot was unclean and within `BootWindow` of the controller start. **Unknown (no agent, 404, no state) is a normal boot** — never a longer silence on a guess. Asserted by AST walk in cmd/controller/r856_wiring_test.go |
|
||||
| `classifyRunStates` (pure fix-3 derivation, v0.164.0) | controller/cmd/controller/main.go | `([]stacks.Stack, quiesced, failedRestart map[string]bool, now time.Time)` → `(dead []web.DeadApp, states []notify.AppRunState)` | classify_runstates_test.go. **THE single fix-3 rule: down = `(IsDownState(st.State) || st.CrashLooping(now)) && !userStopped && !quiesced`.** **`quiesced` is a UNION of TWO suppression sets** (R-330, v0.224.0): `quiesce.Loop.SuppressedStacks()` (whole-guest vzdump/PBS) and `backup.AppStopGuard.SuppressedStacks()` (per-app volume dump / offbox reconstitute / `.fab` export), merged by `unionSuppressed` in `scanDeployedAppRunStates`. **Adding a third way to stop an app means adding its set here** — R-330 was 61 false customer e-mails caused by exactly that omission, with a working suppressor sitting three lines away. C9-F2 (v0.183.0) added the crash-loop term: `restarting` is NOT in `IsDownState` and must not be — adding it alarms on every deploy and update fleet-wide — so a SUSTAINED restarting run (`stacks.crashLoopAfter` = 5 m, above the 120 s deploy timeout, Mealie's 60 s start_period AND R-97b's 180 s grace) becomes down instead. `now` is injected so the threshold is a testable contract. A deliberate UI stop (`compose down` → zero containers → StateStopped, I1) must not alarm — banner OR email — while faults (Exited/Degraded) alarm byte-identically; I2 (P2 census: all catalog services `unless-stopped`) is why a crash never rests at stopped. **Do NOT touch `IsDownState`** (other callers rely on stopped=down) and do NOT filter in `buildDeadAppAlerts`/`NotifyAppStartFailures` — one derivation point. If I1 or I2 changes, revisit the suppression |
|
||||
| `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go |
|
||||
| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
|
||||
| `util.ParseVersionPre` | controller/internal/util/version.go | the same comparator, reading `X.Y.Z-rc.N` as BELOW `X.Y.Z` (v0.314.0, R-1053) — ONLY for „is this box below the floor?" | never use it to pick a tag to offer: the button must not see a test build |
|
||||
| `mediawire.PlexURL` | controller/internal/mediawire/plexaddr.go | Plex's base URL BY ADDRESS, resolved at each call (v0.314.0, R-1060) — every controller call to Plex | Plex refuses the Host name `plex` as non-local (401); never cache the address |
|
||||
| `stacks.GateCapture` / `stacks.ReadGateAtBackup` | controller/internal/stacks/restore_gate.go | the setup gate a per-app backup records, and the restore's read of it (v0.314.0, R-1057) | the unit `app.yaml` key is `setup_gate:` — the live file's keys, so either reads the same |
|
||||
| `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line |
|
||||
| `netProbeReadBack` (package var) | controller/internal/web/netprobe.go | `os.ReadFile` | overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production |
|
||||
| `system.ClassifyPathFS(Timeout)` + `netProbeFSClass` / `Server.classifyFSPath` / `Router.classifyFSPath` | controller/internal/system/fsclass*.go (+ web/netprobe.go, web/server.go, api/router.go seams) | statfs f_type → network/autofs/stub/unknown in THIS namespace (RCA fix 2) | idle autofs = HEALTHY, never force-mount; unknown = fail OPEN; seams injected in netprobe_stub_test.go / networkstub_test.go / deploygate_test.go |
|
||||
@@ -350,19 +422,20 @@ Cross-repo edges:
|
||||
- **New startup self-check**: append check fn in `selftest.Run` (controller/internal/selftest/selftest.go).
|
||||
- **Converting a template to the bundle (i18n, v0.247.0)**: `python3 controller/scripts/i18n_extract.py <template>` moves every Hungarian run into `hu.json` and leaves `{{T "key"}}`; then **review what it LEFT** (it matches Hungarian by accented letter plus an ASCII word list — eight ASCII-only strings were missed on the first three pages), add the English to `en.json`, add fixture states for the page to `i18n_parity_test.go` **captured from the unconverted template first**, and run `controller/scripts/i18n_missing_gate.py`. Copy gates judge the page through `controller/scripts/i18n_bundle.py` (`read_template(path, lang)`), never the raw template.
|
||||
- **New settings field**: struct + accessor pair in controller/internal/settings/settings.go following the Lock→mutate→save pattern.
|
||||
- **A value asked AFTER the app starts (v0.315.0)**: add a `DeployField` with `after_start: true` to the catalog template AND an entry to `afterStartRegistry` in `controller/internal/web/after_start.go`, keyed `slug + "/" + ENV_VAR`, with `Needed(stack) bool` and `Apply(s, r, stack, value) error`. **The catalog may never carry the action** — not a command, not a URL, not a container to exec: the household's text is an argument to a function in this repository, which is the whole point of the design (D2 = A). Both halves are required and each is checked: a declared field with no entry renders no card and 404s its POST; an entry whose field the catalog does not declare also 404s (`TestAfterStart_RegistryAloneIsNotAnInvitation` — that second half was green and unpinned until a red-proof showed a POST for an undeclared field reaching Plex). `Apply` returns `*afterStartError{Status, MsgKey}` to show a sentence of its own inline, and **never logs the value** (it is a credential).
|
||||
- **A tab on the app page (v0.315.0)**: a `case` in `appPageHandler`'s switch, a `render<Tab>Tab` that adds only its own keys to `appCommonData`'s map, a template starting with `layout_start` + `app_shell_start`, and the tab's key in `appTabOrder` with a literal `app_page.tab_<key>` locale key resolved in `appTabLinksLang`. **Resolve the label in Go, not with a composed `{{T (printf …)}}`** — a composed key is invisible to `TestI18nParityCoversEveryMarker` and a missing translation would render the key to the household.
|
||||
|
||||
## 6. Known duplication (observed — NOT fixed)
|
||||
|
||||
| Duplication | Locations |
|
||||
|---|---|
|
||||
| Atomic write ×4 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/setup/handlers.go `atomicWriteFile`; controller/internal/api/router.go `writeConfig0600` |
|
||||
| String truncation ×3 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based, widely used); controller/internal/backup/offbox.go `truncate` |
|
||||
| Atomic write ×3 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/api/router.go `writeConfig0600` (the setup wizard's `atomicWriteFile` went with it, R-554) |
|
||||
| String truncation ×4 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based HEAD, widely used) and `tailStr` (rune-safe TAIL, v0.294.0 R-864 — use it for a command's stderr: the error is printed last); controller/internal/backup/offbox.go `truncate` |
|
||||
| humanizeBytes ×3 | controller/internal/appbackup/appdata.go `HumanizeBytes` (canonical) + private twin; controller/internal/appexport/estimate.go `humanizeBytes`; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge) |
|
||||
| copyFile ×2 | controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go |
|
||||
| dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` |
|
||||
| timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) |
|
||||
| CSRF ×2 | controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled |
|
||||
| Budapest timezone loader ×3 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
|
||||
| Budapest timezone loader ×4 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` vs controller/internal/api/kept_install.go `budapest` (v0.277.0, the kept choice's dates) (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
|
||||
| JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` |
|
||||
| Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) |
|
||||
| DB wait/import ×2 | controller/internal/appbackup/dbdump.go `waitDBReady`/`ImportDump` vs controller/internal/appexport/restore.go `waitForDB`/`importDBDump` |
|
||||
|
||||
@@ -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.18.1
|
||||
ARG RESTIC_SHA256_AMD64=680838f19d67151adba227e1570cdd8af12c19cf1735783ed1ba928bc41f363d
|
||||
ARG RESTIC_SHA256_ARM64=87f53fddde38764095e9c058a3b31834052c37e5826d2acf34e18923c006bd45
|
||||
COPY third_party/restic/restic-release-key.asc /tmp/restic-release-key.asc
|
||||
RUN set -eu; \
|
||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg gpgv bzip2; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) want="${RESTIC_SHA256_AMD64}" ;; \
|
||||
arm64) want="${RESTIC_SHA256_ARM64}" ;; \
|
||||
*) echo "no pinned restic checksum for architecture '${TARGETARCH}'" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
f="restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2"; \
|
||||
url="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}"; \
|
||||
cd /tmp; \
|
||||
curl -fsSL -o "$f" "$url/$f"; curl -fsSL -o SHA256SUMS "$url/SHA256SUMS"; curl -fsSL -o SHA256SUMS.asc "$url/SHA256SUMS.asc"; \
|
||||
gpg --dearmor < /tmp/restic-release-key.asc > /tmp/restic-release-key.gpg; \
|
||||
gpgv --keyring /tmp/restic-release-key.gpg SHA256SUMS.asc SHA256SUMS; \
|
||||
grep " ${f}\$" SHA256SUMS | sha256sum -c -; \
|
||||
echo "${want} ${f}" | sha256sum -c -; \
|
||||
bunzip2 -c "$f" > /usr/local/bin/restic; chmod 0755 /usr/local/bin/restic; \
|
||||
/usr/local/bin/restic version | grep -q "^restic ${RESTIC_VERSION} "
|
||||
|
||||
# Install runtime dependencies:
|
||||
# - docker-cli: for "docker compose" commands
|
||||
# - ca-certificates: for HTTPS (healthchecks pings, git)
|
||||
# - postgresql-client: for pg_dump
|
||||
# - default-mysql-client: for mysqldump
|
||||
# - sqlite3: for SQLite backup
|
||||
# - git: for stack sync from Gitea
|
||||
# - curl: for health pings and debugging
|
||||
# - util-linux: lsblk, blkid, mount (storage init)
|
||||
# - fdisk: sfdisk (partition table writing — separate from util-linux on Debian)
|
||||
# - e2fsprogs: mkfs.ext4 (filesystem formatting)
|
||||
# - rsync: for data migration between storage paths
|
||||
# - parted: partprobe (partition table re-read after sfdisk)
|
||||
# - restic: encrypted off-box (NAS) backup over SFTP (Part B; version pinned by the Debian release)
|
||||
# - openssh-client: restic's sftp backend shells out to `ssh` (the SFTP transport); also ssh-copy-id/sftp
|
||||
# for the SLICE-2 offsite apply-bridge key install
|
||||
# - sshpass: feeds the one-time storage-box password to ssh-copy-id -s -f (offsite apply-bridge)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# --- Runtime stage ---
|
||||
# Debian 13 (R-1105, v0.323.0), pinned by point release AND digest. It waited on Debian 12 until the off-site code's
|
||||
# restic assumptions were re-proven on 0.18 (07-backup-architecture.md §6.8); restic itself no longer comes from here.
|
||||
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f
|
||||
|
||||
# Install runtime dependencies — each one is a program the controller binary runs (R-1105 re-checked every exec):
|
||||
# - docker-cli + compose plugin: every stack operation (below)
|
||||
# - ca-certificates: for HTTPS (healthchecks pings, git, the hub)
|
||||
# - curl: the image's HEALTHCHECK, and the docker repo key below
|
||||
# - gnupg: dearmors docker's repo key below
|
||||
# - git: catalog sync
|
||||
# - util-linux: findmnt (mount reads)
|
||||
# - rsync: data migration between storage paths
|
||||
# - openssh-client: restic's sftp/rclone transports shell out to `ssh`; the off-site key probe
|
||||
# v0.323.0 (R-1105) REMOVED, because the binary never runs them: postgresql-client and default-mysql-client (every
|
||||
# dump and import runs INSIDE the app's own database container — `docker exec`, appbackup/dbdump.go), sqlite3,
|
||||
# fdisk, e2fsprogs, parted (disk work moved to the host agent in v0.37.0), sshpass (the ssh-copy-id key install it
|
||||
# fed was replaced by the hub's key registrar in v0.289.0). Fewer packages, fewer known holes.
|
||||
RUN apt-get update && apt-get upgrade -y --no-install-recommends && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
postgresql-client \
|
||||
default-mysql-client \
|
||||
sqlite3 \
|
||||
util-linux \
|
||||
fdisk \
|
||||
e2fsprogs \
|
||||
rsync \
|
||||
parted \
|
||||
restic \
|
||||
openssh-client \
|
||||
sshpass \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install docker-cli (without daemon)
|
||||
COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic
|
||||
|
||||
# Install docker-cli (without daemon). The repo's codename comes from the image's own /etc/os-release, never a
|
||||
# hard-coded name (a hard-coded `bookworm` would have kept Debian 12's packages on a Debian 13 base).
|
||||
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg \
|
||||
&& . /etc/os-release \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
|
||||
https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
+824
-177
File diff suppressed because it is too large
Load Diff
+84
-1
@@ -16,7 +16,11 @@
|
||||
set -euo pipefail
|
||||
|
||||
# --- Configuration (edit these if your paths differ) ---
|
||||
REPO_DIR="/mnt/5_hdd/felhom.eu/git/felhom-controller"
|
||||
# The default is the shared DooPlex clone, and the release runbook names it. It is overridable
|
||||
# because that clone is shared: when another session has work in progress in it, the clean-tree gate
|
||||
# rightly forbids building over them, and the way to ship is a clean clone at the pushed commit —
|
||||
# not stashing somebody else's work. Set FELHOM_REPO_DIR to that clone.
|
||||
REPO_DIR="${FELHOM_REPO_DIR:-/mnt/5_hdd/felhom.eu/git/felhom-controller}"
|
||||
CONTROLLER_SRC="${REPO_DIR}/controller"
|
||||
WEBSITE_ASSETS_DIR="/mnt/5_hdd/felhom.eu/git/felhom.eu/website/assets"
|
||||
REGISTRY="gitea.dooplex.hu/admin"
|
||||
@@ -61,11 +65,90 @@ info "║ felhom-controller image builder ║"
|
||||
info "╚══════════════════════════════════════╝"
|
||||
info "Version: ${VERSION}"
|
||||
info "Commit: ${GIT_COMMIT}"
|
||||
info "Repo: ${REPO_DIR}"
|
||||
info "Source: ${CONTROLLER_SRC}"
|
||||
info "Build dir: ${BUILD_DIR}"
|
||||
info "Image: ${IMAGE}:${VERSION}"
|
||||
echo ""
|
||||
|
||||
# =========================================================================
|
||||
# A VERSION NUMBER IS PUSHED ONCE (v0.316.0)
|
||||
# =========================================================================
|
||||
# 0.315.0 was built and pushed THREE times from three different commits. A box that pulled between
|
||||
# the first and the last runs different code under the same number — and will never fetch the fix,
|
||||
# because to the box, and to the floor, that number is already current (`09-update-architecture.md`:
|
||||
# a box compares VERSIONS, and re-pulls a tag it already has only when the tag changes). Nothing in
|
||||
# the build stopped it. This does.
|
||||
#
|
||||
# The check runs BEFORE the build, so a refusal costs no minutes. A registry that cannot be reached
|
||||
# is a REFUSAL too: the point is to prove the tag is new, and "I could not ask" is not that proof.
|
||||
# Local builds (no --push) are unaffected — reusing a version while iterating is the normal case.
|
||||
#
|
||||
# `docker manifest inspect` is the probe, so a test can put a fake `docker` on PATH
|
||||
# (scripts/test_build_push_once.sh).
|
||||
if [[ "${ACTION}" == "--push" || "${ACTION}" == "--multiarch" ]]; then
|
||||
step "0/4 — Checking the registry: is ${VERSION} already published?"
|
||||
# `set -e` is on, and a failing command substitution would abort the script before the rc is
|
||||
# ever read — which would turn "the registry said no such tag" into a silent exit. The `|| true`
|
||||
# keeps the failure for us to classify; that classification is the whole check.
|
||||
probe_rc=0
|
||||
probe_out=$(docker manifest inspect "${IMAGE}:${VERSION}" 2>&1) || probe_rc=$?
|
||||
if [[ ${probe_rc} -eq 0 ]]; then
|
||||
error "${IMAGE}:${VERSION} already exists in the registry — bump the version."
|
||||
error "A version number is pushed once. Two images under one number mean a box can run"
|
||||
error "either of them and never learn the difference. If this is a fix for something already"
|
||||
error "pushed, release it as the next patch version instead."
|
||||
exit 1
|
||||
fi
|
||||
# Absent is the ONLY other acceptable answer. Anything that is not a clean "not found" (the
|
||||
# registry is down, the login expired, DNS failed) leaves the question unanswered, and an
|
||||
# unanswered question is a refusal.
|
||||
if ! grep -qiE 'no such manifest|manifest unknown|not found|does not exist|NAME_UNKNOWN|MANIFEST_UNKNOWN' <<<"${probe_out}"; then
|
||||
error "Could not establish whether ${IMAGE}:${VERSION} exists — refusing to build."
|
||||
error "The registry answered: ${probe_out}"
|
||||
error "Proving the tag is new is the point of this check; 'I could not ask' is not that proof."
|
||||
exit 1
|
||||
fi
|
||||
info "${VERSION} is not present in the registry — proceeding."
|
||||
|
||||
# AND THE TREE IT BUILDS FROM MUST BE CLEAN AND PUSHED.
|
||||
#
|
||||
# Until v0.316.3 this was a sentence in a runbook, and a rule without a mechanism is a wish. It
|
||||
# cost a bad push the same evening it was written down: the shared clone had 31 files of another
|
||||
# session's work in progress, and a build from it put their unreleased code into a published
|
||||
# image under my version number. Nobody deployed it; that was luck, not a control.
|
||||
#
|
||||
# Two questions, both of which must be YES:
|
||||
# is every tracked file committed? — otherwise the image holds something git does not
|
||||
# is HEAD what origin/main has? — otherwise the image holds something nobody can fetch
|
||||
# „An unpushed change does not exist" (workspace CLAUDE.md), and an image built from one is a
|
||||
# thing that exists and cannot be reproduced.
|
||||
if [[ -d "${REPO_DIR}/.git" ]]; then
|
||||
dirty=$(cd "${REPO_DIR}" && git status --porcelain 2>/dev/null | wc -l)
|
||||
if [[ "${dirty}" -ne 0 ]]; then
|
||||
error "${REPO_DIR} has ${dirty} uncommitted change(s) — refusing to push a build from it."
|
||||
error "An image built from a dirty tree cannot be reproduced, and on a SHARED clone it"
|
||||
error "may carry work that is not yours. Commit and push, or build from a clean clone"
|
||||
error "with FELHOM_REPO_DIR pointing at it."
|
||||
(cd "${REPO_DIR}" && git status --porcelain | head -10 | sed 's/^/ /')
|
||||
exit 1
|
||||
fi
|
||||
head_sha=$(cd "${REPO_DIR}" && git rev-parse HEAD 2>/dev/null || echo unknown)
|
||||
main_sha=$(cd "${REPO_DIR}" && git rev-parse origin/main 2>/dev/null || echo unknown)
|
||||
if [[ "${head_sha}" != "${main_sha}" ]]; then
|
||||
error "${REPO_DIR} is not at origin/main — refusing to push."
|
||||
error " HEAD ${head_sha}"
|
||||
error " origin/main ${main_sha}"
|
||||
error "An unpushed change does not exist; an image built from one cannot be fetched again."
|
||||
exit 1
|
||||
fi
|
||||
info "Source tree is clean and at origin/main."
|
||||
else
|
||||
error "${REPO_DIR} is not a git checkout — refusing to push a build whose source cannot be identified."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# Step 1: Prepare clean build workspace
|
||||
# =========================================================================
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.279.0 (decision 45): the deploy-done hook — the ONE place a fresh install ends — runs after_install, and
|
||||
// the hook is set on every box, not only inside `if notifier != nil` (a box with no hub still installs apps).
|
||||
// COMPANION RED-PROOF: move SetDeployDoneHook back inside `if notifier != nil` → the second assertion fails;
|
||||
// drop the RunAfterInstall call → the first fails.
|
||||
func TestAfterInstall_IsWiredToEveryFreshInstall(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hook *ast.CallExpr
|
||||
var insideNotifierIf bool
|
||||
var walk func(n ast.Node, underNotifier bool)
|
||||
walk = func(n ast.Node, underNotifier bool) {
|
||||
ast.Inspect(n, func(x ast.Node) bool {
|
||||
if ifs, ok := x.(*ast.IfStmt); ok {
|
||||
if be, ok := ifs.Cond.(*ast.BinaryExpr); ok {
|
||||
if id, ok := be.X.(*ast.Ident); ok && id.Name == "notifier" && be.Op == token.NEQ {
|
||||
walk(ifs.Body, true)
|
||||
if ifs.Else != nil {
|
||||
walk(ifs.Else, underNotifier)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if c, ok := x.(*ast.CallExpr); ok {
|
||||
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && hook == nil {
|
||||
hook, insideNotifierIf = c, underNotifier
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
walk(f, false)
|
||||
if hook == nil {
|
||||
t.Fatal("SetDeployDoneHook is never called")
|
||||
}
|
||||
calls := false
|
||||
ast.Inspect(hook, func(x ast.Node) bool {
|
||||
if s, ok := x.(*ast.SelectorExpr); ok && s.Sel.Name == "RunAfterInstall" {
|
||||
calls = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !calls {
|
||||
t.Fatal("the deploy-done hook does not run RunAfterInstall")
|
||||
}
|
||||
if insideNotifierIf {
|
||||
t.Fatal("SetDeployDoneHook is set only when a notifier exists — a box with no hub would never run after_install")
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
body := mainBody(t)
|
||||
names := callsInMain(t, body)
|
||||
|
||||
if indexOfCall(names, "NotifyBackupFailed") < 0 {
|
||||
if indexOfCall(names, "NotifyInterruptedOperation") < 0 {
|
||||
t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " +
|
||||
"controller died mid-backup and nobody is told (§2.4)")
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
//
|
||||
// R-174 STRENGTHENED THIS. `!= nil` alone is no longer sufficient, because Recover now returns a
|
||||
// non-nil result for a recovery that merely REFUSED starts (an absent data drive) — the drive
|
||||
// gate working as designed. `NotifyBackupFailed` sends `backup_failed`, which is customer-enabled
|
||||
// gate working as designed. `NotifyInterruptedOperation` (R-585; was `NotifyBackupFailed`) sends `backup_failed`, which is customer-enabled
|
||||
// by default (settings.DefaultEnabledEvents), so a nil-only guard would email the customer
|
||||
// "A biztonsági mentés sikertelen!" about an app nothing is wrong with. The guard must consult
|
||||
// Alarming().
|
||||
@@ -156,7 +156,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
}
|
||||
carries := false
|
||||
for _, name := range callsInMain(t, ifst.Body) {
|
||||
if name == "NotifyBackupFailed" {
|
||||
if name == "NotifyInterruptedOperation" {
|
||||
carries = true
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
|
||||
if !guardedByAlarming {
|
||||
t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " +
|
||||
"recovery that only REFUSED starts (drive absent) would be reported through " +
|
||||
"NotifyBackupFailed, a customer-enabled event type, telling the customer their backup " +
|
||||
"NotifyInterruptedOperation (backup_failed), a customer-enabled event type, telling the customer their backup " +
|
||||
"failed when the drive gate was simply doing its job (R-174)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestConvert_ReleaseIsWiredAtStartup — v0.273.0 (`09` §6.4 part 10, B5): the kept pre-conversion datadir
|
||||
// copy is released only by ReleaseConversionCopies, so main.go must CALL it (the seam-built-but-never-wired
|
||||
// class). COMPANION RED-PROOF (REPORT.md): delete the `conversion-copy-release` job — this fails.
|
||||
func TestConvert_ReleaseIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["ReleaseConversionCopies"]) == 0 {
|
||||
t.Fatal("ReleaseConversionCopies is never called — a converted app's old datadir copy would stay on disk for ever")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// `09` §3 decision 28 (v0.269.0) — the box stops a crash loop / OOM storm; never inside a backup's,
|
||||
// a quiesce's or an update's own stop, never twice, never an app already held.
|
||||
func TestD28_StopHoldTellInOrder_AndTheSkips(t *testing.T) {
|
||||
var calls []string
|
||||
d := unhealthyDeps{
|
||||
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
|
||||
return []stacks.UnhealthyVerdict{
|
||||
{Stack: "gokapi", Kind: stacks.UnhealthyCrashLoop, Count: 7, Window: 10 * time.Minute},
|
||||
{Stack: "quiesced", Kind: stacks.UnhealthyCrashLoop, Count: 7},
|
||||
{Stack: "busy", Kind: stacks.UnhealthyCrashLoop, Count: 7},
|
||||
{Stack: "held", Kind: stacks.UnhealthyOOMStorm, Count: 20},
|
||||
}
|
||||
},
|
||||
suppressed: func() map[string]bool { return map[string]bool{"quiesced": true} },
|
||||
busy: func(n string) (bool, string) { return n == "busy", "a restore is running" },
|
||||
holdKind: func(n string) string {
|
||||
if n == "held" {
|
||||
return "update_failed"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
stop: func(n string) error { calls = append(calls, "stop:"+n); return nil },
|
||||
hold: func(n, k string, _ time.Time) (int, error) { calls = append(calls, "hold:"+n+":"+k); return 1, nil },
|
||||
notify: func(v stacks.UnhealthyVerdict, trip int) { calls = append(calls, "notify:"+v.Stack) },
|
||||
}
|
||||
stopped := stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
|
||||
if strings.Join(calls, ",") != "stop:gokapi,hold:gokapi:crash_loop,notify:gokapi" {
|
||||
t.Fatalf("calls = %v — want gokapi stopped, held, told; the quiesced, busy and held apps untouched", calls)
|
||||
}
|
||||
if len(stopped) != 1 || stopped[0] != "gokapi" {
|
||||
t.Fatalf("stopped = %v", stopped)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed stop is neither held nor announced (the household would read "we stopped it" over a running app).
|
||||
func TestD28_AFailedStopIsNotAnnounced(t *testing.T) {
|
||||
var calls []string
|
||||
d := unhealthyDeps{
|
||||
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
|
||||
return []stacks.UnhealthyVerdict{{Stack: "x", Kind: "crash_loop"}}
|
||||
},
|
||||
suppressed: func() map[string]bool { return nil },
|
||||
busy: func(string) (bool, string) { return false, "" },
|
||||
holdKind: func(string) string { return "" },
|
||||
stop: func(string) error { return io.ErrUnexpectedEOF },
|
||||
hold: func(string, string, time.Time) (int, error) { calls = append(calls, "hold"); return 1, nil },
|
||||
notify: func(stacks.UnhealthyVerdict, int) { calls = append(calls, "notify") },
|
||||
}
|
||||
stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("calls = %v after a failed stop", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// The wiring: main's dead-app loop calls stopUnhealthyApps with the detector.
|
||||
func TestD28_TheLoopStopsUnhealthyApps(t *testing.T) {
|
||||
found, withDetector := false, false
|
||||
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "stopUnhealthyApps" {
|
||||
found = true
|
||||
ast.Inspect(call, func(m ast.Node) bool {
|
||||
if sel, ok := m.(*ast.SelectorExpr); ok && sel.Sel.Name == "ObserveUnhealthy" {
|
||||
withDetector = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found || !withDetector {
|
||||
t.Fatalf("main calls stopUnhealthyApps=%v with ObserveUnhealthy=%v — decision 28 is built and never run", found, withDetector)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.317.0 (`16` §4): the agent's same-disk verdict reaches the backup side. A seam built and never wired
|
||||
// has shipped in this repo seven times; this walks main.go for the call AND for the agent call inside it.
|
||||
// COMPANION RED-PROOF: delete the SetDiskJudge block → "not wired"; replace ac.SameDisk with a constant →
|
||||
// "never asks the agent".
|
||||
func TestDiskJudge_IsWiredAtStartup(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var judge *ast.CallExpr
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if c, ok := n.(*ast.CallExpr); ok {
|
||||
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDiskJudge" {
|
||||
judge = c
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if judge == nil {
|
||||
t.Fatal("backupMgr.SetDiskJudge is not wired in main.go — Tier-2 would keep the st_dev rule for ever")
|
||||
}
|
||||
asks := false
|
||||
ast.Inspect(judge, func(n ast.Node) bool {
|
||||
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "SameDisk" {
|
||||
asks = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !asks {
|
||||
t.Fatal("SetDiskJudge is wired but never asks the agent (no SameDisk call inside it)")
|
||||
}
|
||||
}
|
||||
+926
-243
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.283.0 (decision 50, R-720): the deploy-done hook's SUCCESS branch — the one place a fresh install ends —
|
||||
// calls sett.DefaultOffboxOnForNewApp. The rule itself is pinned in internal/settings/offbox_default_test.go;
|
||||
// this pins that it is reached.
|
||||
// COMPANION RED-PROOF: delete the call from the hook → this test fails.
|
||||
func TestDecision50_DefaultIsWiredIntoTheDeployDoneHook(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
c, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && len(c.Args) == 1 {
|
||||
ast.Inspect(c.Args[0], func(m ast.Node) bool {
|
||||
if cc, ok := m.(*ast.CallExpr); ok {
|
||||
if ss, ok := cc.Fun.(*ast.SelectorExpr); ok && ss.Sel.Name == "DefaultOffboxOnForNewApp" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("the deploy-done hook does not call DefaultOffboxOnForNewApp — a new app would start with off-site OFF (R-720)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
)
|
||||
|
||||
// opActionReportFire holds the out-of-cycle report trigger once main has built it (func()). The
|
||||
// executor is built earlier than the trigger, so it reads it through this holder.
|
||||
var opActionReportFire atomic.Value
|
||||
|
||||
// operatorActionHandlers wires the four operator actions (R-314/R-279/R-177, `09` §3 decision 185)
|
||||
// to the running process. ONE wiring site, called from main and from the wiring test, so the test
|
||||
// exercises the doors production uses (the seam-built-but-never-wired trap).
|
||||
//
|
||||
// offsite_backup_now makes the SAME four checks as the household's „back up now"
|
||||
// (offboxRunHandler) and then runs the same call, so an operator press can never do what the
|
||||
// household's own press cannot.
|
||||
func operatorActionHandlers(backupMgr *backup.Manager, sched *scheduler.Scheduler, resultReady func(), logger *log.Logger) report.OperatorActionHandlers {
|
||||
h := report.OperatorActionHandlers{ResultReady: resultReady}
|
||||
if sched != nil {
|
||||
h.RunJob = sched.RunNow
|
||||
}
|
||||
if backupMgr == nil {
|
||||
return h
|
||||
}
|
||||
h.OffsiteBackupNow = func() (string, func(ctx context.Context) error) {
|
||||
switch {
|
||||
case !backupMgr.OffboxConfigured():
|
||||
return "no off-site target is set on this box", nil
|
||||
case !backupMgr.OffboxRunnable():
|
||||
return "the off-site key step (escrow) is not done yet — the run is refused until it is", nil
|
||||
case backupMgr.OffboxOrphaned():
|
||||
return "the off-site store is orphaned (written with an older key) — a run cannot write to it", nil
|
||||
case backupMgr.IsRunning():
|
||||
return "a backup run is already in flight", nil
|
||||
}
|
||||
return "", func(ctx context.Context) error {
|
||||
if logger != nil {
|
||||
logger.Printf("[INFO] [opaction] off-site backup started at the operator's request")
|
||||
}
|
||||
err := backupMgr.RunOffboxBackupWithProgress(ctx)
|
||||
if errors.Is(err, backup.ErrOffboxRunInFlight) {
|
||||
return fmt.Errorf("%w: %v", report.ErrActionRefused, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
h.AbandonStop = backupMgr.StopAbandon
|
||||
h.AbandonExtend = func(days int) (time.Time, error) { return backupMgr.ExtendAbandon(days) }
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// `09` §3 decision 185 (D1), the design's red test (1): a reply carrying abandon_stop during a
|
||||
// countdown stops it IN THE RUNNING MANAGER — and settings.json on disk agrees. This is the reason
|
||||
// the action rides the report reply instead of the CLI lever: the CLI runs a SECOND process, whose
|
||||
// write the running controller can overwrite (the lost update, main.go's R-241 block).
|
||||
func TestD1_AbandonStopActsOnTheRunningManagerAndDisk(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
dir := t.TempDir()
|
||||
settingsPath := filepath.Join(dir, "settings.json")
|
||||
sett, err := settings.Load(settingsPath, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dir
|
||||
cfg.Paths.SystemDataPath = filepath.Join(dir, "sys")
|
||||
mgr := backup.NewManager(cfg, sett, logger)
|
||||
// No test reaches a real ssh or restic (R-488 / R-650).
|
||||
mgr.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
|
||||
t.Fatal("abandon_stop must issue no remote command")
|
||||
return nil, nil
|
||||
})
|
||||
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
|
||||
t.Fatal("abandon_stop must run no restic")
|
||||
return nil, nil
|
||||
})
|
||||
due := time.Now().Add(10 * 24 * time.Hour).UTC()
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
||||
EscrowState: "escrowed",
|
||||
AbandonRepoPath: "/srv/repo.orphaned-20261001",
|
||||
AbandonStartedAt: time.Now().Add(-4 * 24 * time.Hour).UTC().Format(time.RFC3339),
|
||||
AbandonAt: due.Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !mgr.AbandonStatus().Active {
|
||||
t.Fatal("fixture: a countdown must be running")
|
||||
}
|
||||
|
||||
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
|
||||
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpAbandonStop}})
|
||||
|
||||
if mgr.AbandonStatus().Active {
|
||||
t.Fatal("the running manager still counts down after abandon_stop")
|
||||
}
|
||||
onDisk, err := settings.Load(settingsPath, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tg := onDisk.GetOffboxTarget(); tg == nil || tg.AbandonAt != "" || tg.AbandonRepoPath == "" {
|
||||
t.Fatalf("settings.json disagrees: %+v (want no due date, the set-aside path kept)", tg)
|
||||
}
|
||||
rs := o.Results()
|
||||
if len(rs) != 1 || rs[0].Outcome != report.OutcomeDone {
|
||||
t.Fatalf("result = %+v", rs)
|
||||
}
|
||||
|
||||
// Control: the same countdown, a second press → nothing to stop, reported as such (not „done").
|
||||
o.Reconcile([]report.OperatorAction{{ID: 2, Action: report.OpAbandonStop}})
|
||||
if r := o.Results(); len(r) != 1 || r[0].ID != 2 || r[0].Outcome != report.OutcomeFailed {
|
||||
t.Fatalf("second stop: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// offsite_backup_now refuses exactly where the household's „back up now" refuses: no target here.
|
||||
func TestD1_OffsiteBackupNowRefusesWithoutATarget(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
dir := t.TempDir()
|
||||
sett, err := settings.Load(filepath.Join(dir, "settings.json"), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dir
|
||||
mgr := backup.NewManager(cfg, sett, logger)
|
||||
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
|
||||
t.Fatal("no restic may run")
|
||||
return nil, nil
|
||||
})
|
||||
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
|
||||
o.Reconcile([]report.OperatorAction{{ID: 9, Action: report.OpOffsiteBackupNow}})
|
||||
if r := o.Results(); len(r) != 1 || r[0].Outcome != report.OutcomeRefused {
|
||||
t.Fatalf("result = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// run_job reaches the scheduler's RunNow: a registered job runs; a job not registered on this box is
|
||||
// refused.
|
||||
func TestD1_RunJobUsesTheScheduler(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
sched := scheduler.New(logger)
|
||||
ran := make(chan bool, 1)
|
||||
sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { ran <- scheduler.OnDemand(ctx); return nil })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sched.Start(ctx)
|
||||
defer sched.Stop()
|
||||
o := report.NewOperatorActions(ctx, operatorActionHandlers(nil, sched, nil, logger), logger)
|
||||
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpRunJob, Arg: "fill-watch"}, {ID: 2, Action: report.OpRunJob, Arg: "offsite-proof"}})
|
||||
select {
|
||||
case od := <-ran:
|
||||
if !od {
|
||||
t.Fatal("the job did not see itself as on-demand")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("fill-watch never ran")
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for len(o.Results()) < 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
got := map[int64]string{}
|
||||
for _, r := range o.Results() {
|
||||
got[r.ID] = r.Outcome
|
||||
}
|
||||
if got[1] != report.OutcomeDone || got[2] != report.OutcomeRefused {
|
||||
t.Fatalf("outcomes = %v, want 1:done 2:refused (offsite-proof not registered here)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// main.go hands the reply's list to the executor and the trigger to its holder — one call each
|
||||
// (the seam-built-but-never-wired trap: a green executor that main never calls proves nothing).
|
||||
func TestD1_MainWiresTheExecutor(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := map[string]int{}
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch fn := call.Fun.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if x, ok := fn.X.(*ast.Ident); ok {
|
||||
found[x.Name+"."+fn.Sel.Name]++
|
||||
}
|
||||
case *ast.Ident:
|
||||
found[fn.Name]++
|
||||
}
|
||||
return true
|
||||
})
|
||||
for _, want := range []string{"opActions.Reconcile", "report.SetOperatorActions", "operatorActionHandlers", "opActionReportFire.Store"} {
|
||||
if found[want] != 1 {
|
||||
t.Errorf("main.go calls %s %d time(s), want exactly 1", want, found[want])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-1105: restic is pinned OUTSIDE the OS. Until v0.323.0 it came from the runtime's apt list, so the
|
||||
// version was „whatever Debian ships" — a Debian 12 → 13 base move would have swapped restic 0.14.0 for
|
||||
// 0.18.0 under the off-site code with nobody choosing it. The consequences pinned here:
|
||||
// - the version is one explicit ARG, and each architecture's checksum is pinned beside it;
|
||||
// - the fetch checks restic's signature (vendored key, no keyserver) AND both checksums;
|
||||
// - the runtime takes restic ONLY from that stage — no apt package can bring a second one;
|
||||
// - every base image is pinned by digest, and the Docker repo's codename is read from the image.
|
||||
func TestR1105_ResticIsPinnedOutsideTheOS(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(raw)
|
||||
// Stages, split on FROM, with `\`-continued lines joined so an apt list reads as one command.
|
||||
joined := strings.ReplaceAll(src, "\\\n", " ")
|
||||
var stages []string
|
||||
for _, part := range regexp.MustCompile(`(?m)^FROM `).Split(joined, -1)[1:] {
|
||||
stages = append(stages, "FROM "+part)
|
||||
}
|
||||
if len(stages) < 3 {
|
||||
t.Fatalf("expected a builder, a restic and a runtime stage, found %d stages", len(stages))
|
||||
}
|
||||
var fetch string
|
||||
for _, s := range stages {
|
||||
if regexp.MustCompile(`^FROM \S+ AS restic\b`).MatchString(s) {
|
||||
fetch = s
|
||||
}
|
||||
}
|
||||
if fetch == "" {
|
||||
t.Fatal("no `AS restic` stage: restic is no longer fetched as a pinned release binary")
|
||||
}
|
||||
runtime := stages[len(stages)-1]
|
||||
|
||||
if m := regexp.MustCompile(`ARG RESTIC_VERSION=(\d+\.\d+\.\d+)\s`).FindStringSubmatch(fetch); m == nil {
|
||||
t.Error("ARG RESTIC_VERSION=X.Y.Z is missing — the version must be an explicit decision")
|
||||
}
|
||||
for _, arch := range []string{"AMD64", "ARM64"} {
|
||||
if !regexp.MustCompile(`ARG RESTIC_SHA256_` + arch + `=[0-9a-f]{64}\s`).MatchString(fetch) {
|
||||
t.Errorf("no pinned sha256 for %s", arch)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"COPY third_party/restic/restic-release-key.asc",
|
||||
"gpgv --keyring", // SHA256SUMS is restic's, signed
|
||||
`SHA256SUMS | sha256sum -c -`, // the download matches the published sum
|
||||
`echo "${want} ${f}" | sha256sum -c -`, // ...and the sum pinned in this file
|
||||
} {
|
||||
if !strings.Contains(fetch, want) {
|
||||
t.Errorf("the restic stage no longer does %q", want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("../../third_party/restic/restic-release-key.asc"); err != nil {
|
||||
t.Errorf("the vendored release key is missing: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(runtime, "COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic") {
|
||||
t.Error("the runtime does not take restic from the pinned stage")
|
||||
}
|
||||
aptRe := regexp.MustCompile(`apt-get install[^&]*`)
|
||||
for _, inst := range aptRe.FindAllString(runtime, -1) {
|
||||
if regexp.MustCompile(`\srestic(\s|$)`).MatchString(inst) {
|
||||
t.Errorf("the runtime's apt list installs restic — a second, OS-chosen restic: %q", strings.Join(strings.Fields(inst), " "))
|
||||
}
|
||||
}
|
||||
|
||||
for _, from := range regexp.MustCompile(`(?m)^FROM (\S+)`).FindAllStringSubmatch(src, -1) {
|
||||
if !strings.Contains(from[1], "@sha256:") {
|
||||
t.Errorf("base image %s is not pinned by digest", from[1])
|
||||
}
|
||||
}
|
||||
if regexp.MustCompile(`download\.docker\.com/linux/debian (bookworm|trixie|forky)`).MatchString(src) {
|
||||
t.Error("the Docker apt repo codename is hard-coded — it must come from /etc/os-release (VERSION_CODENAME)")
|
||||
}
|
||||
if !strings.Contains(runtime, "${VERSION_CODENAME}") {
|
||||
t.Error("the Docker apt repo line does not read VERSION_CODENAME")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-208: `ARG VERSION` / `ARG GIT_COMMIT` declared ABOVE `RUN go mod download` put a per-build value
|
||||
// into that RUN's cache key, so every release re-downloaded the modules (measured: 208 cache records,
|
||||
// every one with usage count 1). The consequence pinned here, in the builder stage: no ARG whose value
|
||||
// changes per build is declared before the module-download step, and both are still declared before
|
||||
// the `go build` that reads them (or the binary would report "dev"/"unknown").
|
||||
func TestR208_DockerfileVersionArgsSitBelowModuleDownload(t *testing.T) {
|
||||
src, err := os.ReadFile("../../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(string(src), "\n")
|
||||
argRe := regexp.MustCompile(`^\s*ARG\s+(VERSION|GIT_COMMIT|BUILD_TIME)\b`)
|
||||
download, build := -1, -1
|
||||
args := map[string]int{}
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "FROM ") && i > 0 && download >= 0 && build >= 0 {
|
||||
break // the runtime stage: only the builder matters
|
||||
}
|
||||
if m := argRe.FindStringSubmatch(l); m != nil {
|
||||
if _, seen := args[m[1]]; !seen {
|
||||
args[m[1]] = i // the FIRST declaration: any one above the download is the defect
|
||||
}
|
||||
}
|
||||
if strings.Contains(l, "go mod download") && download < 0 {
|
||||
download = i
|
||||
}
|
||||
if strings.Contains(l, "go build") && build < 0 {
|
||||
build = i
|
||||
}
|
||||
}
|
||||
if download < 0 || build < 0 {
|
||||
t.Fatalf("the builder stage no longer has a `go mod download` (%d) or a `go build` (%d) — this test reads the wrong file", download, build)
|
||||
}
|
||||
for _, name := range []string{"VERSION", "GIT_COMMIT"} {
|
||||
at, ok := args[name]
|
||||
if !ok {
|
||||
t.Errorf("ARG %s is not declared in the builder stage — the binary would be built without it", name)
|
||||
continue
|
||||
}
|
||||
if at < download {
|
||||
t.Errorf("R-208: ARG %s (line %d) is declared above `go mod download` (line %d), so every build with a new value re-downloads the modules", name, at+1, download+1)
|
||||
}
|
||||
if at > build {
|
||||
t.Errorf("ARG %s (line %d) is declared after the `go build` that reads it (line %d)", name, at+1, build+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// R-359 — the scheduled job must be PROVEN WIRED.
|
||||
@@ -87,16 +89,33 @@ func TestR359_DebugCallbackIsWired(t *testing.T) {
|
||||
|
||||
func TestR359_OutcomeMessagesCarryNoMachineDetail(t *testing.T) {
|
||||
// The customer gets a sentence; restic's words go to the log. R-379: 615 bytes of raw database
|
||||
// text reached a customer once.
|
||||
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
|
||||
if contains(integrityFailedMsg, bad) {
|
||||
t.Errorf("the failure sentence carries machine detail %q: %q", bad, integrityFailedMsg)
|
||||
}
|
||||
// text reached a customer once. R-585: the sentence is a bundle key now, so BOTH languages are
|
||||
// held to it — an English household must be told what not to do just as plainly.
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// It must still tell them what to do — and what NOT to do.
|
||||
for _, want := range []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"} {
|
||||
if !contains(integrityFailedMsg, want) {
|
||||
t.Errorf("the failure sentence is a dead end; missing %q", want)
|
||||
for _, tc := range []struct {
|
||||
lang string
|
||||
wants []string
|
||||
}{
|
||||
{"hu", []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"}},
|
||||
{"en", []string{"Do not delete anything", "contact us"}},
|
||||
} {
|
||||
msg, fellBack, ok := b.Text(tc.lang, "event.backup_integrity_failed")
|
||||
if !ok || fellBack {
|
||||
t.Fatalf("%s: event.backup_integrity_failed is missing (ok=%v fellBack=%v)", tc.lang, ok, fellBack)
|
||||
}
|
||||
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
|
||||
if contains(msg, bad) {
|
||||
t.Errorf("%s: the failure sentence carries machine detail %q: %q", tc.lang, bad, msg)
|
||||
}
|
||||
}
|
||||
// It must still tell them what to do — and what NOT to do.
|
||||
for _, want := range tc.wants {
|
||||
if !contains(msg, want) {
|
||||
t.Errorf("%s: the failure sentence is a dead end; missing %q", tc.lang, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-363 / R-547: the fill watcher ran once a day (plus once after start), so a filesystem that filled
|
||||
// right after the 03:30 run went unannounced for ~24 h, and a disk held at 96 % for ten minutes raised
|
||||
// nothing. The consequence pinned here: main.go registers the SAME fillWatcher.Check on a periodic
|
||||
// job whose interval is no longer than the shortest measured silent window (10 min), so any such
|
||||
// window contains a check. Edge-triggering (no repeat mails at a faster cadence) is pinned in
|
||||
// internal/fillwatch (TestWarnsOnceThenIsSilent, TestEdgeStateSurvivesARestart).
|
||||
func TestFillWatchRunsOnAnInterval(t *testing.T) {
|
||||
const measuredSilentWindow = 10 * time.Minute // R-547, chaos night 2026-09-17
|
||||
if fillWatchInterval <= 0 || fillWatchInterval > measuredSilentWindow {
|
||||
t.Errorf("fillWatchInterval = %s; it must be >0 and <= %s, or a fill window like R-547's can pass unseen", fillWatchInterval, measuredSilentWindow)
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
periodic := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Every" || len(call.Args) != 3 {
|
||||
return true
|
||||
}
|
||||
iv, ok := call.Args[1].(*ast.Ident)
|
||||
if !ok || iv.Name != "fillWatchInterval" {
|
||||
return true
|
||||
}
|
||||
// The job body must call fillWatcher.Check — the same edge-triggered check, not a new one.
|
||||
ast.Inspect(call.Args[2], func(m ast.Node) bool {
|
||||
if c, ok := m.(*ast.CallExpr); ok {
|
||||
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "Check" {
|
||||
if x, ok := s.X.(*ast.Ident); ok && x.Name == "fillWatcher" {
|
||||
periodic++
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
if periodic != 1 {
|
||||
t.Errorf("R-363: main.go registers fillWatcher.Check on a periodic (sched.Every, fillWatchInterval) job %d times, want 1 — without it the fill check is daily only", periodic)
|
||||
}
|
||||
}
|
||||
@@ -65,12 +65,12 @@ func TestR475_AdapterReadsEveryTier(t *testing.T) {
|
||||
if cb := adapterMethodSelectors(t, "CanBackUp"); !strings.Contains(cb, " CanBackUpApp ") {
|
||||
t.Errorf("CanBackUp must ask backup.CanBackUpApp; selectors:%s", cb)
|
||||
}
|
||||
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " Tier ") {
|
||||
t.Errorf("the hold must be told the chosen TIER, or it cannot name it; selectors:%s", h)
|
||||
}
|
||||
// R-479: and WHAT the copy holds, computed from the app's data layout at hold time.
|
||||
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " UpdateCopyHolds ") || !strings.Contains(h, " HoldAfterFailedUpdateHolding ") {
|
||||
t.Errorf("the hold must carry UpdateCopyHolds through HoldAfterFailedUpdateHolding (R-479); selectors:%s", h)
|
||||
// R-659 (v0.268.0) CHANGED THIS ON PURPOSE: the hold no longer names the precondition copy the
|
||||
// adapter is handed (round 11: that copy was one the restore refuses). The tier, and R-479's
|
||||
// "what the copy holds", are now chosen inside backup.HoldAfterFailedUpdateWhole from every tier —
|
||||
// pinned there by TestR659_TheHoldNamesOnlyAWholeCopy (which asserts the holds-phrase too).
|
||||
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " HoldAfterFailedUpdateWhole ") {
|
||||
t.Errorf("the hold must be recorded through HoldAfterFailedUpdateWhole (R-659); selectors:%s", h)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
type fakeHDD struct {
|
||||
sts []stacks.Stack
|
||||
hdd map[string]string
|
||||
}
|
||||
|
||||
func (f fakeHDD) GetStacks() []stacks.Stack { return f.sts }
|
||||
func (f fakeHDD) LoadAppConfigByName(n string) *stacks.AppConfig {
|
||||
return &stacks.AppConfig{Env: map[string]string{"HDD_PATH": f.hdd[n]}}
|
||||
}
|
||||
|
||||
// R-521: one unplugged drive mailed the operator five times — storage_disconnected plus an
|
||||
// app_start_failed per app on it. The consequence asserted: the NOTIFIER is told the drive's apps are
|
||||
// not down (no per-app mail), while the dashboard's dead list still carries them, and an app on
|
||||
// another drive that is genuinely down still alarms.
|
||||
func TestR521_DriveLostAppsDoNotMailOneByOne(t *testing.T) {
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{"/mnt/felhom-drives/usb", "/mnt/felhom-drives/other"} {
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: p, Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", true, []string{"immich"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sts := []stacks.Stack{
|
||||
stack("immich", stacks.StateExited, true, false), // recorded by the disconnect
|
||||
stack("paperless", stacks.StateExited, true, false), // on the lost drive by HDD_PATH
|
||||
stack("romm", stacks.StateExited, true, false), // another drive, genuinely broken
|
||||
}
|
||||
for i := range sts {
|
||||
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
|
||||
}
|
||||
mgr := fakeHDD{sts: sts, hdd: map[string]string{"immich": "/mnt/felhom-drives/usb", "paperless": "/mnt/felhom-drives/usb", "romm": "/mnt/felhom-drives/other"}}
|
||||
|
||||
dead, states := classifyRunStates(sts, nil, nil, time.Now())
|
||||
notified := downByName(maskDriveLost(states, driveLostApps(sett, mgr)))
|
||||
if notified["immich"] || notified["paperless"] {
|
||||
t.Fatalf("R-521: an app stopped by the lost drive is still reported down to the notifier: %v", notified)
|
||||
}
|
||||
if !notified["romm"] {
|
||||
t.Fatal("a genuinely broken app on another drive stopped alarming — over-correction")
|
||||
}
|
||||
if dn := deadNames(dead); !dn["immich"] || !dn["paperless"] {
|
||||
t.Fatalf("the dashboard's dead list must still carry the drive's apps: %v", dn)
|
||||
}
|
||||
// Reconnected: nothing is masked any more.
|
||||
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := driveLostApps(sett, mgr); len(got) != 0 {
|
||||
t.Fatalf("with no drive disconnected nothing may be masked, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The wiring: the dead-app job hands the notifier the masked states.
|
||||
func TestR521_DeadAppJobMasksDriveLost(t *testing.T) {
|
||||
found := false
|
||||
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "NotifyAppStartFailures" || len(call.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
if inner, ok := call.Args[0].(*ast.CallExpr); ok {
|
||||
if id, ok := inner.Fun.(*ast.Ident); ok && id.Name == "maskDriveLost" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("R-521: main calls NotifyAppStartFailures without maskDriveLost")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/fillwatch"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
type fillPushRecord struct {
|
||||
eventType, severity, message, household string
|
||||
details interface{}
|
||||
}
|
||||
|
||||
type fakeFillPusher struct{ got []fillPushRecord }
|
||||
|
||||
func (f *fakeFillPusher) PushEventHousehold(eventType, severity, message, household string, details interface{}) {
|
||||
f.got = append(f.got, fillPushRecord{eventType, severity, message, household, details})
|
||||
}
|
||||
|
||||
// R-521 F7 (BIGNIGHT 2026-09-14): the guest's own system disk at 95 % produced only `health_degraded`,
|
||||
// whose mail a cooldown swallowed — no disk-specific event reached the hub. The consequence pinned here,
|
||||
// through the REAL target list (fillTargets), the REAL watcher and the REAL push closure (fillEventPush):
|
||||
// the guest's root filesystem at F7's numbers sends the hub's existing `disk_critical` event (severity
|
||||
// critical, a type the hub already allowlists), ONCE (edge-triggered), carrying the Hungarian sentence
|
||||
// AND the household's own (English here).
|
||||
func TestR521F7_RootFilesystemSendsTheDiskEventInBothLanguages(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.SystemDataPath = "/mnt/sys_drive"
|
||||
usage := func(p string) *fillwatch.Usage {
|
||||
if p == system.DockerVolumePath { // the guest's root filesystem as the controller sees it
|
||||
return &fillwatch.Usage{UsedPercent: 95, AvailGB: 3.5, UsedGB: 61.8, TotalGB: 68.7}
|
||||
}
|
||||
return &fillwatch.Usage{UsedPercent: 30, AvailGB: 50, UsedGB: 20, TotalGB: 70}
|
||||
}
|
||||
w := fillwatch.New(filepath.Join(t.TempDir(), "fill.json"), log.New(io.Discard, "", 0),
|
||||
func() []fillwatch.Target { return fillTargets(cfg, nil) }, usage)
|
||||
rec := &fakeFillPusher{}
|
||||
w.SetNotify(fillEventPush(rec))
|
||||
w.SetLang(func() string { return "en" })
|
||||
|
||||
for i := 0; i < 2; i++ { // the second check sees the same band: nothing new (edge-triggered)
|
||||
if err := w.Check(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(rec.got) != 1 {
|
||||
t.Fatalf("pushes = %d (%+v), want exactly 1 disk event for the root filesystem", len(rec.got), rec.got)
|
||||
}
|
||||
p := rec.got[0]
|
||||
if p.eventType != "disk_critical" || p.severity != "critical" {
|
||||
t.Errorf("pushed %s/%s, want disk_critical/critical", p.eventType, p.severity)
|
||||
}
|
||||
if d, _ := p.details.(map[string]any); d == nil || d["path"] != system.DockerVolumePath {
|
||||
t.Errorf("details = %v, want path %q", p.details, system.DockerVolumePath)
|
||||
}
|
||||
if !strings.Contains(p.message, "Alkalmaz") || !strings.Contains(p.message, "kritikusan") {
|
||||
t.Errorf("Hungarian message = %q", p.message)
|
||||
}
|
||||
if !strings.Contains(p.household, "App area") || !strings.Contains(p.household, "critically full") {
|
||||
t.Errorf("household (English) message = %q", p.household)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-659 (v0.268.0) — the pieces are wired: the adapter records the hold through the WHOLE-copy walk
|
||||
// (never the precondition copy again), and the held-event sink sends app_hold_no_whole_copy.
|
||||
// An AST walk, not a substring (a commented-out call satisfies strings.Contains).
|
||||
func TestR659_HoldAndEventAreWired(t *testing.T) {
|
||||
_, f, _ := slice4CallLines(t)
|
||||
var calls []string
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "HoldAfterFailedUpdate" || fn.Recv == nil || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
if st, ok := fn.Recv.List[0].Type.(*ast.StarExpr); !ok || st.X.(*ast.Ident).Name != "updateGuardsAdapter" {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if sel, ok := n.(*ast.SelectorExpr); ok {
|
||||
calls = append(calls, sel.Sel.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
body := strings.Join(calls, " ")
|
||||
if !strings.Contains(body, "HoldAfterFailedUpdateWhole") {
|
||||
t.Fatal("the adapter does not record the hold through HoldAfterFailedUpdateWhole — the precondition copy would be named again (R-659)")
|
||||
}
|
||||
if strings.Contains(body, "HoldAfterFailedUpdateHolding") {
|
||||
t.Fatal("the adapter still calls HoldAfterFailedUpdateHolding with the precondition copy")
|
||||
}
|
||||
sends := false
|
||||
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||
if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "NotifyAppHoldNoWholeCopy" {
|
||||
sends = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !sends {
|
||||
t.Fatal("main never calls NotifyAppHoldNoWholeCopy — support would never be told")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-660 (v0.268.0) — an app HELD after a failed update is stopped by the product and has its own
|
||||
// event. Measured on 9202 2026-09-23 night (chaos rounds 8 and 11): `app_update_held` at 20:56:04 and
|
||||
// 21:42:39, then `app_start_failed` 11 s and 13 s later for the same moment — two alarms, one fact.
|
||||
|
||||
type fakeHeld map[string]bool
|
||||
|
||||
func (f fakeHeld) UpdateHeldStacks() map[string]bool { return f }
|
||||
|
||||
// TestR660_UpdateHeldAppIsNotReportedDown — the consequence, through the suppression the scan builds.
|
||||
// A genuinely exited app beside it must still alarm (the over-correction guard).
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make updateHeldSet return nil. This test then fails at "a held app
|
||||
// was reported DOWN".
|
||||
func TestR660_UpdateHeldAppIsNotReportedDown(t *testing.T) {
|
||||
sts := []stacks.Stack{
|
||||
stack("nextcloud", stacks.StateStopped, true, false), // held after a failed update
|
||||
stack("romm", stacks.StateExited, true, false), // genuinely broken
|
||||
}
|
||||
// A held app's intent is "running" — the household never asked for the stop.
|
||||
for i := range sts {
|
||||
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
|
||||
}
|
||||
dead, states := classifyRunStates(sts, unionSuppressed(nil, updateHeldSet(fakeHeld{"nextcloud": true})), nil, time.Now())
|
||||
down := downByName(states)
|
||||
if down["nextcloud"] || deadNames(dead)["nextcloud"] {
|
||||
t.Fatal("a held app was reported DOWN — the second, false alarm after app_update_held (R-660)")
|
||||
}
|
||||
if !down["romm"] {
|
||||
t.Fatal("a genuinely exited app stopped alarming — the fix silenced a real fault")
|
||||
}
|
||||
// Control: WITHOUT the held set the same app does alarm — so the suppression is what decides.
|
||||
_, states = classifyRunStates(sts, nil, nil, time.Now())
|
||||
if !downByName(states)["nextcloud"] {
|
||||
t.Fatal("control failed: the fixture's app does not alarm even without the suppression")
|
||||
}
|
||||
}
|
||||
|
||||
// The wiring: main passes the backup manager, and the scan unions its held set.
|
||||
func TestR660_ScanIsGivenTheHeldSet(t *testing.T) {
|
||||
_, f, _ := slice4CallLines(t)
|
||||
reads := false
|
||||
for _, d := range f.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != "scanDeployedAppRunStates" || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if call, ok := n.(*ast.CallExpr); ok {
|
||||
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "updateHeldSet" {
|
||||
reads = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !reads {
|
||||
t.Fatal("scanDeployedAppRunStates does not read the update-held set (R-660)")
|
||||
}
|
||||
withB := false
|
||||
ast.Inspect(mainBody(t), func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "scanDeployedAppRunStates" {
|
||||
for _, a := range call.Args {
|
||||
if x, ok := a.(*ast.Ident); ok && x.Name == "backupMgr" {
|
||||
withB = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !withB {
|
||||
t.Fatal("main calls scanDeployedAppRunStates without backupMgr — the held set is built and never read")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/quiesce"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-721 (v0.283.1) — the seam, not the rule. The rule is pinned in internal/backup and internal/quiesce with
|
||||
// fakes that DO answer WantsStopped; production hands the backup manager a *stackAdapter and the quiesce loop
|
||||
// a *stacks.Manager, and a type that does not answer makes the check silently skip. Measured live on 9202
|
||||
// 2026-09-30 with v0.283.0: the adapter lacked the method and the dump restarted a stopped app.
|
||||
// COMPANION RED-PROOF: delete stackAdapter.WantsStopped → this test fails.
|
||||
func TestR721_TheBackupsStackProviderAnswersWantsStopped(t *testing.T) {
|
||||
var prov interface{} = &stackAdapter{}
|
||||
if _, ok := prov.(interface{ WantsStopped(string) bool }); !ok {
|
||||
t.Fatal("the backup manager's production stack provider does not answer WantsStopped — a household Stop is undone by the volume dump (R-721)")
|
||||
}
|
||||
// The quiesce loop is handed the stacks manager itself (main.go quiesce.Options{Stacks: stackMgr}).
|
||||
// The startup crash recovery is handed gatedAppStopStarter{inner: stackMgr} (main.go SetStarter).
|
||||
var gs interface{} = gatedAppStopStarter{inner: (*stacks.Manager)(nil)}
|
||||
if _, ok := gs.(interface{ WantsStopped(string) bool }); !ok {
|
||||
t.Fatal("the crash recovery's starter does not answer WantsStopped (R-721)")
|
||||
}
|
||||
var mgr *stacks.Manager
|
||||
var _ quiesce.Stacks = mgr
|
||||
if _, ok := interface{}(mgr).(interface{ WantsStopped(string) bool }); !ok {
|
||||
t.Fatal("the quiesce loop's production Stacks does not answer WantsStopped (R-721)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
|
||||
)
|
||||
|
||||
// R-856 (`09` §3 decision 143): the dead-app check — the source of app_start_failed and
|
||||
// app_stopped_unhealthy — waits on the crash-aware boot grace, not on the fixed 90 s constant.
|
||||
//
|
||||
// Red-proof: put `time.Since(startTime) < deadAppBootGrace` back as the check's gate — this test fails.
|
||||
func TestR856_DeadAppCheckWaitsOnTheCrashAwareGrace(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var body *ast.BlockStmt
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) < 3 {
|
||||
return true
|
||||
}
|
||||
if sel, ok := call.Fun.(*ast.SelectorExpr); !ok || sel.Sel.Name != "Every" {
|
||||
return true
|
||||
}
|
||||
if lit, ok := call.Args[0].(*ast.BasicLit); ok && lit.Value == `"deadapp-check"` {
|
||||
if fl, ok := call.Args[2].(*ast.FuncLit); ok {
|
||||
body = fl.Body
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if body == nil {
|
||||
t.Fatal("the deadapp-check schedule is gone from main.go")
|
||||
}
|
||||
within, constant := false, false
|
||||
ast.Inspect(body, func(n ast.Node) bool {
|
||||
switch x := n.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if id, ok := x.X.(*ast.Ident); ok && id.Name == "bootGrace" && x.Sel.Name == "Within" {
|
||||
within = true
|
||||
}
|
||||
case *ast.Ident:
|
||||
if x.Name == "deadAppBootGrace" {
|
||||
constant = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !within || constant {
|
||||
t.Errorf("the dead-app check must gate on bootGrace.Within (found=%v) and not on deadAppBootGrace (found=%v)", within, constant)
|
||||
}
|
||||
}
|
||||
|
||||
// The normal grace is today's 90 s: a normal boot must keep it exactly (decision 143's other half).
|
||||
func TestR856_NormalGraceIsTheDeadAppBootGrace(t *testing.T) {
|
||||
if crashboot.NormalGrace != deadAppBootGrace {
|
||||
t.Errorf("crashboot.NormalGrace = %s, deadAppBootGrace = %s — a normal boot must keep today's grace", crashboot.NormalGrace, deadAppBootGrace)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/ast"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
|
||||
)
|
||||
|
||||
// TestR871_CatchUpWiring — v0.295.0 (`09` decision 109): the catch-up is built, triggered at start and on a host
|
||||
// resume, and holds the whole-guest cycle — the seam-built-but-never-wired class (four earlier instances).
|
||||
// COMPANION RED-PROOF: delete the `catchUp.Evaluate(ctx, "controller start")` line → this fails.
|
||||
func TestR871_CatchUpWiring(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
for _, call := range []string{"ResumeWatch", "SetCatchUpFn", "catchUpLegs", "NotifyBackupCatchUp"} {
|
||||
if len(lines[call]) == 0 {
|
||||
t.Errorf("%s is never called in main.go — the catch-up is built but not wired", call)
|
||||
}
|
||||
}
|
||||
// Both triggers, by their reason argument: the start (a literal) and the resume (inside ResumeWatch's callback).
|
||||
_, f, _ := slice4CallLines(t)
|
||||
start := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if call, ok := n.(*ast.CallExpr); ok {
|
||||
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Evaluate" && len(call.Args) == 2 {
|
||||
if lit, ok := call.Args[1].(*ast.BasicLit); ok && lit.Value == `"controller start"` {
|
||||
start++
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if start != 1 {
|
||||
t.Errorf("the catch-up's START trigger (Evaluate(ctx, \"controller start\")) is called %d times, want 1", start)
|
||||
}
|
||||
}
|
||||
|
||||
// The catch-up's leg table holds the three BACKUP legs and nothing else, and the off-site body it shares with the
|
||||
// night never calls the update leg (that is chained only around the SCHEDULED off-site job).
|
||||
// COMPANION RED-PROOF: put `stackMgr.RunUpdateLeg` inside offsiteOnly's body → "the update leg is inside".
|
||||
func TestR871_CatchUpRunsNoUpdateLeg(t *testing.T) {
|
||||
noop := func(context.Context) error { return nil }
|
||||
legs := catchUpLegs(noop, noop, noop)
|
||||
if len(legs) != 3 || legs[nightchain.LegDBDump] == nil || legs[nightchain.LegTier2] == nil || legs[nightchain.LegOffsite] == nil {
|
||||
t.Fatalf("catchUpLegs = %v, want exactly db-dump, tier2, offsite", legs)
|
||||
}
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
i := strings.Index(s, "offsiteOnly := withLeg(nightchain.LegOffsite,")
|
||||
j := strings.Index(s[i:], "sched.Daily(\"offbox-backup\"")
|
||||
if i < 0 || j < 0 {
|
||||
t.Fatal("the shared off-site leg body was not found in main.go")
|
||||
}
|
||||
if body := s[i : i+j]; strings.Contains(body, "RunUpdateLeg") || strings.Contains(body, "chainUpdateLeg") {
|
||||
t.Fatal("the update leg is inside the off-site body the catch-up runs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
||||
)
|
||||
|
||||
// R-970 (v0.322.0) — the box's own hub API key opens NO route on the box.
|
||||
//
|
||||
// Until v0.322.0 a Bearer equal to the hub key reached /api/config/ (read the whole controller.yaml with every secret,
|
||||
// and REPLACE it), /api/selfupdate/ and /api/geo/ from the internet, unsigned — against `01` §4 („no inbound control
|
||||
// endpoint") and §10. The test drives the REAL route table (buildMux) with real web.Server and api.Router: a password
|
||||
// is set (so auth is on), the request carries the key and no session, and the planted controller.yaml must be neither
|
||||
// served nor changed.
|
||||
//
|
||||
// RED-PROOF (2026-10-16, REPORT): with an /api/geo/ route mounted without the session check, this failed at
|
||||
// „GET /api/geo/countries with the hub key answered 200".
|
||||
func TestR970_HubKeyOpensNoDoor(t *testing.T) {
|
||||
const hubKey = "TESTONLY-hub-key-5d1b"
|
||||
const secret = "TESTONLY-tunnel-token-q9"
|
||||
dir := t.TempDir()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
cfgPath := filepath.Join(dir, "controller.yaml")
|
||||
planted := "customer:\n id: t\n domain: example.hu\ninfrastructure:\n cf_tunnel_token: " + secret + "\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(planted), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Default()
|
||||
cfg.Customer.Domain = "example.hu"
|
||||
cfg.Hub.APIKey = hubKey
|
||||
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetPasswordHash("$2a$10$abcdefghijklmnopqrstuuJ1A9pDq6s9F2x1H3bQb0v8Y3wq6z2Om"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ws := web.NewServer(cfg, nil, nil, nil, nil, sett, nil, nil, nil, lg, "test")
|
||||
ar := api.NewRouter(cfg, cfgPath, sett, nil, nil, nil, nil, nil, nil, nil, lg)
|
||||
mux := buildMux(ar, ws)
|
||||
|
||||
// Control: auth is on — a request with no key and no session is refused, so a refusal below means something.
|
||||
ctl := httptest.NewRequest(http.MethodGet, "/api/stacks", nil)
|
||||
ctl.Host = "felhom.example.hu"
|
||||
cw := httptest.NewRecorder()
|
||||
mux.ServeHTTP(cw, ctl)
|
||||
if cw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("control: an anonymous /api/stacks answered %d, want 401 — auth is not on in this harness", cw.Code)
|
||||
}
|
||||
|
||||
paths := []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash",
|
||||
"/api/selfupdate/check", "/api/selfupdate/update", "/api/geo/settings", "/api/geo/countries", "/api/geo/sync"}
|
||||
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
|
||||
for _, p := range paths {
|
||||
for _, m := range methods {
|
||||
req := httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: evil.example\n"))
|
||||
req.Host = "felhom.example.hu"
|
||||
req.Header.Set("Authorization", "Bearer "+hubKey)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code/100 == 2 {
|
||||
t.Errorf("%s %s with the hub key answered %d — the key opens a door", m, p, w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), secret) {
|
||||
t.Fatalf("%s %s with the hub key served the settings file", m, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got, _ := os.ReadFile(cfgPath); string(got) != planted {
|
||||
t.Fatal("controller.yaml was replaced through a hub-key request")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// R-519 (v0.296.0) — an app-data backup run cut off by a power cut or a restart is said on the backup pages until
|
||||
// the next complete run (internal/backup/run_record.go). main() calls this right after the restore record, before
|
||||
// any page or any backup run can read the manager. Pinned by TestMainWiresRunRecord.
|
||||
|
||||
// runRecordFileName lives in DataDir, beside settings.json and the restore record.
|
||||
const runRecordFileName = "appdata-run.json"
|
||||
|
||||
func loadRunRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger) time.Time {
|
||||
if mgr == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
path := filepath.Join(dataDir, runRecordFileName)
|
||||
mgr.SetRunRecordPath(path)
|
||||
cut := mgr.LoadRunRecord()
|
||||
if logger != nil {
|
||||
if !cut.IsZero() {
|
||||
logger.Printf("[WARN] [backup] the app-data backup run started %s was cut off by the stop — the backup pages say so until the next complete run (R-519)", cut.UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
logger.Printf("[INFO] [backup] app-data run record wired: %s (an earlier cut still shown: %t)", path, !mgr.InterruptedRunAt().IsZero())
|
||||
}
|
||||
}
|
||||
return cut
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// R-519: the function main() calls finds a cut run.
|
||||
func TestRunRecordAtStartup_FindsACutRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
before := new(backup.Manager)
|
||||
before.SetRunRecordPath(dir + "/" + runRecordFileName)
|
||||
// the record a run leaves when the power goes mid-run (markRunStarted is unexported — write its file shape)
|
||||
writeRunning(t, dir+"/"+runRecordFileName)
|
||||
after := new(backup.Manager)
|
||||
if cut := loadRunRecordAtStartup(after, dir, lg); cut.IsZero() || after.InterruptedRunAt().IsZero() {
|
||||
t.Fatal("a cut run was not found at startup")
|
||||
}
|
||||
}
|
||||
|
||||
// The seam must be CALLED from main() — an AST call, not a string (a comment would match a string).
|
||||
// RED-PROOF: delete the call in main() → this fails.
|
||||
func TestMainWiresRunRecord(t *testing.T) {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if c, ok := n.(*ast.CallExpr); ok {
|
||||
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "loadRunRecordAtStartup" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("main() never calls loadRunRecordAtStartup — a cut run is never said on the page")
|
||||
}
|
||||
}
|
||||
|
||||
func writeRunning(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
b := []byte(`{"running":true,"started_at":"` + time.Date(2026, 9, 14, 19, 40, 3, 0, time.UTC).Format(time.RFC3339) + `"}`)
|
||||
if err := writeFileForTest(path, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFileForTest(path string, b []byte) error { return os.WriteFile(path, b, 0o600) }
|
||||
@@ -111,3 +111,12 @@ func TestSlice4_UpdatingCheckIsWiredAtStartup(t *testing.T) {
|
||||
t.Fatal("backupMgr.SetUpdatingCheck is never called — the nightly legs cannot see an update in progress")
|
||||
}
|
||||
}
|
||||
|
||||
// R-645 (09 §3 decision 142): the backup manager must be told which apps are not running their pinned
|
||||
// version, or the night backup writes a just-failed definition over the good unit after a hand-lifted hold.
|
||||
func TestR645_VersionCheckIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["SetVersionCheck"]) == 0 {
|
||||
t.Fatal("backupMgr.SetVersionCheck is never called — the night backup cannot see an app off its pin")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.264.0. The update-event sink and the R-646 backfill are both CALLED from main.go (an AST walk —
|
||||
// a comment naming them would not count). A sink built and never wired would fail silently: no event,
|
||||
// no mail, no error — this project's commonest defect, four recorded instances.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateEventSink call in main.go — this fails.
|
||||
func TestUpdateEventSinkIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["SetUpdateEventSink"]) == 0 {
|
||||
t.Fatal("SetUpdateEventSink is never called — an undone or held update would tell nobody")
|
||||
}
|
||||
for _, callee := range []string{"NotifyAppUpdateUndone", "NotifyAppUpdateHeld", "RestoreHoldForLang"} {
|
||||
if len(lines[callee]) == 0 {
|
||||
t.Errorf("main.go must call %s inside the sink", callee)
|
||||
}
|
||||
}
|
||||
if len(lines["BackfillAppliedMeta"]) == 0 || len(lines["AdoptPins"]) == 0 || lines["BackfillAppliedMeta"][0] < lines["AdoptPins"][0] {
|
||||
t.Error("BackfillAppliedMeta (R-646) must be called, after AdoptPins")
|
||||
}
|
||||
}
|
||||
|
||||
// R-647 (2) — the held event carries the copy_holds KEY, never the Hungarian phrase: the hub prints the
|
||||
// raw details as the household mail's `Note:` line. COMPANION RED-PROOF (REPORT.md): wire
|
||||
// UpdateCopyHolds back — this fails.
|
||||
func TestR647_HeldEventCarriesTheCopyHoldsKey(t *testing.T) {
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(src), "d.CopyHolds = backupMgr.UpdateCopyHoldsKey(") {
|
||||
t.Fatal("main.go must fill AppUpdateDetails.CopyHolds with UpdateCopyHoldsKey (the key), not the Hungarian phrase")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.271.0 — the automatic update leg (`09` §6.4 part 7).
|
||||
|
||||
// TestChainUpdateLeg_EveryPath — the leg runs after the off-site half on EVERY path it can take:
|
||||
// nothing to do (no target), ran ok, returned an error (incl. every RunOffboxBackup early return, which
|
||||
// returns to here), and panicked. The off-site error reaches the scheduler unchanged; a panic becomes one.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): call the leg after `return offsite(ctx)` instead of in the defer —
|
||||
// the error and panic cases fail at "the leg did not run after the off-site half".
|
||||
func TestChainUpdateLeg_EveryPath(t *testing.T) {
|
||||
boom := errors.New("sftp: connection refused")
|
||||
cases := []struct {
|
||||
name string
|
||||
offsite func(context.Context) error
|
||||
wantErr string
|
||||
}{
|
||||
{"no off-site target (nothing to do)", func(context.Context) error { return nil }, ""},
|
||||
{"off-site ran ok", func(context.Context) error { return nil }, ""},
|
||||
{"off-site failed / an early return", func(context.Context) error { return boom }, "connection refused"},
|
||||
{"off-site panicked", func(context.Context) error { panic("nil map") }, "panicked"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ran := 0
|
||||
err := chainUpdateLeg(context.Background(), c.offsite, func(context.Context) { ran++ })
|
||||
if ran != 1 {
|
||||
t.Errorf("%s: the leg did not run after the off-site half (ran %d times)", c.name, ran)
|
||||
}
|
||||
if c.wantErr == "" && err != nil {
|
||||
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||
}
|
||||
if c.wantErr != "" && (err == nil || !strings.Contains(err.Error(), c.wantErr)) {
|
||||
t.Errorf("%s: error %v, want one containing %q", c.name, err, c.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateLegIsWiredAtStartup — the leg, its chain and the gate's interlock are CALLED from main.go (an
|
||||
// AST walk; a comment naming them would not count). A seam built and never wired is this project's
|
||||
// commonest defect.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateLeg call — this fails.
|
||||
func TestUpdateLegIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
for _, callee := range []string{"SetUpdateLeg", "chainUpdateLeg", "RunUpdateLeg", "FreshWholeCopy"} {
|
||||
if len(lines[callee]) == 0 {
|
||||
t.Errorf("main.go never calls %s — the automatic update leg is not wired", callee)
|
||||
}
|
||||
}
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(src), "Enabled: sett.GetAppUpdateUnattended,") {
|
||||
t.Error("the leg's switch must be the household's app_update.unattended (sett.GetAppUpdateUnattended)")
|
||||
}
|
||||
if !strings.Contains(string(src), "UpdateLegFn: stackMgr.UpdateLegState") {
|
||||
t.Error("the quiesce loop must be given stackMgr.UpdateLegState (decision 20's interlock)")
|
||||
}
|
||||
// the chain must sit INSIDE the offbox-backup job, not beside it
|
||||
i := strings.Index(string(src), `sched.Daily("offbox-backup"`)
|
||||
if i < 0 || !strings.Contains(string(src)[i:i+600], "chainUpdateLeg(") {
|
||||
t.Error("the offbox-backup job must run through chainUpdateLeg")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoCopyRemoverIsWiredAtStartup — R-671's seam is CALLED from main.go (AST walk).
|
||||
// COMPANION RED-PROOF (REPORT.md): drop the SetUndoCopyRemover call — this fails.
|
||||
func TestUndoCopyRemoverIsWiredAtStartup(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
if len(lines["SetUndoCopyRemover"]) == 0 {
|
||||
t.Fatal("main.go never calls SetUndoCopyRemover — a lifted hold's undo copies would stay for ever")
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,6 @@ stacks:
|
||||
- "cloudflared"
|
||||
- "felhom-controller"
|
||||
- "filebrowser"
|
||||
update_window: "03:00-05:00"
|
||||
compose_command: ""
|
||||
|
||||
# --- Backup ---
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,21 @@ func targetQuery(target string) string {
|
||||
return "?target=" + url.QueryEscape(target)
|
||||
}
|
||||
|
||||
// backupStartQuery is targetQuery plus `trigger=manual` for a household press (R-899).
|
||||
func backupStartQuery(target string, manual bool) string {
|
||||
q := url.Values{}
|
||||
if target != "" {
|
||||
q.Set("target", target)
|
||||
}
|
||||
if manual {
|
||||
q.Set("trigger", "manual")
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "?" + q.Encode()
|
||||
}
|
||||
|
||||
// BackupDueFor reports whether THIS TIER is due. A fresh backup on another tier must not satisfy it
|
||||
// — that filtering happens agent-side (latestSuccessfulBackupForTarget); this just asks per tier.
|
||||
func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse, error) {
|
||||
@@ -80,8 +95,15 @@ func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse,
|
||||
|
||||
// StartBackupFor enqueues a backup of this guest ON THE GIVEN TIER.
|
||||
func (c *Client) StartBackupFor(ctx context.Context, target string) (BackupResponse, error) {
|
||||
return c.StartBackupForTrigger(ctx, target, false)
|
||||
}
|
||||
|
||||
// StartBackupForTrigger is StartBackupFor that also says whether this is a household press (R-899): a press
|
||||
// carries `trigger=manual`, and an agent that knows it does not start the night's OS leg after it. An older
|
||||
// agent ignores the parameter (it reads only `target`), so the request is safe against any agent.
|
||||
func (c *Client) StartBackupForTrigger(ctx context.Context, target string, manual bool) (BackupResponse, error) {
|
||||
var out BackupResponse
|
||||
body, err := c.post(ctx, "/backup"+targetQuery(target), struct{}{})
|
||||
body, err := c.post(ctx, "/backup"+backupStartQuery(target, manual), struct{}{})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -105,6 +127,33 @@ func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResp
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BackupJobsInFlight (R-921) lists the tiers whose backup job the agent holds in flight right now — phase
|
||||
// running or snapshotted, the agent's own definition (localapi backupInFlight). A job in flight on another
|
||||
// tier is the agent's first reason to refuse POST /backup (HTTP 409), so the controller asks this BEFORE it
|
||||
// stops any app. It reads only GET /backup/tiers and GET /backup/status?target= (both agent >= v0.97.0); a
|
||||
// pre-R-82 agent (no /backup/tiers) answers nil, nil. Any other error is returned — the caller fails toward
|
||||
// backing up. It does NOT see the agent's host-wide heavy-operation gate: no endpoint serves it.
|
||||
func (c *Client) BackupJobsInFlight(ctx context.Context) ([]string, error) {
|
||||
tiers, err := c.BackupTiers(ctx)
|
||||
if errors.Is(err, ErrTiersUnsupported) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, t := range tiers.Tiers {
|
||||
st, err := c.BackupStatusFor(ctx, t.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st.Phase == PhaseRunning || st.Phase == PhaseSnapshotted {
|
||||
out = append(out, t.Target)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
|
||||
type SetBackupTargetResponse struct {
|
||||
Target string `json:"target"`
|
||||
|
||||
@@ -255,6 +255,9 @@ const (
|
||||
PhaseRunning = "running"
|
||||
PhaseDone = "done"
|
||||
PhaseFailed = "failed"
|
||||
// PhaseSnapshotted (8B.2): the storage snapshot is taken, the upload still runs and still holds the
|
||||
// guest — the agent counts it as IN FLIGHT (felhom-agent localapi backupInFlight).
|
||||
PhaseSnapshotted = "snapshotted"
|
||||
)
|
||||
|
||||
// BackupDue reports whether a policy-scheduled backup is due for this guest (the quiesce trigger).
|
||||
@@ -272,15 +275,7 @@ func (c *Client) BackupDue(ctx context.Context) (DueResponse, error) {
|
||||
|
||||
// StartBackup enqueues a backup of this guest (the agent vzdump) and returns the job to poll.
|
||||
func (c *Client) StartBackup(ctx context.Context) (BackupResponse, error) {
|
||||
var out BackupResponse
|
||||
body, err := c.post(ctx, "/backup", struct{}{})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode POST /backup: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
return c.StartBackupForTrigger(ctx, "", false)
|
||||
}
|
||||
|
||||
// BackupStatus reports the current/last backup job phase for this guest.
|
||||
@@ -360,8 +355,18 @@ type DiskInfo struct {
|
||||
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
|
||||
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
|
||||
Smart *SmartSummary `json:"smart,omitempty"`
|
||||
// Physical-disk identity (agent v0.157.0, `16` §3): kind, the keys of the physical disks under the place,
|
||||
// proven|unknown + reason. Empty from an older agent or for an absent drive.
|
||||
Kind string `json:"kind,omitempty"` // system | internal | disk | partition | network (felhom.eu `16` §3)
|
||||
PhysicalDisks []string `json:"physical_disks,omitempty"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
IdentityReason string `json:"identity_reason,omitempty"`
|
||||
}
|
||||
|
||||
// PlaceKindInternal is the agent's kind for the one-disk box's internal data place („Belső tárhely", agent ≥ 0.158.0,
|
||||
// felhom.eu architecture/16 §5): a host mount on the system disk, bound at /mnt/felhom-drives/belso.
|
||||
const PlaceKindInternal = "internal"
|
||||
|
||||
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
|
||||
// is not a filesystem UUID (network/lvm targets — not assignable as a host mount).
|
||||
func (d DiskInfo) FSUUID() string {
|
||||
@@ -395,6 +400,10 @@ type FormatResult struct {
|
||||
DurableID string `json:"durable_id,omitempty"`
|
||||
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
|
||||
PendingOp *PendingOp `json:"pending_op,omitempty"`
|
||||
// FSUUID (R-25, agent >= the R-25 agent half) is the UUID of the filesystem the agent just made,
|
||||
// sent only while the format's durable id still names the formatted device. "" = not verified, or
|
||||
// an agent too old to send it. The init flow mounts THIS rather than re-resolving from the /dev path.
|
||||
FSUUID string `json:"fs_uuid,omitempty"`
|
||||
}
|
||||
|
||||
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
|
||||
@@ -420,6 +429,29 @@ var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup de
|
||||
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
|
||||
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
|
||||
|
||||
// SameDiskResult is the agent's answer to POST /disks/same-disk (v0.157.0). Act on Proven first:
|
||||
// Proven+Different = a counted second copy; Proven+!Different = the same disk; !Proven = unknown
|
||||
// (Reason vm|network|usb_bridge|weak_wwn|no_serial|unmapped).
|
||||
type SameDiskResult struct {
|
||||
Different bool `json:"different"`
|
||||
Proven bool `json:"proven"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// SameDisk asks the agent whether two guest paths sit on the same physical disk. Gate it with
|
||||
// Supports(FeatureSameDisk) — an older agent answers 404.
|
||||
func (c *Client) SameDisk(ctx context.Context, a, b string) (SameDiskResult, error) {
|
||||
var out SameDiskResult
|
||||
raw, err := c.post(ctx, "/disks/same-disk", map[string]string{"a": a, "b": b})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /disks/same-disk: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
|
||||
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
|
||||
var out DisksResponse
|
||||
@@ -449,8 +481,37 @@ type DiskCandidate struct {
|
||||
// mountpoint — sending it down the device-attach path would try to mount an in-guest path as if
|
||||
// it were a raw device. See web/attach_sources.go for why the agent's scan cannot supply these.
|
||||
AlreadyMounted bool `json:"already_mounted,omitempty"`
|
||||
// Partitions (agent ≥ 0.158.0, felhom.eu architecture/16 §6): each partition of this disk as its own place —
|
||||
// state empty („format this part"), filesystem („use this part" when mountable; format through the confirmed
|
||||
// path), in_use (already a Felhom drive: shown, never offered). Absent from an older agent.
|
||||
Partitions []CandidatePartition `json:"partitions,omitempty"`
|
||||
// WholeDiskInUse is set by the CONTROLLER (never the agent) when a partition of this disk is a registered drive
|
||||
// (R-542): the disk stays listed for its FREE parts only, and no whole-disk action is offered.
|
||||
WholeDiskInUse bool `json:"whole_disk_in_use,omitempty"`
|
||||
}
|
||||
|
||||
// CandidatePartition mirrors the agent's partition entry (felhom-agent internal/storage/partitions.go).
|
||||
type CandidatePartition struct {
|
||||
Device string `json:"device"`
|
||||
Number int `json:"number,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
FSType string `json:"fstype,omitempty"`
|
||||
State string `json:"state"` // empty | filesystem | in_use
|
||||
Mountable bool `json:"mountable"`
|
||||
DurableID string `json:"durable_id,omitempty"` // uuid:<fs-uuid> — the attach key
|
||||
MountedAt string `json:"mounted_at,omitempty"`
|
||||
// Formattable (agent ≥ 0.158.1): false for every part of a disk with a mounted node — the root mkfs wrapper refuses
|
||||
// those and trusts no „it is Felhom's own" claim. Only formattable parts are offered for formatting.
|
||||
Formattable bool `json:"formattable"`
|
||||
}
|
||||
|
||||
// Partition states (agent ≥ 0.158.0).
|
||||
const (
|
||||
PartEmpty = "empty"
|
||||
PartFilesystem = "filesystem"
|
||||
PartInUse = "in_use"
|
||||
)
|
||||
|
||||
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
|
||||
// unclaimed) and attach (the mountable-FS subset).
|
||||
type CandidatesResult struct {
|
||||
@@ -808,6 +869,8 @@ type FormatStatusResult struct {
|
||||
Device string `json:"device"`
|
||||
FSType string `json:"fstype"`
|
||||
Error string `json:"error"`
|
||||
// FSUUID (R-25): see FormatResult.FSUUID — "" until the job is done and verified.
|
||||
FSUUID string `json:"fs_uuid,omitempty"`
|
||||
}
|
||||
|
||||
// FormatStatus fetches the agent's most-recent format-job record.
|
||||
@@ -1059,6 +1122,13 @@ type SmartSummary struct {
|
||||
ReallocatedSectors *int `json:"reallocated_sectors"`
|
||||
PendingSectors *int `json:"pending_sectors"`
|
||||
OfflineUncorrectable *int `json:"offline_uncorrectable"`
|
||||
// R-330 (agent v0.150.0+): three more SATA raw counters — 187 Reported_Uncorrect, 188
|
||||
// Command_Timeout (vendor-packed on some drives: carried as reported), 199 UDMA_CRC_Error_Count.
|
||||
// nil = unknown (an older agent, an NVMe/USB device, or not reported), never zero. CARRIED ONLY:
|
||||
// no verdict, banner or mail reads them yet — that would change what a household is told.
|
||||
ReportedUncorrect *int64 `json:"reported_uncorrect,omitempty"`
|
||||
CommandTimeout *int64 `json:"command_timeout,omitempty"`
|
||||
UDMACRCErrors *int64 `json:"udma_crc_errors,omitempty"`
|
||||
// NVMe attributes.
|
||||
CriticalWarning *int `json:"critical_warning"`
|
||||
MediaErrors *int `json:"media_errors"`
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CrashGuardState mirrors the agent's GET /host/crash-guard (R-856, `09` §3 decision 143): what the
|
||||
// host's crash guard (`felhom-crash-guard`, `11` §5.9) recorded about the most recent HOST boot. The
|
||||
// agent reads /var/lib/felhom-crash-guard/state.json (0644) and passes these fields through.
|
||||
//
|
||||
// Present=false: the host has no crash guard or no state yet. An agent that predates the route answers
|
||||
// 404 (a *StatusError) — both mean UNKNOWN, and the controller then keeps its normal boot grace.
|
||||
type CrashGuardState struct {
|
||||
Present bool `json:"present"`
|
||||
LastBootAt string `json:"last_boot_at,omitempty"` // RFC3339 UTC ("2006-01-02T15:04:05Z")
|
||||
LastBootUnclean bool `json:"last_boot_unclean"`
|
||||
Tripped bool `json:"tripped"`
|
||||
}
|
||||
|
||||
// CrashGuard calls GET /host/crash-guard.
|
||||
func (c *Client) CrashGuard(ctx context.Context) (CrashGuardState, error) {
|
||||
var out CrashGuardState
|
||||
body, err := c.get(ctx, "/host/crash-guard")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /host/crash-guard: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-856: GET /host/crash-guard decodes the crash guard's fields, and an agent that predates the route
|
||||
// surfaces as a typed 404 (the caller reads it as unknown → the normal boot grace).
|
||||
func TestR856_CrashGuardDecodesAndAnOlderAgentIs404(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /host/crash-guard", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":true,"last_boot_at":"2026-10-04T12:00:00Z","last_boot_unclean":true,"tripped":false}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
st, err := c.CrashGuard(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !st.Present || !st.LastBootUnclean || st.LastBootAt != "2026-10-04T12:00:00Z" || st.Tripped {
|
||||
t.Fatalf("state = %+v", st)
|
||||
}
|
||||
|
||||
old := httptest.NewTLSServer(http.NewServeMux())
|
||||
defer old.Close()
|
||||
_, err = clientFor(t, old, strings.TrimPrefix(old.URL, "https://")).CrashGuard(context.Background())
|
||||
var se *StatusError
|
||||
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
|
||||
t.Fatalf("an older agent must answer a typed 404, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,10 @@ const (
|
||||
// out loud — a true sentence about our own incuriosity that a customer reads as a statement about
|
||||
// their code.
|
||||
RecoveryCodeOpensRetained
|
||||
// RecoveryOlderUnchecked — the code did not open the current package, no earlier package opened it, and
|
||||
// NOT every earlier package the hub holds was tried (R-304, agent 424). So whether the code is wrong is
|
||||
// NOT known. The customer must not be told to check their typing as if it were.
|
||||
RecoveryOlderUnchecked
|
||||
)
|
||||
|
||||
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
|
||||
@@ -251,6 +255,12 @@ func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained bool) Recove
|
||||
return RecoveryCodeOpensRetained
|
||||
}
|
||||
return RecoveryUnknown
|
||||
case http.StatusFailedDependency:
|
||||
// R-304. NOT version-gated, deliberately — unlike 400 and 422 above. Those two make a claim about the
|
||||
// customer's code (wrong / correct), so a status we did not design must not be read as one. This class
|
||||
// claims only „not everything was checked; contact support", which is the same direction as
|
||||
// RecoveryUnknown: misreading a stray 424 here can never accuse anyone.
|
||||
return RecoveryOlderUnchecked
|
||||
case http.StatusBadRequest:
|
||||
if trustRefusal {
|
||||
return RecoveryAskedAndRefused
|
||||
@@ -276,6 +286,8 @@ func (f RecoveryFailure) String() string {
|
||||
return "bundle-too-old"
|
||||
case RecoveryCodeOpensRetained:
|
||||
return "code-opens-retained"
|
||||
case RecoveryOlderUnchecked:
|
||||
return "older-unchecked"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -83,6 +83,16 @@ const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
|
||||
// for two months, and it heals itself when the agent updates.
|
||||
const FeatureRetainedRecoveryClass Feature = "retained_recovery_class"
|
||||
|
||||
// FeatureHostPowerOff is the household power-off (R-998, agent v0.155.0): POST /host/poweroff. A POST that switches
|
||||
// the machine off cannot be probed, so the VERSION decides. The settings card hides only on a definite SupportNo; on
|
||||
// Unknown it stays, and an old agent's 404 is answered with the update sentence.
|
||||
const FeatureHostPowerOff Feature = "host_poweroff"
|
||||
|
||||
// FeatureSameDisk — POST /disks/same-disk (agent v0.157.0, felhom.eu architecture/16-storage-physical-identity.md
|
||||
// §4): the agent's physical-disk verdict for two guest paths. Below it the Tier-2 runner keeps the old st_dev
|
||||
// rule (backup.Manager.diskRelation), logged once.
|
||||
const FeatureSameDisk Feature = "same_disk"
|
||||
|
||||
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
|
||||
// refuses — the existing agent-error paths speak honestly when the agent is down).
|
||||
type SupportState int
|
||||
@@ -159,6 +169,13 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
|
||||
FeatureRetainedRecoveryClass: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe
|
||||
},
|
||||
// R-998: probing would switch the machine off. The row must exist (table-gap rule above).
|
||||
FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe
|
||||
},
|
||||
FeatureSameDisk: func(ctx context.Context, p SupportProber) error {
|
||||
return errNoRecoveryProbe // not probeable: decided by the version header alone
|
||||
},
|
||||
}
|
||||
|
||||
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
|
||||
@@ -192,6 +209,11 @@ var featureMinAgent = map[Feature]string{
|
||||
// screen must keep saying it cannot tell the two apart — which was true, and is what this gate
|
||||
// preserves for boxes that have not updated yet.
|
||||
FeatureRetainedRecoveryClass: "0.129.0",
|
||||
|
||||
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
|
||||
FeatureHostPowerOff: "0.155.0",
|
||||
|
||||
FeatureSameDisk: "0.157.0",
|
||||
}
|
||||
|
||||
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
|
||||
)
|
||||
|
||||
// ── POST /host/poweroff (R-998, agent >= 0.155.0, felhom.eu documentation/architecture/14-host-power-off.md) ──
|
||||
//
|
||||
// The household's „A gép kikapcsolása". The agent answers 202 (it shuts this guest down, then the host) or 409
|
||||
// with a machine-readable reason. Unlike post(), the 409 BODY matters here: the dashboard tells the household
|
||||
// WHY (a backup runs, too soon after start, already asked…), so this call reads it.
|
||||
|
||||
// Agent refusal reasons (stable wire values; mirror felhom-agent internal/localapi/hostpoweroff.go).
|
||||
const (
|
||||
PowerOffTooSoon = "too_soon_after_boot"
|
||||
PowerOffAlreadyAsked = "already_requested_this_boot"
|
||||
PowerOffBackup = "backup_running"
|
||||
PowerOffRestoreTest = "restore_test_running"
|
||||
PowerOffOSUpdate = "os_update_running"
|
||||
PowerOffCtrlUpdate = "controller_update_running"
|
||||
PowerOffDrive = "drive_operation_running"
|
||||
PowerOffEscrow = "escrow_ceremony_running"
|
||||
PowerOffBusy = "host_busy"
|
||||
PowerOffOtherGuests = "other_guests_running"
|
||||
PowerOffUnknown = "state_unknown"
|
||||
)
|
||||
|
||||
// PowerOffRefused is the agent's 409: Reason is one of the constants above (or a newer one), Detail is for the log.
|
||||
type PowerOffRefused struct {
|
||||
Reason string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (e *PowerOffRefused) Error() string {
|
||||
return "agent refused the power-off: " + e.Reason + " (" + e.Detail + ")"
|
||||
}
|
||||
|
||||
// HostPowerOff asks the agent to shut this guest down and power the host off. nil = accepted (the box goes down
|
||||
// within minutes). A *PowerOffRefused = the agent said no, with its reason. A *StatusError 404 = an agent that
|
||||
// predates the route.
|
||||
func (c *Client) HostPowerOff(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/host/poweroff", bytes.NewReader([]byte("{}")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
start := time.Now()
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentapi: POST /host/poweroff: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.noteAgentVersion(resp)
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
logx.Debugf(c.logger, "[agentapi] POST /host/poweroff -> %d (%dms)", resp.StatusCode, time.Since(start).Milliseconds())
|
||||
switch resp.StatusCode {
|
||||
case http.StatusAccepted, http.StatusOK:
|
||||
return nil
|
||||
case http.StatusConflict:
|
||||
var env struct {
|
||||
Data struct {
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(raw, &env) != nil || env.Data.Reason == "" {
|
||||
return &PowerOffRefused{Reason: PowerOffUnknown, Detail: "unreadable 409 body"}
|
||||
}
|
||||
return &PowerOffRefused{Reason: env.Data.Reason, Detail: env.Data.Detail}
|
||||
default:
|
||||
return &StatusError{Method: http.MethodPost, Path: "/host/poweroff", Code: resp.StatusCode}
|
||||
}
|
||||
}
|
||||
|
||||
// IsPowerOffRefused returns the refusal, if err is one.
|
||||
func IsPowerOffRefused(err error) (*PowerOffRefused, bool) {
|
||||
var r *PowerOffRefused
|
||||
ok := errors.As(err, &r)
|
||||
return r, ok
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-330 (disk health Phase 2, the wire only). The agent (v0.150.0+) sends three more SATA raw
|
||||
// counters; the controller must DECODE them (a field the struct lacks is silently dropped by
|
||||
// encoding/json) and must not yet ACT on them.
|
||||
|
||||
// The 2026-08-14 failing drive as the agent now reports it (values from
|
||||
// felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json).
|
||||
const r330AgentJSON = `{"health":"PASSED","temperature_c":40,"power_on_hours":null,
|
||||
"reallocated_sectors":0,"pending_sectors":352,"offline_uncorrectable":352,
|
||||
"reported_uncorrect":1001,"command_timeout":0,"udma_crc_errors":0,
|
||||
"critical_warning":null,"media_errors":null,"percentage_used":null}`
|
||||
|
||||
// COMPANION RED-PROOF (observed): rename the controller tag `reported_uncorrect` → `reported_uncorrectable`
|
||||
// → this fails with "187 must decode from the agent's JSON (raw 1001); got <nil>". Restored.
|
||||
func TestR330_DecodesTheThreeCounters(t *testing.T) {
|
||||
var s SmartSummary
|
||||
if err := json.Unmarshal([]byte(r330AgentJSON), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.ReportedUncorrect == nil || *s.ReportedUncorrect != 1001 {
|
||||
t.Fatalf("187 must decode from the agent's JSON (raw 1001); got %v", s.ReportedUncorrect)
|
||||
}
|
||||
// A measured ZERO stays a zero (not nil): 188 and 199 were reported as 0 by this drive.
|
||||
if s.CommandTimeout == nil || *s.CommandTimeout != 0 || s.UDMACRCErrors == nil || *s.UDMACRCErrors != 0 {
|
||||
t.Fatalf("188/199 reported as 0 must decode as a measured 0; got %v %v", s.CommandTimeout, s.UDMACRCErrors)
|
||||
}
|
||||
|
||||
// An older agent omits the keys → nil (unknown), never 0.
|
||||
var old SmartSummary
|
||||
if err := json.Unmarshal([]byte(`{"health":"PASSED","pending_sectors":0}`), &old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if old.ReportedUncorrect != nil || old.CommandTimeout != nil || old.UDMACRCErrors != nil {
|
||||
t.Fatalf("an older agent's report must leave the counters nil (unknown); got %v %v %v",
|
||||
old.ReportedUncorrect, old.CommandTimeout, old.UDMACRCErrors)
|
||||
}
|
||||
}
|
||||
|
||||
// CARRIED ONLY: tonight the counters change no verdict, label or degraded-attribute list — reading
|
||||
// them is a change to what a household is told and is not taken here (R-330's next slice).
|
||||
func TestR330_CountersChangeNoVerdictYet(t *testing.T) {
|
||||
with := realDrive()
|
||||
big := int64(5000)
|
||||
with.ReportedUncorrect, with.CommandTimeout, with.UDMACRCErrors = &big, &big, &big
|
||||
without := realDrive()
|
||||
for _, prior := range []DiskPrior{{}, {SawUncorrectable: true}} {
|
||||
if a, b := DiskVerdictFor(with, prior), DiskVerdictFor(without, prior); a != b {
|
||||
t.Fatalf("prior %+v: the R-330 counters changed the verdict (%v vs %v) — that is a household-facing change", prior, a, b)
|
||||
}
|
||||
}
|
||||
if a, b := DegradedAttributes(with), DegradedAttributes(without); !reflect.DeepEqual(a, b) {
|
||||
t.Fatalf("the R-330 counters changed the degraded-attribute list: %v vs %v", a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-899: a household press tells the agent so (`trigger=manual`), and nothing else does — the agent runs the
|
||||
// night's OS leg only after a backup that is not a press. The request the agent RECEIVES is asserted.
|
||||
func TestR899_PressCarriesTriggerManual(t *testing.T) {
|
||||
var got []string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /backup", func(w http.ResponseWriter, r *http.Request) {
|
||||
got = append(got, r.URL.RawQuery)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"job_id":"backup-9201-2","phase":"running"}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.StartBackup(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.StartBackupFor(ctx, "local"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.StartBackupForTrigger(ctx, "local", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.StartBackupForTrigger(ctx, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"", "target=local", "target=local&trigger=manual", "trigger=manual"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("queries = %q, want %q", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("query %d = %q, want %q (all: %q)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-921: the pre-check reads the agent's real wire shapes — GET /backup/tiers, then GET /backup/status per
|
||||
// target — and reports exactly the tiers whose job is running or snapshotted (the agent's in-flight set).
|
||||
func TestBackupJobsInFlight_ReportsRunningAndSnapshottedTiers(t *testing.T) {
|
||||
phases := map[string]string{"local": "done", "felhom-pbs": "snapshotted", "extra": "running"}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[` +
|
||||
`{"target":"local","cadence_seconds":86400,"primary":true,"storage":"present"},` +
|
||||
`{"target":"felhom-pbs","cadence_seconds":604800,"primary":false,"storage":"present"},` +
|
||||
`{"target":"extra","cadence_seconds":604800,"primary":false,"storage":"present"}]}}`))
|
||||
})
|
||||
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
tg := r.URL.Query().Get("target")
|
||||
ph, ok := phases[tg]
|
||||
if !ok {
|
||||
http.Error(w, "unexpected target "+tg, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"phase":"` + ph + `","target":"` + tg + `"}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
got, err := c.BackupJobsInFlight(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BackupJobsInFlight: %v", err)
|
||||
}
|
||||
if want := []string{"felhom-pbs", "extra"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("in-flight tiers = %v, want %v (done must not count; running and snapshotted must)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-R-82 agent (no /backup/tiers → 404) has no per-tier jobs: nil, nil — never an error that would be
|
||||
// read as "cannot tell", and never a busy verdict.
|
||||
func TestBackupJobsInFlight_PreR82AgentIsNotBusy(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
s := httptest.NewTLSServer(mux) // every route 404s
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
got, err := c.BackupJobsInFlight(context.Background())
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("pre-R-82 agent: got %v, %v — want nil, nil", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A status read that fails is returned, so the loop can fail toward backing up and say so.
|
||||
func TestBackupJobsInFlight_StatusErrorIsReturned(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[{"target":"local","primary":true}]}}`))
|
||||
})
|
||||
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
if _, err := c.BackupJobsInFlight(context.Background()); err == nil {
|
||||
t.Fatal("a failed status read was swallowed — the caller could not tell it from 'nothing in flight'")
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWriteConfig0600 asserts F8: controller.yaml is persisted 0600 (it holds infra secrets), even when
|
||||
// the target file already existed with looser (0644) permissions. POSIX modes only — skipped on Windows.
|
||||
func TestWriteConfig0600(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX file modes not represented on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "controller.yaml")
|
||||
|
||||
// Pre-create with world-readable 0644 to prove the helper tightens an existing file.
|
||||
if err := os.WriteFile(path, []byte("old: true\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeConfig0600(path, []byte("hub:\n api_key: redacted\n")); err != nil {
|
||||
t.Fatalf("writeConfig0600: %v", err)
|
||||
}
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode := fi.Mode().Perm(); mode != 0o600 {
|
||||
t.Fatalf("config mode = %o, want 0600", mode)
|
||||
}
|
||||
// No leftover temp file.
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("temp file not cleaned up")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// D4 (`16` §5): the no-drive refusal is a 400 the household can act on — not a 500.
|
||||
func TestD4_NeedsDriveIs400(t *testing.T) {
|
||||
err := util.MsgErrorf(stacks.ErrNeedsDrive, "err.stacks.needs_drive")
|
||||
if got := deployStatusFor(err); got != http.StatusBadRequest {
|
||||
t.Fatalf("ErrNeedsDrive → %d, want 400", got)
|
||||
}
|
||||
}
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
+313
-201
@@ -1,15 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -109,8 +106,8 @@ func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, s
|
||||
func (r *Router) SetRestarter(fn func()) { r.restart = fn }
|
||||
|
||||
// refuseNetworkStubDeploy is the deploy-time stub gate (RCA fix 2). Non-empty return = the
|
||||
// Hungarian refusal for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
|
||||
// local stub. Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
|
||||
// refusal KEY for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
|
||||
// local stub; the caller renders it in the request's language (v0.252.0, R-557). Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
|
||||
// classification timeout/unknown fails OPEN (a wedged share is the unreachable badge's business);
|
||||
// local (non-network) and empty paths keep today's behavior exactly.
|
||||
func (r *Router) refuseNetworkStubDeploy(hdd string) string {
|
||||
@@ -120,7 +117,7 @@ func (r *Router) refuseNetworkStubDeploy(hdd string) string {
|
||||
if r.classifyFSPath(hdd) != system.FSClassStub {
|
||||
return ""
|
||||
}
|
||||
return "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek."
|
||||
return "api.deploy.netstorage_unreachable"
|
||||
}
|
||||
|
||||
// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
|
||||
@@ -180,17 +177,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case path == "/selfupdate/update" && req.Method == http.MethodPost:
|
||||
r.selfupdateTrigger(w, req)
|
||||
|
||||
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
|
||||
case path == "/config/apply" && req.Method == http.MethodPost:
|
||||
r.configApply(w, req)
|
||||
|
||||
// GET /api/config/hash — return current config file hash
|
||||
case path == "/config/hash" && req.Method == http.MethodGet:
|
||||
r.configHash(w, req)
|
||||
|
||||
// GET /api/config — return raw controller.yaml content
|
||||
case path == "/config" && req.Method == http.MethodGet:
|
||||
r.configContent(w, req)
|
||||
// v0.322.0 (R-970): /api/config, /api/config/hash and /api/config/apply are GONE. They read the box's
|
||||
// controller.yaml with every secret in it and replaced it — reachable with the box's own hub key, unsigned,
|
||||
// which contradicted `01-topology-and-trust.md` §4/§10. Nothing called them (the hub never connects to a box;
|
||||
// the agent injects config at provision; the signed config bundle writes root files). Pinned by
|
||||
// TestR970_ConfigRoutesAreGone and cmd/controller TestR970_HubKeyOpensNoDoor.
|
||||
|
||||
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
|
||||
case path == "/selfrestart" && req.Method == http.MethodPost:
|
||||
@@ -223,19 +214,19 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// POST /api/stacks/{name}/start
|
||||
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "start", extractName(path, "/start"))
|
||||
r.actionStack(w, req, "start", extractName(path, "/start"))
|
||||
|
||||
// POST /api/stacks/{name}/stop
|
||||
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "stop", extractName(path, "/stop"))
|
||||
r.actionStack(w, req, "stop", extractName(path, "/stop"))
|
||||
|
||||
// POST /api/stacks/{name}/restart
|
||||
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "restart", extractName(path, "/restart"))
|
||||
r.actionStack(w, req, "restart", extractName(path, "/restart"))
|
||||
|
||||
// POST /api/stacks/{name}/update
|
||||
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "update", extractName(path, "/update"))
|
||||
r.actionStack(w, req, "update", extractName(path, "/update"))
|
||||
|
||||
// POST /api/stacks/{name}/optional-config
|
||||
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
|
||||
@@ -358,11 +349,11 @@ func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
|
||||
}
|
||||
|
||||
func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) rescanStacks(w http.ResponseWriter, req *http.Request) {
|
||||
r.logger.Printf("[INFO] [api] Manual stack rescan requested")
|
||||
if err := r.stackMgr.ScanStacks(); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Stack rescan failed: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
stackCount := len(r.stackMgr.GetStacks())
|
||||
@@ -373,22 +364,49 @@ func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
func (r *Router) getStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
stack, ok := r.stackMgr.GetStack(name)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
|
||||
return
|
||||
}
|
||||
// v0.264.0 (R-606): the page polls this for the Update button's label and the outcome. Rendered in
|
||||
// the reader's language; a Hungarian reader gets exactly the bytes it always got. GetStack returns a
|
||||
// COPY, so nothing here touches the manager's own state.
|
||||
// v0.265.0 (R-647): EVERY reader, Hungarian included — a Hungarian reader of an English box read
|
||||
// the hold in English before. On a Hungarian box a Hungarian reader still gets the same bytes.
|
||||
lang := r.langFor(req)
|
||||
stack.UpdatePhaseLabel = stacks.UpdatePhaseLabelIn(lang, stack.UpdatePhase)
|
||||
stack.UpdateError = r.stackMgr.UpdateErrorFor(*stack, lang)
|
||||
stack.HoldReason = r.stackMgr.HoldReasonFor(*stack, lang)
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
|
||||
}
|
||||
|
||||
func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
// markPasswordFieldsRequired (R-619) serves the deploy refusal's real rule on the wire: a
|
||||
// `type: password` field is MANDATORY however the template's `required` reads, because the deploy
|
||||
// never generates a password the household has not seen (stacks/deploy.go, the "password" case).
|
||||
// Served `required:false`, a caller that trusts the contract was refused 400. Derived here, not stored,
|
||||
// so templates need no edit. meta is the handler's own fresh LoadMetadata copy, so changing it touches
|
||||
// no shared state. Pinned by TestR619_PasswordFieldIsServedAsRequired.
|
||||
func markPasswordFieldsRequired(meta *stacks.Metadata) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
for i := range meta.DeployFields {
|
||||
if meta.DeployFields[i].Type == "password" {
|
||||
meta.DeployFields[i].Required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name string) {
|
||||
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
markPasswordFieldsRequired(meta)
|
||||
data := map[string]interface{}{
|
||||
"metadata": meta,
|
||||
"app_config": appCfg,
|
||||
@@ -398,13 +416,35 @@ func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name st
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
||||
}
|
||||
|
||||
// deployStatusFor maps a deploy refusal to its HTTP status by the refusal's KIND (R-553).
|
||||
//
|
||||
// It used to read the customer's Hungarian sentence — `strings.Contains(err.Error(), "kötelező")`,
|
||||
// „memória", plus the English "does not exist" / "already deployed". Localisation slice 2 translates
|
||||
// exactly those words, and on that day every validation refusal would have become a 500: the customer
|
||||
// sees a server error for a field they simply left empty, and the API contract changes silently.
|
||||
// The sentences are untouched; the kinds come from internal/stacks (deploy_errors.go).
|
||||
// Pinned by TestR553_Deploy_DecisionSurvivesWordingChange.
|
||||
func deployStatusFor(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, stacks.ErrProtectedStack):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing), errors.Is(err, stacks.ErrNeedsDrive),
|
||||
errors.Is(err, stacks.ErrNotEnoughMemory):
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
limitBody(w, req)
|
||||
r.logger.Printf("[INFO] [api] Deploy requested for stack: %s", name)
|
||||
r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)
|
||||
|
||||
var body struct {
|
||||
Values map[string]string `json:"values"`
|
||||
Values map[string]string `json:"values"`
|
||||
KeptData string `json:"kept_data"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
||||
@@ -422,7 +462,15 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)",
|
||||
name, st.Meta.EffectiveLifecycle())
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false,
|
||||
Error: "Ez az alkalmazás jelenleg nem telepíthető."})
|
||||
Error: r.msg(req, "api.deploy.not_installable")})
|
||||
return
|
||||
}
|
||||
|
||||
// v0.320.0: a protected infra stack is part of the box, never an app to install. Refused here, before the
|
||||
// kept-data check or anything else that reads the request, and again inside DeployStack for every other caller.
|
||||
if st, ok := r.stackMgr.GetStack(name); ok && (st.Protected || r.cfg.IsProtectedStack(name)) {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: a protected infra stack is part of the box", name)
|
||||
writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: r.msg(req, "api.deploy.part_of_box")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -433,9 +481,8 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)",
|
||||
name, hr.AvailGB, hr.ReserveGB, hr.TotalGB)
|
||||
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false, Error: fmt.Sprintf(
|
||||
"Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
|
||||
hr.AvailGB, hr.ReserveGB)})
|
||||
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false,
|
||||
Error: r.msg(req, "api.deploy.no_space", hr.AvailGB, hr.ReserveGB)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -443,9 +490,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
// must see a network filesystem (or its healthy idle autofs trigger) in THIS namespace — the one
|
||||
// the app will consume the path in. A stub (plain local dir after a guest reboot) would silently
|
||||
// send the app's data to the system drive.
|
||||
if msg := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); msg != "" {
|
||||
if key := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); key != "" {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: network HDD_PATH %s is a stub in the controller namespace", name, body.Values["HDD_PATH"])
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: msg})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, key)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -463,22 +510,38 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
return
|
||||
}
|
||||
|
||||
// R-839: HDD_PATH must name the drive itself, never a folder inside it — see
|
||||
// settings.DriveOfSubPath. The deploy page only offers drive roots; this guards every other caller.
|
||||
if drive, inside := r.sett.DriveOfSubPath(body.Values["HDD_PATH"]); inside {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: HDD_PATH %s is a folder inside the drive %s, not the drive (R-839)", name, body.Values["HDD_PATH"], drive)
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.deploy.hdd_path_inside_drive", drive)})
|
||||
return
|
||||
}
|
||||
|
||||
// `09` §3 decision 36: the app's drive folder already holds its old data → the household chooses.
|
||||
if handled := r.keptDataAtInstall(w, req, name, body.Values["HDD_PATH"], body.KeptData); handled {
|
||||
return
|
||||
}
|
||||
|
||||
// R-704 (v0.278.0): a fresh install drops a hold an EARLIER install left under this name.
|
||||
r.dropLeftoverHold(name, "install")
|
||||
|
||||
deployReq := stacks.DeployRequest{
|
||||
StackName: name,
|
||||
Values: body.Values,
|
||||
KeptData: body.KeptData,
|
||||
}
|
||||
|
||||
warning, err := r.stackMgr.DeployStack(deployReq)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err)
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "already deployed") {
|
||||
status = http.StatusConflict
|
||||
// R-553 — the status code comes from the refusal's KIND (deployStatusFor), never from its words.
|
||||
resp := apiResponse{OK: false, Error: r.errText(req, err)}
|
||||
if errors.Is(err, stacks.ErrNeedsDrive) {
|
||||
// D4 (`16` §5): the wizard renders the sentence with a link to the drives page.
|
||||
resp.Data = map[string]string{"code": "needs_drive", "link": "/storage"}
|
||||
}
|
||||
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, deployStatusFor(err), resp)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -486,9 +549,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
// polls GET /api/stacks/{name}). The old "Stack X deployed" message asserted completion before it
|
||||
// was true — misleading for API/script consumers. Report that the deploy STARTED, not that it
|
||||
// finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
|
||||
resp := apiResponse{OK: true, Message: "Telepítés elindítva – az állapot a kártyán követhető"}
|
||||
if warning != "" {
|
||||
resp.Data = map[string]string{"warning": warning}
|
||||
resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")}
|
||||
if d := r.deployWarningData(req, warning); d != nil {
|
||||
resp.Data = d
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, resp)
|
||||
|
||||
@@ -566,7 +629,37 @@ func desiredStateForAction(action string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
// stackOpStatusFor maps a stack-lifecycle refusal (stop/start/restart/update, remove, delete) to its
|
||||
// HTTP status by the refusal's KIND (R-569) — the R-553 rule one handler family over.
|
||||
//
|
||||
// Until R-569 these three handlers matched "protected", "not found", "not deployed", "still running"
|
||||
// and "not orphaned" in err.Error(). Those strings are internal English, so localisation never moved
|
||||
// them; a reworded internal message would have, silently turning a 404/403/409 into a 500. The kinds
|
||||
// come from internal/stacks (stack_errors.go); the messages are unchanged.
|
||||
//
|
||||
// One function serves the three families because their kinds are disjoint: an action never returns
|
||||
// ErrNotOrphaned, a remove never returns an UpdateRefusal. Pinned by
|
||||
// TestR569_StackOpStatus_SurvivesRewording and TestR569_HandlersUseTheKind.
|
||||
func stackOpStatusFor(err error) int {
|
||||
var ref *stacks.UpdateRefusal
|
||||
switch {
|
||||
case errors.Is(err, stacks.ErrStackNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.As(err, &ref):
|
||||
if ref.Reason == "not_found" {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, stacks.ErrProtectedStack):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, stacks.ErrNotDeployed), errors.Is(err, stacks.ErrStillRunning),
|
||||
errors.Is(err, stacks.ErrNotOrphaned):
|
||||
return http.StatusConflict
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, name string) {
|
||||
r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
|
||||
r.dbg("actionStack: action=%s name=%s", action, name)
|
||||
|
||||
@@ -586,9 +679,23 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
// R-439 (slice 4): `update` is in this list. It was not until v0.237.0, so a held app could be
|
||||
// updated — the one action most likely to make a held app's data worse. Pinned by
|
||||
// TestR439_UpdateOfAHeldAppIsRefused.
|
||||
// v0.269.0 (`09` §3 decision 28): an app the BOX stopped for a crash loop / OOM storm is started
|
||||
// again by the household's Start — the hold is lifted and the app gets one more try. Restart and
|
||||
// Update stay refused while it holds; any other hold kind is untouched.
|
||||
if action == "start" && r.backupMgr != nil && r.backupMgr.HoldKind(name) == settings.HoldReasonUnhealthyStop {
|
||||
if r.backupMgr.LiftUnhealthyStop(name) {
|
||||
r.logger.Printf("[INFO] [api] %s: the unhealthy stop is LIFTED by Start — one more try (decision 28)", name)
|
||||
}
|
||||
}
|
||||
if action == "start" || action == "restart" || action == "update" {
|
||||
if held, why := r.restoreHoldFor(name); held {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why})
|
||||
// `reason` (v0.261.0) — THIS LINE FIRES BEFORE UpdatePreflight, so without it a held app
|
||||
// answers 409 with no machine-readable reason at all, and an unattended caller cannot
|
||||
// tell it from a passing backup window: it would press a terminally-refused button for
|
||||
// ever. Found while writing the reason test, not by reading. `held` is TERMINAL until a
|
||||
// person acts, and it is the one reason that matters most to get right.
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why,
|
||||
Data: map[string]string{"reason": "held"}})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -597,7 +704,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
if gated, hdd := r.startGatedByMissingDrive(name); gated {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{
|
||||
OK: false,
|
||||
Error: fmt.Sprintf("A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.", hdd),
|
||||
Error: r.msg(req, "api.start.drive_missing", hdd),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -617,7 +724,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
if afterMB > usableMB {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{
|
||||
OK: false,
|
||||
Error: fmt.Sprintf("Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)", stackMemMB, usableMB-usedMB, usedMB, usableMB),
|
||||
Error: r.msg(req, "api.start.not_enough_memory", stackMemMB, usableMB-usedMB, usedMB, usableMB),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -634,7 +741,21 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
if ref.Reason == "not_found" {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: ref.Message})
|
||||
// errText, not ref.Message (v0.260.0): a refusal built with refuseUpdateErr carries its
|
||||
// bundle key, so the household reads the refusal in its own language. A refusal still
|
||||
// built from a Hungarian literal has no key and errText returns ref.Message byte for
|
||||
// byte — which is why this line is safe to change for all of them at once, and why the
|
||||
// R-524 downgrade refusal below is not a key nobody reads (the "seam built but never
|
||||
// wired" class).
|
||||
//
|
||||
// `data.reason` (v0.261.0) is ADDITIVE and is for a caller that is not a person. The
|
||||
// sentence says WHAT happened; only the reason says whether trying again can ever work —
|
||||
// `busy`/`updating`/`deploying`/`migrating`/`self_updating` are TRANSIENT, `held` and
|
||||
// `downgrade` are TERMINAL until a human acts. An unattended caller that cannot tell
|
||||
// those apart either gives up on a passing backup window or presses a refused button for
|
||||
// ever. The sentence is unchanged, so no page moves.
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, ref),
|
||||
Data: map[string]string{"reason": ref.Reason}})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -653,7 +774,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
r.logger.Printf("[ERROR] [api] %s for %s refused: could not record desired state: %v", action, name, derr)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{
|
||||
OK: false,
|
||||
Error: "A művelet nem hajtható végre: az alkalmazás beállításai nem menthetők.",
|
||||
Error: r.msg(req, "api.action.state_not_saved"),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -675,18 +796,8 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err)
|
||||
status := http.StatusInternalServerError
|
||||
var ref *stacks.UpdateRefusal
|
||||
if errors.As(err, &ref) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -695,11 +806,25 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
// finished — and "completed" exists only as update_phase=done on GET /api/stacks/{name}, which is
|
||||
// written after the app's health is known. Pinned by TestR443_UpdateIsNeverReportedCompleteSynchronously.
|
||||
if action == "update" {
|
||||
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: "Frissítés elindult – az állapot a kártyán követhető",
|
||||
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msg(req, "api.update.started"),
|
||||
Data: map[string]interface{}{"accepted": true, "completed": false}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
|
||||
// R-642 (v0.263.0): a start or restart is NEVER reported "completed". `compose up -d` returns 0
|
||||
// over an app that is about to crash-loop — measured 2026-09-23 on docmost and romm, each answering
|
||||
// 200 "start completed" while `Restarting (1)` behind a 404 front door. What this knows at return is
|
||||
// the state the containers are in right now, so that is what it says; whether the APP works is
|
||||
// the health probe's to answer, not this line's. Pinned by TestR642_StartIsNeverReportedCompleted.
|
||||
if action == "start" || action == "restart" {
|
||||
state := ""
|
||||
_ = r.stackMgr.RefreshStatus()
|
||||
if st, ok := r.stackMgr.GetStack(name); ok {
|
||||
state = string(st.State)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, startAnswer(name, action, state))
|
||||
} else {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
|
||||
}
|
||||
|
||||
// Trigger integration lifecycle hooks after successful action
|
||||
if r.integrationMgr != nil {
|
||||
@@ -726,11 +851,11 @@ func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request,
|
||||
|
||||
if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Optional config update failed for %s: %v", name, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.settings.updated")})
|
||||
}
|
||||
|
||||
// --- Integration API handlers ---
|
||||
@@ -771,13 +896,13 @@ func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, pro
|
||||
state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Integration toggle failed for %s:%s: %v", provider, target, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
msg := "Integráció engedélyezve"
|
||||
msg := r.msg(req, "api.integration.enabled")
|
||||
if !body.Enabled {
|
||||
msg = "Integráció kikapcsolva"
|
||||
msg = r.msg(req, "api.integration.disabled")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
|
||||
}
|
||||
@@ -795,22 +920,23 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str
|
||||
|
||||
output, err := r.stackMgr.GetLogs(name, lines)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
|
||||
}
|
||||
|
||||
func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
func (r *Router) getStackHDDData(w http.ResponseWriter, req *http.Request, name string) {
|
||||
resp, err := r.stackMgr.GetStackHDDData(name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
resp.KeepDataOnly = r.stackMgr.RemoveKeepsDataOnly(name) // decision 27: the dialog offers keep-data only
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
||||
}
|
||||
|
||||
func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
func (r *Router) getStackBackupData(w http.ResponseWriter, req *http.Request, name string) {
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
||||
return
|
||||
@@ -828,12 +954,68 @@ func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name
|
||||
|
||||
resp, err := r.stackMgr.GetStackBackupData(name, nsRoot, mirrors)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
||||
}
|
||||
|
||||
// dropLeftoverHold (R-704, v0.278.0) clears the update or crash-loop hold of an app that is NOT installed,
|
||||
// before a new install of it begins (a plain install, or „use my kept data"). Such a hold can only have
|
||||
// been left by an earlier install: before v0.242.0 a removal kept update holds, and before v0.278.0 it
|
||||
// kept crash-loop stops — measured 2026-09-28: a nextcloud update hold from 2026-09-13 on demo-hp made
|
||||
// the backup skip the FRESH install's volumes and unit, so its off-site copy held no data; on 9202 a
|
||||
// calcom crash-loop stop refused the fresh install's update. A restore hold (R-379) is never touched.
|
||||
// Pinned by TestR704_AFreshInstallDropsALeftoverHold.
|
||||
func (r *Router) dropLeftoverHold(name, why string) {
|
||||
if r.sett == nil {
|
||||
return
|
||||
}
|
||||
if st, ok := r.stackMgr.GetStack(name); ok && (st.Deployed || st.Deploying) {
|
||||
return
|
||||
}
|
||||
if cleared, err := r.sett.ClearUpdateHold(name); err != nil {
|
||||
r.logger.Printf("[WARN] [api] %s %s: the hold an earlier install left could not be cleared: %v", why, name, err)
|
||||
} else if cleared {
|
||||
r.logger.Printf("[INFO] [api] %s %s: a hold left by an EARLIER install of this app is dropped (R-704)", why, name)
|
||||
}
|
||||
}
|
||||
|
||||
// clearInterruptedRestoreNotice (R-552) drops the removed app's „Megszakadt visszaállítás" notice. The
|
||||
// notice tells the household to run the restore again; once the app is removed that advice has no
|
||||
// subject, and nothing else would ever clear it. Pinned by TestR552_RemoveClearsTheInterruptedRestoreNotice.
|
||||
func (r *Router) clearInterruptedRestoreNotice(name string) {
|
||||
if r.backupMgr == nil {
|
||||
return
|
||||
}
|
||||
if r.backupMgr.ClearInterruptedRestore(name) {
|
||||
r.logger.Printf("[INFO] [api] remove %s: its interrupted-restore notice is cleared with it (R-552)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// removeVerificationCopy (R-706, v0.279.0) deletes the app's off-site VERIFICATION copy
|
||||
// (`backups/offsite-restore/<app>`, left by a full off-site restore for the household to inspect) when the
|
||||
// app is removed "with its backups". Measured 2026-09-28 on demo-hp: ~1 GB stayed after such a removal, and
|
||||
// the app list no longer showed anything it belonged to. Returns the removed path as the removal reports it.
|
||||
// Pinned by TestR706_RemovalWithBackupsDeletesTheVerificationCopy.
|
||||
func (r *Router) removeVerificationCopy(name string) []string {
|
||||
if r.backupMgr == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, c := range r.backupMgr.ListOffsiteRestoreCopies() {
|
||||
if c.Stack != name {
|
||||
continue
|
||||
}
|
||||
if err := r.backupMgr.DeleteOffsiteRestoreCopy(name); err != nil {
|
||||
r.logger.Printf("[WARN] [api] remove %s: its off-site verification copy %s could not be deleted: %v", name, c.Path, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s (%s)", c.Path, c.SizeHuman))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
||||
@@ -853,6 +1035,14 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
}
|
||||
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
|
||||
|
||||
// v0.269.0 (`09` §3 decision 27, R-666): while a held app's page says support is informed, only
|
||||
// "remove the app, keep my data" is allowed. Refused BEFORE anything is removed (live-probes rule).
|
||||
if (body.RemoveHDDData || body.RemoveBackups) && r.stackMgr.RemoveKeepsDataOnly(name) {
|
||||
r.logger.Printf("[WARN] [api] Remove of %s with data REFUSED: the app is held with no whole copy (support is informed) — only keep-data is allowed (decision 27)", name)
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, stacks.ErrRemoveKeepDataWhileSupport)})
|
||||
return
|
||||
}
|
||||
|
||||
// Compute backup paths to remove if requested. Disk-tier (cross-drive rsync)
|
||||
// backup has moved to the host agent; only the app-data DB-dump path is removed here.
|
||||
//
|
||||
@@ -879,23 +1069,26 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
// R-633: "the backup side owns this app right now" is a CONFLICT, not a server error. It is
|
||||
// matched by TYPE rather than by prose — the status mapping below greps the error text, and
|
||||
// the busy sentence contains none of the words it looks for, so the first live run of this
|
||||
// guard answered 500 while doing exactly the right thing.
|
||||
var busy *stacks.RemoveBusyError
|
||||
if errors.As(err, &busy) {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 {
|
||||
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...)
|
||||
}
|
||||
if body.RemoveBackups {
|
||||
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.removeVerificationCopy(name)...)
|
||||
}
|
||||
|
||||
// R-486 (v0.240.0): the app's backup preferences — and with them the Tier-2 RECORD that
|
||||
// tier2RecordedCopyDir needs — are forgotten ONLY when the customer asked for the backups to be
|
||||
@@ -919,6 +1112,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
r.logger.Printf("[INFO] [api] remove %s: its update hold is cleared with it (R-491)", name)
|
||||
}
|
||||
}
|
||||
r.clearInterruptedRestoreNotice(name)
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
|
||||
|
||||
@@ -960,17 +1154,8 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -992,9 +1177,9 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
// R-490 / R-465: the same default-storage-path fallback every other reader of the always-empty
|
||||
// cfg.Paths.HDDPath has; without it hdd_configured was false on every box.
|
||||
hddPath := r.cfg.Paths.HDDPath
|
||||
// R-490 / R-465 / R-492: the default storage path is the only source (the always-empty global
|
||||
// paths.hdd_path was deleted in R-492); without it hdd_configured was false on every box.
|
||||
hddPath := ""
|
||||
if r.sett != nil {
|
||||
if p := r.sett.GetDefaultStoragePath(); p != "" {
|
||||
hddPath = p
|
||||
@@ -1104,7 +1289,7 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
|
||||
// moved to the host agent (slice 8C).
|
||||
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) triggerBackup(w http.ResponseWriter, req *http.Request) {
|
||||
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
|
||||
if r.backupMgr == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
||||
@@ -1113,7 +1298,7 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
if r.backupMgr.IsRunning() {
|
||||
r.dbg("triggerBackup: backup already running, rejecting")
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1121,23 +1306,23 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
r.backupMgr.MarkManualRun() // R-182: operator-triggered — its digest must not be collapsed into the nightly one
|
||||
go r.backupMgr.RunDBDumps(context.Background())
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.started")})
|
||||
}
|
||||
|
||||
// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a
|
||||
// different physical disk). Auto-targets and applies the rootfs-headroom guard internally.
|
||||
func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) triggerTier2(w http.ResponseWriter, req *http.Request) {
|
||||
if r.backupMgr == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
||||
return
|
||||
}
|
||||
if r.backupMgr.IsRunning() {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
|
||||
return
|
||||
}
|
||||
r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered")
|
||||
go r.backupMgr.RunAllTier2()
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.tier2_started")})
|
||||
}
|
||||
|
||||
// --- Metrics handlers ---
|
||||
@@ -1154,7 +1339,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
||||
samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Failed to query system metrics: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1182,7 +1367,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
||||
}})
|
||||
}
|
||||
|
||||
func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) metricsContainerSummary(w http.ResponseWriter, req *http.Request) {
|
||||
if r.metricsStore == nil {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
|
||||
return
|
||||
@@ -1191,7 +1376,7 @@ func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request)
|
||||
summary, err := r.metricsStore.QueryContainerSummary()
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Failed to query container summary: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1210,7 +1395,7 @@ func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name
|
||||
samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Failed to query container metrics for %s: %v", name, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1324,108 +1509,17 @@ func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
|
||||
}
|
||||
|
||||
func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) {
|
||||
if r.updater == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
|
||||
return
|
||||
}
|
||||
if err := r.updater.TriggerUpdate("manual"); err != nil {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
|
||||
return
|
||||
}
|
||||
r.logger.Println("[INFO] [api] Manual self-update triggered")
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
|
||||
}
|
||||
|
||||
// --- Config apply handler ---
|
||||
|
||||
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
||||
r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
|
||||
// Read YAML body (limit to 1MB)
|
||||
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate it's parseable YAML by attempting to load it
|
||||
if _, err := config.LoadFromBytes(body); err != nil {
|
||||
r.logger.Printf("[WARN] [api] Config apply rejected: invalid YAML: %v", err)
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// No-op guard: if the pushed config is byte-identical to what is already on disk, do
|
||||
// nothing — don't rewrite, don't restart. The hub may re-push the same config
|
||||
// idempotently, and a self-restart on every push would be a needless flap.
|
||||
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
|
||||
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."})
|
||||
return
|
||||
}
|
||||
|
||||
// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
|
||||
// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
|
||||
// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
|
||||
if err := writeConfig0600(r.configPath, body); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
|
||||
return
|
||||
}
|
||||
|
||||
// Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
|
||||
// effect on restart — singletons such as the Cloudflare client are built once at
|
||||
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
|
||||
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
|
||||
flushResponse(w)
|
||||
if r.restart != nil {
|
||||
r.restart()
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename),
|
||||
// falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS
|
||||
// enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml
|
||||
// holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file.
|
||||
func writeConfig0600(path string, body []byte) error {
|
||||
tmpPath := path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, body, 0600); err != nil {
|
||||
return fmt.Errorf("writing temp config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.Chmod(path, 0600); err != nil {
|
||||
return fmt.Errorf("chmod config 0600: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
|
||||
hash, err := config.FileHash(r.configPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
|
||||
}
|
||||
|
||||
func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
|
||||
data, err := os.ReadFile(r.configPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
|
||||
w.Write(data)
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.update.kicked_off")})
|
||||
}
|
||||
|
||||
// --- Asset sync handlers ---
|
||||
@@ -1464,3 +1558,21 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
func limitBody(w http.ResponseWriter, req *http.Request) {
|
||||
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
|
||||
}
|
||||
|
||||
// startAnswer is R-642's answer for a start or restart: what was requested and the state the
|
||||
// containers are in right now — never "completed", which nothing at this point can know.
|
||||
func startAnswer(name, action, state string) apiResponse {
|
||||
return apiResponse{OK: true,
|
||||
Message: "Stack " + name + " " + action + " requested — state now: " + state,
|
||||
Data: map[string]interface{}{"state": state}}
|
||||
}
|
||||
|
||||
// deployWarningData renders the deploy's soft warning for the answer. R-575: DeployStack hands back the
|
||||
// warning's bundle KEY, never a sentence, so it is rendered here in the reader's language. nil = none.
|
||||
// Pinned by TestR575_OvercommitWarningFollowsTheLanguage.
|
||||
func (r *Router) deployWarningData(req *http.Request, warningKey string) map[string]string {
|
||||
if warningKey == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"warning": r.msg(req, warningKey)}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -28,61 +27,6 @@ func newRestartTestRouter(t *testing.T, prior []byte) (*Router, *int, string) {
|
||||
return r, &calls, path
|
||||
}
|
||||
|
||||
// a minimal config that passes config.LoadFromBytes (customer.id + customer.domain required).
|
||||
var validConfig = []byte("customer:\n id: test\n domain: test.example\n")
|
||||
|
||||
func TestConfigApply_ChangedConfig_Restarts(t *testing.T) {
|
||||
prior := []byte("customer:\n id: old\n domain: old.example\n")
|
||||
r, calls, path := newRestartTestRouter(t, prior)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
|
||||
rec := httptest.NewRecorder()
|
||||
r.configApply(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("restart called %d times, want 1 (config changed)", *calls)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(got, validConfig) {
|
||||
t.Fatalf("config not written: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// COMPANION: an identical re-push must NOT restart (the old handler always restarted /
|
||||
// always ran its post-apply hook regardless of whether anything changed).
|
||||
func TestConfigApply_IdenticalConfig_NoRestart(t *testing.T) {
|
||||
r, calls, _ := newRestartTestRouter(t, validConfig) // prior == body
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
|
||||
rec := httptest.NewRecorder()
|
||||
r.configApply(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if *calls != 0 {
|
||||
t.Fatalf("restart called %d times, want 0 (config byte-identical)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigApply_InvalidYAML_NoRestart(t *testing.T) {
|
||||
r, calls, _ := newRestartTestRouter(t, validConfig)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader([]byte("customer:\n id: only-id-no-domain\n")))
|
||||
rec := httptest.NewRecorder()
|
||||
r.configApply(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (missing customer.domain)", rec.Code)
|
||||
}
|
||||
if *calls != 0 {
|
||||
t.Fatalf("restart called %d times, want 0 (validation failed)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfRestart_InvokesRestarter(t *testing.T) {
|
||||
r, calls, _ := newRestartTestRouter(t, nil)
|
||||
|
||||
|
||||
@@ -49,12 +49,14 @@ func (g *apiFakeGuards) RestorePoints(_ context.Context, _ string, accept func(s
|
||||
}
|
||||
return stacks.UpdateRestorePoint{}, false, g.points
|
||||
}
|
||||
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
|
||||
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
|
||||
func (g *apiFakeGuards) BackupNow(context.Context, string) error { return nil }
|
||||
func (g *apiFakeGuards) SafetyDump(context.Context, string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint) error { return nil }
|
||||
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const slice4AppYAML = "deployed: true\nenv: {}\npinned_images:\n app: nginx:1.27\n"
|
||||
|
||||
@@ -97,7 +99,7 @@ func newSlice4Router(t *testing.T) (*Router, *settings.Settings, *apiFakeGuards,
|
||||
func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.actionStack(w, "update", "app")
|
||||
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", "app")
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
@@ -113,7 +115,7 @@ func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
|
||||
func TestR439_UpdateOfAHeldAppIsRefused(t *testing.T) {
|
||||
r, sett, g, dir := newSlice4Router(t)
|
||||
g.points, g.cannotBackUp = nil, true // the preflight's own refusal would say "no backup" — not the hold
|
||||
g.blindToHolds = true // only the router's line can produce the hold's sentence
|
||||
g.blindToHolds = true // only the router's line can produce the hold's sentence
|
||||
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "app", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-13T01:30:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-609 (v0.261.0) — the 409 carries a machine-readable REASON, additively.
|
||||
//
|
||||
// WHY. `UpdateRefusal.Reason` has existed since v0.237.0 and never left the process: the body carried
|
||||
// only the translated sentence. A caller that is not a person cannot parse a Hungarian sentence, and
|
||||
// the distinction it needs is not decorative:
|
||||
//
|
||||
// TRANSIENT — busy, updating, deploying, migrating, self_updating → try again later
|
||||
// TERMINAL — held, downgrade → never press this again
|
||||
//
|
||||
// An unattended caller that cannot tell them apart either gives up on a passing backup window, or
|
||||
// presses a terminally-refused button on every pass for ever. `09` §6.2's caller reads exactly this.
|
||||
//
|
||||
// The SENTENCE is unchanged and stays where it was, so no page moves — this is purely additive.
|
||||
|
||||
// reasonOf pulls `data.reason` out of a refusal body, or "" when absent.
|
||||
func reasonOf(t *testing.T, resp apiResponse) string {
|
||||
t.Helper()
|
||||
if resp.Data == nil {
|
||||
return ""
|
||||
}
|
||||
m, ok := resp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data is not an object: %#v", resp.Data)
|
||||
}
|
||||
s, _ := m["reason"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// TestR609_EveryRefusalCarriesItsReason is table-driven over the refusal paths reachable without
|
||||
// docker, and it INCLUDES the router's own hold line — which fires before UpdatePreflight and was
|
||||
// the one that had no reason at all until this test was written.
|
||||
//
|
||||
// COMPANION RED-PROOF (run 2026-09-21): drop the `Data:` field from either writeJSON in actionStack's
|
||||
// update paths. The corresponding row fails with `reason = ""`. Reverted.
|
||||
func TestR609_EveryRefusalCarriesItsReason(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
arrange func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string)
|
||||
wantReason string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "held — the ROUTER's own line, before the preflight",
|
||||
arrange: func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string) {
|
||||
if err := sett.SetRestoreHold(settings.RestoreHold{
|
||||
Stack: "app", At: "2026-09-21T08:00:00Z",
|
||||
Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-21T01:30:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantReason: "held", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "no_backup — nothing on any tier and none can be taken",
|
||||
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, g *apiFakeGuards, _ string) {
|
||||
g.points, g.cannotBackUp = nil, true
|
||||
},
|
||||
wantReason: "no_backup", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "self_updating — the controller is swapping itself (v0.261.0)",
|
||||
arrange: func(_ *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {
|
||||
r.stackMgr.SetSelfUpdatingCheck(func() bool { return true })
|
||||
},
|
||||
wantReason: "self_updating", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "downgrade — the box runs something newer than the catalog (R-524)",
|
||||
arrange: func(t *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, dir string) {
|
||||
setInstalledAndCatalog(t, r, dir, "nginx:1.28", "nginx:1.27")
|
||||
},
|
||||
wantReason: "downgrade", wantCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "not_found — an app that exists nowhere",
|
||||
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {},
|
||||
wantReason: "not_found", wantCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r, sett, g, dir := newSlice4Router(t)
|
||||
c.arrange(t, r, sett, g, dir)
|
||||
|
||||
name := "app"
|
||||
if c.wantReason == "not_found" {
|
||||
name = "no-such-app"
|
||||
}
|
||||
code, resp := postUpdateNamed(t, r, name)
|
||||
|
||||
if code != c.wantCode {
|
||||
t.Errorf("code = %d, want %d (error %q)", code, c.wantCode, resp.Error)
|
||||
}
|
||||
if resp.OK {
|
||||
t.Fatal("a refusal must not answer ok")
|
||||
}
|
||||
if got := reasonOf(t, resp); got != c.wantReason {
|
||||
t.Errorf("reason = %q, want %q", got, c.wantReason)
|
||||
}
|
||||
// The sentence is the household's and must still be there, in words — never a bare key.
|
||||
if resp.Error == "" {
|
||||
t.Error("the reason is ADDITIVE: the sentence must still be present")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestR609_ASuccessfulActionCarriesNoReason — the field is for refusals only, so a caller cannot
|
||||
// mistake a normal answer for one.
|
||||
func TestR609_ASuccessfulActionCarriesNoReason(t *testing.T) {
|
||||
r, _, _, _ := newSlice4Router(t)
|
||||
_, resp := postStopNamed(t, r, "app")
|
||||
if got := reasonOf(t, resp); got != "" {
|
||||
t.Errorf("a non-refusal must carry no reason, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// postUpdateNamed is postUpdate for an arbitrary app name (the not_found row needs one that is absent).
|
||||
func postUpdateNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", name)
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
// postStopNamed exercises a NON-update action, as the control that `reason` is refusal-only.
|
||||
func postStopNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "stop", name)
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
// setInstalledAndCatalog puts the app AHEAD of the catalog, which is R-524's Ahead arm.
|
||||
//
|
||||
// It writes the REAL files the real code reads — `installed_images` into the app's app.yaml and a
|
||||
// catalog template under <DataDir>/catalog-cache/templates/<app>/ — and then rescans. **No test-only
|
||||
// seam is added to production code for this**: the whole point of the api tests here is that they go
|
||||
// through the production handler over a real stacks.Manager, and a setter that only tests call would
|
||||
// be a second way to reach the state, which is how the two drift apart.
|
||||
func setInstalledAndCatalog(t *testing.T, r *Router, dir, installed, catalog string) {
|
||||
t.Helper()
|
||||
appYAML := slice4AppYAML + "installed_images:\n app:\n ref: " + installed +
|
||||
"\n digest: sha256:x\n at: \"2026-09-01T00:00:00Z\"\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catDir := filepath.Join(r.cfg.Paths.DataDir, "catalog-cache", "templates", "app")
|
||||
if err := os.MkdirAll(catDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(catDir, "docker-compose.yml"),
|
||||
[]byte("services:\n app:\n image: "+catalog+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.stackMgr.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Positive control: the state this helper claims to create must actually exist, or the row it
|
||||
// serves would pass for the wrong reason (a different refusal firing first).
|
||||
if got := stacks.CatalogOrder(mustStack(t, r, "app")); got != stacks.UpdateOrderAhead {
|
||||
t.Fatalf("fixture did not produce the AHEAD state; CatalogOrder = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStack(t *testing.T, r *Router, name string) stacks.Stack {
|
||||
t.Helper()
|
||||
st, ok := r.stackMgr.GetStack(name)
|
||||
if !ok {
|
||||
t.Fatalf("stack %q missing", name)
|
||||
}
|
||||
return *st
|
||||
}
|
||||
@@ -83,6 +83,21 @@ type RecoveryInfo struct {
|
||||
// 0600 app.yaml.
|
||||
PortableSecretEnvVars []string
|
||||
PortableSecrets map[string]string
|
||||
// InstalledImages (v0.275.0, R-696) is what each compose service is RUNNING — app.yaml's
|
||||
// `installed_images`, as `ref@digest` (the plain ref when no digest was recorded). Stamped beside each
|
||||
// data file the backup writes, so the data says which versions wrote it. Nil = unknown, never current.
|
||||
InstalledImages map[string]string
|
||||
// SetupGate (v0.314.0, R-1057) is the app's setup gate AT THIS BACKUP: „closed" (its first setup was not done) or
|
||||
// „open" (and by whom). Nil for an app whose template has no gate. The restore applies it, so a backup made before
|
||||
// the household's first setup never comes back open (`09` §3 decision 253).
|
||||
SetupGate *GateState
|
||||
}
|
||||
|
||||
// GateState is the setup gate as a backup records it (the unit's app.yaml `setup_gate:`). The same two keys as the
|
||||
// live app.yaml's record, so a restore reads either file the same way (stacks.ReadGateAtBackup).
|
||||
type GateState struct {
|
||||
State string `yaml:"state"`
|
||||
OpenedBy string `yaml:"opened_by,omitempty"`
|
||||
}
|
||||
|
||||
// ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
@@ -136,7 +138,7 @@ func DiscoverDatabases(ctx context.Context, logger *log.Logger, debug bool, know
|
||||
// whole `docker ps` output, so a trailing empty field on the LAST line would be eaten and that
|
||||
// row would arrive one column short. Image is never empty, so ending on it keeps every row the
|
||||
// same width.
|
||||
cmd := exec.CommandContext(ctx, "docker", "ps", "--format",
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "ps", "--format",
|
||||
"{{.ID}}\t{{.Names}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Image}}", "--filter", "status=running")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -226,6 +228,17 @@ func DumpAll(ctx context.Context, dbs []DiscoveredDB, dumpDir string, logger *lo
|
||||
return results
|
||||
}
|
||||
|
||||
// syncDumpDir fsyncs a directory so a rename inside it survives a power cut (R-10). A variable so a
|
||||
// test can observe the call; production never replaces it.
|
||||
var syncDumpDir = func(dir string) error {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
|
||||
// CanonicalDumpName is the app's own dump filename for one database: `<stack>-<dbtype>.sql`. It is
|
||||
// the name the replay loop matches EXACTLY, which is why nothing else may ever be written to it.
|
||||
func CanonicalDumpName(db DiscoveredDB) string {
|
||||
@@ -279,7 +292,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
|
||||
defer cancel()
|
||||
|
||||
// Verify container is still running
|
||||
checkCmd := exec.CommandContext(dumpCtx, "docker", "inspect", "--format", "{{.State.Running}}", db.ContainerID)
|
||||
checkCmd := dockerexec.CommandContext(dumpCtx, "docker", "inspect", "--format", "{{.State.Running}}", db.ContainerID)
|
||||
checkOut, err := checkCmd.Output()
|
||||
if err != nil || strings.TrimSpace(string(checkOut)) != "true" {
|
||||
result.Error = fmt.Errorf("container %s no longer running", db.ContainerName)
|
||||
@@ -294,7 +307,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
|
||||
var cmd *exec.Cmd
|
||||
switch db.DBType {
|
||||
case DBTypePostgres:
|
||||
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
"pg_dump", "-U", db.DBUser, "-d", db.DBName,
|
||||
"--clean", "--if-exists", "--no-owner", "--no-privileges")
|
||||
if debug {
|
||||
@@ -312,7 +325,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
|
||||
}
|
||||
return result
|
||||
}
|
||||
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
"mariadb-dump", "-u", "root", "-p***",
|
||||
"--single-transaction", "--routines", "--triggers", db.DBName)
|
||||
if debug {
|
||||
@@ -320,7 +333,7 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
|
||||
db.ContainerID[:12], db.DBName)
|
||||
}
|
||||
// Actual command with real password (not logged)
|
||||
cmd = exec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(dumpCtx, "docker", "exec", db.ContainerID,
|
||||
"mariadb-dump", "-u", "root", "-p"+password,
|
||||
"--single-transaction", "--routines", "--triggers", db.DBName)
|
||||
default:
|
||||
@@ -391,6 +404,14 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
// R-10 (T-6E-1): the rename is durable only once the DIRECTORY entry is on disk. Without this a
|
||||
// power cut right after the rename can leave the old dump (or none) under the final name, even
|
||||
// though the data itself was fsynced above. Best-effort, like atomicPromoteTar's dir.Sync() in
|
||||
// internal/backup/backup.go — the dump is already complete, so a failed dir sync is logged, not
|
||||
// fatal. Pinned by TestDumpOneTo_SyncsTheDumpDirectoryAfterRename.
|
||||
if err := syncDumpDir(dumpDir); err != nil {
|
||||
logger.Printf("[DEBUG] DumpOne: syncing dump directory %s after rename failed (best-effort): %v", dumpDir, err)
|
||||
}
|
||||
|
||||
result.FilePath = finalPath
|
||||
result.Size = stat.Size()
|
||||
@@ -671,7 +692,7 @@ func ListDumpFiles(dumpDir string, cached func(name string, size int64, mod time
|
||||
}
|
||||
|
||||
func populateDBEnv(ctx context.Context, db *DiscoveredDB) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "inspect", db.ContainerID,
|
||||
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -759,14 +780,14 @@ func ImportDump(ctx context.Context, db DiscoveredDB, dumpPath string, logger *l
|
||||
// MariaDB's DDL is not transactional, so a partial apply there is unavoidable at the engine
|
||||
// and is why the rollback in offbox_reconstitute.go exists and is the actual fix. Do not
|
||||
// read this flag as making the rollback optional.
|
||||
cmd = exec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
|
||||
"psql", "-v", "ON_ERROR_STOP=1", "--single-transaction", "-U", user, "-d", dbName)
|
||||
case DBTypeMariaDB:
|
||||
password := getMariaDBPassword(impCtx, db.ContainerID)
|
||||
if password == "" {
|
||||
return fmt.Errorf("could not determine MariaDB root password for %s", db.ContainerName)
|
||||
}
|
||||
cmd = exec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
|
||||
cmd = dockerexec.CommandContext(impCtx, "docker", "exec", "-i", db.ContainerID,
|
||||
"mariadb", "-u", "root", "-p"+password, db.DBName)
|
||||
default:
|
||||
return fmt.Errorf("unsupported DB type: %s", db.DBType)
|
||||
@@ -813,10 +834,10 @@ func waitDBReady(ctx context.Context, db DiscoveredDB, timeout time.Duration) er
|
||||
if user == "" {
|
||||
user = "postgres"
|
||||
}
|
||||
cmd = exec.CommandContext(c, "docker", "exec", db.ContainerID, "pg_isready", "-U", user)
|
||||
cmd = dockerexec.CommandContext(c, "docker", "exec", db.ContainerID, "pg_isready", "-U", user)
|
||||
case DBTypeMariaDB:
|
||||
pw := getMariaDBPassword(c, db.ContainerID)
|
||||
cmd = exec.CommandContext(c, "docker", "exec", db.ContainerID, "mariadb-admin", "ping", "-u", "root", "-p"+pw)
|
||||
cmd = dockerexec.CommandContext(c, "docker", "exec", db.ContainerID, "mariadb-admin", "ping", "-u", "root", "-p"+pw)
|
||||
default:
|
||||
cancel()
|
||||
return fmt.Errorf("unsupported DB type: %s", db.DBType)
|
||||
@@ -834,7 +855,7 @@ func waitDBReady(ctx context.Context, db DiscoveredDB, timeout time.Duration) er
|
||||
}
|
||||
|
||||
func getMariaDBPassword(ctx context.Context, containerID string) string {
|
||||
cmd := exec.CommandContext(ctx, "docker", "inspect", containerID,
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "inspect", containerID,
|
||||
"--format", "{{range .Config.Env}}{{println .}}{{end}}")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
@@ -976,3 +997,69 @@ func cleanupTmpFiles(dumpDir string, logger *log.Logger) {
|
||||
}
|
||||
|
||||
// M1: formatBytes removed — use humanizeBytes() from appdata.go (same package, no duplication).
|
||||
|
||||
// Completion markers — the last comment each engine's dump tool writes, and only when it finished.
|
||||
// R-640 (measured 2026-09-23 on 9202): the first half of a real pg_dump, loaded with
|
||||
// `psql -v ON_ERROR_STOP=1 --single-transaction`, returned rc 0 and left 42 tables with 0 users —
|
||||
// psql treats end-of-file inside a COPY as end of data and commits. A truncated MariaDB dump fails
|
||||
// its load, but only after it has already replaced half the tables. The header and CREATE TABLE
|
||||
// checks in ValidateDump cannot see either: only the END of the file can.
|
||||
const (
|
||||
pgCompleteMarker = "-- PostgreSQL database dump complete"
|
||||
mariadbCompleteMarker = "-- Dump completed"
|
||||
)
|
||||
|
||||
// ErrDumpIncomplete is returned (wrapped) by CheckDumpComplete when the marker is absent.
|
||||
var ErrDumpIncomplete = errors.New("database copy is incomplete: the engine's completion marker is missing")
|
||||
|
||||
// CheckDumpComplete reports whether a (possibly .gz) dump ends with its engine's completion marker.
|
||||
// It reads the whole stream but keeps only the last 4 KiB — pg_dump may print a `\unrestrict` line
|
||||
// after its marker, so the marker is searched in the tail rather than required on the last line.
|
||||
// An engine this function does not know is an error: "cannot tell" must never load.
|
||||
func CheckDumpComplete(path string, dbType DBType) error {
|
||||
var marker string
|
||||
switch dbType {
|
||||
case DBTypePostgres:
|
||||
marker = pgCompleteMarker
|
||||
case DBTypeMariaDB:
|
||||
marker = mariadbCompleteMarker
|
||||
default:
|
||||
return fmt.Errorf("%s: unknown database type %q, completeness cannot be checked", filepath.Base(path), dbType)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
defer f.Close()
|
||||
var r io.Reader = f
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
gr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening gzip %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
defer gr.Close()
|
||||
r = gr
|
||||
}
|
||||
const tailSize = 4096
|
||||
tail := make([]byte, 0, 2*tailSize)
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
n, rerr := r.Read(buf)
|
||||
if n > 0 {
|
||||
tail = append(tail, buf[:n]...)
|
||||
if len(tail) > tailSize {
|
||||
tail = append(tail[:0], tail[len(tail)-tailSize:]...)
|
||||
}
|
||||
}
|
||||
if rerr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("reading %s: %w", filepath.Base(path), rerr)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(tail), marker) {
|
||||
return fmt.Errorf("%s (%s): %w", filepath.Base(path), dbType, ErrDumpIncomplete)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestAppBindAndCaptureRootAgree(t *testing.T) {
|
||||
const sys = "/mnt/sys_drive"
|
||||
for _, drive := range []string{"/mnt/felhom-usb", "/mnt/felhom-drives/hdd_1", "/mnt/sys_drive"} {
|
||||
nsRoot := NamespaceRootFor(drive, sys)
|
||||
appBind := UserdataDir(nsRoot) // what the deploy sets as ${USERDATA_PATH}
|
||||
appBind := UserdataDir(nsRoot) // what the deploy sets as ${USERDATA_PATH}
|
||||
captureRoot := UserdataDir(nsRoot) // what the capture set resolves RootUserdata against
|
||||
if appBind != captureRoot {
|
||||
t.Fatalf("drive %q: the app binds %q while the backup captures %q", drive, appBind, captureRoot)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package appbackup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-10 (T-6E-1): DumpOneTo fsynced the dump FILE and renamed it, but never fsynced the DIRECTORY, so
|
||||
// the rename itself was not durable — the asymmetry with atomicPromoteTar (backup.go), which does.
|
||||
// The consequence asserted: a successful dump syncs the directory that holds the final file, AFTER
|
||||
// the final name exists (the sync must cover the rename, not precede it).
|
||||
//
|
||||
// No real docker: a `docker` stub in t.TempDir() on PATH (R-650's sanctioned seam) answers the
|
||||
// running-check and prints a small SQL dump.
|
||||
func TestDumpOneTo_SyncsTheDumpDirectoryAfterRename(t *testing.T) {
|
||||
bin := t.TempDir()
|
||||
stub := "#!/bin/sh\ncase \"$1\" in\n inspect) echo true ;;\n exec) printf 'CREATE TABLE t (id int);\\nINSERT INTO t VALUES (1);\\n' ;;\nesac\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
|
||||
dumpDir := filepath.Join(t.TempDir(), "unit")
|
||||
final := filepath.Join(dumpDir, "app-postgres.sql")
|
||||
|
||||
var synced []string
|
||||
finalExistedAtSync := false
|
||||
orig := syncDumpDir
|
||||
syncDumpDir = func(dir string) error {
|
||||
synced = append(synced, dir)
|
||||
if _, err := os.Stat(final); err == nil {
|
||||
finalExistedAtSync = true
|
||||
}
|
||||
return orig(dir)
|
||||
}
|
||||
t.Cleanup(func() { syncDumpDir = orig })
|
||||
|
||||
db := DiscoveredDB{ContainerName: "app-db", ContainerID: "0123456789abcdef", DBType: DBTypePostgres, DBUser: "u", DBName: "d", StackName: "app"}
|
||||
res := DumpOneTo(context.Background(), db, final, log.New(io.Discard, "", 0), false)
|
||||
if res.Error != nil {
|
||||
t.Fatalf("dump failed: %v", res.Error)
|
||||
}
|
||||
if _, err := os.Stat(final + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Errorf("the .tmp scratch file is still present after a successful dump")
|
||||
}
|
||||
if len(synced) != 1 || synced[0] != dumpDir {
|
||||
t.Fatalf("R-10: the dump directory was not fsynced after the rename: synced=%v, want [%s]", synced, dumpDir)
|
||||
}
|
||||
if !finalExistedAtSync {
|
||||
t.Errorf("R-10: the directory was synced BEFORE the final name existed — the rename is not covered")
|
||||
}
|
||||
}
|
||||
@@ -190,9 +190,9 @@ func TestImportDumpErrorDoesNotCarryEngineStderr(t *testing.T) {
|
||||
// test; what CAN be pinned is the thing the mutation changes.
|
||||
//
|
||||
// TWO PROPERTIES, both mutation-detectable:
|
||||
// 1. `finalPath` is never reassigned — the caller's destination is the destination.
|
||||
// 2. the scratch file is derived from `finalPath`, not from the canonical name, so a nightly dump
|
||||
// and a safety dump running into the same directory cannot share it.
|
||||
// 1. `finalPath` is never reassigned — the caller's destination is the destination.
|
||||
// 2. the scratch file is derived from `finalPath`, not from the canonical name, so a nightly dump
|
||||
// and a safety dump running into the same directory cannot share it.
|
||||
func TestDumpOneToHonoursTheDestinationItWasGiven(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "dbdump.go", nil, 0)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package appbackup
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-640: CheckDumpComplete reads the END of the copy. pg_dump 17.6+ prints `\unrestrict <key>` after
|
||||
// its marker, so the marker need not be the last line.
|
||||
func TestR640_CheckDumpComplete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
big := make([]byte, 200*1024) // longer than the kept tail: the marker must be found at the END
|
||||
for i := range big {
|
||||
big[i] = 'x'
|
||||
}
|
||||
cases := []struct {
|
||||
name, body string
|
||||
t DBType
|
||||
gz, ok bool
|
||||
}{
|
||||
{"pg complete", "-- PostgreSQL database dump\nCOPY t FROM stdin;\n1\n\\.\n\n--\n-- PostgreSQL database dump complete\n--\n\n\\unrestrict abc\n", DBTypePostgres, false, true},
|
||||
{"pg cut inside COPY", "-- PostgreSQL database dump\nCOPY t FROM stdin;\n1\n", DBTypePostgres, false, false},
|
||||
{"pg marker only at the start is not an end", "-- PostgreSQL database dump complete\n" + string(big) + "\n", DBTypePostgres, false, false},
|
||||
{"pg complete, gzipped", "-- PostgreSQL database dump\n" + string(big) + "\n-- PostgreSQL database dump complete\n", DBTypePostgres, true, true},
|
||||
{"pg cut, gzipped", "-- PostgreSQL database dump\n" + string(big), DBTypePostgres, true, false},
|
||||
{"mariadb complete", "-- MariaDB dump 10.19\nINSERT INTO `t` VALUES (1);\n-- Dump completed on 2026-09-23\n", DBTypeMariaDB, false, true},
|
||||
{"mariadb cut", "-- MariaDB dump 10.19\nINSERT INTO `t` VALUES (1),(2", DBTypeMariaDB, false, false},
|
||||
{"unknown engine never passes", "anything\n-- Dump completed\n", DBType("sqlite"), false, false},
|
||||
}
|
||||
for i, c := range cases {
|
||||
p := filepath.Join(dir, "d"+string(rune('a'+i))+".sql")
|
||||
if c.gz {
|
||||
p += ".gz"
|
||||
f, _ := os.Create(p)
|
||||
w := gzip.NewWriter(f)
|
||||
w.Write([]byte(c.body))
|
||||
w.Close()
|
||||
f.Close()
|
||||
} else if err := os.WriteFile(p, []byte(c.body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := CheckDumpComplete(p, c.t)
|
||||
if (err == nil) != c.ok {
|
||||
t.Errorf("%s: ok=%v, got err=%v", c.name, c.ok, err)
|
||||
}
|
||||
if !c.ok && c.t != "sqlite" && !errors.Is(err, ErrDumpIncomplete) {
|
||||
t.Errorf("%s: want ErrDumpIncomplete, got %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
@@ -221,7 +222,7 @@ func DecryptFile(inputPath, outputPath, password string) error {
|
||||
}
|
||||
if !hmac.Equal(mac.Sum(nil), storedMAC) {
|
||||
os.Remove(outputPath)
|
||||
return errors.New("jelszó hibás vagy a fájl sérült")
|
||||
return util.MsgError("err.appexport.jelszo_hibas_vagy_a_fajl_serult")
|
||||
}
|
||||
|
||||
return out.Sync()
|
||||
|
||||
@@ -6,10 +6,11 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -696,7 +697,7 @@ func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Ma
|
||||
// Renaming can't help either (the import couldn't map the new name), so the only honest
|
||||
// outcome is a loud failure.
|
||||
if prev, dup := claimed[subdir]; dup {
|
||||
return fmt.Errorf("két adatkönyvtár azonos névvel végződik (%q: %s és %s) — a csomag nem tudná megkülönböztetni őket", subdir, prev, mount)
|
||||
return util.MsgError("err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es", subdir, prev, mount)
|
||||
}
|
||||
claimed[subdir] = mount
|
||||
|
||||
@@ -730,7 +731,7 @@ func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Ma
|
||||
// controller itself runs containerized (the v0.124.0 HIGH finding). Package var so unit tests
|
||||
// inject a recorder (no docker on test boxes).
|
||||
var dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stdout = stdout
|
||||
var errBuf bytes.Buffer
|
||||
@@ -845,7 +846,7 @@ func assertBundleDataComplete(tmpDir string, manifest *Manifest) error {
|
||||
// bundle. A needs_hdd app with NO data of any kind is a hollow bundle by definition; refuse it
|
||||
// loudly so a future discovery gap can never again ship silently.
|
||||
if manifest.NeedsHDD && !manifest.HasHDDData && !manifest.HasVolumeData {
|
||||
return fmt.Errorf("a mentés nem tartalmaz alkalmazásadatot (0 adatkönyvtár, 0 kötet egy adattárolós alkalmazásnál)")
|
||||
return util.MsgError("err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -22,13 +22,13 @@ type fabProv struct {
|
||||
mounts []string
|
||||
}
|
||||
|
||||
func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath }
|
||||
func (p *fabProv) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath }
|
||||
func (p *fabProv) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
|
||||
// R-203: these fixtures use ENROLLED drive paths, where the namespace root IS the drive path.
|
||||
// Delegating keeps that identity explicit rather than hardcoding it.
|
||||
func (p *fabProv) GetStackNamespaceRoot(name string) string { return p.GetStackHDDPath(name) }
|
||||
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
|
||||
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
|
||||
func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||
return p.binds, p.has
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -168,7 +170,7 @@ func ReadManifestFromEncryptedFAB(fabPath, password string) (*Manifest, error) {
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := DecryptFile(fabPath, tmpPath, password); err != nil {
|
||||
return nil, fmt.Errorf("visszafejtés sikertelen: %w", err)
|
||||
return nil, util.MsgError("err.appexport.visszafejtes_sikertelen", err)
|
||||
}
|
||||
|
||||
return ReadManifestFromFAB(tmpPath)
|
||||
@@ -689,7 +691,7 @@ func validateBundleData(tmpDir string, manifest *Manifest) error {
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(tmpDir, "data", "volumes", v+".tar"))
|
||||
if err != nil || fi.Size() == 0 {
|
||||
return fmt.Errorf("a(z) %q kötet adata hiányzik a csomagból", v)
|
||||
return util.MsgError("err.appexport.a_z_kotet_adata_hianyzik_a", v)
|
||||
}
|
||||
}
|
||||
for _, s := range manifest.HDDSubdirs {
|
||||
@@ -698,7 +700,7 @@ func validateBundleData(tmpDir string, manifest *Manifest) error {
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(tmpDir, "data", "hdd", s+".tar"))
|
||||
if err != nil || fi.Size() == 0 {
|
||||
return fmt.Errorf("a(z) %q adatkönyvtár tartalma hiányzik a csomagból", s)
|
||||
return util.MsgError("err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a", s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -936,11 +938,12 @@ func classifyDBImage(service, image, container string) *dbServiceInfo {
|
||||
|
||||
// composeExecEnv runs docker compose in the given stack directory with env vars.
|
||||
func composeExecEnv(stackDir string, env map[string]string, args ...string) ([]byte, error) {
|
||||
defer dockerexec.BeginImageWork(args)() // R-863: no image clean-up while a restore may pull
|
||||
cmdArgs := append([]string{"compose"}, args...)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", cmdArgs...)
|
||||
cmd.Dir = stackDir
|
||||
cmd.Env = os.Environ()
|
||||
for k, v := range env {
|
||||
@@ -988,14 +991,14 @@ func waitForDB(containerID, dbType string, env map[string]string) error {
|
||||
if user == "" {
|
||||
user = "postgres"
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "docker", "exec", containerID,
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", containerID,
|
||||
"pg_isready", "-U", user)
|
||||
case "mariadb":
|
||||
password := env["MYSQL_ROOT_PASSWORD"]
|
||||
if password == "" {
|
||||
password = env["MARIADB_ROOT_PASSWORD"]
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "docker", "exec", containerID,
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", containerID,
|
||||
"mysqladmin", "ping", "-u", "root", "-p"+password)
|
||||
default:
|
||||
cancel()
|
||||
@@ -1046,7 +1049,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
|
||||
if dbName == "" {
|
||||
dbName = user
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
||||
"psql", "-U", user, "-d", dbName)
|
||||
case "mariadb":
|
||||
password := env["MYSQL_ROOT_PASSWORD"]
|
||||
@@ -1057,7 +1060,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
|
||||
if dbName == "" {
|
||||
dbName = env["MARIADB_DATABASE"]
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
||||
"mysql", "-u", "root", "-p"+password, dbName)
|
||||
default:
|
||||
return fmt.Errorf("unknown DB type: %s", dbType)
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Part A of the version-travel brief (controller v0.275.0, R-696, `07` §6.6): a backup's data and its
|
||||
// version travel together. Measured before the fix on 9202 (`audits/version-travel-2026-09-26/A1/`): the
|
||||
// periodic refresh re-captured the unit's DEFINITION two minutes after an update, over the previous
|
||||
// version's DATA; Tier 1's time moved to the refresh; a restore in that window started a PostgreSQL 16
|
||||
// datadir under the 18 definition and left the app down.
|
||||
//
|
||||
// Every test asserts a CONSEQUENCE a household or the update would see — which definition the restore
|
||||
// starts, which time the precondition reads, which copy the release trusts — not the mechanism.
|
||||
|
||||
// vtStack is one app's stack dir + a provider whose ImagePins are what production derives them from:
|
||||
// ParseComposeImages of the stack's compose (the adapter's GetStackRecoveryInfo does exactly that).
|
||||
type vtStack struct {
|
||||
t *testing.T
|
||||
tmp string
|
||||
drive string
|
||||
stackDir string
|
||||
fake *fakeRecoveryProvider
|
||||
m *Manager
|
||||
}
|
||||
|
||||
func vtCompose(pgMajor string) string {
|
||||
return "services:\n app:\n image: example/app:0.96.0\n app-db:\n image: postgres:" + pgMajor + "-alpine\n"
|
||||
}
|
||||
|
||||
func newVTStack(t *testing.T, pgMajor string) *vtStack {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
v := &vtStack{t: t, tmp: tmp, drive: filepath.Join(tmp, "drive"), stackDir: filepath.Join(tmp, "stack")}
|
||||
if err := os.MkdirAll(v.stackDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.fake = &fakeRecoveryProvider{hdd: v.drive, running: true}
|
||||
v.m = &Manager{
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
systemDataPath: filepath.Join(tmp, "system"),
|
||||
stackProvider: v.fake,
|
||||
version: "vtest",
|
||||
}
|
||||
v.setVersion(pgMajor)
|
||||
return v
|
||||
}
|
||||
|
||||
// setVersion is what an update does to the stack dir: the definition moves, and what runs moves with it.
|
||||
func (v *vtStack) setVersion(pgMajor string) {
|
||||
v.t.Helper()
|
||||
mustWrite(v.t, filepath.Join(v.stackDir, "docker-compose.yml"), vtCompose(pgMajor))
|
||||
mustWrite(v.t, filepath.Join(v.stackDir, ".felhom.yml"), "display_name: App "+pgMajor+"\n")
|
||||
mustWrite(v.t, filepath.Join(v.stackDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: vt\n")
|
||||
v.fake.info = RecoveryInfo{
|
||||
StackDir: v.stackDir,
|
||||
DisplayName: "App",
|
||||
ImagePins: ParseComposeImages(filepath.Join(v.stackDir, "docker-compose.yml")),
|
||||
NonSecretEnv: map[string]string{"SUBDOMAIN": "vt"},
|
||||
InstalledImages: map[string]string{
|
||||
"app": "example/app:0.96.0@sha256:aaa",
|
||||
"app-db": "postgres:" + pgMajor + "-alpine@sha256:" + pgMajor + pgMajor,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (v *vtStack) unitDir() string { return RecoveryUnitPath(v.drive, "app") }
|
||||
|
||||
// backupLegs is what a data run writes, through the SAME stamp call the legs make: a database dump and
|
||||
// a volume tar, both dated `at` (so "the refresh ran later" is a fact of the clock, not of the test).
|
||||
func (v *vtStack) backupLegs(at time.Time, marker string) {
|
||||
v.t.Helper()
|
||||
sql := filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql")
|
||||
tar := filepath.Join(UnitVolumeDumpDir(v.unitDir()), "app_db.tar")
|
||||
mustWrite(v.t, sql, pgDump(1)+"-- "+marker+"\n")
|
||||
mustWrite(v.t, tar, "tar:"+marker)
|
||||
for _, p := range []string{sql, tar} {
|
||||
if err := os.Chtimes(p, at, at); err != nil {
|
||||
v.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
v.m.stampDataFile("app", v.unitDir(), "db-dumps/app-postgres.sql")
|
||||
v.m.stampDataFile("app", v.unitDir(), "volume-dumps/app_db.tar")
|
||||
}
|
||||
|
||||
func (v *vtStack) manifest() *RecoveryManifest {
|
||||
v.t.Helper()
|
||||
man := readManifest(UnitManifestFile(v.unitDir()))
|
||||
if man == nil {
|
||||
v.t.Fatal("no readable manifest")
|
||||
}
|
||||
return man
|
||||
}
|
||||
|
||||
func (v *vtStack) unitComposeImages() []string {
|
||||
return ParseComposeImages(filepath.Join(UnitComposeDir(v.unitDir()), "docker-compose.yml"))
|
||||
}
|
||||
|
||||
// theWindow builds the measured A1 state: a data run at PostgreSQL 16 two hours ago, then the update to
|
||||
// 18, then the periodic refresh NOW.
|
||||
func theWindow(t *testing.T) (*vtStack, time.Time) {
|
||||
t.Helper()
|
||||
v := newVTStack(t, "16")
|
||||
dataAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Second)
|
||||
v.backupLegs(dataAt, "written-by-16")
|
||||
if err := v.m.CaptureRecoveryUnit("app"); err != nil { // the data run's capture
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.setVersion("18") // the guarded update moved the pin
|
||||
if err := v.m.captureRecoveryUnit("app", false); err != nil { // the 5-minute refresh
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v, dataAt
|
||||
}
|
||||
|
||||
// A5 red-proof 1 — the manifest refresh after a pin change no longer moves Tier 1's time.
|
||||
// Pre-fix (v0.274.0): ListRestorePoints = newest of the manifest's and the dumps' mtimes → "now".
|
||||
func TestA5_RefreshAfterAPinChangeDoesNotMoveTier1sTime(t *testing.T) {
|
||||
v, dataAt := theWindow(t)
|
||||
pts, found := v.m.ListRestorePoints("app")
|
||||
if !found || len(pts) != 1 {
|
||||
t.Fatalf("restore points = %v found=%v, want one", pts, found)
|
||||
}
|
||||
got, err := time.Parse(time.RFC3339, pts[0].Time)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(dataAt) {
|
||||
t.Fatalf("Tier 1's time = %s, want the DATA's time %s — the refresh %s made a two-hour-old dump read as new",
|
||||
got.Format(time.RFC3339), dataAt.Format(time.RFC3339), time.Since(got).Round(time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
// The definition stays with its data: after the refresh, compose/ still names the 16 definition, the
|
||||
// manifest says both (image_pins = what runs, data.image_pins = what wrote the data).
|
||||
// Pre-fix: the refresh rewrote compose/ to 18 (A1 S3b).
|
||||
func TestA2_TheUnitKeepsTheDefinitionItsDataBelongsTo(t *testing.T) {
|
||||
v, _ := theWindow(t)
|
||||
if got := v.unitComposeImages(); !samePins(got, ParseComposeImages(writeTmpCompose(t, vtCompose("16")))) {
|
||||
t.Fatalf("the unit's compose/ names %v — the refresh paired the 18 definition with the 16 data", got)
|
||||
}
|
||||
man := v.manifest()
|
||||
if man.Data == nil || !strings.Contains(strings.Join(man.Data.ImagePins, " "), "postgres:16-alpine") {
|
||||
t.Fatalf("manifest data = %+v, want the 16 pins", man.Data)
|
||||
}
|
||||
if !strings.Contains(strings.Join(man.ImagePins, " "), "postgres:18-alpine") {
|
||||
t.Fatalf("manifest image_pins = %v, want the app's CURRENT (18) pins", man.ImagePins)
|
||||
}
|
||||
if got := man.Data.Files["db-dumps/app-postgres.sql"].Images["app-db"]; !strings.HasPrefix(got, "postgres:16-alpine@sha256:") {
|
||||
t.Fatalf("the dump's recorded engine = %q, want postgres:16 with its digest", got)
|
||||
}
|
||||
// And a SECOND refresh writes nothing: the frozen checksums describe what compose/ holds.
|
||||
before, _ := os.Stat(UnitManifestFile(v.unitDir()))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if err := v.m.captureRecoveryUnit("app", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := os.Stat(UnitManifestFile(v.unitDir()))
|
||||
if !after.ModTime().Equal(before.ModTime()) {
|
||||
t.Fatal("a second refresh rewrote the manifest — the frozen unit thrashes the drive every five minutes")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTmpCompose(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "docker-compose.yml")
|
||||
mustWrite(t, p, body)
|
||||
return p
|
||||
}
|
||||
|
||||
// The next data run replaces the data AND the definition: both are 18 afterwards.
|
||||
func TestA2_TheNextDataRunMovesDataAndDefinitionTogether(t *testing.T) {
|
||||
v, _ := theWindow(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
v.backupLegs(now, "written-by-18")
|
||||
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(v.unitComposeImages(), " "); !strings.Contains(got, "postgres:18-alpine") {
|
||||
t.Fatalf("after a data run on 18 the unit's compose/ names %s, want 18", got)
|
||||
}
|
||||
if man := v.manifest(); man.Data == nil || man.Data.At != now.Format(time.RFC3339) || man.Data.Mixed {
|
||||
t.Fatalf("data = %+v, want at %s, not mixed", man.Data, now.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// vtRestorer records the definition the restore STARTS the data with.
|
||||
type vtRestorer struct {
|
||||
*fakeRecoveryProvider
|
||||
startedWith []string
|
||||
}
|
||||
|
||||
func (f *vtRestorer) RecreateStackDefinitionFromUnit(name, composeDir string, env map[string]string) error {
|
||||
f.startedWith = ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml"))
|
||||
return f.fakeRecoveryProvider.RecreateStackDefinitionFromUnit(name, composeDir, env)
|
||||
}
|
||||
|
||||
func vtRestoreSeams(m *Manager) (vols *[]string) {
|
||||
var vd []string
|
||||
m.volumeReplayFrom = func(_, dir string) (int, error) { vd = append(vd, dir); return 1, nil }
|
||||
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
|
||||
return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil
|
||||
}
|
||||
m.importDBDump = func(context.Context, DiscoveredDB, string) error { return nil }
|
||||
return &vd
|
||||
}
|
||||
|
||||
// A5 red-proof 3 — a restore in the window starts the OLD version with the OLD data, and says so.
|
||||
// Pre-fix: the unit's definition was 18 (the refresh), so the 16 datadir was started under 18 (A1 S4).
|
||||
func TestA5_ARestoreInTheWindowStartsTheOldVersionWithTheOldData(t *testing.T) {
|
||||
v, dataAt := theWindow(t)
|
||||
rec := &vtRestorer{fakeRecoveryProvider: v.fake}
|
||||
v.m.stackProvider = rec
|
||||
vtRestoreSeams(v.m)
|
||||
|
||||
res, err := v.m.RestoreFromRecoveryUnit("app")
|
||||
if err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
if got := strings.Join(rec.startedWith, " "); !strings.Contains(got, "postgres:16-alpine") || strings.Contains(got, "postgres:18") {
|
||||
t.Fatalf("the restore started the 16 data with the definition %s — a restore must never mix versions", got)
|
||||
}
|
||||
if !res.VersionChanged || !samePins(res.DataPins, ParseComposeImages(writeTmpCompose(t, vtCompose("16")))) || !res.DataAt.Equal(dataAt) {
|
||||
t.Fatalf("result = changed %v pins %v at %s, want changed, the 16 pins, %s", res.VersionChanged, res.DataPins, res.DataAt, dataAt)
|
||||
}
|
||||
}
|
||||
|
||||
// A restore never starts data with a definition it does not belong to: a unit whose compose/ names other
|
||||
// pins than its data, and a unit whose files were written by different versions, are refused BEFORE
|
||||
// anything is touched (no stop, no volume, no recreate).
|
||||
func TestA3_AMismatchedOrMixedUnitIsRefusedBeforeAnythingMoves(t *testing.T) {
|
||||
t.Run("definition-not-the-data's", func(t *testing.T) {
|
||||
v, _ := theWindow(t)
|
||||
// What v0.274.0's refresh left behind: the NEW definition in compose/ over the old data.
|
||||
mustWrite(t, filepath.Join(UnitComposeDir(v.unitDir()), "docker-compose.yml"), vtCompose("18"))
|
||||
vols := vtRestoreSeams(v.m)
|
||||
_, err := v.m.RestoreFromRecoveryUnit("app")
|
||||
if !errors.Is(err, ErrUnitVersionMismatch) {
|
||||
t.Fatalf("err = %v, want ErrUnitVersionMismatch", err)
|
||||
}
|
||||
if len(v.fake.calls) != 0 || len(*vols) != 0 {
|
||||
t.Fatalf("the refusal touched the app: calls=%v volumes=%v", v.fake.calls, *vols)
|
||||
}
|
||||
})
|
||||
t.Run("mixed", func(t *testing.T) {
|
||||
v, _ := theWindow(t)
|
||||
// The DB leg ran under 18, the volume leg failed and kept its 16 tar.
|
||||
sql := filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql")
|
||||
mustWrite(t, sql, pgDump(1))
|
||||
v.m.stampDataFile("app", v.unitDir(), "db-dumps/app-postgres.sql")
|
||||
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if man := v.manifest(); man.Data == nil || !man.Data.Mixed {
|
||||
t.Fatalf("data = %+v, want Mixed", man.Data)
|
||||
}
|
||||
vols := vtRestoreSeams(v.m)
|
||||
_, err := v.m.RestoreFromRecoveryUnit("app")
|
||||
if !errors.Is(err, ErrUnitVersionMismatch) {
|
||||
t.Fatalf("err = %v, want ErrUnitVersionMismatch", err)
|
||||
}
|
||||
if len(v.fake.calls) != 0 || len(*vols) != 0 {
|
||||
t.Fatalf("the refusal touched the app: calls=%v volumes=%v", v.fake.calls, *vols)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// An older unit (no stamps) restores as before: its definition, VersionsUnknown, no refusal.
|
||||
func TestA3_AnUnstampedUnitRestoresAsBefore(t *testing.T) {
|
||||
v := newVTStack(t, "16")
|
||||
mustWrite(t, filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql"), pgDump(1))
|
||||
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if man := v.manifest(); man.Data != nil {
|
||||
t.Fatalf("an unstamped dump produced data %+v — unknown must stay unknown", man.Data)
|
||||
}
|
||||
rec := &vtRestorer{fakeRecoveryProvider: v.fake}
|
||||
v.m.stackProvider = rec
|
||||
vtRestoreSeams(v.m)
|
||||
res, err := v.m.RestoreFromRecoveryUnit("app")
|
||||
if err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
if !res.VersionsUnknown || res.VersionChanged || len(rec.startedWith) == 0 {
|
||||
t.Fatalf("result = %+v started=%v, want VersionsUnknown and the unit's definition started", res, rec.startedWith)
|
||||
}
|
||||
}
|
||||
|
||||
// A5 red-proof 4 — the update's precondition refuses a stale dump that a refresh made look new.
|
||||
// Pre-fix: Tier 1 = the refresh's manifest time → "0m old" → accepted, and the update leaned on a copy
|
||||
// of the previous version's data from two hours before.
|
||||
func TestA5_ThePreconditionRefusesAStaleDumpARefreshMadeLookNew(t *testing.T) {
|
||||
v, dataAt := theWindow(t)
|
||||
now := time.Now()
|
||||
fresh := func(p UpdateTierPoint) bool { return now.Sub(p.At) <= time.Hour }
|
||||
p, ok, seen := v.m.UpdateRestorePoints(context.Background(), "app", fresh)
|
||||
if ok {
|
||||
t.Fatalf("the precondition ACCEPTED tier %d at %s (%s old) — the data is from %s",
|
||||
p.Tier, p.At.Format(time.RFC3339), now.Sub(p.At).Round(time.Second), dataAt.Format(time.RFC3339))
|
||||
}
|
||||
if len(seen) != 1 || seen[0].Tier != UpdateTierLocal || !seen[0].At.Equal(dataAt) {
|
||||
t.Fatalf("seen = %+v, want Tier 1 at the data's time %s", seen, dataAt.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// A unit with no data file at all is dated by the data run that confirmed it, and a refresh keeps it.
|
||||
func TestA4_AUnitWithoutDataFilesIsDatedByItsDataRun(t *testing.T) {
|
||||
v := newVTStack(t, "16")
|
||||
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := v.manifest().Data
|
||||
if first == nil || first.At == "" {
|
||||
t.Fatalf("data = %+v, want the data run's time", first)
|
||||
}
|
||||
v.setVersion("18")
|
||||
if err := v.m.captureRecoveryUnit("app", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := v.manifest().Data; got == nil || got.At != first.At {
|
||||
t.Fatalf("a refresh moved the data time: %+v → %+v", first, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The undo copies are not data: an update's safety dump written after the backup must not date Tier 1.
|
||||
func TestA4_AnUndoCopyNeverDatesTheUnit(t *testing.T) {
|
||||
v := newVTStack(t, "16")
|
||||
old := time.Now().Add(-3 * time.Hour).UTC().Truncate(time.Second)
|
||||
mustWrite(t, filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql"), pgDump(1))
|
||||
_ = os.Chtimes(filepath.Join(UnitDBDumpDir(v.unitDir()), "app-postgres.sql"), old, old)
|
||||
if err := v.m.captureRecoveryUnit("app", false); err != nil { // unstamped: the legacy rule
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(UnitDBDumpDir(v.unitDir()), preRestoreDumpPrefix+"20260926T000000Z-app-postgres.sql"), pgDump(1))
|
||||
pts, _ := v.m.ListRestorePoints("app")
|
||||
if len(pts) != 1 || pts[0].Time != old.Format(time.RFC3339) {
|
||||
t.Fatalf("Tier 1 = %+v, want the dump's %s (not the undo copy's, not the manifest's)", pts, old.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// The stamps are written by the PRODUCTION leg (seam discipline): RunAppBackupNow → the dump seam → the
|
||||
// stamp → the capture's `data`. A stamp helper nobody calls is the "seam built but never wired" shape.
|
||||
func TestA2_TheUpdatesOwnBackupStampsItsDataThroughTheRealLeg(t *testing.T) {
|
||||
v := newVTStack(t, "16")
|
||||
v.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
|
||||
return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil
|
||||
}
|
||||
v.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult {
|
||||
p := filepath.Join(dir, "app-postgres.sql")
|
||||
mustWrite(t, p, pgDump(1))
|
||||
return DumpResult{DB: db, FilePath: p}
|
||||
}
|
||||
v.m.perAppTier2 = func(string) error { return nil }
|
||||
if err := v.m.RunAppBackupNow(context.Background(), "app"); err != nil {
|
||||
t.Fatalf("RunAppBackupNow: %v", err)
|
||||
}
|
||||
man := v.manifest()
|
||||
if man.Data == nil || man.Data.Mixed || len(man.Data.Files) != 1 {
|
||||
t.Fatalf("data = %+v, want one stamped dump", man.Data)
|
||||
}
|
||||
st := man.Data.Files["db-dumps/app-postgres.sql"]
|
||||
if st.Images["app-db"] != "postgres:16-alpine@sha256:1616" || !samePins(st.Pins, v.fake.info.ImagePins) {
|
||||
t.Fatalf("stamp = %+v, want the running images and the definition's pins", st)
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
b, _ := os.ReadFile(UnitManifestFile(v.unitDir()))
|
||||
_ = json.Unmarshal(b, &raw)
|
||||
if _, ok := raw["data"]; !ok {
|
||||
t.Fatal("manifest.json carries no `data` key")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: a mirror taken after the update of a unit whose data is older is as old as that data.
|
||||
func TestA4_ATier2CopyIsAsOldAsItsData(t *testing.T) {
|
||||
copyAt := time.Now().UTC().Truncate(time.Second)
|
||||
dataAt := copyAt.Add(-2 * time.Hour)
|
||||
p := Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: copyAt.Format(time.RFC3339), DataDate: dataAt.Format(time.RFC3339)}
|
||||
got, ok := p.ProvenCopyTime()
|
||||
if !ok || !got.Equal(dataAt) {
|
||||
t.Fatalf("Tier 2 proven at %s ok=%v, want the data's %s", got, ok, dataAt)
|
||||
}
|
||||
p.DataDate = ""
|
||||
if got, _ := p.ProvenCopyTime(); !got.Equal(copyAt) {
|
||||
t.Fatalf("with no data date, Tier 2 = %s, want the copy's %s (as before)", got, copyAt)
|
||||
}
|
||||
}
|
||||
|
||||
// The household's version label names every image — a PostgreSQL step is visible in it.
|
||||
func TestA3_PinsVersionNamesEveryImage(t *testing.T) {
|
||||
got := PinsVersion([]string{"docmost/docmost:0.96.0@sha256:b5", "postgres:16-alpine@sha256:72", "gitea.dooplex.hu/x/redis:7-alpine"})
|
||||
if got != "docmost:0.96.0, postgres:16-alpine, redis:7-alpine" {
|
||||
t.Fatalf("PinsVersion = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// vtReconProvider is the reconstitution fixture's provider with a LIVE version (18) and a recorder for
|
||||
// the definition the restore writes.
|
||||
type vtReconProvider struct {
|
||||
*recordingProvider
|
||||
livePins []string
|
||||
wroteDef []string
|
||||
wroteAtCall int
|
||||
}
|
||||
|
||||
func (p *vtReconProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
||||
return RecoveryInfo{DisplayName: "Immich", ImagePins: p.livePins}, true
|
||||
}
|
||||
func (p *vtReconProvider) RecreateStackDefinitionFromUnit(_, composeDir string, _ map[string]string) error {
|
||||
p.wroteDef = ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml"))
|
||||
p.wroteAtCall = len(p.calls)
|
||||
p.calls = append(p.calls, "recreate")
|
||||
return nil
|
||||
}
|
||||
|
||||
// vtSnapshotUnit gives the fixture's scratch unit a definition and a `data` block (a snapshot taken by
|
||||
// v0.275.0), and returns the unit dir.
|
||||
func vtSnapshotUnit(t *testing.T, m *Manager, pgMajor string, withData bool) string {
|
||||
t.Helper()
|
||||
scratch, _, err := m.offboxRestoreScratchDir("immich")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var unit string
|
||||
_ = filepath.Walk(scratch, func(p string, fi os.FileInfo, _ error) error {
|
||||
if fi != nil && !fi.IsDir() && fi.Name() == "manifest.json" {
|
||||
unit = filepath.Dir(p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if unit == "" {
|
||||
t.Fatal("no scratch unit")
|
||||
}
|
||||
mustWrite(t, filepath.Join(UnitComposeDir(unit), "docker-compose.yml"), vtCompose(pgMajor))
|
||||
mustWrite(t, filepath.Join(UnitComposeDir(unit), "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: vt\n")
|
||||
man := readManifest(UnitManifestFile(unit))
|
||||
if withData {
|
||||
man.Data = &UnitData{At: "2026-09-26T02:15:01Z", ImagePins: ParseComposeImages(filepath.Join(UnitComposeDir(unit), "docker-compose.yml"))}
|
||||
}
|
||||
if err := writeManifest(UnitManifestFile(unit), man); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return unit
|
||||
}
|
||||
|
||||
// Off-site (Tier 3): v0.274.0 never wrote the definition — last night's snapshot data went under the
|
||||
// app's NEW definition (A1, read from source). Now the snapshot's own definition is written BEFORE any
|
||||
// file, volume or database is touched, and the database service is resolved from it.
|
||||
func TestA3_TheOffsiteRestoreBringsTheSnapshotsVersionBack(t *testing.T) {
|
||||
m, prov, imported := reconFixture(t, "20260926T021500Z", "2026-09-26T02:15:01Z", pgDump(1))
|
||||
vp := &vtReconProvider{recordingProvider: prov, livePins: ParseComposeImages(writeTmpCompose(t, vtCompose("18")))}
|
||||
m.SetStackProvider(vp)
|
||||
vtSnapshotUnit(t, m, "16", true)
|
||||
|
||||
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
|
||||
if err != nil {
|
||||
t.Fatalf("reconstitute: %v", err)
|
||||
}
|
||||
if got := strings.Join(vp.wroteDef, " "); !strings.Contains(got, "postgres:16-alpine") {
|
||||
t.Fatalf("the restore wrote the definition %q — want the snapshot's 16", got)
|
||||
}
|
||||
if vp.wroteAtCall != 1 || vp.calls[0] != "stop" {
|
||||
t.Fatalf("calls = %v — the definition must be written right after the stop, before any data", vp.calls)
|
||||
}
|
||||
if !res.VersionChanged || res.DataAt.Format(time.RFC3339) != "2026-09-26T02:15:01Z" || len(*imported) != 1 {
|
||||
t.Fatalf("result changed=%v at=%s imported=%v", res.VersionChanged, res.DataAt, *imported)
|
||||
}
|
||||
if got := strings.Join(vp.gotServices, ","); got != "app-db" {
|
||||
t.Fatalf("the DB-only start was %q — the database service must come from the definition that RUNS (the snapshot's)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Same version, or a snapshot from before v0.275.0: nothing is written — the path of every earlier release.
|
||||
func TestA3_TheOffsiteRestoreLeavesTheDefinitionWhenNothingDiffers(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
live string
|
||||
withData bool
|
||||
unknown bool
|
||||
}{{"same-version", "16", true, false}, {"pre-v0.275.0-snapshot", "18", false, true}} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
m, prov, _ := reconFixture(t, "20260926T021500Z", "2026-09-26T02:15:01Z", pgDump(1))
|
||||
vp := &vtReconProvider{recordingProvider: prov, livePins: ParseComposeImages(writeTmpCompose(t, vtCompose(c.live)))}
|
||||
m.SetStackProvider(vp)
|
||||
vtSnapshotUnit(t, m, "16", c.withData)
|
||||
res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
|
||||
if err != nil {
|
||||
t.Fatalf("reconstitute: %v", err)
|
||||
}
|
||||
if vp.wroteDef != nil || res.VersionChanged || res.VersionsUnknown != c.unknown {
|
||||
t.Fatalf("wrote %v changed=%v unknown=%v", vp.wroteDef, res.VersionChanged, res.VersionsUnknown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: a snapshot pushed after a failed dump leg carries older data than its own time — the recorded
|
||||
// push caps it; a snapshot newer than the last recorded push (another box) keeps its own time.
|
||||
func TestA4_AnOffsiteCopyIsAsOldAsTheDataItWasPushedWith(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
snap := time.Date(2026, 9, 26, 2, 15, 0, 0, time.UTC)
|
||||
if got := m.offsiteDataTime("app", snap); !got.Equal(snap) {
|
||||
t.Fatalf("no record: %s, want the snapshot's own time", got)
|
||||
}
|
||||
data := snap.Add(-24 * time.Hour)
|
||||
if err := sett.SetOffsiteDataAt("app", settings.OffsiteDataRecord{PushedAt: snap.Add(time.Minute).Format(time.RFC3339), DataAt: data.Format(time.RFC3339)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := m.offsiteDataTime("app", snap); !got.Equal(data) {
|
||||
t.Fatalf("recorded push: %s, want the data's %s", got, data)
|
||||
}
|
||||
later := snap.Add(2 * time.Hour)
|
||||
if got := m.offsiteDataTime("app", later); !got.Equal(later) {
|
||||
t.Fatalf("a snapshot newer than the recorded push: %s, want its own %s", got, later)
|
||||
}
|
||||
}
|
||||
|
||||
// The push records the pushed unit's DATA time (the production call in runOffboxInternal).
|
||||
func TestA4_ThePushRecordsTheUnitsDataTime(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
v := newVTStack(t, "16")
|
||||
dataAt := time.Now().Add(-26 * time.Hour).UTC().Truncate(time.Second)
|
||||
v.backupLegs(dataAt, "x")
|
||||
if err := v.m.CaptureRecoveryUnit("app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.recordOffsiteDataAt("app", v.unitDir())
|
||||
rec, ok := sett.GetOffsiteDataAt("app")
|
||||
if !ok || rec.DataAt != dataAt.Format(time.RFC3339) || rec.PushedAt == "" {
|
||||
t.Fatalf("record = %+v ok=%v, want data at %s", rec, ok, dataAt.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// The production push path writes the record (seam discipline: recordOffsiteDataAt is CALLED by
|
||||
// runOffboxInternal after a successful snapshot, not only testable on its own).
|
||||
func TestA4_TheOffsiteRunRecordsThePushedDataTime(t *testing.T) {
|
||||
drive := t.TempDir()
|
||||
m, sett, prov := classifiedOffboxManager(t, drive)
|
||||
u := mkUnit(t, drive, "immich")
|
||||
if err := writeManifest(UnitManifestFile(u), &RecoveryManifest{AppName: "immich", Data: &UnitData{At: "2026-09-25T02:15:01Z"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prov.hdd["immich"] = drive
|
||||
prov.has["immich"] = true
|
||||
_ = sett.SetAppOffbox("immich", true)
|
||||
m.SetOffsitePreDumpFn(func(context.Context) error { return nil })
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case contains(args, "cat") && contains(args, "config"):
|
||||
return []byte(`{"version":2}`), nil
|
||||
case contains(args, "snapshots"):
|
||||
return []byte(`[]`), nil
|
||||
case contains(args, "stats"):
|
||||
return []byte(`{"total_size":123}`), nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
rec, ok := sett.GetOffsiteDataAt("immich")
|
||||
if !ok || rec.DataAt != "2026-09-25T02:15:01Z" || rec.PushedAt == "" {
|
||||
t.Fatalf("after a successful push the data-time record is %+v ok=%v, want the unit's data time", rec, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// R-699 (v0.275.0) — a just-installed app's unit, captured by the status refresh before any backup ran,
|
||||
// holds only the definition. It stays LISTED (restorable as the definition) but is never a copy the
|
||||
// update's precondition leans on; after a data run it is.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): drop the DataProven skip in updateTierPoint — the precondition then
|
||||
// accepts the dump-less unit ("Tier 1 … 0s old"), which is what 9202 logged for tandoor on 2026-09-27.
|
||||
func TestR699_ADefinitionOnlyUnitIsNotAPreconditionCopy(t *testing.T) {
|
||||
v := newVTStack(t, "16")
|
||||
if err := v.m.captureRecoveryUnit("app", false); err != nil { // the refresh, right after the install
|
||||
t.Fatal(err)
|
||||
}
|
||||
pts, _ := v.m.ListRestorePoints("app")
|
||||
if len(pts) != 1 || pts[0].DataProven {
|
||||
t.Fatalf("points = %+v, want one listed, NOT proven", pts)
|
||||
}
|
||||
any := func(UpdateTierPoint) bool { return true }
|
||||
if p, ok, _ := v.m.UpdateRestorePoints(context.Background(), "app", any); ok {
|
||||
t.Fatalf("the precondition accepted tier %d at %s — a unit with no data is not a copy", p.Tier, p.At)
|
||||
}
|
||||
// The update's own "back up first" is a data run: afterwards the unit IS a copy.
|
||||
v.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
|
||||
return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil
|
||||
}
|
||||
v.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult {
|
||||
p := filepath.Join(dir, "app-postgres.sql")
|
||||
mustWrite(t, p, pgDump(1))
|
||||
return DumpResult{DB: db, FilePath: p}
|
||||
}
|
||||
v.m.perAppTier2 = func(string) error { return nil }
|
||||
if err := v.m.RunAppBackupNow(context.Background(), "app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := v.m.UpdateRestorePoints(context.Background(), "app", any); !ok {
|
||||
t.Fatal("after a backup the own unit is still not a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR699_ADefinitionOnlyMirrorIsNotAPreconditionCopy(t *testing.T) {
|
||||
p := Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: "2026-09-27T09:36:36Z", DataDate: "2026-09-27T09:36:36Z", DataUnproven: true}
|
||||
if _, ok := p.ProvenCopyTime(); ok {
|
||||
t.Fatal("a mirror of a definition-only unit counted as a proven copy")
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func (h *admissionHarness) runOneBackupRun() {
|
||||
done := h.m.beginAdmissionRun()
|
||||
defer done()
|
||||
h.m.runVolumeDumps()
|
||||
h.m.captureAllRecoveryUnits()
|
||||
h.m.captureAllRecoveryUnits(false)
|
||||
}
|
||||
|
||||
// ── The instrument: a checksum of the whole backup tree ──────────────────────────────────────────
|
||||
@@ -664,7 +664,7 @@ func TestAdmission_IsWiredIntoEveryProductionWriteLeg(t *testing.T) {
|
||||
|
||||
// 2. The DB leg consults it BEFORE the dump. Order is the whole point: a gate after the write is
|
||||
// the defect, relocated.
|
||||
assertGateBefore(t, calls["runDBDumpsInternal"], "admitApp", "DumpOne",
|
||||
assertGateBefore(t, calls["runDBDumpsInternal"], "admitApp", "dumpOneOrDefault", // v0.275.0: the dump goes through the seam wrapper
|
||||
"the DATABASE leg dumps before consulting the reserve")
|
||||
|
||||
// 3. The volume leg consults it BEFORE the dump seam — which stops the stack as its first act.
|
||||
@@ -691,7 +691,7 @@ func TestAdmission_IsWiredIntoEveryProductionWriteLeg(t *testing.T) {
|
||||
}
|
||||
return true
|
||||
})
|
||||
assertGateBefore(t, capCalls["captureAllRecoveryUnits"], "admitApp", "CaptureRecoveryUnit",
|
||||
assertGateBefore(t, capCalls["captureAllRecoveryUnits"], "admitApp", "captureRecoveryUnit", // v0.275.0: the data-run flag is passed through
|
||||
"the CAPTURE leg captures before consulting the reserve")
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type AppBackupInfo = appbackup.AppBackupInfo
|
||||
type AppDataPath = appbackup.AppDataPath
|
||||
type AppDockerVolume = appbackup.AppDockerVolume
|
||||
type RecoveryInfo = appbackup.RecoveryInfo
|
||||
type GateState = appbackup.GateState
|
||||
type ClassifiedBind = appbackup.ClassifiedBind
|
||||
|
||||
// --- type aliases (dbdump) ---
|
||||
|
||||
@@ -62,6 +62,23 @@ func (r AppStopReason) humanReason() string {
|
||||
}
|
||||
}
|
||||
|
||||
// MessageKey is the bundle key that names this reason in the household's language (R-585), or "" for a
|
||||
// reason this build has no sentence for — the caller then shows the raw reason, as humanReason does.
|
||||
// Pinned against humanReason by TestR585_InterruptedOperationFollowsTheHousehold (internal/notify): the
|
||||
// English bundle text must equal what the operator's log line says.
|
||||
func (r AppStopReason) MessageKey() string {
|
||||
switch r {
|
||||
case ReasonVolumeDump:
|
||||
return "appstop.reason.volume_dump"
|
||||
case ReasonOffboxReconstitute:
|
||||
return "appstop.reason.offbox_reconstitute"
|
||||
case ReasonAppExport:
|
||||
return "appstop.reason.app_export"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// AppStopMarker is the persisted "these apps were stopped by an operation that has not reported
|
||||
// finishing — they are owed a restart" note.
|
||||
type AppStopMarker struct {
|
||||
@@ -279,7 +296,14 @@ func (g *AppStopGuard) Recover() *AppStopRecovery {
|
||||
m.Reason.humanReason(), m.OpID, len(m.Stacks), m.Stacks)
|
||||
|
||||
res := &AppStopRecovery{Reason: m.Reason, OpID: m.OpID, StartedAt: m.StartedAt}
|
||||
hs, _ := g.starter.(interface{ WantsStopped(string) bool })
|
||||
for _, name := range m.Stacks {
|
||||
if hs != nil && hs.WantsStopped(name) {
|
||||
// R-721 (v0.283.1): the household pressed Stop while the interrupted operation had the app down.
|
||||
// It is owed nothing; the marker still clears below.
|
||||
g.logger.Printf("[INFO] [appstop] crash recovery: NOT restarting %s — the household stopped it", name)
|
||||
continue
|
||||
}
|
||||
if err := g.starter.StartStack(name); err != nil {
|
||||
// R-174: a REFUSAL is not a failure. The starter's gate has said this app must not be
|
||||
// started (an absent data drive), so the app is left down deliberately and the holder
|
||||
|
||||
@@ -3,9 +3,9 @@ package backup
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// Manager orchestrates app-data backups: database dumps and Docker-volume tars.
|
||||
@@ -23,6 +25,14 @@ import (
|
||||
// moved out of the controller into the host agent (slice 8C). This Manager now
|
||||
// only owns the app-data domain.
|
||||
type Manager struct {
|
||||
// R-897: when this controller started, and the seams of the post-boot drive wait (driveready.go). Zero
|
||||
// startedAt = no wait (a Manager built outside NewManager, e.g. a test that does not ask for it).
|
||||
startedAt time.Time
|
||||
driveLive func(root string) bool
|
||||
driveWaitPoll func(time.Duration)
|
||||
nowFn func() time.Time
|
||||
driveWarned map[string]bool
|
||||
driveMu sync.Mutex // its own lock: the capture sweep may run while m.mu is held elsewhere
|
||||
cfg *config.Config
|
||||
logger *log.Logger
|
||||
settings *settings.Settings
|
||||
@@ -111,10 +121,20 @@ type Manager struct {
|
||||
// offboxSSH (v0.142.0) is the raw-ssh exec seam for the orphaned-repo move-aside (restic has no
|
||||
// rename); tests inject a fake. Nil → the real ssh invocation (defaultOffboxSSH).
|
||||
offboxSSH func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error)
|
||||
// offsiteMoveAside (v0.289.0, decision 69) asks the hub to set the repository aside.
|
||||
offsiteMoveAside func(ctx context.Context) (string, error)
|
||||
// offsiteWindow (v0.289.0, decision 68) asks the hub for a clean-up window. nil → no box retention.
|
||||
offsiteWindow OffsiteWindowClient
|
||||
// offsiteAbandon (v0.290.0, decision 74) hands a due set-aside deletion to the hub.
|
||||
offsiteAbandon OffsiteAbandonClient
|
||||
|
||||
// offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable
|
||||
// in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb).
|
||||
offboxSizer func(path string) int64
|
||||
// offsiteFit (decision 50, v0.283.0) — the last off-site size estimate against the quota, for the page.
|
||||
offsiteFitMu sync.Mutex
|
||||
offsiteFit OffsiteFit
|
||||
offsiteFitRunning bool
|
||||
// offboxNow (v0.206.0, R-241) is the abandonment countdown's clock. Nil → time.Now.
|
||||
//
|
||||
// IT EXISTS SO THE TERMINAL STEP IS TESTABLE WITHOUT SHORTENING A LIVE TIMER (§7.4). The sweep is
|
||||
@@ -143,6 +163,9 @@ type Manager struct {
|
||||
// offboxFreeFn (3a) — the free-space probe for the restore headroom gate, overridable in tests (the
|
||||
// Windows `go test` host has no `df`). Nil → the real diskFreeBytes (df --output=avail).
|
||||
offboxFreeFn func(path string) int64
|
||||
// driveMountedFn (R-362) answers "is this registered drive still mounted?" when a restore cannot
|
||||
// create its directory. nil → system.IsMountPoint. Tests inject it; production never sets it.
|
||||
driveMountedFn func(path string) bool
|
||||
|
||||
// offboxLatestSnapFn (R-357) overrides the restic snapshot lookup, and it exists for one reason:
|
||||
// without it, ReconstituteFromOffsite's new headroom gate cannot be tested at the level that
|
||||
@@ -154,6 +177,13 @@ type Manager struct {
|
||||
// INIT/TEST ONLY. Nil in production, where offboxLatestSnapshot runs unchanged.
|
||||
offboxLatestSnapFn func(ctx context.Context, stack string) (string, []string, error)
|
||||
|
||||
// keptUnitRestoreFn (R-691 (2)) — the kept load's unit restore; nil → RestoreFromRecoveryUnitAt.
|
||||
// INIT/TEST ONLY.
|
||||
keptUnitRestoreFn func(app, unitDir string) (UnitRestoreResult, error)
|
||||
|
||||
// hollow (Part D, v0.279.0) — the running apps whose newest copy holds no data. See hollow_watch.go.
|
||||
hollow hollowWatch
|
||||
|
||||
// F17 restore seams — overridable in tests so the .sql re-import orchestration can be unit-tested
|
||||
// without Docker. Default to the real DiscoverDatabases / ImportDump (lazy-init in reimportDBDumps).
|
||||
discoverDBs func(ctx context.Context) ([]DiscoveredDB, error)
|
||||
@@ -170,8 +200,21 @@ type Manager struct {
|
||||
// disconnected) can be unit-tested without Docker. Nil → the real DumpAppVolumesSafe.
|
||||
dumpVolumesSafe func(stackName string) error
|
||||
|
||||
// dumpOne (v0.275.0) — the per-database dump seam, nil → the real DumpOne. It lets a test drive the
|
||||
// REAL legs (and so the stamps they write) without a database container.
|
||||
dumpOne func(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.Logger, debug bool) DumpResult
|
||||
|
||||
// stampMu serialises writes of a unit's data-stamps.json (v0.275.0, data_versions.go). The legs run
|
||||
// under the running flag already; this keeps a stray concurrent caller from losing a stamp.
|
||||
stampMu sync.Mutex
|
||||
|
||||
// updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck.
|
||||
updatingCheck func(stackName string) bool
|
||||
// versionCheck (R-645) — nil-safe; see versionSkip / SetVersionCheck.
|
||||
versionCheck func(stackName string) (detail string, mismatch bool)
|
||||
// undoCopyRemover (R-671, v0.272.0) deletes an app's leftover undo copies — stacks.Manager.RemoveUndoCopies,
|
||||
// wired in main.go (SetUndoCopyRemover). nil-safe: without it the copies stay, as before.
|
||||
undoCopyRemover func(stackName string) int
|
||||
|
||||
// R-475 update-precondition seams, one per tier. Nil → the real Tier2UnitRestorePoint /
|
||||
// ListRestorePoints / OffsiteSnapshotTimes. They let a test reach Tier 1 and Tier 3 without a drive
|
||||
@@ -179,6 +222,8 @@ type Manager struct {
|
||||
updateTier2PointFn func(stackName string) (Tier2RestorePoint, error)
|
||||
updateTier1PointsFn func(stackName string) ([]RestorePoint, bool)
|
||||
updateOffsiteTimesFn func(ctx context.Context) (map[string]time.Time, error)
|
||||
// freeBytesFn (v0.269.0, decision 26): the whole restore's disk-floor check; nil → statfs.
|
||||
freeBytesFn func(path string) int64
|
||||
|
||||
// R-354 volume-REPLAY seam — the mirror of the F17 DB seams above, so the off-site path's new
|
||||
// volume leg is unit-testable without Docker. Nil → the real restoreDockerVolumesFrom.
|
||||
@@ -241,6 +286,11 @@ type Manager struct {
|
||||
// and Tier-2 correctly refuses them — which makes the off-drive tests unrunnable rather than wrong.
|
||||
// Nil → the real system.SamePhysicalDevice (production always takes this path).
|
||||
samePhysicalDevice func(a, b string) bool
|
||||
// diskJudge (v0.317.0, felhom.eu architecture/16-storage-physical-identity.md §4) asks the HOST AGENT
|
||||
// whether two guest paths share a physical disk. supported=false (agent older than 0.157.0, or the
|
||||
// call failed) → diskRelation keeps the old st_dev rule, logged once. nil → the old rule (tests).
|
||||
diskJudge func(a, b string) (v DiskVerdict, supported bool)
|
||||
diskLegacyOnce sync.Once
|
||||
|
||||
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
|
||||
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
|
||||
@@ -267,6 +317,9 @@ type Manager struct {
|
||||
// R-550: persistence of the above (restore_record.go) and the per-app interrupted notices.
|
||||
opRecordPath string
|
||||
opInterrupted map[string]RestoreOpResult
|
||||
// R-519: the app-data run record (run_record.go) and the start of the last run a stop cut off (zero = none).
|
||||
runRecordPath string
|
||||
runInterrupted time.Time
|
||||
|
||||
// Cached status for page rendering (refreshed periodically)
|
||||
cachedStatus *FullBackupStatus
|
||||
@@ -279,9 +332,13 @@ type FullBackupStatus struct {
|
||||
Running bool
|
||||
|
||||
// DB Dumps
|
||||
LastDBDump *DBDumpStatus
|
||||
DumpFiles []DumpFileInfo
|
||||
DiscoveredDBs []DiscoveredDB
|
||||
LastDBDump *DBDumpStatus
|
||||
// InterruptedRunAt (R-519): the start of the last app-data run a power cut or a restart cut off; zero = the
|
||||
// newest run finished. Shown on /backups and /backups/apps until a run ends with every step OK.
|
||||
InterruptedRunAt time.Time
|
||||
Interrupted bool // InterruptedRunAt is set (the template gate: a time.Time is always "true" to `if`)
|
||||
DumpFiles []DumpFileInfo
|
||||
DiscoveredDBs []DiscoveredDB
|
||||
|
||||
// Schedule
|
||||
DBDumpSchedule string
|
||||
@@ -304,10 +361,6 @@ type FullBackupStatus struct {
|
||||
// backup used to render with a blank drive label). Matches the tier-2 UI wording.
|
||||
const systemDriveLabel = "Belső SSD (rendszer)"
|
||||
|
||||
// singleCopyNotice is the honest single-drive signal (F6): shown when no off-drive tier-2 target
|
||||
// exists, instead of silently implying a 3-2-1 guarantee the box cannot provide.
|
||||
const singleCopyNotice = "Csak egy másolat készül (nincs második meghajtó) — a 3-2-1 mentéshez csatlakoztasson egy második meghajtót vagy offsite tárolót."
|
||||
|
||||
// DBDumpStatus holds the last DB dump result.
|
||||
type DBDumpStatus struct {
|
||||
LastRun time.Time
|
||||
@@ -326,6 +379,7 @@ func NewManager(cfg *config.Config, sett *settings.Settings, logger *log.Logger)
|
||||
logger: logger,
|
||||
settings: sett,
|
||||
systemDataPath: cfg.Paths.SystemDataPath,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
// R-166: its OWN file next to quiesce-state.json, never inside it — one file, one writer.
|
||||
m.appStop = NewAppStopGuard(filepath.Join(cfg.Paths.DataDir, "appstop-state.json"), logger)
|
||||
@@ -516,6 +570,11 @@ func (m *Manager) beginOffsiteRunStamp(runID string) func() {
|
||||
func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
start := time.Now()
|
||||
m.logger.Printf("[INFO] [backup] Starting database dump run")
|
||||
// R-519: the run is on record while it runs; a stop before markRunEnded leaves it "running", which the next
|
||||
// start turns into the page's interrupted-run notice (run_record.go). Every return path ends the record.
|
||||
m.markRunStarted(start)
|
||||
runOK := false
|
||||
defer func() { m.markRunEnded(runOK) }()
|
||||
|
||||
// R-181: open the per-run admission scope HERE, because this function is the single orchestrator
|
||||
// of all three write legs. Each app's reserve verdict is taken at its first write of this run and
|
||||
@@ -532,7 +591,13 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
defer m.beginRunSummary(kind, newRunID())()
|
||||
defer m.emitRunSummary()
|
||||
|
||||
dbs, err := DiscoverDatabases(ctx, m.logger, m.isDebug(), m.knownStackNames())
|
||||
discover := m.discoverDBs
|
||||
if discover == nil {
|
||||
discover = func(ctx context.Context) ([]DiscoveredDB, error) {
|
||||
return DiscoverDatabases(ctx, m.logger, m.isDebug(), m.knownStackNames())
|
||||
}
|
||||
}
|
||||
dbs, err := discover(ctx)
|
||||
if err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] Database discovery failed: %v", err)
|
||||
return err
|
||||
@@ -567,6 +632,14 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// R-645 (09 §3 decision 142): an app not running its pinned version is left alone — its dump
|
||||
// would land in the unit beside the good definition and become the restore point. A SKIP: the
|
||||
// unit keeps the last good copy, and the backups page says why.
|
||||
if why, skip := m.versionSkip(db.StackName); skip {
|
||||
m.logger.Printf("[WARN] [backup] Skipping DB dump for %s — it is not running its pinned version (%s); the last good backup is kept (R-645)", db.StackName, why)
|
||||
summary = append(summary, fmt.Sprintf("SKIP %s (not running its pinned version)", db.ContainerName))
|
||||
continue
|
||||
}
|
||||
// R-181: the reserve, BEFORE the first byte of this app's backup is written. This is usually
|
||||
// where an app's verdict is taken, because the DB leg runs first; the volume leg and the
|
||||
// capture then read the same memo. SKIP, not FAIL — a deliberate hold is not a broken dump,
|
||||
@@ -579,7 +652,7 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
|
||||
dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), db.StackName)
|
||||
|
||||
result := DumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
|
||||
result := m.dumpOneOrDefault(ctx, db, dumpDir)
|
||||
results = append(results, result)
|
||||
|
||||
if result.Error != nil {
|
||||
@@ -590,6 +663,8 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
} else {
|
||||
totalSize += result.Size
|
||||
summary = append(summary, fmt.Sprintf("OK %s (%s)", result.DB.ContainerName, humanizeBytes(result.Size)))
|
||||
// v0.275.0 (R-696): the dump records the versions that wrote it, at the moment it is written.
|
||||
m.stampDataFile(db.StackName, RecoveryUnitPath(m.namespaceRoot(drivePath), db.StackName), "db-dumps/"+filepath.Base(result.FilePath))
|
||||
|
||||
// Persist validation result to settings.json
|
||||
if m.settings != nil && result.FilePath != "" {
|
||||
@@ -637,14 +712,18 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
|
||||
strings.Join(failedSummaryLines(summary), "; "))
|
||||
}
|
||||
|
||||
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest).
|
||||
m.captureAllRecoveryUnits()
|
||||
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest). A DATA run:
|
||||
// the capture folds the stamps the legs above just wrote (v0.275.0).
|
||||
m.captureAllRecoveryUnits(true)
|
||||
// Part D (v0.279.0): a RUNNING app whose unit still holds no data after this run is an alarm.
|
||||
m.checkLocalCopies()
|
||||
|
||||
// F5 (CAMPAIGN-3): after the units are fresh on the CURRENT drives, prune any orphaned
|
||||
// backups/primary/<app> dir an app left on an OLD drive when its HDD_PATH moved — pure disk
|
||||
// residue, invisible in the snapshot list. Guarded (deployed + different current drive only).
|
||||
m.pruneStalePrimaryDirs()
|
||||
|
||||
runOK = allOK
|
||||
// No silent partials: a DB-dump or volume-dump failure fails the whole run.
|
||||
if !allOK {
|
||||
return fmt.Errorf("some backup steps failed: %s", strings.Join(failedSummaryLines(summary), "; "))
|
||||
@@ -702,6 +781,12 @@ func (m *Manager) runVolumeDumps() (summary []string, dumped int, allOK bool) {
|
||||
summary = append(summary, fmt.Sprintf("SKIP %s volumes (held)", stack.Name))
|
||||
continue
|
||||
}
|
||||
// R-645: never stop, dump or restart an app that is not running its pinned version.
|
||||
if why, skip := m.versionSkip(stack.Name); skip {
|
||||
m.logger.Printf("[WARN] [backup] Skipping volume dump for %s — it is not running its pinned version (%s); the last good backup is kept (R-645)", stack.Name, why)
|
||||
summary = append(summary, fmt.Sprintf("SKIP %s volumes (not running its pinned version)", stack.Name))
|
||||
continue
|
||||
}
|
||||
// Volume check FIRST — a volume-less stack must not be stopped at all (see gate-order note).
|
||||
if len(m.stackProvider.GetDockerVolumes(stack.Name)) == 0 {
|
||||
if m.isDebug() {
|
||||
@@ -722,6 +807,16 @@ func (m *Manager) runVolumeDumps() (summary []string, dumped int, allOK bool) {
|
||||
continue
|
||||
}
|
||||
|
||||
// R-634 (v0.265.0): an app whose deploy is still running is NOT an installation to back up,
|
||||
// and stopping it is what broke the deploy. The list is taken once at the start of the run, so
|
||||
// the question is asked again here, immediately before the stop. A SKIP, not a failure: there
|
||||
// is nothing of the household's in it yet, and a FAIL would page the operator for a deploy.
|
||||
if m.stackIsDeploying(stack.Name) {
|
||||
m.logger.Printf("[INFO] [backup] Skipping volume dump for %s — the app is still being deployed (R-634)", stack.Name)
|
||||
summary = append(summary, fmt.Sprintf("SKIP %s volumes (deploying)", stack.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
// R-181: the reserve, ahead of DumpAppVolumesSafe so a refused app is NOT stopped. For an app
|
||||
// that already has a DB this is a memo lookup taken before its DB dump; for a volume-only app
|
||||
// this is where its verdict is taken, still before its first byte.
|
||||
@@ -806,6 +901,8 @@ func (m *Manager) DumpAppVolumes(stackName string) error {
|
||||
if info, _ := os.Stat(tarPath); info != nil {
|
||||
m.logger.Printf("[INFO] [backup] Volume dump: %s/%s → %s", stackName, volName, humanizeBytes(info.Size()))
|
||||
}
|
||||
// v0.275.0 (R-696): the tar records the versions that wrote it.
|
||||
m.stampDataFile(stackName, RecoveryUnitPath(m.namespaceRoot(drivePath), stackName), "volume-dumps/"+volName+".tar")
|
||||
}
|
||||
|
||||
// Clean up tars (and any orphan `.tar.tmp` from a killed run) for volumes that no longer exist.
|
||||
@@ -844,7 +941,7 @@ func (m *Manager) tarVolumeOrDefault(volName, dumpDir string) ([]byte, error) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
|
||||
cmd := dockerexec.CommandContext(ctx, "docker", "run", "--rm",
|
||||
"-v", volName+":/vol:ro",
|
||||
"-v", dumpDir+":/out",
|
||||
"alpine", "tar", "cf", "/out/"+volName+".tar.tmp", "-C", "/vol", ".")
|
||||
@@ -913,6 +1010,12 @@ func (m *Manager) DumpAppVolumesSafe(stackName string) error {
|
||||
|
||||
dumpErr := m.DumpAppVolumes(stackName)
|
||||
|
||||
if hs, ok := m.stackProvider.(interface{ WantsStopped(string) bool }); ok && hs.WantsStopped(stackName) {
|
||||
// R-721: the household pressed Stop while the dump had the app down — it stays stopped.
|
||||
m.logger.Printf("[INFO] [backup] %s NOT restarted after the volume dump: the household stopped it meanwhile", stackName)
|
||||
m.appStop.End() // nothing is owed a restart — the household wants it stopped
|
||||
return dumpErr
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] Restarting %s after volume dump", stackName)
|
||||
startErr := m.stackProvider.StartStack(stackName)
|
||||
if startErr != nil {
|
||||
@@ -1141,7 +1244,7 @@ func (m *Manager) RefreshCache(nextDBDump time.Time) {
|
||||
func() {
|
||||
defer m.beginRunSummary(runKindRefresh, "")()
|
||||
defer m.emitRunSummary()
|
||||
m.captureAllRecoveryUnits()
|
||||
m.captureAllRecoveryUnits(false) // a REFRESH: never moves the unit's data time or its definition away from its data
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1149,6 +1252,7 @@ func (m *Manager) RefreshCache(nextDBDump time.Time) {
|
||||
m.mu.Lock()
|
||||
status.Running = m.running
|
||||
status.LastDBDump = m.lastDBDump
|
||||
status.InterruptedRunAt, status.Interrupted = m.runInterrupted, !m.runInterrupted.IsZero()
|
||||
|
||||
// Cross-check lastDBDump results inside lock to prevent torn writes.
|
||||
if m.lastDBDump != nil && len(files) > 0 {
|
||||
@@ -1191,7 +1295,8 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
|
||||
// Update dynamic fields that don't need subprocess calls
|
||||
status.Running = m.running
|
||||
status.NextDBDump = nextDBDump
|
||||
status.SingleCopyWarning = m.singleCopyWarning() // F6: honest single-drive signal
|
||||
status.SingleCopyWarning = m.singleCopyWarning() // F6: honest single-drive signal
|
||||
status.InterruptedRunAt, status.Interrupted = m.runInterrupted, !m.runInterrupted.IsZero() // R-519
|
||||
// Deep-copy lastDBDump so callers cannot mutate shared state.
|
||||
if m.lastDBDump != nil {
|
||||
copyDump := *m.lastDBDump
|
||||
@@ -1207,6 +1312,12 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
|
||||
var results []DumpResult
|
||||
var latestTime time.Time
|
||||
for _, f := range status.DumpFiles {
|
||||
// 2026-10-10: a `pre-restore-` undo copy (R-379) is not part of any run — it stays in the file
|
||||
// list, never in the run's table (demo-hp listed adventurelog four times after a restart).
|
||||
// Pinned by TestSynthesisedRunListsNoUndoCopy.
|
||||
if f.IsUndo {
|
||||
continue
|
||||
}
|
||||
results = append(results, DumpResult{
|
||||
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
|
||||
FilePath: f.FileName,
|
||||
@@ -1217,10 +1328,15 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
|
||||
latestTime = f.ModTime
|
||||
}
|
||||
}
|
||||
status.LastDBDump = &DBDumpStatus{
|
||||
LastRun: latestTime,
|
||||
Results: results,
|
||||
Success: true,
|
||||
// R-519: after a restart the run's result is not in memory, so it is SYNTHESISED from the files. A run
|
||||
// the stop cut off left a fresh .sql beside last night's tars — "Success" must not be read off that.
|
||||
// Only undo copies on disk → no run to show (nil, as with no files at all).
|
||||
if len(results) > 0 {
|
||||
status.LastDBDump = &DBDumpStatus{
|
||||
LastRun: latestTime,
|
||||
Results: results,
|
||||
Success: m.runInterrupted.IsZero(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1234,6 +1350,8 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
|
||||
DBDumpSchedule: m.cfg.Backup.DBDumpSchedule,
|
||||
NextDBDump: nextDBDump,
|
||||
SingleCopyWarning: m.singleCopyWarning(), // F6
|
||||
InterruptedRunAt: m.runInterrupted, // R-519
|
||||
Interrupted: !m.runInterrupted.IsZero(),
|
||||
}
|
||||
if m.lastDBDump != nil {
|
||||
copyDump := *m.lastDBDump
|
||||
@@ -1246,38 +1364,121 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
|
||||
return status
|
||||
}
|
||||
|
||||
// DiskVerdict is the agent's answer to "do these two paths share a physical disk?" (`16` §4).
|
||||
type DiskVerdict struct {
|
||||
Different bool
|
||||
Proven bool
|
||||
Reason string // "" | same_disk | vm | network | usb_bridge | weak_wwn | no_serial | unmapped
|
||||
}
|
||||
|
||||
// DiskRelation is what a Tier-2 decision acts on.
|
||||
type DiskRelation int
|
||||
|
||||
const (
|
||||
// DiskDifferent — a different physical disk, proven by the agent (or the old st_dev rule said so):
|
||||
// the copy is made and COUNTS as a second copy.
|
||||
DiskDifferent DiskRelation = iota
|
||||
// DiskSame — the same physical disk: no second copy is made (`16` D1).
|
||||
DiskSame
|
||||
// DiskUnknown — the agent cannot prove it (a VM, a USB bridge that hides the serial): the copy is made
|
||||
// but does NOT count, and the page says why (`16` D2).
|
||||
DiskUnknown
|
||||
)
|
||||
|
||||
// SetDiskJudge wires the agent's same-disk call (main: agentapi FeatureSameDisk).
|
||||
func (m *Manager) SetDiskJudge(fn func(a, b string) (DiskVerdict, bool)) { m.diskJudge = fn }
|
||||
|
||||
// diskRelation is the ONE place a Tier-2 or one-copy decision asks "same disk?" (`16` §4). The agent's
|
||||
// verdict wins when it can give one; "unmapped" (a path the agent did not make, e.g. an absent drive) and
|
||||
// an older agent fall back to today's st_dev rule — never a silent change, never a crash. Pinned by
|
||||
// diskrelation_test.go.
|
||||
func (m *Manager) diskRelation(a, b string) (DiskRelation, string) {
|
||||
if m.diskJudge != nil {
|
||||
v, ok := m.diskJudge(a, b)
|
||||
if ok && v.Reason != "unmapped" {
|
||||
switch {
|
||||
case v.Proven && v.Different:
|
||||
return DiskDifferent, ""
|
||||
case v.Proven:
|
||||
return DiskSame, v.Reason
|
||||
default:
|
||||
return DiskUnknown, v.Reason
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
m.diskLegacyOnce.Do(func() {
|
||||
m.logger.Printf("[INFO] [backup] same-disk: the host agent cannot answer (older than 0.157.0, or the call failed) — using the filesystem-device rule")
|
||||
})
|
||||
}
|
||||
}
|
||||
if m.sameDevice(a, b) {
|
||||
return DiskSame, "st_dev"
|
||||
}
|
||||
return DiskDifferent, "st_dev"
|
||||
}
|
||||
|
||||
// sameDevice reports whether two paths sit on the same physical device, through the test seam when
|
||||
// one is installed. Nil seam → system.SamePhysicalDevice, i.e. byte-for-byte the previous behaviour.
|
||||
func (m *Manager) sameDevice(a, b string) bool {
|
||||
if m.samePhysicalDevice != nil {
|
||||
return m.samePhysicalDevice(a, b)
|
||||
}
|
||||
// R-668 (v0.269.0): FAIL CLOSED. system.SamePhysicalDevice answers "different" when either path cannot
|
||||
// be stat'd, and a Tier-2 target chosen on that answer is written wherever the path gets re-created —
|
||||
// measured on 9202 2026-09-24: a removed folder on nextcloud's OWN disk became its "second drive". A
|
||||
// path we cannot read is treated as the SAME disk: no second copy is claimed on it.
|
||||
if _, err := os.Stat(a); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] same-disk check: %s cannot be read (%v) — treated as the same disk", a, err)
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(b); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] same-disk check: %s cannot be read (%v) — treated as the same disk", b, err)
|
||||
return true
|
||||
}
|
||||
return system.SamePhysicalDevice(a, b)
|
||||
}
|
||||
|
||||
// hasOffDriveTarget reports whether any registered, schedulable storage path lives on a physical disk
|
||||
// OTHER than the system drive — i.e. whether a genuine off-drive (tier-2) copy is possible at all.
|
||||
// When false the box is single-drive: tier-1 is the ONLY local copy and 3-2-1 needs a 2nd drive or
|
||||
// offsite (F6 — surfaced honestly via SingleCopyWarning, never a faked guarantee).
|
||||
func (m *Manager) hasOffDriveTarget() bool {
|
||||
// offDriveCopyState reports whether any registered, schedulable, NON-network storage path is on a physical
|
||||
// disk OTHER than the system drive — counted only when proven (`16` §4). unknown is true when such a path
|
||||
// exists but the agent cannot prove it is another disk (a VM, a hidden USB serial). A NAS never counts
|
||||
// (R-1075: it is never a Tier-2 target, so it must not hide the one-copy notice).
|
||||
func (m *Manager) offDriveCopyState() (counted, unknown bool) {
|
||||
if m.settings == nil || m.systemDataPath == "" {
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||
if sp.Path == m.systemDataPath || m.sameDevice(m.systemDataPath, sp.Path) {
|
||||
if sp.Path == m.systemDataPath || sp.IsNetwork() {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
switch rel, _ := m.diskRelation(m.systemDataPath, sp.Path); rel {
|
||||
case DiskDifferent:
|
||||
return true, false
|
||||
case DiskUnknown:
|
||||
unknown = true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false, unknown
|
||||
}
|
||||
|
||||
// singleCopyWarning returns the honest single-drive notice, or "" when an off-drive target exists.
|
||||
// hasOffDriveTarget reports whether a COUNTED off-drive (tier-2) copy is possible at all.
|
||||
func (m *Manager) hasOffDriveTarget() bool {
|
||||
counted, _ := m.offDriveCopyState()
|
||||
return counted
|
||||
}
|
||||
|
||||
// singleCopyWarning returns the honest notice (`16` §4), or "" when a counted off-drive target exists:
|
||||
// "only one copy" when there is no second disk (or it is the same disk); "cannot check" when a second
|
||||
// drive exists whose disk the agent cannot prove. Both sentences are i18n (informal voice — R-1072).
|
||||
func (m *Manager) singleCopyWarning() string {
|
||||
if m.hasOffDriveTarget() {
|
||||
counted, unknown := m.offDriveCopyState()
|
||||
switch {
|
||||
case counted:
|
||||
return ""
|
||||
case unknown:
|
||||
return m.note("note.backup.single_copy_unproven")
|
||||
default:
|
||||
return m.note("note.backup.single_copy")
|
||||
}
|
||||
return singleCopyNotice
|
||||
}
|
||||
|
||||
// sysDriveLabelFor returns the drive label for a stack's tier-1 restore point — the clear
|
||||
@@ -1375,3 +1576,46 @@ func dbNames(dbs []DiscoveredDB) string {
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// boxLang is the language a SAVED note is written in (release C, R-557 slice 2 option 1).
|
||||
//
|
||||
// A background run has no request and no reader, so it writes in the box's language at the moment it
|
||||
// writes. A household that switches sees last night's note in the old language until the next run
|
||||
// rewrites it — the operator's ruling, and the reason the reader never translates stored text.
|
||||
func (m *Manager) boxLang() string {
|
||||
if m.settings == nil {
|
||||
return i18n.Default
|
||||
}
|
||||
return m.settings.GetLanguage()
|
||||
}
|
||||
|
||||
// note renders a saved note in the box's language.
|
||||
func (m *Manager) note(key string, args ...interface{}) string {
|
||||
return util.Text(m.boxLang(), key, args...)
|
||||
}
|
||||
|
||||
// noteErr renders an error into a saved note in the box's language: its bundle message when it
|
||||
// carries one (release B), its own text otherwise.
|
||||
func (m *Manager) noteErr(err error) string { return util.ErrText(m.boxLang(), err) }
|
||||
|
||||
// deployingReporter is the OPTIONAL half of the stack provider that can say a deploy is in flight
|
||||
// (R-634). Optional on purpose: the production adapter implements it, and the many test fakes that
|
||||
// predate it need not — a provider that cannot answer is read as "not deploying", today's behaviour.
|
||||
type deployingReporter interface {
|
||||
IsDeploying(name string) bool
|
||||
}
|
||||
|
||||
func (m *Manager) stackIsDeploying(name string) bool {
|
||||
if d, ok := m.stackProvider.(deployingReporter); ok {
|
||||
return d.IsDeploying(name)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dumpOneOrDefault runs the dumpOne seam, or the real DumpOne.
|
||||
func (m *Manager) dumpOneOrDefault(ctx context.Context, db DiscoveredDB, dumpDir string) DumpResult {
|
||||
if m.dumpOne != nil {
|
||||
return m.dumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
|
||||
}
|
||||
return DumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestFloor_RefusesTheAppAndLeavesItsPreviousUnitByteIdentical(t *testing.T)
|
||||
}
|
||||
before := checksumFile(t, prev)
|
||||
|
||||
h.m.captureAllRecoveryUnits()
|
||||
h.m.captureAllRecoveryUnits(false)
|
||||
|
||||
// The refused app must NOT have been attempted at all — the floor is checked BEFORE any write.
|
||||
for _, hit := range h.prov.infoHits {
|
||||
@@ -170,7 +170,7 @@ func TestFloor_NeverDeletesAnotherAppsUnit(t *testing.T) {
|
||||
}
|
||||
before := checksumFile(t, keep)
|
||||
|
||||
h.m.captureAllRecoveryUnits()
|
||||
h.m.captureAllRecoveryUnits(false)
|
||||
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Fatalf("another app's unit was DELETED to make room: %v — nothing here is generational, so "+
|
||||
@@ -190,7 +190,7 @@ func TestFloor_LargeUnitWithAmpleSpaceIsCaptured(t *testing.T) {
|
||||
// A huge app on a huge, mostly-empty filesystem: 40% used, 600 GB free.
|
||||
h.usage["immich"] = &UnitSpace{Path: h.dir, UsedPercent: 40, AvailGB: 600, TotalGB: 1000, UsedGB: 400}
|
||||
|
||||
h.m.captureAllRecoveryUnits()
|
||||
h.m.captureAllRecoveryUnits(false)
|
||||
|
||||
if len(h.events) != 0 {
|
||||
t.Fatalf("a capture was refused on a filesystem with 600 GB free (%+v) — the floor has become "+
|
||||
@@ -209,7 +209,7 @@ func TestFloor_TheOld20GCeilingIsGone(t *testing.T) {
|
||||
// deliberately far above 20 so that a literal `UsedGB > 20` cap cannot survive this test: a
|
||||
// fixture sitting exactly on the old boundary would pass under the very shape it forbids.
|
||||
h.usage["immich"] = &UnitSpace{Path: h.dir, UsedPercent: 40, AvailGB: 180, TotalGB: 300, UsedGB: 120}
|
||||
h.m.captureAllRecoveryUnits()
|
||||
h.m.captureAllRecoveryUnits(false)
|
||||
if len(h.events) != 0 {
|
||||
t.Fatalf("refused with 180 GB free: %+v — a fixed per-area limit survives somewhere", h.events)
|
||||
}
|
||||
@@ -258,7 +258,7 @@ func TestFloor_UnreadableFilesystemNeitherRefusesNorWarns(t *testing.T) {
|
||||
h := newFloorHarness(t, "immich")
|
||||
// No entry → the injected reader returns nil, which is what system.GetDiskUsage does on error.
|
||||
|
||||
h.m.captureAllRecoveryUnits()
|
||||
h.m.captureAllRecoveryUnits(false)
|
||||
|
||||
if len(h.events) != 0 {
|
||||
t.Fatalf("an UNREADABLE filesystem produced %d alert(s): %+v — an absent, unmounted or "+
|
||||
@@ -275,7 +275,7 @@ func TestFloor_UnreadableFilesystemNeitherRefusesNorWarns(t *testing.T) {
|
||||
func TestErrCaptureFloor_IsMatchable(t *testing.T) {
|
||||
h := newFloorHarness(t, "immich")
|
||||
h.setSpace("immich", 99, 0.2)
|
||||
h.m.captureAllRecoveryUnits()
|
||||
h.m.captureAllRecoveryUnits(false)
|
||||
if len(h.events) != 1 {
|
||||
t.Fatalf("want 1 event, got %d", len(h.events))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Decision 28: the stop is a hold (same store — no start path revives it), Start lifts ONLY that kind,
|
||||
// and a second stop within 24 h says support is informed.
|
||||
func TestD28_HoldTripsAndLift(t *testing.T) {
|
||||
sett := slice4Settings(t)
|
||||
m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
|
||||
t0 := time.Date(2026, 9, 24, 22, 0, 0, 0, time.UTC)
|
||||
trip, err := m.HoldUnhealthy("gokapi", "crash_loop", t0)
|
||||
if err != nil || trip != 1 {
|
||||
t.Fatalf("trip=%d err=%v", trip, err)
|
||||
}
|
||||
held, why := m.RestoreHoldForLang("gokapi", "hu")
|
||||
if !held || !strings.Contains(why, "Indítás gombbal") || strings.Contains(why, "gyfélszolgálat") {
|
||||
t.Fatalf("first stop: held=%v %q", held, why)
|
||||
}
|
||||
if !m.UpdateHeldStacks()["gokapi"] {
|
||||
t.Fatal("an unhealthy stop is a product stop — it must be in the app-down suppression set")
|
||||
}
|
||||
if m.HoldKind("gokapi") != settings.HoldReasonUnhealthyStop || !m.LiftUnhealthyStop("gokapi") {
|
||||
t.Fatal("Start could not lift the unhealthy stop")
|
||||
}
|
||||
if held, _ := m.RestoreHoldFor("gokapi"); held {
|
||||
t.Fatal("still held after Start")
|
||||
}
|
||||
trip, _ = m.HoldUnhealthy("gokapi", "oom_storm", t0.Add(3*time.Hour))
|
||||
_, why = m.RestoreHoldForLang("gokapi", "en")
|
||||
if trip != 2 || !strings.Contains(why, "support has been told") {
|
||||
t.Fatalf("second stop within 24 h: trip=%d %q", trip, why)
|
||||
}
|
||||
m.LiftUnhealthyStop("gokapi")
|
||||
if trip, _ = m.HoldUnhealthy("gokapi", "crash_loop", t0.Add(30*time.Hour)); trip != 1 {
|
||||
t.Fatalf("a stop 27 h after the last is a first stop again, got trip %d", trip)
|
||||
}
|
||||
// Start never lifts another kind.
|
||||
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "nextcloud", At: t0.Format(time.RFC3339), Reason: settings.HoldReasonUpdateFailed})
|
||||
if m.LiftUnhealthyStop("nextcloud") {
|
||||
t.Fatal("Start lifted an UPDATE hold")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// ── The backup's data and its version travel together (controller v0.275.0, R-696, `07` §6.6) ────────
|
||||
//
|
||||
// WHAT WENT WRONG. The recovery unit's DEFINITION (compose/, image_pins) was re-captured by the periodic
|
||||
// status refresh as soon as the app's pin moved, while its DATA (db-dumps/, volume-dumps/) is written
|
||||
// only by the backup legs. So for up to a day after an update the unit said "new version" and held the
|
||||
// old version's data. Measured on 9202 2026-09-26 (`audits/version-travel-2026-09-26/A1/`): after a
|
||||
// PostgreSQL 16 → 18 step the unit held the 18 definition over a 16 dump and a 16 datadir tar; a restore
|
||||
// poured the 16 datadir back, `postgres:18` refused it and the app was left down. And Tier 1's "proven
|
||||
// at" was the manifest's refresh time, so the kept pre-conversion copy was released on a backup taken
|
||||
// BEFORE the conversion (demo-hp, 2026-09-26 night).
|
||||
//
|
||||
// THE SHAPE. Every data file a backup leg writes gets a STAMP beside it, at the moment it is written:
|
||||
// its size and mtime (so a file rewritten by anything else is recognised as unstamped), the definition's
|
||||
// image pins, and what each service was running (`installed_images`, ref@digest). The unit capture folds
|
||||
// the stamps into the manifest's `data` block — the time of the unit's data (its OLDEST stamped file:
|
||||
// the copy is as fresh as its stalest part) and the versions that wrote it — and KEEPS the definition
|
||||
// the data belongs to: when the pins have moved since the data was written, compose/ is not rewritten
|
||||
// until the next data run replaces the data. The manifest's `image_pins` stays the app's CURRENT pins,
|
||||
// so it says both. Restores read `data` to start the data with its own definition (restore_unit.go),
|
||||
// and the update/release read `data.at` as the unit's time (restore_points.go).
|
||||
//
|
||||
// UNKNOWN IS NEVER CURRENT. A unit whose data files are not all validly stamped (written before
|
||||
// v0.275.0, or by a path that does not stamp) has NO `data` block, restores as before with a WARN, and
|
||||
// its time is the newest DATA file's mtime — never the manifest's.
|
||||
|
||||
// dataStampsFile sits in the unit root, beside manifest.json, so it travels with every copy of the unit
|
||||
// (the Tier-2 mirror copies the whole unit, the off-site snapshot includes it).
|
||||
const dataStampsFile = "data-stamps.json"
|
||||
|
||||
// DataStamp is one data file's record, written by the leg that wrote the file.
|
||||
type DataStamp struct {
|
||||
// At is the file's mtime when it was stamped, RFC3339Nano UTC — the stamp is valid only while the
|
||||
// file still has exactly this mtime and Size.
|
||||
At string `json:"at"`
|
||||
Size int64 `json:"size"`
|
||||
// Pins are the definition's image pins (the compose `image:` lines) when the file was written.
|
||||
Pins []string `json:"pins"`
|
||||
// Images is service -> ref@digest running when the file was written. Empty when not observed.
|
||||
Images map[string]string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// UnitData is the manifest's account of the data the unit holds.
|
||||
type UnitData struct {
|
||||
// At is RFC3339 UTC: the unit's data time — the oldest data file's stamp, or, for a unit with no data
|
||||
// files, the data run that confirmed it. This is the unit's "proven at", never the manifest's time.
|
||||
At string `json:"at"`
|
||||
// ImagePins are the definition pins the data belongs to — what compose/ holds. Empty when Mixed.
|
||||
ImagePins []string `json:"image_pins"`
|
||||
// Images is the running set (service -> ref@digest) when the oldest file was written.
|
||||
Images map[string]string `json:"images,omitempty"`
|
||||
// Files are the stamps, keyed by the path relative to the unit ("db-dumps/x.sql").
|
||||
Files map[string]DataStamp `json:"files,omitempty"`
|
||||
// Mixed: the files were written under DIFFERENT pins — no one definition fits all of them, so a
|
||||
// restore of this unit is refused (a restore never starts data with a definition it does not belong to).
|
||||
Mixed bool `json:"mixed,omitempty"`
|
||||
}
|
||||
|
||||
// DataTime parses At. False when absent or unreadable.
|
||||
func (d *UnitData) DataTime() (time.Time, bool) {
|
||||
if d == nil || d.At == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, d.At)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
func readDataStamps(unitDir string) map[string]DataStamp {
|
||||
data, err := os.ReadFile(filepath.Join(unitDir, dataStampsFile))
|
||||
if err != nil {
|
||||
return map[string]DataStamp{}
|
||||
}
|
||||
var out map[string]DataStamp
|
||||
if json.Unmarshal(data, &out) != nil || out == nil {
|
||||
return map[string]DataStamp{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stampDataFile records the file at <unitDir>/<rel> as written NOW by the current definition and
|
||||
// running images. Called by the legs right after a dump or a tar is promoted to its final name. A failure
|
||||
// to stamp is a WARN and leaves the file unstamped — the unit then reads as "versions unknown", which is
|
||||
// the pre-v0.275.0 behaviour, never a false claim.
|
||||
func (m *Manager) stampDataFile(stackName, unitDir, rel string) {
|
||||
fi, err := os.Stat(filepath.Join(unitDir, rel))
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [backup] %s: cannot stamp %s (%v) — its versions will read as unknown", stackName, rel, err)
|
||||
return
|
||||
}
|
||||
var pins []string
|
||||
var images map[string]string
|
||||
if m.stackProvider != nil {
|
||||
if info, ok := m.stackProvider.GetStackRecoveryInfo(stackName); ok {
|
||||
pins, images = definitionPins(info), info.InstalledImages
|
||||
}
|
||||
}
|
||||
m.stampMu.Lock()
|
||||
defer m.stampMu.Unlock()
|
||||
stamps := readDataStamps(unitDir)
|
||||
stamps[rel] = DataStamp{At: fi.ModTime().UTC().Format(time.RFC3339Nano), Size: fi.Size(), Pins: pins, Images: images}
|
||||
// Entries whose file is gone (a volume the app no longer has) are dropped here, so the file stays small.
|
||||
for k := range stamps {
|
||||
if _, err := os.Stat(filepath.Join(unitDir, k)); err != nil {
|
||||
delete(stamps, k)
|
||||
}
|
||||
}
|
||||
body, err := json.MarshalIndent(stamps, "", " ")
|
||||
if err == nil {
|
||||
err = atomicWrite(filepath.Join(unitDir, dataStampsFile), append(body, '\n'), 0644)
|
||||
}
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [backup] %s: writing the data stamp for %s failed (%v) — its versions will read as unknown", stackName, rel, err)
|
||||
return
|
||||
}
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [backup] %s: stamped %s (%d B) with pins %v", stackName, rel, fi.Size(), pins)
|
||||
}
|
||||
}
|
||||
|
||||
// foldUnitData builds the manifest's `data` block from the unit's data files and their stamps.
|
||||
//
|
||||
// - no data files: a data run confirms the unit NOW under the current pins; a refresh keeps `prev`;
|
||||
// - every file validly stamped: the oldest stamp's time; its pins when all agree, else Mixed;
|
||||
// - any file unstamped or re-written since its stamp: nil — the versions are UNKNOWN.
|
||||
func foldUnitData(unitDir string, dbDumps, volDumps []string, dataRun bool, now time.Time, pins []string, images map[string]string, prev *UnitData) *UnitData {
|
||||
var rels []string
|
||||
for _, n := range dbDumps {
|
||||
rels = append(rels, "db-dumps/"+n)
|
||||
}
|
||||
for _, n := range volDumps {
|
||||
rels = append(rels, "volume-dumps/"+n)
|
||||
}
|
||||
if len(rels) == 0 {
|
||||
if dataRun {
|
||||
return &UnitData{At: now.UTC().Format(time.RFC3339), ImagePins: pins, Images: images}
|
||||
}
|
||||
return prev
|
||||
}
|
||||
stamps := readDataStamps(unitDir)
|
||||
out := &UnitData{Files: map[string]DataStamp{}}
|
||||
var oldest time.Time
|
||||
for _, rel := range rels {
|
||||
st, ok := stamps[rel]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(unitDir, rel))
|
||||
if err != nil || fi.Size() != st.Size || fi.ModTime().UTC().Format(time.RFC3339Nano) != st.At {
|
||||
return nil
|
||||
}
|
||||
t := fi.ModTime().UTC()
|
||||
if oldest.IsZero() || t.Before(oldest) {
|
||||
oldest = t
|
||||
out.ImagePins, out.Images = st.Pins, st.Images
|
||||
}
|
||||
out.Files[rel] = st
|
||||
}
|
||||
for _, st := range out.Files {
|
||||
if !samePins(st.Pins, out.ImagePins) {
|
||||
out.Mixed = true
|
||||
}
|
||||
}
|
||||
if out.Mixed {
|
||||
out.ImagePins, out.Images = nil, nil
|
||||
}
|
||||
out.At = oldest.Format(time.RFC3339)
|
||||
return out
|
||||
}
|
||||
|
||||
// samePins compares two pin lists as SETS (compose order is file order on both sides, but a set compare
|
||||
// cannot be fooled by it).
|
||||
func samePins(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
x := append([]string(nil), a...)
|
||||
y := append([]string(nil), b...)
|
||||
sort.Strings(x)
|
||||
sort.Strings(y)
|
||||
return stringSliceEqual(x, y)
|
||||
}
|
||||
|
||||
// unitDataEqual is the manifest-rewrite check's view of `data`.
|
||||
func unitDataEqual(a, b *UnitData) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
if a.At != b.At || a.Mixed != b.Mixed || !stringSliceEqual(a.ImagePins, b.ImagePins) || len(a.Files) != len(b.Files) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a.Files {
|
||||
w, ok := b.Files[k]
|
||||
if !ok || v.At != w.At || v.Size != w.Size {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// PinsVersion is the household's name for a set of pins: every image as `name:tag`, registry path and
|
||||
// digest stripped, in compose order ("docmost:0.96.0, postgres:16-alpine, redis:7-alpine"). All of them,
|
||||
// because a version step can move ANY service — a PostgreSQL 16 → 18 step leaves the app's own tag as it
|
||||
// was, and a label naming only the first image would read the same on both sides of it (seen on the
|
||||
// first draft of the mismatch sentence: „(0.96.0) … (0.96.0)").
|
||||
func PinsVersion(pins []string) string {
|
||||
out := make([]string, 0, len(pins))
|
||||
for _, ref := range pins {
|
||||
if i := strings.Index(ref, "@"); i >= 0 {
|
||||
ref = ref[:i]
|
||||
}
|
||||
if i := strings.LastIndex(ref, "/"); i >= 0 {
|
||||
ref = ref[i+1:]
|
||||
}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// recordOffsiteDataAt remembers the data time of the unit just pushed off-site (v0.275.0, R-696).
|
||||
func (m *Manager) recordOffsiteDataAt(stackName, unitDir string) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
t, ok := unitNewestArtifact(unitDir)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if err := m.settings.SetOffsiteDataAt(stackName, settings.OffsiteDataRecord{PushedAt: now, DataAt: t.UTC().Format(time.RFC3339)}); err != nil {
|
||||
m.logger.Printf("[WARN] [offbox] %s: recording the pushed copy's data time failed: %v — the off-site copy is dated by its snapshot", stackName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// offsiteDataTime caps an off-site snapshot's time with the data time this box recorded when it pushed
|
||||
// it. A snapshot NEWER than the last recorded push (taken by another box, or a record lost) keeps its
|
||||
// own time: the cap applies only to a copy this box knows the content of.
|
||||
func (m *Manager) offsiteDataTime(stackName string, snapshotAt time.Time) time.Time {
|
||||
if m.settings == nil {
|
||||
return snapshotAt
|
||||
}
|
||||
rec, ok := m.settings.GetOffsiteDataAt(stackName)
|
||||
if !ok {
|
||||
return snapshotAt
|
||||
}
|
||||
pushed, perr := time.Parse(time.RFC3339, rec.PushedAt)
|
||||
data, derr := time.Parse(time.RFC3339, rec.DataAt)
|
||||
if perr != nil || derr != nil || snapshotAt.After(pushed) || !data.Before(snapshotAt) {
|
||||
return snapshotAt
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// UnitDumpStamp is one stamped database dump of an app's own unit (v0.275.0).
|
||||
type UnitDumpStamp struct {
|
||||
File string
|
||||
At time.Time
|
||||
Images map[string]string
|
||||
}
|
||||
|
||||
// UnitDumpStamps returns the app's OWN unit's database dumps as its manifest records them — only when
|
||||
// the unit's data is known (a `data` block); an unstamped unit returns nothing, and a release that needs
|
||||
// one waits (fail closed).
|
||||
func (m *Manager) UnitDumpStamps(stackName string) []UnitDumpStamp {
|
||||
man := readManifest(UnitManifestFile(m.primaryUnitDirFor(stackName)))
|
||||
if man == nil || man.Data == nil {
|
||||
return nil
|
||||
}
|
||||
var out []UnitDumpStamp
|
||||
for rel, st := range man.Data.Files {
|
||||
if !strings.HasPrefix(rel, "db-dumps/") || !strings.HasSuffix(rel, ".sql") {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, st.At)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, UnitDumpStamp{File: rel, At: t, Images: st.Images})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].File < out[j].File })
|
||||
return out
|
||||
}
|
||||
|
||||
// definitionPins are the pins of the definition a capture copies into compose/: the stack dir's own
|
||||
// docker-compose.yml, parsed. ONE source for the stamps, the fold and the freeze, so "the data's pins"
|
||||
// and "the pins compose/ holds" are read from the same file the restore will start (production's
|
||||
// RecoveryInfo.ImagePins is that parse too; a provider that says otherwise cannot make them disagree).
|
||||
func definitionPins(info RecoveryInfo) []string {
|
||||
if info.StackDir != "" {
|
||||
if p := ParseComposeImages(filepath.Join(info.StackDir, "docker-compose.yml")); len(p) > 0 {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return info.ImagePins
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// felhom.eu architecture/16-storage-physical-identity.md §4 — the agent's verdict decides Tier-2 and the
|
||||
// one-copy notice. Each test asserts the CONSEQUENCE (is a target picked, does it count, what does the page
|
||||
// say), not the mechanism.
|
||||
|
||||
// judgeBy answers per target path; paths not in the map are "unmapped" (→ the old st_dev rule).
|
||||
func judgeBy(m map[string]DiskVerdict) func(a, b string) (DiskVerdict, bool) {
|
||||
return func(a, b string) (DiskVerdict, bool) {
|
||||
if v, ok := m[b]; ok {
|
||||
return v, true
|
||||
}
|
||||
return DiskVerdict{Reason: "unmapped"}, true
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
provenDifferent = DiskVerdict{Different: true, Proven: true}
|
||||
provenSame = DiskVerdict{Different: false, Proven: true, Reason: "same_disk"}
|
||||
unknownVM = DiskVerdict{Different: true, Proven: false, Reason: "vm"}
|
||||
)
|
||||
|
||||
func addDrive(t *testing.T, sett *settings.Settings, sp settings.StoragePath) {
|
||||
t.Helper()
|
||||
sp.Schedulable = true
|
||||
if err := sett.AddStoragePath(sp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 1: different + proven → made, counted, notice hidden.
|
||||
func TestDisk16_ProvenDifferent_CountedNoNotice(t *testing.T) {
|
||||
m, sett := newTestManager(t, "/srv/sys")
|
||||
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
|
||||
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/b": provenDifferent}))
|
||||
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
|
||||
if err != nil || tg.Unproven || tg.NamespaceRoot != "/mnt/b" {
|
||||
t.Fatalf("want /mnt/b counted, got %+v err=%v", tg, err)
|
||||
}
|
||||
if w := m.singleCopyWarning(); w != "" {
|
||||
t.Fatalf("a proven second disk must hide the notice, got %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 2: same disk (two partitions) → NOT made (D1), notice shown — even though st_dev would say "different".
|
||||
func TestDisk16_SameDisk_NotMadeAndNoticeShown(t *testing.T) {
|
||||
m, sett := newTestManager(t, "/srv/sys")
|
||||
addDrive(t, sett, settings.StoragePath{Path: "/mnt/part2", Label: "P2"})
|
||||
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return provenSame, true })
|
||||
if tg, err := m.selectTier2TargetFrom("app", "/mnt/part1", 1, 1); err == nil {
|
||||
t.Fatalf("a same-disk partition must not be a Tier-2 target, got %+v", tg)
|
||||
}
|
||||
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
|
||||
t.Fatalf("same disk: want the one-copy notice, got %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 3: unknown (a VM) → made, NOT counted (D2), notice shown with the "cannot check" sentence.
|
||||
func TestDisk16_Unknown_MadeNotCountedCannotCheck(t *testing.T) {
|
||||
m, sett := newTestManager(t, "/srv/sys")
|
||||
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/media_a", Label: "A"})
|
||||
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { return unknownVM, true })
|
||||
tg, err := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1)
|
||||
if err != nil || !tg.Unproven || tg.UnprovenReason != "vm" {
|
||||
t.Fatalf("unknown: want the copy made and marked unproven, got %+v err=%v", tg, err)
|
||||
}
|
||||
w := m.singleCopyWarning()
|
||||
if w != m.note("note.backup.single_copy_unproven") || !strings.Contains(w, "nem tudja ellenőrizni") {
|
||||
t.Fatalf("unknown: want the cannot-check notice, got %q", w)
|
||||
}
|
||||
// A proven-different drive beats an unknown one.
|
||||
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/real", Label: "R"})
|
||||
m.SetDiskJudge(judgeBy(map[string]DiskVerdict{"/mnt/felhom-drives/media_a": unknownVM, "/mnt/felhom-drives/real": provenDifferent}))
|
||||
if tg, _ := m.selectTier2TargetFrom("app", "/mnt/felhom-drives/adatlemez", 1, 1); tg == nil || tg.NamespaceRoot != "/mnt/felhom-drives/real" || tg.Unproven {
|
||||
t.Fatalf("a proven drive must win over an unknown one, got %+v", tg)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 4: a network share → never a Tier-2 target and never hides the notice (R-1075).
|
||||
func TestDisk16_NetworkShare_NeverHidesTheNotice(t *testing.T) {
|
||||
m, sett := newTestManager(t, "/srv/sys")
|
||||
addDrive(t, sett, settings.StoragePath{Path: "/mnt/felhom-drives/nas", Label: "NAS", Kind: settings.StorageKindNetwork})
|
||||
// No judge at all — the OLD st_dev rule calls the NAS "different", which is exactly R-1075.
|
||||
if m.hasOffDriveTarget() {
|
||||
t.Fatal("R-1075: a NAS made the box claim an off-drive copy")
|
||||
}
|
||||
if w := m.singleCopyWarning(); w != m.note("note.backup.single_copy") {
|
||||
t.Fatalf("a NAS must not hide the one-copy notice, got %q", w)
|
||||
}
|
||||
if tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1); err == nil && !tg.IsSystemDrive {
|
||||
t.Fatalf("a NAS must never be a Tier-2 target, got %+v", tg)
|
||||
}
|
||||
}
|
||||
|
||||
// An older agent (supported=false) keeps today's behaviour exactly: the st_dev seam decides.
|
||||
func TestDisk16_OldAgent_KeepsTheStDevRule(t *testing.T) {
|
||||
m, sett := newTestManager(t, "/srv/sys")
|
||||
addDrive(t, sett, settings.StoragePath{Path: "/mnt/b", Label: "B"})
|
||||
calls := 0
|
||||
m.SetDiskJudge(func(a, b string) (DiskVerdict, bool) { calls++; return DiskVerdict{}, false })
|
||||
tg, err := m.selectTier2TargetFrom("app", "/srv/sys", 1, 1)
|
||||
if err != nil || tg.NamespaceRoot != "/mnt/b" || tg.Unproven {
|
||||
t.Fatalf("old agent: want today's pick (/mnt/b, counted), got %+v err=%v", tg, err)
|
||||
}
|
||||
if calls == 0 {
|
||||
t.Fatal("the judge was never asked")
|
||||
}
|
||||
if m.singleCopyWarning() != "" {
|
||||
t.Fatal("old agent: a second drive by st_dev must still hide the notice (today's behaviour)")
|
||||
}
|
||||
// And the st_dev rule's own same-disk answer still stands.
|
||||
m.samePhysicalDevice = func(a, b string) bool { return true }
|
||||
if _, err := m.selectTier2TargetFrom("app", "/srv/x", 1, 1); err == nil {
|
||||
t.Fatal("old agent: st_dev 'same' must still refuse the target")
|
||||
}
|
||||
}
|
||||
|
||||
// The success record carries the mark the app row renders.
|
||||
func TestDisk16_UnprovenCopyIsRecorded(t *testing.T) {
|
||||
m, sett := newTestManager(t, "/srv/sys")
|
||||
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a", Unproven: true, UnprovenReason: "vm"}, 1, "", 0)
|
||||
cd := sett.GetCrossDriveConfig("app")
|
||||
if cd == nil || !cd.Unproven || cd.UnprovenReason != "vm" {
|
||||
t.Fatalf("want the unproven mark recorded, got %+v", cd)
|
||||
}
|
||||
m.recordTier2Success("app", &Tier2Target{NamespaceRoot: "/mnt/a"}, 1, "", 0)
|
||||
if cd := sett.GetCrossDriveConfig("app"); cd.Unproven {
|
||||
t.Fatal("a later proven copy must clear the mark")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user